mirror of
https://github.com/brues-code/ClassicAPI.git
synced 2026-11-13 22:50:44 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d14f0b44c1 | ||
|
|
008282d65a | ||
|
|
9a8c314d69 | ||
|
|
4b1bf16ba5 | ||
|
|
5528fb5fa4 | ||
|
|
7ab32df2aa | ||
|
|
8e1429bfac | ||
|
|
9322382596 | ||
|
|
c3cf9e6f3a | ||
|
|
f5d6046523 | ||
|
|
e1ebf6ecce | ||
|
|
698827e9c5 | ||
|
|
60530e32fc | ||
|
|
3cffb71b20 | ||
|
|
7887237a6e | ||
|
|
98af79137b | ||
|
|
622882725f | ||
|
|
0517bb3753 | ||
|
|
fb17488237 | ||
|
|
b1ee8cc183 | ||
|
|
c08ded6db4 | ||
|
|
640f78b2eb | ||
|
|
a51ec40e52 | ||
|
|
2ce5b007af | ||
|
|
be92d721f0 | ||
|
|
b81f672ad7 | ||
|
|
fdbe66b057 | ||
|
|
bd1bd9f5d8 | ||
|
|
b6698ecc7e | ||
|
|
e123a6facc | ||
|
|
695077b6da | ||
|
|
4d744b6638 | ||
|
|
c2a4cbdf27 | ||
|
|
d30ae53fbf | ||
|
|
8f32955823 | ||
|
|
2778643f4f | ||
|
|
3b60ca4094 | ||
|
|
c20be0cef1 | ||
|
|
1ad6b57202 | ||
|
|
993c82cca7 | ||
|
|
94e77ef5af | ||
|
|
1458d60b51 | ||
|
|
8b106ae0d6 | ||
|
|
3c4d4eda5f | ||
|
|
6aea4a1752 | ||
|
|
6304a253dd | ||
|
|
fbe40743c0 | ||
|
|
772cd5ec15 | ||
|
|
a962c76f09 | ||
|
|
292e206dee | ||
|
|
eef7140722 | ||
|
|
5279c463ed | ||
|
|
855b338736 | ||
|
|
6ca6af1497 | ||
|
|
1170a4e63a | ||
|
|
0c8468cf2f | ||
|
|
5196aab4b8 | ||
|
|
536c5e3c51 | ||
|
|
c06bed9158 | ||
|
|
e793f80f6b | ||
|
|
ded767bf19 | ||
|
|
70a69b64fd | ||
|
|
0f5b50ea14 | ||
|
|
be6dc200fe | ||
|
|
7f52afeef4 |
@@ -43,6 +43,7 @@ Util\Rectangle.lua
|
||||
Util\Vector2D.lua
|
||||
Util\Vector3D.lua
|
||||
Util\Vector4D.lua
|
||||
Util\UiMapPoint.lua
|
||||
Util\UIParent.lua
|
||||
Util\AuraDurationModifiers.lua
|
||||
Util\AuraUtil.lua
|
||||
|
||||
@@ -79,6 +79,53 @@ end
|
||||
|
||||
CAPI_MouseoverClearedCompat(GameTooltip)
|
||||
|
||||
-- Restores vanilla calling for ONE frame's script handler. Our DLL hands script
|
||||
-- handlers modern positional arguments (`self`, then the event's own args) on
|
||||
-- top of the `this` / `arg1` globals vanilla uses. That is additive for a
|
||||
-- handler declared `function()`, but a handler declared with a parameter of its
|
||||
-- own -- one written to be BOTH called directly and used as a script -- now
|
||||
-- receives `self` in that parameter instead of the nil it was written for.
|
||||
--
|
||||
-- The wrapper below takes no parameters, so those extra arguments stop here,
|
||||
-- and the original runs with the empty argument list vanilla gave it. The
|
||||
-- globals are untouched by the nesting, so the handler still reads `this` and
|
||||
-- `event` normally.
|
||||
function CAPI_VanillaScriptArgsCompat(frame, script)
|
||||
if not frame then return end
|
||||
local original = frame:GetScript(script)
|
||||
if not original then return end
|
||||
frame:SetScript(script, function()
|
||||
original()
|
||||
end)
|
||||
end
|
||||
|
||||
-- LunaUnitFrames sets `SetupGroupHeader(groupType)` as its group headers'
|
||||
-- OnEvent while also calling it directly with a group name. As a handler it
|
||||
-- expects `groupType` to be nil so it falls back to `this.unitGroup`. Given
|
||||
-- `self` instead, it looks the header up by frame rather than by name, finds
|
||||
-- nothing, and indexes nil (units.lua:358, issue #41). Its raid counterpart
|
||||
-- takes the header frame itself, so `self` is already what it wants -- only the
|
||||
-- group headers need this.
|
||||
if C_AddOns.DoesAddOnExist("LunaUnitFrames") then
|
||||
EventUtil.ContinueOnAddOnLoaded("LunaUnitFrames", function()
|
||||
if not (LunaUF and LunaUF.Units) then return end
|
||||
|
||||
local function FixGroupHeader(unit)
|
||||
CAPI_VanillaScriptArgsCompat(getglobal("LUFHeader" .. (unit or "")),
|
||||
"OnEvent")
|
||||
end
|
||||
|
||||
-- Headers are built on demand, so cover the ones already up and every
|
||||
-- one created later.
|
||||
FixGroupHeader("party")
|
||||
FixGroupHeader("partytarget")
|
||||
FixGroupHeader("partypet")
|
||||
hooksecurefunc(LunaUF.Units, "LoadGroupHeader", function(self, unit)
|
||||
FixGroupHeader(unit)
|
||||
end)
|
||||
end)
|
||||
end
|
||||
|
||||
if C_AddOns.DoesAddOnExist("Puppeteer") then
|
||||
EventUtil.ContinueOnAddOnLoaded("Puppeteer", function()
|
||||
CAPI_MouseoverClearedCompat(PTEnemyUpdater)
|
||||
@@ -97,3 +144,19 @@ end
|
||||
if C_AddOns.DoesAddOnExist("Cartographer") and not WorldMapDeathRelease then
|
||||
CreateFrame("Button", "WorldMapDeathRelease", WorldMapButton or UIParent):Hide()
|
||||
end
|
||||
|
||||
-- ModernSpellBook (Spellbook\MSB_Spellbook.lua) re-hooks its "show all ranks"
|
||||
-- checkbox by calling HookScript as a GLOBAL -- HookScript(frame, "OnClick",
|
||||
-- handler). That global never existed on any client: HookScript has always
|
||||
-- been the widget method frame:HookScript, which our DLL provides. MSB even
|
||||
-- guards the call with `if checkbox.HookScript` (the method IS present), then
|
||||
-- calls the global (nil) -> "attempt to call global 'HookScript'". Define the
|
||||
-- global so the call resolves, forwarding to the method. Gated on the addon
|
||||
-- being present so the global namespace stays clean otherwise.
|
||||
if C_AddOns.DoesAddOnExist("ModernSpellBook") and not HookScript then
|
||||
function HookScript(frame, script, handler)
|
||||
if frame and frame.HookScript then
|
||||
frame:HookScript(script, handler)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -14,24 +14,61 @@
|
||||
-- * FindAuraByName(auraName, unit, filter)
|
||||
-- * UnpackAuraData(auraData)
|
||||
--
|
||||
-- Iteration batches through C_UnitAuras.GetAuraSlots + a by-slot fetch
|
||||
-- (continuation tokens), exactly as retail's does: one enumeration per batch,
|
||||
-- then a direct fetch per aura -- linear in the aura count. The by-index getters
|
||||
-- (GetAuraDataByIndex / UnitAura) re-walk the aura array from slot 0 on every
|
||||
-- call, so iterating by index is quadratic; don't go back to it here.
|
||||
--
|
||||
-- Two deliberate differences from that source:
|
||||
-- 1. Retail's ForEachAura/FindAura batch through GetAuraSlots / GetAuraDataBySlot
|
||||
-- (continuation tokens) -- 1.12 has no slot-batching API, so the bodies here
|
||||
-- iterate by index (1, 2, ...) until the accessor returns no aura. Same public
|
||||
-- contract, different internals.
|
||||
-- 2. The non-packed ForEachAura and FindAura source each aura from the positional
|
||||
-- C_UnitAuras.UnitAura (no table), so both are zero-allocation here -- FindAura
|
||||
-- in particular, where the retail version allocates a table per aura scanned.
|
||||
-- 1. Retail's helpers receive GetAuraSlots' return list as varargs
|
||||
-- (ForEachAuraHelper / FindAuraRecurse). Lua 5.0 builds an `arg` table for
|
||||
-- EVERY vararg call, so that shape allocates one table per batch here --
|
||||
-- exactly the per-frame garbage this library exists to avoid. We use
|
||||
-- GetAuraSlots' fill-a-table form instead (a table as its 5th argument,
|
||||
-- a ClassicAPI extension): the slot ids land in a reusable buffer and the
|
||||
-- call returns `continuationToken, n`. No vararg frame anywhere.
|
||||
-- 2. The non-packed ForEachAura and FindAura fetch each aura through the
|
||||
-- positional C_UnitAuras.UnitAuraBySlot (no table), the way retail's
|
||||
-- AuraUtil did before it retired the positional by-slot accessor. So both
|
||||
-- are zero-allocation here -- FindAura in particular, where the current
|
||||
-- retail version allocates a table per aura scanned.
|
||||
--
|
||||
-- The comparator / dispel-priority / border helpers (DefaultAuraCompare,
|
||||
-- UnitFrameDebuffComparator, ShouldDisplayDebuff, SetAuraBorder*, IsPriorityDebuff)
|
||||
-- are NOT ported: they depend on isPriorityAura / auraInstanceID / the aura
|
||||
-- visualization pipeline / secure-call machinery, none of which exist on 1.12.
|
||||
|
||||
local UnitAura = C_UnitAuras.UnitAura
|
||||
local GetAuraSlots = C_UnitAuras.GetAuraSlots
|
||||
local GetAuraDataBySlot = C_UnitAuras.GetAuraDataBySlot
|
||||
local UnitAuraBySlot = C_UnitAuras.UnitAuraBySlot
|
||||
|
||||
AuraUtil = AuraUtil or {};
|
||||
|
||||
-- Slot-id buffers for GetAuraSlots' fill form, one per nesting level: a `func`
|
||||
-- or `predicate` that itself iterates another unit's auras gets its own buffer
|
||||
-- instead of clobbering the outer scan's. Costs one table per nesting level for
|
||||
-- the session (retail's varargs are re-entrant for free; ours must be explicit).
|
||||
-- An error thrown out of `func` skips the release and leaves `depth` one higher
|
||||
-- for the rest of the session -- harmless: later scans use the next buffer up,
|
||||
-- allocated once.
|
||||
local slotBufs = {};
|
||||
local depth = 0;
|
||||
|
||||
local function AcquireSlotBuf()
|
||||
depth = depth + 1;
|
||||
local buf = slotBufs[depth];
|
||||
if not buf then
|
||||
buf = {};
|
||||
slotBufs[depth] = buf;
|
||||
end
|
||||
return buf;
|
||||
end
|
||||
|
||||
local function ReleaseSlotBuf()
|
||||
depth = depth - 1;
|
||||
end
|
||||
|
||||
-- Returns the AuraData table's fields in the classic `UnitAura` positional order,
|
||||
-- for code written against that shape. Retail appends `unpack(auraData.points)`;
|
||||
-- vanilla auras carry no points, so the tuple ends at timeMod (15 values). Field
|
||||
@@ -58,66 +95,56 @@ function AuraUtil.UnpackAuraData(auraData)
|
||||
auraData.timeMod;
|
||||
end
|
||||
|
||||
-- Calls `func` for each aura on `unit` matching `filter`, in index order, until
|
||||
-- `func` returns a truthy value or the auras run out. Non-packed (default) passes
|
||||
-- the 15 positional `UnitAura` values (no table); `usePackedAura` passes the
|
||||
-- AuraData table from GetAuraDataByIndex. `batchSize <= 0` does nothing; any other
|
||||
-- value (or nil) visits every matching aura -- `batchSize` is retail's slot-batch
|
||||
-- hint, not a cap on how many auras are visited.
|
||||
-- Calls `func` for each aura on `unit` matching `filter`, in order, until `func`
|
||||
-- returns a truthy value or the auras run out. Non-packed (default) passes the 15
|
||||
-- positional `UnitAura` values (no table); `usePackedAura` passes the AuraData
|
||||
-- table. `batchSize` is the number of slots fetched per GetAuraSlots call (nil =
|
||||
-- all at once); it never caps how many auras are visited. `batchSize <= 0` does
|
||||
-- nothing.
|
||||
function AuraUtil.ForEachAura(unit, filter, batchSize, func, usePackedAura)
|
||||
if batchSize and batchSize <= 0 then
|
||||
return;
|
||||
end
|
||||
local index = 1;
|
||||
while true do
|
||||
local done;
|
||||
if usePackedAura then
|
||||
local auraData = C_UnitAuras.GetAuraDataByIndex(unit, index, filter);
|
||||
if auraData == nil then
|
||||
local buf = AcquireSlotBuf();
|
||||
local continuationToken, n;
|
||||
repeat
|
||||
continuationToken, n = GetAuraSlots(unit, filter, batchSize, continuationToken, buf);
|
||||
for i = 1, n do
|
||||
local done;
|
||||
if usePackedAura then
|
||||
done = func(GetAuraDataBySlot(unit, buf[i]));
|
||||
else
|
||||
done = func(UnitAuraBySlot(unit, buf[i]));
|
||||
end
|
||||
if done then
|
||||
ReleaseSlotBuf();
|
||||
return;
|
||||
end
|
||||
done = func(auraData);
|
||||
else
|
||||
local name, icon, count, dispelType, duration, expirationTime, source,
|
||||
isStealable, nameplateShowPersonal, spellId, canApplyAura,
|
||||
isBossDebuff, castByPlayer, nameplateShowAll, timeMod =
|
||||
UnitAura(unit, index, filter);
|
||||
if name == nil then
|
||||
return;
|
||||
end
|
||||
done = func(name, icon, count, dispelType, duration, expirationTime,
|
||||
source, isStealable, nameplateShowPersonal, spellId, canApplyAura,
|
||||
isBossDebuff, castByPlayer, nameplateShowAll, timeMod);
|
||||
end
|
||||
if done then
|
||||
return;
|
||||
end
|
||||
index = index + 1;
|
||||
end
|
||||
end
|
||||
|
||||
-- Recursive worker: `...` is the current aura's positional values (nil when the
|
||||
-- index is past the last aura). Tail-recurses to the next index. Lua 5.0 has
|
||||
-- proper tail calls, so depth is constant regardless of aura count.
|
||||
local function FindAuraRecurse(predicate, unit, filter, index, arg1, arg2, arg3, ...)
|
||||
if ... == nil then
|
||||
return nil; -- past the last aura -> not found
|
||||
end
|
||||
if predicate(arg1, arg2, arg3, ...) then
|
||||
return ...;
|
||||
end
|
||||
index = index + 1;
|
||||
return FindAuraRecurse(predicate, unit, filter, index, arg1, arg2, arg3,
|
||||
UnitAura(unit, index, filter));
|
||||
until continuationToken == nil;
|
||||
ReleaseSlotBuf();
|
||||
end
|
||||
|
||||
-- Returns the positional values of the first aura for which `predicate` returns
|
||||
-- truthy, or nil. `predicate` receives up to three caller args followed by the
|
||||
-- aura's positional values: predicate(arg1, arg2, arg3, name, icon, ...). Sources
|
||||
-- auras from the positional accessor, so no table is allocated while scanning.
|
||||
-- auras from the positional by-slot accessor, so no table is allocated while
|
||||
-- scanning.
|
||||
function AuraUtil.FindAura(predicate, unit, filter, arg1, arg2, arg3)
|
||||
return FindAuraRecurse(predicate, unit, filter, 1, arg1, arg2, arg3,
|
||||
UnitAura(unit, 1, filter));
|
||||
local buf = AcquireSlotBuf();
|
||||
local continuationToken, n;
|
||||
repeat
|
||||
continuationToken, n = GetAuraSlots(unit, filter, nil, continuationToken, buf);
|
||||
for i = 1, n do
|
||||
local slot = buf[i];
|
||||
if predicate(arg1, arg2, arg3, UnitAuraBySlot(unit, slot)) then
|
||||
ReleaseSlotBuf();
|
||||
return UnitAuraBySlot(unit, slot);
|
||||
end
|
||||
end
|
||||
until continuationToken == nil;
|
||||
ReleaseSlotBuf();
|
||||
return nil;
|
||||
end
|
||||
|
||||
-- Returns the positional values of the first aura whose name matches `auraName`,
|
||||
|
||||
@@ -1,101 +1,100 @@
|
||||
-- Backport of 3.3.5 FrameXML/GlobalCallbackRegistry.lua to vanilla 1.12 / Lua 5.0.
|
||||
--
|
||||
-- Implementation differences from the 3.3.5 source:
|
||||
-- - 3.3.5 ref-counts WoW frame events via a SetAttribute/OnAttributeChanged
|
||||
-- dance because attribute mutations are taint-safe in the secure-template
|
||||
-- model. 1.12 has neither secure templates nor SetAttribute on frames,
|
||||
-- so we keep an ordinary {[event]=count} table and Register/Unregister
|
||||
-- on 0<->1 transitions.
|
||||
-- Implementation notes:
|
||||
-- - 3.3.5 ref-counts frame events through a SetAttribute/OnAttributeChanged
|
||||
-- dance (attribute mutations are taint-safe under secure templates).
|
||||
-- ClassicAPI backports SetAttribute, but routing counters through it is
|
||||
-- heavier than a plain table: every set lowercases the name, runs the
|
||||
-- secure-attribute parsing, and fires OnAttributeChanged. So we keep an
|
||||
-- ordinary {[event]=count} table and Register/Unregister on 0<->1.
|
||||
-- - OnEvent takes its (self, event, ...) positionally (modern script args,
|
||||
-- on by default) and forwards straight to TriggerEvent, falling back to
|
||||
-- the event/arg globals if that switch is turned off.
|
||||
|
||||
EventRegistry = CreateFromMixins(CallbackRegistryMixin)
|
||||
EventRegistry = CreateFromMixins(CallbackRegistryMixin);
|
||||
|
||||
function EventRegistry:OnLoad()
|
||||
CallbackRegistryMixin.OnLoad(self)
|
||||
self:SetUndefinedEventsAllowed(true)
|
||||
CallbackRegistryMixin.OnLoad(self);
|
||||
self:SetUndefinedEventsAllowed(true);
|
||||
|
||||
self.eventCounts = {}
|
||||
self.frameEventFrame = CreateFrame("Frame")
|
||||
self.frameEventFrame.registry = self
|
||||
self.frameEventFrame:SetScript("OnEvent", function()
|
||||
-- 1.12 globals: `event` is the event name; `arg1..argN` are payload.
|
||||
-- We don't repack them here; CallbackRegistryMixin:TriggerEvent uses
|
||||
-- Lua-5.0 varargs, so we forward through a small wrapper.
|
||||
self:DispatchGameEvent(event, arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9)
|
||||
end)
|
||||
self.eventCounts = {};
|
||||
self.frameEventFrame = CreateFrame("Frame");
|
||||
self.frameEventFrame:SetScript("OnEvent", function(frameEventFrame, evt, ...)
|
||||
if evt ~= nil then
|
||||
-- Modern positional script args (default): (self, event, payload...),
|
||||
-- forwarded with the exact arg count.
|
||||
self:TriggerEvent(evt, ...);
|
||||
else
|
||||
-- SetModernScriptArgs is off: read the event/arg globals 1.12 always
|
||||
-- sets. This path keeps the historical 9-arg cap.
|
||||
self:TriggerEvent(event, arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9);
|
||||
end
|
||||
end);
|
||||
|
||||
-- Stop delivering events once logout begins: a callback firing during the
|
||||
-- engine's teardown can touch state that's already gone. A dedicated frame
|
||||
-- owns PLAYER_LOGOUT so this fires regardless of what consumers registered
|
||||
-- — folding the check into frameEventFrame's OnEvent would depend on the
|
||||
-- ref-counted event set still holding PLAYER_LOGOUT at logout time.
|
||||
self.logoutFrame = CreateFrame("Frame")
|
||||
self.logoutFrame:RegisterEvent("PLAYER_LOGOUT")
|
||||
self.logoutFrame:SetScript("OnEvent", function()
|
||||
self.frameEventFrame:UnregisterAllEvents()
|
||||
self.frameEventFrame:SetScript("OnEvent", nil)
|
||||
end)
|
||||
end
|
||||
|
||||
-- Helper so we can forward up to nine event args (the most any vanilla
|
||||
-- event currently emits) into TriggerEvent's vararg machinery.
|
||||
function EventRegistry:DispatchGameEvent(event, ...)
|
||||
self:TriggerEvent(event, unpack(arg))
|
||||
self.logoutFrame = CreateFrame("Frame");
|
||||
self.logoutFrame:RegisterEvent("PLAYER_LOGOUT");
|
||||
self.logoutFrame:SetScript("OnEvent", function()
|
||||
self.frameEventFrame:UnregisterAllEvents();
|
||||
self.frameEventFrame:SetScript("OnEvent", nil);
|
||||
end);
|
||||
end
|
||||
|
||||
function EventRegistry:RegisterFrameEvent(frameEvent)
|
||||
local n = (self.eventCounts[frameEvent] or 0) + 1
|
||||
self.eventCounts[frameEvent] = n
|
||||
if n == 1 then
|
||||
self.frameEventFrame:RegisterEvent(frameEvent)
|
||||
end
|
||||
local n = (self.eventCounts[frameEvent] or 0) + 1;
|
||||
self.eventCounts[frameEvent] = n;
|
||||
if n == 1 then
|
||||
self.frameEventFrame:RegisterEvent(frameEvent);
|
||||
end
|
||||
end
|
||||
|
||||
function EventRegistry:UnregisterFrameEvent(frameEvent)
|
||||
local n = self.eventCounts[frameEvent] or 0
|
||||
if n > 0 then
|
||||
n = n - 1
|
||||
self.eventCounts[frameEvent] = n
|
||||
if n == 0 then
|
||||
self.frameEventFrame:UnregisterEvent(frameEvent)
|
||||
end
|
||||
end
|
||||
local n = self.eventCounts[frameEvent] or 0;
|
||||
if n > 0 then
|
||||
n = n - 1;
|
||||
self.eventCounts[frameEvent] = n;
|
||||
if n == 0 then
|
||||
self.frameEventFrame:UnregisterEvent(frameEvent);
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
function EventRegistry:RegisterFrameEventAndCallback(frameEvent, ...)
|
||||
self:RegisterFrameEvent(frameEvent)
|
||||
return self:RegisterCallback(frameEvent, unpack(arg))
|
||||
self:RegisterFrameEvent(frameEvent);
|
||||
return self:RegisterCallback(frameEvent, ...);
|
||||
end
|
||||
|
||||
local function CreateCallbackHandle(cbr, cbrHandle, frameEvent)
|
||||
local handle = {
|
||||
Unregister = function()
|
||||
cbr:UnregisterFrameEvent(frameEvent)
|
||||
cbrHandle:Unregister()
|
||||
end,
|
||||
}
|
||||
return handle
|
||||
-- Wrapped in a table for future flexibility.
|
||||
local handle = {
|
||||
Unregister = function()
|
||||
cbr:UnregisterFrameEvent(frameEvent);
|
||||
cbrHandle:Unregister();
|
||||
end,
|
||||
};
|
||||
return handle;
|
||||
end
|
||||
|
||||
|
||||
function EventRegistry:RegisterFrameEventAndCallbackWithHandle(frameEvent, ...)
|
||||
self:RegisterFrameEvent(frameEvent)
|
||||
local cbrHandle = self:RegisterCallbackWithHandle(frameEvent, unpack(arg))
|
||||
return CreateCallbackHandle(self, cbrHandle, frameEvent)
|
||||
self:RegisterFrameEvent(frameEvent);
|
||||
local cbrHandle = self:RegisterCallbackWithHandle(frameEvent, ...);
|
||||
return CreateCallbackHandle(self, cbrHandle, frameEvent);
|
||||
end
|
||||
|
||||
function EventRegistry:UnregisterFrameEventAndCallback(frameEvent, ...)
|
||||
self:UnregisterFrameEvent(frameEvent)
|
||||
self:UnregisterCallback(frameEvent, unpack(arg))
|
||||
self:UnregisterFrameEvent(frameEvent);
|
||||
self:UnregisterCallback(frameEvent, ...);
|
||||
end
|
||||
|
||||
function EventRegistry:GetEventCounts(...)
|
||||
local counts = {}
|
||||
for i = 1, arg.n do
|
||||
local frameEvent = arg[i]
|
||||
local count = self.eventCounts[frameEvent] or "?"
|
||||
table.insert(counts, frameEvent .. " : " .. tostring(count))
|
||||
end
|
||||
local counts = {};
|
||||
for i = 1, select("#", ...) do
|
||||
local frameEvent = select(i, ...);
|
||||
local count = self.eventCounts[frameEvent] or "?";
|
||||
table.insert(counts, ("%s : %s"):format(frameEvent, tostring(count)));
|
||||
end
|
||||
|
||||
return table.concat(counts, "\n")
|
||||
return table.concat(counts, "\n");
|
||||
end
|
||||
|
||||
EventRegistry:OnLoad()
|
||||
EventRegistry:OnLoad();
|
||||
|
||||
@@ -7,19 +7,42 @@ function RegisterNewSlashCommand(callback, command, commandAlias)
|
||||
end
|
||||
|
||||
SlashCmdList["FOCUS"] = function(msg)
|
||||
if msg == "" then
|
||||
FocusUnit();
|
||||
else
|
||||
FocusUnit(msg);
|
||||
end
|
||||
if ( msg == "" ) then
|
||||
FocusUnit();
|
||||
else
|
||||
local action, target = SecureCmdOptionParse(msg);
|
||||
if ( action ) then
|
||||
if ( not target or target == "focus" ) then
|
||||
target = action;
|
||||
end
|
||||
FocusUnit(target);
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
SlashCmdList["CLEARFOCUS"] = function(msg)
|
||||
ClearFocus()
|
||||
if ( SecureCmdOptionParse(msg) ) then
|
||||
ClearFocus();
|
||||
end
|
||||
end
|
||||
|
||||
SlashCmdList["EQUIP_SET"] = function(msg)
|
||||
if msg ~= "" then
|
||||
C_EquipmentSet.UseEquipmentSet(C_EquipmentSet.GetEquipmentSetID(msg))
|
||||
end
|
||||
local set = SecureCmdOptionParse(msg);
|
||||
if ( set and set ~= "" ) then
|
||||
C_EquipmentSet.UseEquipmentSet(C_EquipmentSet.GetEquipmentSetID(set))
|
||||
end
|
||||
end
|
||||
|
||||
SlashCmdList["CLICK"] = function(msg)
|
||||
local action = SecureCmdOptionParse(msg);
|
||||
if ( action and action ~= "" ) then
|
||||
local name, mouseButton = string.match(action, "([^%s]+)%s+([^%s]+)");
|
||||
if ( not name ) then
|
||||
name = action;
|
||||
end
|
||||
local button = GetClickFrame(name);
|
||||
if ( button and button:IsObjectType("Button") ) then
|
||||
button:Click(mouseButton);
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -92,3 +92,37 @@ function CreateSimpleTextureMarkup(file, width, height, xOffset, yOffset)
|
||||
, yOffset or 0
|
||||
);
|
||||
end
|
||||
|
||||
function CreateAtlasMarkup(atlasName, width, height, offsetX, offsetY)
|
||||
return string.format("|A:%s:%d:%d:%d:%d|a",
|
||||
atlasName
|
||||
, height or 0
|
||||
, width or 0
|
||||
, offsetX or 0
|
||||
, offsetY or 0
|
||||
);
|
||||
end
|
||||
|
||||
-- Raid target markers, published as atlas names.
|
||||
--
|
||||
-- All eight markers live in one 4x4 sheet, so naming a single marker means
|
||||
-- naming a cell inside that sheet — exactly what an atlas is for. Blizzard
|
||||
-- publishes no atlas names for these, so the names here follow the standalone
|
||||
-- marker files that other clients ship. An addon written against those files
|
||||
-- finds the art under the name it already knows.
|
||||
--
|
||||
-- Marker N sits at column (N-1) mod 4 and row floor((N-1) / 4), counting from
|
||||
-- the top left. That is the same cell math the chat marker tokens use.
|
||||
local RAID_TARGET_SHEET = "Interface\\TargetingFrame\\UI-RaidTargetingIcons";
|
||||
local RAID_TARGET_COLUMNS = 4;
|
||||
local RAID_TARGET_CELL = 0.25; -- a 64 pixel cell in a 256 pixel sheet
|
||||
|
||||
for marker = 1, 8 do
|
||||
local index = marker - 1;
|
||||
local left = math.mod(index, RAID_TARGET_COLUMNS) * RAID_TARGET_CELL;
|
||||
local top = math.floor(index / RAID_TARGET_COLUMNS) * RAID_TARGET_CELL;
|
||||
C_Texture.RegisterAtlas("UI-RaidTargetingIcon_"..marker, RAID_TARGET_SHEET,
|
||||
64, 64,
|
||||
left, left + RAID_TARGET_CELL,
|
||||
top, top + RAID_TARGET_CELL);
|
||||
end
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
function MouseIsOver(region, topOffset, bottomOffset, leftOffset, rightOffset)
|
||||
return region:IsMouseOver(topOffset, bottomOffset, leftOffset, rightOffset);
|
||||
if region:IsMouseOver(topOffset, bottomOffset, leftOffset, rightOffset) then
|
||||
return 1;
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
-- Backport of retail's Blizzard_ObjectAPI/UiMapPoint.lua. A UiMapPoint is the
|
||||
-- plain struct the C_Map waypoint calls take and return:
|
||||
-- { uiMapID = number, position = Vector2DMixin, z = number? }
|
||||
local CreateVector2D = CreateVector2D
|
||||
|
||||
UiMapPoint = {};
|
||||
|
||||
function UiMapPoint.CreateFromCoordinates(uiMapID, x, y, z)
|
||||
return { uiMapID = uiMapID, position = CreateVector2D(x, y), z = z };
|
||||
end
|
||||
|
||||
function UiMapPoint.CreateFromVector2D(uiMapID, position, z)
|
||||
return { uiMapID = uiMapID, position = CreateVector2D(position.x, position.y), z = z };
|
||||
end
|
||||
|
||||
function UiMapPoint.CreateFromVector3D(uiMapID, position)
|
||||
return { uiMapID = uiMapID, position = CreateVector2D(position.x, position.y), z = position.z };
|
||||
end
|
||||
@@ -80,6 +80,8 @@ SET_NOTE = "Notiz eintragen";
|
||||
SILVER_AMOUNT_SYMBOL = "S";
|
||||
SLASH_CLEARFOCUS1 = "/fokusaufheben";
|
||||
SLASH_CLEARFOCUS2 = "/clearfocus";
|
||||
SLASH_CLICK1 = "/klick";
|
||||
SLASH_CLICK2 = "/click";
|
||||
SLASH_EQUIP_SET1 = "/Ausrüstungsset";
|
||||
SLASH_EQUIP_SET2 = "/equipset";
|
||||
SLASH_FOCUS1 = "/fokus";
|
||||
|
||||
@@ -81,6 +81,8 @@ SILVER_AMOUNT_SYMBOL = "s";
|
||||
SILVER_AMOUNT_TEXTURE = "%d|TInterface\\MoneyFrame\\UI-MoneyIcons:%d:%d:0:0:4:1:1:2:0:1|t";
|
||||
SLASH_CLEARFOCUS1 = "/clearfocus";
|
||||
SLASH_CLEARFOCUS2 = "/clearfocus";
|
||||
SLASH_CLICK1 = "/click";
|
||||
SLASH_CLICK2 = "/click";
|
||||
SLASH_EQUIP_SET1 = "/equipset";
|
||||
SLASH_EQUIP_SET2 = "/equipset";
|
||||
SLASH_FOCUS1 = "/focus";
|
||||
|
||||
@@ -80,6 +80,8 @@ SET_NOTE = "Establecer nota";
|
||||
SILVER_AMOUNT_SYMBOL = "p";
|
||||
SLASH_CLEARFOCUS1 = "/quitarenfoque";
|
||||
SLASH_CLEARFOCUS2 = "/clearfocus";
|
||||
SLASH_CLICK1 = "/clic";
|
||||
SLASH_CLICK2 = "/click";
|
||||
SLASH_EQUIP_SET1 = "/conjequip";
|
||||
SLASH_EQUIP_SET2 = "/equipset";
|
||||
SLASH_FOCUS1 = "/enfoque";
|
||||
|
||||
@@ -80,6 +80,8 @@ SET_NOTE = "Écrire une note";
|
||||
SILVER_AMOUNT_SYMBOL = "a";
|
||||
SLASH_CLEARFOCUS1 = "/clearfocus";
|
||||
SLASH_CLEARFOCUS2 = "/annulerfocal";
|
||||
SLASH_CLICK1 = "/click";
|
||||
SLASH_CLICK2 = "/cliquer";
|
||||
SLASH_EQUIP_SET1 = "/equipset";
|
||||
SLASH_EQUIP_SET2 = "/équipens";
|
||||
SLASH_FOCUS1 = "/focus";
|
||||
|
||||
@@ -80,6 +80,8 @@ SET_NOTE = "Добавить заметку";
|
||||
SILVER_AMOUNT_SYMBOL = "с";
|
||||
SLASH_CLEARFOCUS1 = "/clearfocus";
|
||||
SLASH_CLEARFOCUS2 = "/сбросфокуса";
|
||||
SLASH_CLICK1 = "/click";
|
||||
SLASH_CLICK2 = "/щелчок";
|
||||
SLASH_EQUIP_SET1 = "/комплект";
|
||||
SLASH_EQUIP_SET2 = "/equipset";
|
||||
SLASH_FOCUS1 = "/focus";
|
||||
|
||||
@@ -80,6 +80,8 @@ SET_NOTE = "设置备注";
|
||||
SILVER_AMOUNT_SYMBOL = "银";
|
||||
SLASH_CLEARFOCUS1 = "/clearfocus";
|
||||
SLASH_CLEARFOCUS2 = "/clearfocus";
|
||||
SLASH_CLICK1 = "/click";
|
||||
SLASH_CLICK2 = "/click";
|
||||
SLASH_EQUIP_SET1 = "/equipset";
|
||||
SLASH_EQUIP_SET2 = "/equipset";
|
||||
SLASH_FOCUS1 = "/focus";
|
||||
|
||||
@@ -80,6 +80,8 @@ SET_NOTE = "設定註記";
|
||||
SILVER_AMOUNT_SYMBOL = "銀";
|
||||
SLASH_CLEARFOCUS1 = "/clearfocus";
|
||||
SLASH_CLEARFOCUS2 = "/清除專注目標";
|
||||
SLASH_CLICK1 = "/click";
|
||||
SLASH_CLICK2 = "/點擊";
|
||||
SLASH_EQUIP_SET1 = "/equipset";
|
||||
SLASH_EQUIP_SET2 = "/裝備套裝";
|
||||
SLASH_FOCUS1 = "/focus";
|
||||
|
||||
@@ -21,19 +21,22 @@ The flagship feature: run modern Lua 5.1 addon code on 1.12's Lua 5.0 VM.
|
||||
|
||||
| Feature | Effect |
|
||||
|---------|--------|
|
||||
| Syntax | Compiles the Lua 5.1 length (`#`), modulo (`%`), `...`-expression, `0x` hex-literal, and leveled long bracket (`[=[ ]=]`) syntax that vanilla's Lua 5.0 rejects, by rewriting addon source before it compiles. Each addon file also receives its `(name, table)` through `...` (`local name, tbl = ...`). See [Lua 5.1 syntax](docs/API.md#lua-51-syntax). |
|
||||
| String methods | Every string value accepts method calls — `("asd"):upper()`, `("%d gold"):format(n)`, `msg:match("^!(%w+)")` — resolving through the `string` table, the way Lua 5.1 works. Vanilla's Lua 5.0 errors with `attempt to index a string value`. Works on literals and variables, in-world and on the login screen, and inside coroutines. See [String methods](docs/API.md#string-methods-supper-sformat). |
|
||||
| Script-handler arguments | Frame-script handlers receive their values as positional arguments — `OnMouseWheel(self, delta)`, `OnClick(self, button)`, `OnEvent(self, event, ...)`, etc. — the way 5.1+ clients do, so modern addon ports work unmodified. The `this` / `arg1` globals stay set. A handler that declares no parameters is unaffected. A handler that declared a parameter and expected nil now receives its real value. On by default. `SetModernScriptArgs(false)` restores exact vanilla dispatch. See [SetModernScriptArgs](docs/API.md#setmodernscriptargsenable--getmodernscriptargs). |
|
||||
| [Syntax](docs/API.md#lua-51-syntax) | Compiles the Lua 5.1 length (`#`), modulo (`%`), `...`-expression, `0x` hex-literal, and leveled long bracket (`[=[ ]=]`) syntax that vanilla's Lua 5.0 rejects, by rewriting addon source before it compiles. Each addon file also receives its `(name, table)` through `...` (`local name, tbl = ...`). |
|
||||
| [String methods](docs/API.md#string-methods-supper-sformat) | Every string value accepts method calls — `("asd"):upper()`, `("%d gold"):format(n)`, `msg:match("^!(%w+)")` — resolving through the `string` table, the way Lua 5.1 works. Works on literals and variables, in-world and on the login screen, and inside coroutines. |
|
||||
| [Script-handler arguments](docs/API.md#setmodernscriptargsenable--getmodernscriptargs) | Frame-script handlers receive their values as positional arguments — `OnMouseWheel(self, delta)`, `OnClick(self, button)`, `OnEvent(self, event, ...)`, etc. — so modern addon ports work unmodified. The `this` / `arg1` globals stay set. A handler that declares no parameters is unaffected. A handler that declared a parameter and expected nil now receives its real value. On by default. `SetModernScriptArgs(false)` restores exact vanilla dispatch. |
|
||||
|
||||
### Modern client behaviors
|
||||
|
||||
| Feature | Effect |
|
||||
|---------|--------|
|
||||
| Inline textures | Draws inline texture markup (`\|T…\|t`) as icons in FontStrings, chat, and tooltips, the way 4.3.4+ clients do. Vanilla 1.12 shows the raw escape as literal text instead. This covers item and spell icons, raid-target markers, and the coin icons in money strings. `GetStringWidth` and `GetStringHeight` count the icons, so measured width and text wrapping stay correct. Done in pure C++ by hooking the engine's text pipeline — no addon. |
|
||||
| Inline textures | Draws inline texture markup (`\|T…\|t`) and atlas markup (`\|A…\|a`) as icons in FontStrings, chat, and tooltips. This covers item and spell icons, raid-target markers, and the coin icons in money strings. `GetStringWidth` and `GetStringHeight` count the icons, so measured width and text wrapping stay correct. Done in pure C++ by hooking the engine's text pipeline — no addon. |
|
||||
| Tooltip line cap | Lifts `GameTooltip`'s hard 30-line limit to 60 for every `GameTooltipTemplate` frame (`GameTooltip`, `ShoppingTooltip1/2`, `ItemRefTooltip`, AtlasLoot, …). Stat-heavy tooltips and comparison blocks (e.g. pfUI's eqcompare) no longer have their extra lines silently dropped. Done in pure C++ by growing the engine's FontString pool at tooltip-creation time. |
|
||||
| Event-driven nameplates | The modern `C_NamePlate` API, driven by real events instead of vanilla frame-scraping. `NAME_PLATE_UNIT_ADDED` / `NAME_PLATE_UNIT_REMOVED` fire as plates appear and vanish, `nameplate1`..`nameplateN` tokens resolve with every `UnitX` function (and fire `UNIT_HEALTH`, `UNIT_AURA`, … as `arg1 == "nameplateN"`), and `C_NamePlate.GetNamePlateForUnit` / `GetNamePlates` hand back the live frames. See [NamePlate](docs/API.md#nameplate). |
|
||||
| Focus target | A sticky focus unit, like later clients. `FocusUnit("target")` pins it and `ClearFocus()` drops it, with `PLAYER_FOCUS_CHANGED` on every change. The `focus` / `focustarget` tokens resolve with every `UnitX` function and fire unit events (`UNIT_HEALTH`, `UNIT_AURA`, … as `arg1 == "focus"`), and the predefined `FOCUSTARGET` / `TARGETFOCUS` keybinds are ready to set. See [Focus](docs/API.md#focus). |
|
||||
| Multi-flavor & conditional TOC | Loads modern multi-flavor addons that ship one folder. Selects a version-specific TOC (`<Name>_ClassicAPI.toc` or `<Name>_Turtle.toc`) and the matching keybinding file (`Bindings_ClassicAPI.xml` / `Bindings_Turtle.xml`), accepts a comma-separated `## Interface:` version list (compatible when it includes the client version `11200`), and honors per-line `[AllowLoadGameType]` / `[AllowLoadTextLocale]` conditions and `[Family]` / `[Game]` / `[TextLocale]` path variables inside a TOC. See [Conditional and multi-flavor TOC loading](docs/API.md#conditional-and-multi-flavor-toc-loading). |
|
||||
| [Event-driven nameplates](docs/API.md#nameplate) | The modern `C_NamePlate` API, driven by real events. `NAME_PLATE_UNIT_ADDED` / `NAME_PLATE_UNIT_REMOVED` fire as plates appear and vanish, `nameplate1`..`nameplateN` tokens resolve with every `UnitX` function (and fire `UNIT_HEALTH`, `UNIT_AURA`, … as `arg1 == "nameplateN"`), and `C_NamePlate.GetNamePlateForUnit` / `GetNamePlates` hand back the live frames. |
|
||||
| [Focus target](docs/API.md#focus) | A sticky focus unit. `FocusUnit("target")` pins it and `ClearFocus()` drops it, with `PLAYER_FOCUS_CHANGED` on every change. The `focus` / `focustarget` tokens resolve with every `UnitX` function and fire unit events (`UNIT_HEALTH`, `UNIT_AURA`, … as `arg1 == "focus"`), and the predefined `FOCUSTARGET` / `TARGETFOCUS` keybinds are ready to set. |
|
||||
| [Retail-like `/reload`](docs/API.md#reload-picks-up-new-addons-and-new-files) | `/reload` picks up addon changes made while the game runs. A new folder under `Interface\AddOns\` registers and loads as a normal addon, new files added to an existing addon's TOC load, a newly installed addon's first SavedVariables save survives `/reload`, `##` metadata edits (`## SavedVariables:`, `## Dependencies:`, `## Title:`, …) take effect, and a deleted addon folder drops from the addon list. |
|
||||
| [Multi-flavor & conditional TOC](docs/API.md#conditional-and-multi-flavor-toc-loading) | Loads modern multi-flavor addons that ship one folder. Selects a version-specific TOC (`<Name>_ClassicAPI.toc` or `<Name>_Turtle.toc`) and the matching keybinding file (`Bindings_ClassicAPI.xml` / `Bindings_Turtle.xml`), accepts a comma-separated `## Interface:` version list (compatible when it includes the client version `11200`), and honors per-line `[AllowLoadGameType]` / `[AllowLoadTextLocale]` conditions and `[Family]` / `[Game]` / `[TextLocale]` path variables inside a TOC. |
|
||||
| Settings profiles | A `-config <name>` launch switch. The client reads and writes `WTF\<name>` in place of `WTF\Config.wtf`, so one install can hold several settings profiles. Add it to the launcher shortcut, for example `VanillaFixes.exe -config Config2.wtf`. The name must be a plain filename, with no folder in it. On first use the new file does not exist yet, so the client starts from its defaults and writes the file when you quit. |
|
||||
| [Any texture size](docs/API.md#texture-size-and-shape) | Textures load at any size and any shape. Width and height do not need to be powers of two, and there is no fixed upper limit. The only limit is the maximum texture size of your graphics card. This applies to every texture path: `SetTexture`, sprite sheets, inline textures, tooltips, and masks. The client allocates memory for a large texture only when an addon loads one, so small textures cost nothing extra. |
|
||||
|
||||
## Full API reference
|
||||
|
||||
@@ -47,15 +50,14 @@ reference in **[docs/API.md](docs/API.md)**.
|
||||
| Namespace | Calls |
|
||||
|-----------|-------|
|
||||
| [Action](docs/API.md#action) | `GetActionInfo` |
|
||||
| [AddOns](docs/API.md#addons) | `C_AddOns.DoesAddOnExist`, `C_AddOns.GetAddOnLocalTable`, `C_AddOns.GetAddOnName`, `C_AddOns.GetAddOnNotes`, `C_AddOns.GetAddOnOptionalDependencies`, `C_AddOns.GetAddOnSecurity`, `C_AddOns.GetAddOnTitle`, `C_AddOns.IsAddOnLoadable`, `C_AddOns.IsAddOnLoaded` |
|
||||
| [AddOns](docs/API.md#addons) | `C_AddOns.DoesAddOnExist`, `C_AddOns.GetAddOnLocalTable`, `C_AddOns.GetAddOnName`, `C_AddOns.GetAddOnNotes`, `C_AddOns.GetAddOnOptionalDependencies`, `C_AddOns.GetAddOnSecurity`, `C_AddOns.GetAddOnTitle`, `C_AddOns.IsAddOnLoadable`, `C_AddOns.IsAddOnLoaded`, `C_AddOns.LoadAddOn` |
|
||||
| [AuctionHouse](docs/API.md#auctionhouse) | `C_AuctionHouse.PostItem` |
|
||||
| [AuraUtil](docs/API.md#aurautil) | `AuraUtil.FindAura`, `AuraUtil.FindAuraByName`, `AuraUtil.ForEachAura`, `AuraUtil.UnpackAuraData` |
|
||||
| [Bindings](docs/API.md#bindings) | `SetBindingSpell`, `SetBindingItem`, `SetBindingMacro`, `SetBindingClick`, `SetOverrideBinding`, `SetOverrideBindingSpell`, `SetOverrideBindingItem`, `SetOverrideBindingMacro`, `SetOverrideBindingClick`, `ClearOverrideBindings` |
|
||||
| [Chat](docs/API.md#chat) | `GetCurrentChatGUID` |
|
||||
| [ChatBubbles](docs/API.md#chatbubbles) | `C_ChatBubbles.GetAllChatBubbles` |
|
||||
| [Class](docs/API.md#class) | `FillLocalizedClassList` |
|
||||
| [ColorUtil](docs/API.md#colorutil) | `C_ColorUtil.ConvertRGBToHSV`, `C_ColorUtil.ConvertHSVToRGB`, `C_ColorUtil.ConvertHSVToHSL`, `C_ColorUtil.ConvertHSLToHSV`, `C_ColorUtil.ConvertHSLToRGB`, `C_ColorUtil.GenerateTextColorCode`, `C_ColorUtil.WrapTextInColor`, `C_ColorUtil.WrapTextInColorCode` |
|
||||
| [Combat](docs/API.md#combat) | `InCombatLockdown` |
|
||||
| [Combat](docs/API.md#combat) | `InCombatLockdown`, `StartAttack`, `StopAttack` |
|
||||
| [CVar](docs/API.md#cvar) | `C_CVar.GetCVarBool` |
|
||||
| [Cursor](docs/API.md#cursor) | `GetCursorInfo` |
|
||||
| [Container](docs/API.md#container) | `C_Container.CalculateTotalNumberOfFreeBagSlots`, `C_Container.GetContainerItemCharges`, `C_Container.GetContainerItemDurability`, `C_Container.GetContainerItemID`, `C_Container.GetContainerItemInfo`, `C_Container.GetContainerItemRepairCost`, `C_Container.GetContainerNumFreeSlots`, `C_Container.GetItemCooldown`, `C_Container.HasContainerItem`, `C_Container.IsContainerItemOpenable`, `C_Container.MoveItem`, `C_Container.PlayerHasHearthstone`, `C_Container.SwapItems`, `C_Container.UseHearthstone`, `GetItemCooldown` |
|
||||
@@ -65,9 +67,9 @@ reference in **[docs/API.md](docs/API.md)**.
|
||||
| [EquipmentSet](docs/API.md#equipmentset) | `C_EquipmentSet.CanUseEquipmentSets`, `C_EquipmentSet.ClearIgnoredSlotsForSave`, `C_EquipmentSet.CreateEquipmentSet`, `C_EquipmentSet.DeleteEquipmentSet`, `C_EquipmentSet.EquipmentSetContainsLockedItems`, `C_EquipmentSet.GetEquipmentSetID`, `C_EquipmentSet.GetEquipmentSetIDs`, `C_EquipmentSet.GetEquipmentSetInfo`, `C_EquipmentSet.GetIgnoredSlots`, `C_EquipmentSet.GetItemIDs`, `C_EquipmentSet.GetItemLocations`, `C_EquipmentSet.GetNumEquipmentSets`, `C_EquipmentSet.IgnoreSlotForSave`, `C_EquipmentSet.IsSlotIgnoredForSave`, `C_EquipmentSet.ModifyEquipmentSet`, `C_EquipmentSet.SaveEquipmentSet`, `C_EquipmentSet.UnignoreSlotForSave`, `C_EquipmentSet.UseEquipmentSet` |
|
||||
| [Events](docs/API.md#events) | `C_EventUtils.IsEventValid`, `GetFramesRegisteredForEvent` |
|
||||
| [Expansion](docs/API.md#expansion) | `ClassicExpansionAtLeast`, `ClassicExpansionAtMost`, `GetClassicExpansionLevel` |
|
||||
| [Faction](docs/API.md#faction) | `C_Reputation.GetFactionDataByIndex`, `C_Reputation.GetFactionStandings`, `C_Reputation.GetLastStandingChange`, `C_Reputation.GetWatchedFactionData`, `C_Reputation.SetWatchedFactionByID`, `GetFactionIDByIndex`, `GetFactionInfoByID`, `GetFactionParentID` |
|
||||
| [Faction](docs/API.md#faction) | `C_Reputation.GetFactionDataByID`, `C_Reputation.GetFactionDataByIndex`, `C_Reputation.GetFactionStandings`, `C_Reputation.GetLastStandingChange`, `C_Reputation.GetWatchedFactionData`, `C_Reputation.IsFactionActive`, `C_Reputation.IsFactionActiveByID`, `C_Reputation.IsFactionInactive`, `C_Reputation.SetFactionActive`, `C_Reputation.SetFactionActiveByID`, `C_Reputation.SetFactionInactive`, `C_Reputation.SetFactionInactiveByID`, `C_Reputation.SetSelectedFaction`, `C_Reputation.SetSelectedFactionByID`, `C_Reputation.SetWatchedFactionByID`, `C_Reputation.ToggleFactionAtWar`, `C_Reputation.ToggleFactionAtWarByID`, `GetFactionIDByIndex`, `GetFactionInfoByID`, `GetFactionParentID` |
|
||||
| [Focus](docs/API.md#focus) | `ClearFocus`, `FocusUnit` |
|
||||
| [Frame](docs/API.md#frame) | `region:SetPoint("point")` (one-arg form), `region:SetSize`, `region:GetSize`, `region:IsMouseOver`, `region:GetRect`, `region:IsDragging`, `GetMouseFoci`, `frame:SetShown`, `fontstring:GetStringHeight`, `fontstring:GetUnboundedStringWidth`, `fontstring:GetWrappedWidth`, `fontstring:GetNumLines`, `fontstring:GetLineHeight`, `fontstring:IsTruncated`, `fontstring:SetMaxLines`, `fontstring:GetMaxLines`, `fontstring:SetFormattedText`, `texture:SetRotation`, `texture:GetRotation`, `texture:SetVertexOffset`, `texture:GetVertexOffset`, `texture:SetColorTexture`, `texture:SetMask`, `frame:CreateMaskTexture`, `texture:AddMaskTexture`, `texture:RemoveMaskTexture`, `texture:GetNumMaskTextures`, `texture:GetMaskTexture`, `fontstring:SetRotation`, `fontstring:GetRotation`, `frame:SetResizeBounds`, `frame:HookScript`, `frame:IsEventRegistered`, `frame:GetEffectiveAlpha`, `frame:SetAttribute`, `frame:SetAttributeNoHandler`, `frame:ClearAttribute`, `frame:GetAttribute`, `OnAttributeChanged` (script), `SetModernScriptArgs`, `GetModernScriptArgs`, `SecureCmdOptionParse`, `RegisterStateDriver`, `UnregisterStateDriver`, `RegisterAttributeDriver`, `UnregisterAttributeDriver`, `RegisterUnitWatch`, `UnregisterUnitWatch`, `UnitWatchRegistered`, `SecureButton_GetAttribute`, `SecureButton_GetUnit` |
|
||||
| [Frame](docs/API.md#frame) | `region:SetPoint("point")` (one-arg form), `region:SetSize`, `region:GetSize`, `region:IsMouseOver`, `region:GetRect`, `region:IsDragging`, `GetMouseFoci`, `frame:SetShown`, `fontstring:GetStringHeight`, `fontstring:GetUnboundedStringWidth`, `fontstring:GetWrappedWidth`, `fontstring:GetNumLines`, `fontstring:GetLineHeight`, `fontstring:IsTruncated`, `fontstring:SetMaxLines`, `fontstring:GetMaxLines`, `fontstring:SetFormattedText`, `texture:SetRotation`, `texture:GetRotation`, `texture:SetVertexOffset`, `texture:GetVertexOffset`, `texture:SetColorTexture`, `texture:SetAtlas`, `texture:GetAtlas`, `texture:SetSpriteSheetCell`, `texture:SetMask`, `frame:CreateMaskTexture`, `texture:AddMaskTexture`, `texture:RemoveMaskTexture`, `texture:GetNumMaskTextures`, `texture:GetMaskTexture`, `fontstring:SetRotation`, `fontstring:GetRotation`, `editBox:SetCursorPosition`, `editBox:GetCursorPosition`, `editBox:GetUTF8CursorPosition`, `editBox:ClearHighlightText`, `editBox:HasFocus`, `editBox:HasText`, `editBox:SetHighlightColor`, `editBox:GetHighlightColor`, `editBox:ClearHistory`, `frame:SetResizeBounds`, `frame:HookScript`, `frame:IsEventRegistered`, `frame:GetEffectiveAlpha`, `frame:SetAttribute`, `frame:SetAttributeNoHandler`, `frame:ClearAttribute`, `frame:GetAttribute`, `OnAttributeChanged` (script), `SetModernScriptArgs`, `GetModernScriptArgs`, `PreClick` (script), `PostClick` (script), `GetClickFrame` |
|
||||
| [FriendList](docs/API.md#friendlist) | `C_FriendList.GetFriendInfo`, `C_FriendList.GetFriendInfoByIndex`, `C_FriendList.GetNumFriends`, `C_FriendList.GetNumOnlineFriends`, `C_FriendList.GetNumWhoResults`, `C_FriendList.GetWhoInfo`, `C_FriendList.IsFriend`, `C_FriendList.IsIgnored`, `C_FriendList.IsIgnoredByGuid`, `C_FriendList.IsWhoQueryPending`, `C_FriendList.SendWhoQueryByName`, `C_FriendList.SetFriendNotes`, `C_FriendList.SetFriendNotesByIndex` |
|
||||
| [GameObject](docs/API.md#gameobject) | `C_GameObjectInfo.GetGameObjectInfoByID`, `C_GameObjectInfo.RequestLoadGameObjectByID`, `ClosestGameObjectPosition` |
|
||||
| [Glue](docs/API.md#glue) | `C_Glue.IsOnGlueScreen` |
|
||||
@@ -83,7 +85,7 @@ reference in **[docs/API.md](docs/API.md)**.
|
||||
| [Lua](docs/API.md#lua) | `collectgarbage` (5.1 options), `coroutine.create`, `coroutine.resume`, `coroutine.running`, `coroutine.status`, `coroutine.wrap`, `coroutine.yield`, `CreateFromMixins`, `math.fmod`, `math.huge`, `math.modf`, `Mixin`, `select`, `string.gmatch`, `string.gsub` (table replacement), `string.match`, `string.reverse`, `strjoin`, `strreplace`, `strrev`, `strsplit`, `strtrim`, `table.count`, `table.maxn`, `table.wipe`, `unpack` (range args) |
|
||||
| [Macros](docs/API.md#macros) | `C_Macro.CreateMacro`, `C_Macro.EditMacro`, `GetLooseMacroIcons`, `GetLooseMacroItemIcons`, `GetMacroIcons`, `GetMacroItemIcons`, `GetMacroSpell` |
|
||||
| [Mail](docs/API.md#mail) | `GetInboxItemLink`, `GetSendMailItemLink` |
|
||||
| [Map](docs/API.md#map) | `C_Map.GetAreaInfo`, `C_Map.GetAreas`, `C_Map.GetAreaTriggerInfo`, `C_Map.GetAreaTriggers`, `C_Map.GetBestMapForUnit`, `C_Map.GetMapAreaIDs`, `C_Map.GetMapOverlays`, `C_Map.GetMapWorldSize` |
|
||||
| [Map](docs/API.md#map) | `C_Map.CanSetUserWaypointOnMap`, `C_Map.ClearUserWaypoint`, `C_Map.GetAreaInfo`, `C_Map.GetAreas`, `C_Map.GetAreaTriggerInfo`, `C_Map.GetAreaTriggers`, `C_Map.GetBestMapForUnit`, `C_Map.GetFallbackWorldMapID`, `C_Map.GetMapAreaIDs`, `C_Map.GetMapArtLayers`, `C_Map.GetMapArtLayerTextures`, `C_Map.GetMapChildrenInfo`, `C_Map.GetMapInfo`, `C_Map.GetMapInfoAtPosition`, `C_Map.GetMapOverlays`, `C_Map.GetMapPosFromWorldPos`, `C_Map.GetMapRectOnMap`, `C_Map.GetMapWorldSize`, `C_Map.GetPlayerMapPosition`, `C_Map.GetUserWaypoint`, `C_Map.GetUserWaypointFromHyperlink`, `C_Map.GetUserWaypointHyperlink`, `C_Map.GetUserWaypointPositionForMap`, `C_Map.GetWorldPosFromMapPos`, `C_Map.HasUserWaypoint`, `C_Map.MapHasArt`, `C_Map.SetUserWaypoint` |
|
||||
| [MapExplorationInfo](docs/API.md#mapexplorationinfo) | `C_MapExplorationInfo.GetExploredMapTextures`, `C_MapExplorationInfo.GetUnexploredMapTextures` |
|
||||
| [MerchantFrame](docs/API.md#merchantframe) | `C_MerchantFrame.GetBuybackItemID`, `C_MerchantFrame.GetItemInfo`, `C_MerchantFrame.GetNumJunkItems`, `C_MerchantFrame.IsMerchantItemRefundable`, `C_MerchantFrame.IsSellAllJunkEnabled`, `C_MerchantFrame.SellAllJunkItems` |
|
||||
| [NamePlate](docs/API.md#nameplate) | `C_NamePlate.GetNamePlateForGUID`, `C_NamePlate.GetNamePlateForUnit`, `C_NamePlate.GetNamePlateGUIDs`, `C_NamePlate.GetNamePlates` |
|
||||
@@ -93,17 +95,19 @@ reference in **[docs/API.md](docs/API.md)**.
|
||||
| [Quest](docs/API.md#quest) | `C_QuestLog.GetNumQuestObjectives`, `C_QuestLog.GetQuestDetails`, `C_QuestLog.GetHeaderIndexForQuest`, `C_QuestLog.GetLogIndexForQuestID`, `C_QuestLog.GetQuestIDForLogIndex`, `C_QuestLog.GetTitleForQuestID`, `C_QuestLog.IsOnQuest`, `C_QuestLog.IsQuestDataCachedByID`, `C_QuestLog.IsUnitOnQuest`, `C_QuestLog.RequestLoadQuestByID`, `GetQuestLogLeaderBoardID` |
|
||||
| [Spell](docs/API.md#spell) | `C_Spell.CancelSpellByID`, `C_Spell.CastAtCursor`, `C_Spell.CastAtUnit`, `C_Spell.CastingInfo`, `C_Spell.ChannelInfo`, `C_Spell.DoesSpellExist`, `C_Spell.GetSchoolString`, `C_Spell.GetSpellCooldown`, `C_Spell.GetSpellDescription`, `C_Spell.GetSpellDispelType`, `C_Spell.GetSpellEffectMechanics`, `C_Spell.GetSpellInfo`, `C_Spell.GetSpellLevelInfo`, `C_Spell.GetSpellLink`, `C_Spell.GetSpellMechanicByID`, `C_Spell.GetSpellName`, `C_Spell.GetSpellPowerCost`, `C_Spell.GetSpellRadius`, `C_Spell.GetSpellReagents`, `C_Spell.GetSpellRequiredTargetLevel`, `C_Spell.GetSpellSubtext`, `C_Spell.GetSpellTexture`, `C_Spell.IsAutoAttackSpell`, `C_Spell.IsCurrentSpell`, `C_Spell.IsNextMeleeSpell`, `C_Spell.IsRangedAutoAttackSpell`, `C_Spell.IsSelfBuff`, `C_Spell.IsSpellHarmful`, `C_Spell.IsSpellHelpful`, `C_Spell.IsSpellInRange`, `C_Spell.IsSpellPassive`, `C_Spell.IsSpellUsable`, `C_Spell.ResetsMeleeSwing`, `C_Spell.SpellHasRange`, `C_Spell.UnitCastingInfo`, `C_Spell.UnitChannelInfo`, `CanDualWield`, `CancelSpellByName`, `CastSpellNoToggle`, `GetCraftSpellID`, `GetSpellBonusDamage`, `GetSpellBonusHealing`, `GetSpellInfo`, `GetSpellLink`, `GetSpellRadius`, `GetSpellRequiredTargetLevel`, `GetSpellSchool`, `IsHarmfulSpell`, `IsHelpfulSpell`, `IsPassiveSpell`, `IsPlayerSpell`, `IsSpellKnown`, `IsUsableSpell`, `SpellHasRange` |
|
||||
| [SpellBook](docs/API.md#spellbook) | `C_SpellBook.GetCurrentLevelSpells`, `C_SpellBook.GetSkillLineName`, `C_SpellBook.GetSkillLineRank`, `C_SpellBook.GetSpellBookItemInfo`, `C_SpellBook.GetSpellLevelLearned`, `C_SpellBook.GetSpellSkillLine`, `C_SpellBook.IsAutoAttackSpellBookItem`, `C_SpellBook.IsRangedAutoAttackSpellBookItem`, `FindSpellBookSlotByID` |
|
||||
| [State](docs/API.md#state) | `CancelShapeshiftForm`, `Dismount`, `GetMirrorTimerInfo`, `GetMirrorTimerProgress`, `GetShapeshiftFormID`, `IsAssistingRitual`, `IsFalling`, `IsIndoors`, `IsInGroup`, `IsInRaid`, `IsLoggedIn`, `IsMounted`, `IsOutdoors`, `IsStealthed`, `IsSwimming` |
|
||||
| [State](docs/API.md#state) | `CancelShapeshiftForm`, `Dismount`, `GetMirrorTimerInfo`, `GetMirrorTimerProgress`, `GetShapeshiftFormID`, `GetSheathState`, `IsAssistingRitual`, `IsFalling`, `IsIndoors`, `IsInGroup`, `IsInRaid`, `IsLoggedIn`, `IsMounted`, `IsOutdoors`, `IsStealthed`, `IsSwimming` |
|
||||
| [System](docs/API.md#system) | `CopyToClipboard`, `GetPhysicalScreenSize` |
|
||||
| [Talent](docs/API.md#talent) | `GetTalentIDByIndex`, `GetTalentSpellID` |
|
||||
| [Targeting](docs/API.md#targeting) | `GetPlayerFacing`, `TargetDirectionEnemy`, `TargetDirectionFriend`, `TargetNearest`, `TargetNearestEnemyPlayer`, `TargetNearestFriendPlayer` |
|
||||
| [TaxiMap](docs/API.md#taximap) | `C_TaxiMap.GetTaxiNodesForMap`, `C_TaxiMap.GetAllTaxiNodes`, `C_TaxiMap.GetTaxiPaths`, `C_TaxiMap.GetTaxiPathWaypoints`, `C_TaxiMap.GetTaxiRoute` |
|
||||
| [Texture](docs/API.md#texture) | `C_Texture.GetAtlasElementID`, `C_Texture.GetAtlasElements`, `C_Texture.GetAtlasExists`, `C_Texture.GetAtlasID`, `C_Texture.GetAtlasInfo`, `C_Texture.RegisterAtlas` |
|
||||
| [Time](docs/API.md#time) | `C_DateAndTime.AdjustTimeByDays`, `C_DateAndTime.AdjustTimeByMinutes`, `C_DateAndTime.CompareCalendarTime`, `C_DateAndTime.GetCalendarTimeFromEpoch`, `C_DateAndTime.GetCurrentCalendarTime`, `C_DateAndTime.GetSecondsUntilDailyReset`, `C_DateAndTime.GetServerTimeLocal`, `C_Timer.After`, `C_Timer.NewTicker`, `C_Timer.NewTimer`, `GetServerTime`, `GetTimeCached` |
|
||||
| [Totem](docs/API.md#totem) | `GetTotemInfo`, `GetTotemTimeLeft`, `GetTotemDuration`, `TargetTotem` |
|
||||
| [Tracking](docs/API.md#tracking) | `GetNumTrackingTypes`, `GetTrackingInfo`, `SetTracking` |
|
||||
| [TradeSkillUI](docs/API.md#tradeskillui) | `C_TradeSkillUI.GetTradeSkillListLink`, `C_TradeSkillUI.GetCraftListLink`, `C_TradeSkillUI.GetTradeSkillListRecipes` |
|
||||
| [UIColor](docs/API.md#uicolor) | `C_UIColor.GetColors` |
|
||||
| [Unit](docs/API.md#unit) | `ClosestUnitPosition`, `GetUnitSpeed`, `UnitClassBase`, `UnitCreatureFamilyID`, `UnitCreatureID`, `UnitCreatureTypeID`, `UnitDistanceSquared`, `UnitGUID`, `UnitHealthMissing`, `UnitInLineOfSight`, `UnitInRange`, `UnitIsAFK`, `UnitIsDND`, `UnitIsFeignDeath`, `UnitIsInMyGuild`, `UnitIsMinion`, `UnitIsOtherPlayersPet`, `UnitIsPet`, `UnitIsPossessed`, `UnitOwnerGUID`, `UnitPosition`, `UnitPower`, `UnitPowerMax`, `UnitPowerMissing`, `UnitPowerType`, `UnitRaceBase`, `UnitSpellHaste`, `UnitSpellTargetName`, `UnitStandState`, `UnitSubName`, `UnitTokenFromGUID` |
|
||||
| [UnitAuras](docs/API.md#unitauras) | `C_UnitAuras.GetAuraDataByIndex`, `C_UnitAuras.GetAuraDataBySpellName`, `C_UnitAuras.GetAuraDispelTypeColor`, `C_UnitAuras.GetBuffDataByIndex`, `C_UnitAuras.GetDebuffDataByIndex`, `C_UnitAuras.GetPlayerAuraBySpellID`, `C_UnitAuras.GetUnitAuraBySpellID`, `C_UnitAuras.GetUnitAuras`, `C_UnitAuras.RegisterAuraDurationModifierByTrigger`, `C_UnitAuras.RegisterComboDuration`, `C_UnitAuras.UnitAura`, `C_UnitAuras.UnitBuff`, `C_UnitAuras.UnitDebuff` |
|
||||
| [Unit](docs/API.md#unit) | `ClosestUnitPosition`, `GetUnitSpeed`, `UnitClassBase`, `UnitCreatedBySpell`, `UnitCreatureFamilyID`, `UnitCreatureID`, `UnitCreatureTypeID`, `UnitDistanceSquared`, `UnitGUID`, `UnitHealthMissing`, `UnitInLineOfSight`, `UnitInRange`, `UnitIsAFK`, `UnitIsDND`, `UnitIsFeignDeath`, `UnitIsInMyGuild`, `UnitIsMinion`, `UnitIsOtherPlayersPet`, `UnitIsPet`, `UnitIsPossessed`, `UnitOwnerGUID`, `UnitPosition`, `UnitPower`, `UnitPowerMax`, `UnitPowerMissing`, `UnitPowerType`, `UnitRaceBase`, `UnitSpellHaste`, `UnitSpellTargetName`, `UnitStandState`, `UnitSubName`, `UnitTokenFromGUID` |
|
||||
| [UnitAuras](docs/API.md#unitauras) | `C_UnitAuras.GetAuraDataByIndex`, `C_UnitAuras.GetAuraDataBySlot`, `C_UnitAuras.GetAuraDataBySpellName`, `C_UnitAuras.GetAuraDispelTypeColor`, `C_UnitAuras.GetAuraSlots`, `C_UnitAuras.GetBuffDataByIndex`, `C_UnitAuras.GetDebuffDataByIndex`, `C_UnitAuras.GetPlayerAuraBySpellID`, `C_UnitAuras.GetUnitAuraBySpellID`, `C_UnitAuras.GetUnitAuras`, `C_UnitAuras.RegisterAuraDurationModifierByTrigger`, `C_UnitAuras.RegisterComboDuration`, `C_UnitAuras.UnitAura`, `C_UnitAuras.UnitAuraBySlot`, `C_UnitAuras.UnitBuff`, `C_UnitAuras.UnitDebuff` |
|
||||
| [VoiceChat](docs/API.md#voicechat) | `C_VoiceChat.GetTtsVoices`, `C_VoiceChat.GetRemoteTtsVoices`, `C_VoiceChat.SpeakText`, `C_VoiceChat.StopSpeakingText`, `C_TTSSettings.GetSpeechRate`, `C_TTSSettings.GetSpeechVolume`, `C_TTSSettings.GetSpeechVoiceID`, `C_TTSSettings.GetVoiceOptionName`, `C_TTSSettings.SetSpeechRate`, `C_TTSSettings.SetSpeechVolume`, `C_TTSSettings.SetVoiceOption`, `C_TTSSettings.SetVoiceOptionByName`, `C_TTSSettings.SetDefaultSettings`, `C_TTSSettings.RefreshVoices` |
|
||||
| [XMLUtil](docs/API.md#xmlutil) | `C_XMLUtil.DoesTemplateExist`, `C_XMLUtil.GetTemplateInfo`, `C_XMLUtil.GetTemplates` |
|
||||
|
||||
@@ -115,9 +119,7 @@ reference in **[docs/API.md](docs/API.md)**.
|
||||
Registered on the **glue** Lua state (the engine that runs the login,
|
||||
realm-select, and character-select screens). The persistence entries
|
||||
in the first row are *glue-only* — they exist to support GlueXML
|
||||
patches that need a small persistence surface across sessions, since
|
||||
vanilla 1.12 glue ships no general-purpose persistence API beyond
|
||||
`GetSavedAccountName`/`SetSavedAccountName` (saturated by autologin).
|
||||
patches that need a small persistence surface across sessions.
|
||||
The rest of the table is in-game calls that we also mirror onto
|
||||
the glue state because GlueXML had no way to reach them otherwise.
|
||||
|
||||
@@ -136,7 +138,7 @@ the glue state because GlueXML had no way to reach them otherwise.
|
||||
<summary><b>Macros</b> — engine-level parsing extensions</summary>
|
||||
|
||||
Engine-level extensions to macro parsing and dispatch — no new Lua
|
||||
functions, just behavior the stock 1.12 engine didn't have. See the
|
||||
functions. See the
|
||||
[Macros section in the Lua reference](docs/API.md#macros) for details.
|
||||
|
||||
| Form | What it does |
|
||||
@@ -214,6 +216,7 @@ when launching with `-console`), not as Lua functions. See the
|
||||
| `UNIT_SPELLCAST_RETICLE_CLEAR` | `"player", "", spellID, spellName, rank` |
|
||||
| `UPDATE_INVENTORY_DURABILITY` | *(none)* |
|
||||
| `UPDATE_SHAPESHIFT_FORM` | *(none)* |
|
||||
| `USER_WAYPOINT_UPDATED` | *(none)* |
|
||||
| `VOICE_CHAT_TTS_PLAYBACK_STARTED` | `numConsumers, utteranceID, durationMS, destination` |
|
||||
| `VOICE_CHAT_TTS_PLAYBACK_FINISHED` | `numConsumers, utteranceID, destination` |
|
||||
| `VOICE_CHAT_TTS_PLAYBACK_FAILED` | `status, utteranceID, destination` |
|
||||
@@ -226,7 +229,7 @@ when launching with `-console`), not as Lua functions. See the
|
||||
|
||||
| Group | Constants |
|
||||
|-------|-----------|
|
||||
| Version | `CLASSIC_API_VERSION` |
|
||||
| Version | `CLASSIC_API_VERSION`, `INTERFACE_VERSION` |
|
||||
| Expansion | `LE_EXPANSION_LEVEL_CURRENT`, `LE_EXPANSION_CLASSIC` … `LE_EXPANSION_MIDNIGHT` |
|
||||
| Item quality | `LE_ITEM_QUALITY_POOR` … `LE_ITEM_QUALITY_WOWTOKEN` |
|
||||
| Unit stat | `LE_UNIT_STAT_STRENGTH` … `LE_UNIT_STAT_SPIRIT` |
|
||||
@@ -254,7 +257,7 @@ when launching with `-console`), not as Lua functions. See the
|
||||
<details>
|
||||
<summary><b>Bindings</b> — direct-action and override binding families</summary>
|
||||
|
||||
ClassicAPI backports the later-client direct-action and temporary override
|
||||
ClassicAPI backports the direct-action and temporary override
|
||||
binding families. Permanent bindings use the standard `SPELL`, `ITEM`,
|
||||
`MACRO`, and `CLICK` command strings and can be saved normally. Overrides are
|
||||
session-only, frame-owned, and support the usual priority flag.
|
||||
@@ -335,7 +338,7 @@ identically in both cases.
|
||||
## Bundled addon: DebugTools
|
||||
|
||||
A 1.12.1 / Lua 5.0 backport of Blizzard's `Blizzard_DebugTools` addon
|
||||
(originally shipped with the 3.0 client) lives in [`AddOns/DebugTools/`](AddOns/DebugTools/).
|
||||
lives in [`AddOns/DebugTools/`](AddOns/DebugTools/).
|
||||
It's an independent addon — it doesn't use anything ClassicAPI adds, and
|
||||
ClassicAPI works fine without it. It's bundled here because it's the
|
||||
natural companion for testing and debugging anything written against the
|
||||
@@ -350,12 +353,11 @@ Slash commands provided:
|
||||
| `/framestack` (or `/fstack`) | Tooltip showing the frame hierarchy under the mouse cursor. |
|
||||
| `/luaerrors` (or `/scripterrors`) | Lua error display window. |
|
||||
|
||||
Lua globals provided (backports of 3.3.5 helpers that don't exist in
|
||||
1.12):
|
||||
Lua globals provided (backports of Blizzard helpers):
|
||||
|
||||
| Global | Purpose |
|
||||
|--------|---------|
|
||||
| `print(...)` | Backport of the 3.3.5 `print` — concats varargs with `" "` and pushes to `DEFAULT_CHAT_FRAME`. Routes through `setprinthandler`'s handler; falls back via `geterrorhandler` if the handler errors. |
|
||||
| `print(...)` | Backport of Blizzard's `print` — concats varargs with `" "` and pushes to `DEFAULT_CHAT_FRAME`. Routes through `setprinthandler`'s handler; falls back via `geterrorhandler` if the handler errors. |
|
||||
| `setprinthandler(func)` / `getprinthandler()` | Install / query a custom print handler. Useful for redirecting print output in tests. |
|
||||
| `tostringall(...)` | Apply `tostring()` to every vararg, preserving the count. Lua 5.0-compatible (uses `arg.n` since `select` doesn't exist in 5.0). |
|
||||
|
||||
|
||||
+2350
-1459
File diff suppressed because it is too large
Load Diff
@@ -556,9 +556,26 @@ Render the modern inline-texture escape (warcraft.wiki.gg/wiki/UI_escape_sequenc
|
||||
|Tpath:height[:width[:offsetX:offsetY[:texWidth:texHeight:left:right:top:bottom[:rV:gV:bV]]]]|t
|
||||
```
|
||||
|
||||
Atlas markup (`|A:atlas:…|a`) is Legion+ (needs an atlas DB 1.12 lacks) — out
|
||||
of scope. FileDataID paths are Legion+ — 1.12 resolves texture PATH strings
|
||||
only.
|
||||
Atlas markup is also supported:
|
||||
|
||||
```
|
||||
|A:atlasName:height:width[:offsetX:offsetY]|a
|
||||
```
|
||||
|
||||
It shares every scanner, measure, wrap and placement stage with `|T` — only the
|
||||
payload parser differs. `ParseAtlasIcon` resolves the name through
|
||||
`Texture::Atlas` and fills the SAME `IconDesc`, because an atlas is exactly the
|
||||
"path + normalized sub-rect" that struct already carries for a `|T` span with
|
||||
texcoords. A `height`/`width` of `0` resolves to the atlas's own pixel size at
|
||||
PARSE time (not the line font height `|T` falls back to), which is what keeps the
|
||||
rest of the pipeline marker-agnostic. Tiling atlases draw one quad, so
|
||||
`tilesHorizontally` / `tilesVertically` are not honored here.
|
||||
|
||||
Adding any further marker means touching `IconStartLen`, `FindIconClose`,
|
||||
`InlineSpanLen`, the ghost guard in `FlushLayout` — and, mandatory,
|
||||
`chat/IconFilter.cpp`, or the marker becomes a chat-spoof vector.
|
||||
|
||||
FileDataID paths are Legion+ — 1.12 resolves texture PATH strings only.
|
||||
|
||||
## Confirmed: 1.12 has ZERO inline-texture support
|
||||
|
||||
|
||||
@@ -213,6 +213,8 @@ filenames in Lua 5.0 by behaviour.
|
||||
|--------------|--------------------------|---------------------------------------------------|
|
||||
| `0x006F3140` | `setobj` / TValue copy | 16-byte memcpy |
|
||||
| `0x006F58B0` | `equalobj` | called by `lua_equal` |
|
||||
| `0x006F6050` | `luaD_precall` | frame setup for a call; Lua closure: reads Proto (`+0xc` of the closure), `is_vararg` at Proto `+0x46` → `adjust_varargs`, `numparams` `+0x45`, `maxstacksize` `+0x47`; C closure: reserves 0x14 slots and calls the C function |
|
||||
| `0x006F6200` | `adjust_varargs` | stock 5.0: pads missing fixed params with nil, then `luaH_new(L, nvar, 1)` + `luaH_setnum` per extra arg + sets field `"n"` and pushes the table. So EVERY call to a vararg Lua function allocates one table, whether the body uses `arg` or `...` — keep vararg helpers off per-frame paths |
|
||||
| `0x006F65A0` | `luaD_call` | the actual call dispatcher |
|
||||
| `0x006F6960` | `luaD_pcall` | protected call dispatcher |
|
||||
| `0x006F7340` | `luaD_growstack` (or checkstack) | invoked when stack needs space |
|
||||
|
||||
+19
-50
@@ -15,16 +15,13 @@
|
||||
#include "Game.h"
|
||||
#include "MinHook.h"
|
||||
#include "Offsets.h"
|
||||
#include "config/FileSwitch.h"
|
||||
#include "event/Custom.h"
|
||||
#include "luasyntax/StringMethods.h"
|
||||
#include "model/DisplayInfo.h"
|
||||
#include "nameplate/Walk.h"
|
||||
#include "player/NameCache.h"
|
||||
#include "text/InlineTexture.h"
|
||||
#include "text/InlineTexturePool.h"
|
||||
#include "texture/Mask.h"
|
||||
#include "texture/Transform.h"
|
||||
#include "tooltip/SetEvents.h"
|
||||
|
||||
static Game::FrameScript_Initialize_t FrameScript_Initialize_o = nullptr;
|
||||
static Game::LoadScriptFunctions_t LoadScriptFunctions_o = nullptr;
|
||||
@@ -40,53 +37,17 @@ static FrameRegisterEvent_t FrameRegisterEvent_o = nullptr;
|
||||
static void __fastcall InvalidFunctionPtrCheck_h() {}
|
||||
|
||||
static bool __fastcall FrameScript_Initialize_h() {
|
||||
// BEFORE the engine tears down the old event table (at the start of
|
||||
// FrameScript_Initialize), invalidate our cached slot indices. The
|
||||
// table is rebuilt at a fresh allocation; the old slots are stale.
|
||||
Event::Custom::PrepareForReload();
|
||||
// Fire every module's PrepareForReload BEFORE the engine tears down the old
|
||||
// event table / Lua state. Each module that keeps reload-fragile state (Lua
|
||||
// refs, frame-pointer-keyed maps, cached event-slot indices) self-registers
|
||||
// via `static const Game::ReloadAutoRegister _reload{&PrepareForReload};`,
|
||||
// so this one call clears them all — a new module can't forget to wire in
|
||||
// (see the ReloadAutoRegister doc in Game.h). This hook fires on both
|
||||
// `/reload` and `/logout` (the engine re-inits the Lua state in both).
|
||||
Game::RunReloadCleanups();
|
||||
|
||||
// Clear the nameplate diff state so currently-visible plates
|
||||
// refire CREATED and UNIT_ADDED on the next tick — re-presents
|
||||
// them to the freshly reloaded UI, matching modern WoW. The
|
||||
// wrappers themselves now live in the engine's own registry
|
||||
// (via `FrameScript_Object::ScriptRegister`), which the engine
|
||||
// tears down and rebuilds across the Lua reset — no per-module
|
||||
// cache to clear here.
|
||||
NamePlate::Events::PrepareForReload();
|
||||
|
||||
// The reload teardown destroys every icon pool texture, fontstring,
|
||||
// and text node — forget all inline-icon pointers now so nothing
|
||||
// touches them (see InlineTexture.h / InlineTexturePool.h).
|
||||
Text::InlineTexture::PrepareForReload();
|
||||
Text::InlineTexturePool::PrepareForReload();
|
||||
|
||||
// Drop per-region corner transforms (SetRotation / SetVertexOffset) — the
|
||||
// reload destroys every addon texture and the region pool reuses their pointers.
|
||||
Texture::Transform::PrepareForReload();
|
||||
|
||||
// Drop per-region texture masks for the same reason (SetMask).
|
||||
Texture::Mask::PrepareForReload();
|
||||
|
||||
// Drop pending character-dress jobs and their engine compositors — the
|
||||
// reload destroys every Model frame the jobs point at.
|
||||
Model::DisplayInfo::PrepareForReload();
|
||||
|
||||
// Drop the per-tooltip OnTooltipSet* handler cells — the reload destroys
|
||||
// and recreates every tooltip object, and the handler refs die with the
|
||||
// Lua reset (issue #33: the cell table filled after ~18 reloads, then
|
||||
// recycled tooltip addresses fired dead refs).
|
||||
Tooltip::SetEvents::PrepareForReload();
|
||||
|
||||
// Forget the captured `string` table before the world Lua state resets
|
||||
// (this hook also fires on /logout, where the state's tables die) —
|
||||
// string-method resolution fails closed (stock index error) until
|
||||
// LoadScriptFunctions re-captures.
|
||||
LuaSyntax::StringMethods::PrepareForReload();
|
||||
|
||||
// Persist the name cache before the engine starts tearing down.
|
||||
// This hook fires on both `/reload` and `/logout` (the engine
|
||||
// re-initializes Lua state in both cases), giving us a clean
|
||||
// deterministic save point for the common lifecycle events.
|
||||
// Persist the name cache before the engine tears down — a SAVE, not a clear,
|
||||
// so it stays an explicit call rather than a ReloadAutoRegister.
|
||||
Player::NameCache::Flush();
|
||||
|
||||
FrameScript_Initialize_o();
|
||||
@@ -247,6 +208,14 @@ BOOL APIENTRY DllMain(HMODULE hModule, DWORD reason, LPVOID lpReserved) {
|
||||
if (reason == DLL_PROCESS_ATTACH) {
|
||||
DisableThreadLibraryCalls(hModule);
|
||||
|
||||
// The ONE thing that has to happen this early, and the only module this
|
||||
// file names directly. The engine reads its config file during boot,
|
||||
// several calls before VanillaFixes invokes `Load`, so a `-config`
|
||||
// switch cannot be applied from the normal install path — by then the
|
||||
// file has been read. This is a single 4-byte store, not a hook, so it
|
||||
// carries none of the loader-lock cost described above.
|
||||
Config::FileSwitch::Apply();
|
||||
|
||||
// Install nothing here (loader lock — see the block comment above).
|
||||
//
|
||||
// Under VanillaFixes, `Load` is the ONLY install trigger. VfPatcher.dll
|
||||
|
||||
@@ -17,6 +17,12 @@
|
||||
|
||||
namespace Game {
|
||||
|
||||
void *ResolveUnitToken(const char *token) {
|
||||
using ResolveUnitToken_t = void *(__fastcall *)(const char *token);
|
||||
auto fn = reinterpret_cast<ResolveUnitToken_t>(Offsets::FUN_RESOLVE_UNIT_TOKEN);
|
||||
return fn(token);
|
||||
}
|
||||
|
||||
namespace Lua {
|
||||
// Each entry binds a typed function pointer in `Game::Lua::` to the
|
||||
// corresponding raw VA in `Offsets`. The X-macro keeps the column-aligned
|
||||
@@ -40,6 +46,7 @@ namespace Lua {
|
||||
F(RawGet, lua_rawget, LUA_RAW_GET) \
|
||||
F(SetTable, lua_settable, LUA_SET_TABLE) \
|
||||
F(RawSet, lua_rawset, LUA_RAW_SET) \
|
||||
F(SetMetatable,lua_setmetatable,LUA_SET_METATABLE)\
|
||||
F(Insert, lua_insert, LUA_INSERT) \
|
||||
F(Remove, lua_remove, LUA_REMOVE) \
|
||||
F(GetTop, lua_gettop, LUA_GET_TOP) \
|
||||
@@ -288,6 +295,7 @@ namespace {
|
||||
ModuleAutoRegister *g_moduleHead = nullptr;
|
||||
GlueModuleAutoRegister *g_glueModuleHead = nullptr;
|
||||
HookAutoRegister *g_hookHead = nullptr;
|
||||
ReloadAutoRegister *g_reloadHead = nullptr;
|
||||
} // namespace
|
||||
|
||||
ModuleAutoRegister::ModuleAutoRegister(Fn f) : fn(f), next(g_moduleHead) {
|
||||
@@ -309,6 +317,15 @@ void RunGlueModuleRegistrations() {
|
||||
node->fn();
|
||||
}
|
||||
|
||||
ReloadAutoRegister::ReloadAutoRegister(Fn f) : fn(f), next(g_reloadHead) {
|
||||
g_reloadHead = this;
|
||||
}
|
||||
|
||||
void RunReloadCleanups() {
|
||||
for (auto *node = g_reloadHead; node != nullptr; node = node->next)
|
||||
node->fn();
|
||||
}
|
||||
|
||||
HookAutoRegister::HookAutoRegister(uintptr_t target, void *hook, void **original)
|
||||
: target(target), hook(hook), original(original), next(g_hookHead) {
|
||||
g_hookHead = this;
|
||||
|
||||
+48
@@ -57,6 +57,19 @@ inline T &Ref(uintptr_t address) {
|
||||
return *reinterpret_cast<T *>(address);
|
||||
}
|
||||
|
||||
// --- Engine primitives ------------------------------------------------------
|
||||
|
||||
// Resolve a unit token (`"player"`, `"target"`, `"party3"`, `"nameplate1"`,
|
||||
// …) to its live `CGUnit_C *`, through the engine's own resolver
|
||||
// (`FUN_RESOLVE_UNIT_TOKEN`). Returns null for a valid token that currently
|
||||
// has no unit (e.g. `"target"` with nothing targeted). NOTE: the engine
|
||||
// RAISES a Lua error for a string that isn't a valid token form (an arbitrary
|
||||
// name like `"Bob"`), so only pass real unit tokens — the same contract every
|
||||
// stock `Unit*` global carries. Centralizes the
|
||||
// `reinterpret_cast<…>(FUN_RESOLVE_UNIT_TOKEN)` idiom that was copy-pasted
|
||||
// across a dozen modules.
|
||||
void *ResolveUnitToken(const char *token);
|
||||
|
||||
using FrameScript_Initialize_t = bool(__fastcall *)();
|
||||
using LoadScriptFunctions_t = void(__fastcall *)();
|
||||
|
||||
@@ -110,6 +123,9 @@ using lua_gettable_t = void(__fastcall *)(void *L, int idx);
|
||||
using lua_rawget_t = void(__fastcall *)(void *L, int idx);
|
||||
using lua_settable_t = void(__fastcall *)(void *L, int idx);
|
||||
using lua_rawset_t = void(__fastcall *)(void *L, int idx);
|
||||
// Pops the table at the top and installs it as the metatable of the value
|
||||
// at idx; returns 1.
|
||||
using lua_setmetatable_t = int(__fastcall *)(void *L, int idx);
|
||||
using lua_insert_t = void(__fastcall *)(void *L, int idx);
|
||||
using lua_remove_t = void(__fastcall *)(void *L, int idx);
|
||||
using lua_gettop_t = int(__fastcall *)(void *L);
|
||||
@@ -165,6 +181,7 @@ extern const lua_gettable_t GetTable;
|
||||
extern const lua_rawget_t RawGet;
|
||||
extern const lua_settable_t SetTable;
|
||||
extern const lua_rawset_t RawSet;
|
||||
extern const lua_setmetatable_t SetMetatable;
|
||||
extern const lua_insert_t Insert;
|
||||
extern const lua_remove_t Remove;
|
||||
extern const lua_gettop_t GetTop;
|
||||
@@ -412,4 +429,35 @@ struct HookAutoRegister {
|
||||
// and stops on first failure.
|
||||
bool RunHookRegistrations();
|
||||
|
||||
// Self-registration for per-/reload state cleanup. A module that keeps
|
||||
// file-static state which goes STALE across a game /reload declares a
|
||||
// file-scope `static const Game::ReloadAutoRegister _reload{&PrepareForReload};`
|
||||
// right after its `void PrepareForReload()`. `RunReloadCleanups()` — called
|
||||
// from the FrameScript_Initialize hook, BEFORE the engine tears down the Lua
|
||||
// state — fires them all, so DllMain no longer hand-maintains the list and a
|
||||
// new module can't forget to wire itself in.
|
||||
//
|
||||
// State is "reload-fragile" when it survives in C++ but its meaning does not:
|
||||
// - Lua registry refs / handler cells (the Lua reset invalidates them);
|
||||
// - maps/sets keyed by a frame/object POINTER (the allocator recycles
|
||||
// addresses, so an old entry aliases an unrelated new object);
|
||||
// - cached event-slot indices (the engine rebuilds the event table).
|
||||
// Firing a stale entry is the recurring bug this prevents (Tooltip::SetEvents
|
||||
// issue #33, Frame::Attributes). A pure-C++ cache keyed by spellID/itemID is
|
||||
// NOT fragile and must not register.
|
||||
//
|
||||
// Same lifetime + ordering rules as ModuleAutoRegister: static-init chaining,
|
||||
// undefined cross-TU order — fine, because each callback only clears its OWN
|
||||
// state. This is the "clear stale state" category only; a save-before-teardown
|
||||
// like Player::NameCache::Flush stays an explicit DllMain call (it persists
|
||||
// rather than clears, and also runs on the glue-return path).
|
||||
struct ReloadAutoRegister {
|
||||
using Fn = void (*)();
|
||||
explicit ReloadAutoRegister(Fn fn);
|
||||
Fn fn;
|
||||
ReloadAutoRegister *next;
|
||||
};
|
||||
|
||||
void RunReloadCleanups();
|
||||
|
||||
} // namespace Game
|
||||
|
||||
+676
-7
@@ -226,6 +226,35 @@ enum Offsets {
|
||||
// so a pointer-keyed cell never goes stale). Same pattern + ABI modeling as
|
||||
// FUN_GAMETOOLTIP_SCRIPT_RESOLVER.
|
||||
FUN_FRAME_SCRIPT_RESOLVER = 0x0076A0D0,
|
||||
|
||||
// Button widget script-name resolver — __thiscall(button, const char *name)
|
||||
// -> int* slot, 0 for an unknown name. Sits at vtable+0xC on all four
|
||||
// button-family vtables (Button + subclasses). Delegates to the base frame
|
||||
// resolver FUN_FRAME_SCRIPT_RESOLVER first, then maps the two button
|
||||
// scripts: OnClick -> button+0x4CC, OnDoubleClick -> button+0x4D4 (each an
|
||||
// 8-byte {handler, context} slot). Vanilla has no PreClick/PostClick, so
|
||||
// `Frame::ClickEvents` co-hooks this to hand out an external per-button cell
|
||||
// for those two names — same technique as FUN_GAMETOOLTIP_SCRIPT_RESOLVER.
|
||||
FUN_BUTTON_SCRIPT_RESOLVER = 0x00778C50,
|
||||
// Button OnClick script slot offset (the resolver's OnClick return). Used
|
||||
// by `Frame::ClickEvents` to recognize an OnClick fire at the runner hook
|
||||
// below: the fire passes slotPtr == button + this offset.
|
||||
OFF_BUTTON_ONCLICK_HANDLER = 0x4CC,
|
||||
// Frame-script runner WITH exec-context stamping — __cdecl(void *frame,
|
||||
// int *slotPtr, const char *fmt, void *vaPtr). Saves DAT_00ceeac0, stamps
|
||||
// it from slotPtr[1] (the cell's context), then calls FUN_FRAME_RUN_SCRIPT_ARGS
|
||||
// (the arg'd runner Frame::ScriptArgs hooks) and restores. Every arg'd
|
||||
// input-script fire funnels through here via FUN_007026F0 (its variadic
|
||||
// forwarder); the event dispatcher FUN_00703F50 is the only other caller.
|
||||
// `Frame::ClickEvents` co-hooks it, gates on `slotPtr == frame +
|
||||
// OFF_BUTTON_ONCLICK_HANDLER` (an exact OnClick match — no other fire passes
|
||||
// that slot address), and fires PreClick before / PostClick after by
|
||||
// re-invoking with the same (fmt, vaPtr) so the button-name arg is reused.
|
||||
// Deliberately NOT the button click vmethod FUN_00779540, which SuperWoW
|
||||
// inline-hooks for click-casting (a second hook there faults, ERROR #132 —
|
||||
// see the note near FUN_SCRIPT_FRAME_GET_STRATA); this runner is uncontested.
|
||||
FUN_FRAME_RUN_SCRIPT_WITH_CONTEXT = 0x00702710,
|
||||
|
||||
// The other per-object tooltip builders, co-hooked the same way as
|
||||
// FUN_GAMETOOLTIP_BUILD_ITEM to back OnTooltipSetSpell / OnTooltipSetUnit /
|
||||
// OnTooltipSetGameObject (see Tooltip::SetEvents). Each is the single funnel
|
||||
@@ -270,7 +299,12 @@ enum Offsets {
|
||||
// the pointer at +0xB8 (both verified from the setters below). We read a
|
||||
// line's text/color and re-apply them one slot down, then set line 0.
|
||||
FUN_FONTSTRING_SET_TEXT = 0x00771D80, // __thiscall(fs, text, flag=0)
|
||||
FUN_FONTSTRING_SET_COLOR = 0x0077F750, // __thiscall(fs, colorBGRA*)
|
||||
FUN_FONTSTRING_SET_COLOR = 0x0077F750, // __thiscall(region, colorBGRA*) — generic region color setter (fontstrings + textures)
|
||||
// Generic region color GETTER — `void __thiscall(region, uint32* outBGRA)`,
|
||||
// writes the packed {b,g,r,a} (0xFFFFFFFF when the region has no explicit
|
||||
// color). The read half of Texture:GetVertexColor (FUN_0079aa50); paired
|
||||
// with FUN_FONTSTRING_SET_COLOR above. Backs EditBox:GetHighlightColor.
|
||||
FUN_REGION_GET_COLOR = 0x0077F8C0,
|
||||
OFF_FONTSTRING_TEXT = 0xF0, // char* — current text buffer
|
||||
OFF_FONTSTRING_COLOR_PTR = 0xB8, // ptr → 4-byte {b,g,r,a} color storage
|
||||
// A line's FontString is only positioned once shown: set the desired
|
||||
@@ -440,6 +474,13 @@ enum Offsets {
|
||||
// handler as the 7.0 `SetColorTexture` (which IS exactly the numeric form), so
|
||||
// the clamp and opaque-alpha default come straight from the engine.
|
||||
FUN_SCRIPT_TEXTURE_SET_TEXTURE = 0x0079BB40,
|
||||
// Script_Texture_SetTexCoord — the Lua `texture:SetTexCoord(...)` handler
|
||||
// (entry 17 of the Texture method batch at 0x0087C128). Takes the 4-arg
|
||||
// (left, right, top, bottom) and 8-arg corner forms and writes through to
|
||||
// FUN_SIMPLETEXTURE_SET_TEXCOORD below. texture/Atlas.cpp delegates to this
|
||||
// handler rather than the native setter so an atlas set inherits the engine's
|
||||
// own arg validation and both coordinate forms.
|
||||
FUN_SCRIPT_TEXTURE_SET_TEXCOORD = 0x0079BEB0,
|
||||
// CSimpleTexture::SetTexCoord — `__thiscall(tex, float[4])`. Struct field
|
||||
// order is {top, left, bottom, right} = {v0, u0, v1, u1} (verified from the
|
||||
// SetTexCoord handler 0x0079BEB0's Lua-arg → struct mapping). Natural
|
||||
@@ -856,6 +897,29 @@ enum Offsets {
|
||||
FUN_FIND_CVAR = 0x0063DEC0,
|
||||
OFF_CVAR_VALUE_STR = 0x20,
|
||||
|
||||
// The config filename, as an immediate operand. Boot calls
|
||||
// `FUN_0063D380("Config.wtf")` from `FUN_00402350` at `0x00402370`
|
||||
// (`B9 80 E5 82 00` = `MOV ECX, 0x0082E580`), and that function keeps the
|
||||
// pointer at `VAR_CONFIG_FILENAME_PTR`, which then drives BOTH sides:
|
||||
// load — `0x0063D428`: `MOV ECX,[0x00C4EDD4]; CALL 0x0063D820`
|
||||
// save — `FUN_0063D980` builds `"WTF\"` + the same pointer
|
||||
// So one pointer names the file for reading and writing, and swapping it
|
||||
// redirects both consistently. This constant is the 4-byte OPERAND (the
|
||||
// instruction VA + 1), which `Config::FileSwitch` overwrites with its own
|
||||
// string to implement the `-config` switch.
|
||||
//
|
||||
// It is patched rather than hooked because the call happens during boot,
|
||||
// BEFORE VanillaFixes calls our `Load` export — VF hooks GetCPUFrequency
|
||||
// (`0x0042C060`), reached via `FUN_00641260` from the `FUN_0063A230()`
|
||||
// that `FUN_00402350` runs several calls AFTER the config is already read.
|
||||
// Our DllMain does run in time (VF injects every dlls.txt DLL while the
|
||||
// process is still CREATE_SUSPENDED, resuming only afterwards), and a
|
||||
// 4-byte store needs no trampoline.
|
||||
PATCH_CONFIG_FILENAME_PTR = 0x00402371,
|
||||
// Where `FUN_0063D380` stores that pointer. Read by the loader call above
|
||||
// and by the save.
|
||||
VAR_CONFIG_FILENAME_PTR = 0x00C4EDD4,
|
||||
|
||||
// Internal CVar registrar — what `Script_RegisterCVar` calls after a
|
||||
// `FindCVar` miss (the call at `0x00488B8A`). `__fastcall`; ECX=name,
|
||||
// EDX is a second string slot the script path leaves 0, then six
|
||||
@@ -1060,6 +1124,19 @@ enum Offsets {
|
||||
OFF_UNIT_FIELD_CHARMEDBY = 0x10,
|
||||
OFF_UNIT_FIELD_CREATEDBY = 0x20,
|
||||
|
||||
// UNIT_CREATED_BY_SPELL — the spell that summoned this unit (descriptor
|
||||
// byte +0x230, field index 0x8C). Set for every spell-summoned unit:
|
||||
// totems (the shaman's totem-drop spell), pets, guardians, wild summons.
|
||||
// Verified two ways: the unit-title builder `FUN_0052FD30` reads
|
||||
// `[desc+0x230]` as the summoning spell (then switches on its Spell.dbc
|
||||
// Effect[0] to pick "Guardian"/"Creation"), and the server stamps it
|
||||
// with `m_spellInfo->Id` (tortoise-wow `EffectSummonTotem`/
|
||||
// `EffectSummonGuardian`/`EffectSummon`). A broadcast field, so it's
|
||||
// readable for any unit in range. `Unit::Pet` exposes it as
|
||||
// `UnitCreatedBySpell`. NB: this is the SUMMONING spell — distinct from
|
||||
// the spell a totem casts (that's server-side only, never broadcast).
|
||||
OFF_UNIT_FIELD_CREATED_BY_SPELL = 0x230,
|
||||
|
||||
// Pet-vs-minion discriminator for an owned unit: `int __fastcall(unit)`.
|
||||
// `0x00605570` is really the engine's **creature-type resolver** — it's
|
||||
// `Script_UnitCreatureType`'s (`0x0051A280`) inner helper and returns the
|
||||
@@ -1450,6 +1527,30 @@ enum Offsets {
|
||||
UNIT_AURA_DEBUFF_COUNT = 16, // slot range 32..47 (harmful)
|
||||
UNIT_AURA_TOTAL = 48,
|
||||
UNIT_AURA_VISIBLE_MASK = 0x0E, // nibble mask used by the engine's visibility gate
|
||||
// Per-slot flag nibble bits. THE MEANING OF 0x02/0x04/0x08 IS NOT THE SAME
|
||||
// ON EVERY SERVER, so read them only through `Aura::Data::IsSlotHarmful`,
|
||||
// which picks the right interpretation.
|
||||
//
|
||||
// Stock (vmangos SpellAuraDefines.h + SetAuraFlag, verified): the bits are
|
||||
// CANCELABLE 0x01, EFF_INDEX_2 0x02, EFF_INDEX_1 0x04, EFF_INDEX_0 0x08 —
|
||||
// one bit per spell effect index that carries an aura, plus cancelable for
|
||||
// a positive aura. NO POLARITY IS ENCODED. Polarity comes from the slot
|
||||
// range instead (0..31 helpful, 32..47 harmful), which is exact because
|
||||
// the slot search is a strict if/else on IsPositive and never crosses over.
|
||||
// Reading 0x08 as "harmful" here would really be reading "effect index 0
|
||||
// has an aura", which is true of nearly every aura — so nearly every BUFF
|
||||
// would come back as a debuff.
|
||||
//
|
||||
// Turtle (tortoise-wow SetAuraFlag, verified) replaces the effect-index
|
||||
// bits with polarity: positive gets 0x04 (+ CANCELABLE unless
|
||||
// SPELL_ATTR_CANT_CANCEL), negative gets 0x08. It has to, because Turtle
|
||||
// also drops the `else` in the slot search so a debuff spills into 0..31
|
||||
// once the 16 harmful slots are full (and sets UNIT_FLAG_AURAS_VISIBLE so
|
||||
// the client still renders it). There the range is unreliable and the
|
||||
// nibble is the real answer — the exact inverse of stock.
|
||||
UNIT_AURA_FLAG_CANCELABLE = 0x01,
|
||||
UNIT_AURA_FLAG_HELPFUL = 0x04, // Turtle only; stock = EFF_INDEX_1
|
||||
UNIT_AURA_FLAG_HARMFUL = 0x08, // Turtle only; stock = EFF_INDEX_0
|
||||
|
||||
// PLAYER_FIELD_MOD_DAMAGE_DONE_POS/NEG — the player's per-school spell
|
||||
// damage bonus. Offsets from the engine's own UpdateField name/index
|
||||
@@ -1481,6 +1582,15 @@ enum Offsets {
|
||||
// `+0x2B0`. Both `Script_UnitBuff` and `Script_UnitDebuff` call
|
||||
// it to filter their aura iteration; we call it the same way.
|
||||
FUN_SPELL_IS_VISIBLE_AURA = 0x00519860,
|
||||
// The TOOLTIP variant of the gate: what `Script_GameTooltip_SetUnitBuff`
|
||||
// (0x00534AC0) / `SetUnitDebuff` (0x00534E30) apply per slot while
|
||||
// turning a Lua index into a slot. `__fastcall(spellRecord) -> bool`:
|
||||
// Attributes & 0x80 (HIDDEN_CLIENTSIDE) clear, AttributesEx & 0x10000000
|
||||
// (NO_AURA_ICON) clear, and no EffectApplyAuraName in {44, 45, 151}
|
||||
// (the tracking auras). Verified by decompile; differs from the UnitBuff
|
||||
// gate above, so an index into one space is not an index into the other
|
||||
// — `GameTooltip:SetUnitAura` counts with THIS one when it translates.
|
||||
FUN_GAMETOOLTIP_AURA_VISIBLE = 0x00534DF0,
|
||||
|
||||
// Player-only aura timing tables. Unlike UNIT_FIELD_AURA (which is
|
||||
// populated for any unit but has no timing info), these are the
|
||||
@@ -1755,6 +1865,15 @@ enum Offsets {
|
||||
// from SMSG_SPELL_START, not server-broadcast state).
|
||||
OFF_UNIT_CAST_SPELL = 0xC8C,
|
||||
|
||||
// CGUnit field: the unit's sheath state as a plain int member (NOT a
|
||||
// descriptor UpdateField). 0 = sheathed / no weapon shown, 1 = melee
|
||||
// weapon drawn, 2 = ranged weapon drawn. Read and written by the engine's
|
||||
// own sheath toggler `FUN_005EB480` (behind `ToggleSheath`) and its setter
|
||||
// `FUN_00611CF0` (`*(this+0xD40) = newState`, saving the prior state to
|
||||
// `+0xD3C`). `Unit::Sheath::GetSheathState` reads the local player's value
|
||||
// and returns it 1-based (Lua's GetSheathState is 1=None/2=Melee/3=Ranged).
|
||||
OFF_UNIT_SHEATH_STATE = 0xD40,
|
||||
|
||||
// NetClient message dispatch — `__thiscall void(void *conn, uint32_t
|
||||
// arg, CDataStore *packet)`. Reads the u16 opcode off the packet
|
||||
// cursor, then invokes `[conn + opcode*4 + 0x74]` (the handler table
|
||||
@@ -1785,6 +1904,13 @@ enum Offsets {
|
||||
SMSG_SPELL_CHANNEL_UPDATE = 0x13A,
|
||||
SMSG_SPELL_DELAYED = 0x1E2,
|
||||
SMSG_SPELL_FAILED_OTHER = 0x2A6,
|
||||
// Melee swing result broadcast. Body (verified against the server's own
|
||||
// writer, tortoise-wow Unit::SendAttackStateUpdate): u32 hitInfo,
|
||||
// packedGuid attacker, packedGuid victim, u32 totalDamage, u8 subCount,
|
||||
// per-sub {u32 school, f32 coeff, u32 damage, u32 absorb, i32 resist},
|
||||
// u32 targetState, u32, u32 spellId, u32 blocked. `Aura::JudgementRefresh`
|
||||
// reads through totalDamage.
|
||||
SMSG_ATTACKERSTATEUPDATE = 0x14A,
|
||||
|
||||
// NetClient send — `__thiscall void(void *conn, CDataStore *packet)`.
|
||||
// The outgoing counterpart of FUN_NET_MESSAGE_DISPATCH: every CMSG the
|
||||
@@ -1843,6 +1969,11 @@ enum Offsets {
|
||||
VAR_FRAME_METHOD_REGISTRY = 0x00CF4D38,
|
||||
VAR_TEXTURE_METHOD_REGISTRY = 0x00CF5434,
|
||||
VAR_FONTSTRING_METHOD_REGISTRY = 0x00CF5400,
|
||||
// EditBox — 48 methods (table 0x0087BB68), ctx per
|
||||
// docs/BlizzardScriptAPI.md. Backs `EditBox:SetCursorPosition` /
|
||||
// `GetCursorPosition` (`EditBox::Methods`), the modern cursor / focus /
|
||||
// text-state methods vanilla's EditBox lacks.
|
||||
VAR_EDITBOX_METHOD_REGISTRY = 0x00CF5378,
|
||||
|
||||
// Model frame — backs `Model:SetDisplayInfo(creatureDisplayID)`
|
||||
// (`Model::DisplayInfo`). Registry ctx per docs/raw_methods.txt (table
|
||||
@@ -3560,6 +3691,22 @@ enum Offsets {
|
||||
VAR_FACTION_DISPLAY_COUNT = 0x00B73764,
|
||||
VAR_FACTION_VISIBLE_MAX_INDEX = 0x00B73760,
|
||||
|
||||
// Reputation-pane selection. Stored as a FACTION ID, not a list
|
||||
// index: `Script_SetSelectedFaction` (below) takes a 1-based
|
||||
// displayed-list index but immediately resolves it through
|
||||
// `FUN_RESOLVE_FACTION_INDEX` and stores the resulting factionID
|
||||
// here, and `Script_GetSelectedFaction` (0x004D6C00) walks the
|
||||
// displayed list to map it back to a 1-based index (0 when the
|
||||
// stored id isn't currently listed). So selecting by ID is a
|
||||
// direct write — no index round-trip. Verified by decompiling
|
||||
// both accessors.
|
||||
VAR_FACTION_SELECTED_ID = 0x00B73768,
|
||||
|
||||
// `Script_SetSelectedFaction` — the stock `SetSelectedFaction(index)`
|
||||
// global. `int __fastcall(void *L)`, standard Lua C ABI, so it can be
|
||||
// re-registered under a namespace table as-is.
|
||||
FUN_SCRIPT_SET_SELECTED_FACTION = 0x004D6BB0,
|
||||
|
||||
// Faction.dbc — standard 5-DWORD class shape, records pointer at +0x08
|
||||
// and count at +0x0C of the class instance at 0x00C0DD48. Records is an
|
||||
// array of `FactionRec *` indexed directly by factionID (1-based;
|
||||
@@ -3753,6 +3900,20 @@ enum Offsets {
|
||||
// original (see `src/faction/UnitFactionPolyfill.cpp`).
|
||||
FUN_FACTION_SET_AT_WAR = 0x004D5FD0,
|
||||
|
||||
// `FactionGetAtWar(factionID)` — `uint __fastcall(ecx = factionID)`.
|
||||
// Resolves factionID → repListID and returns the `AT_WAR` flag bit as
|
||||
// 0/1; returns 0 when the faction has no rep slot. The read half of
|
||||
// `Script_FactionToggleAtWar`'s read-negate-write pair.
|
||||
FUN_FACTION_GET_AT_WAR = 0x004D61B0,
|
||||
|
||||
// `Script_FactionToggleAtWar` — the stock `FactionToggleAtWar(index)`
|
||||
// global. Resolves its 1-based index through
|
||||
// `FUN_RESOLVE_FACTION_INDEX`, then (for a non-zero factionID) calls
|
||||
// GET_AT_WAR and hands the negated value to SET_AT_WAR. Standard Lua C
|
||||
// ABI, so it re-registers under a namespace table as-is. Toggling by
|
||||
// faction id is that same pair without the index resolve.
|
||||
FUN_SCRIPT_FACTION_TOGGLE_AT_WAR = 0x004D6950,
|
||||
|
||||
// `FactionSetInactive(factionID, newState)` — `__fastcall(ecx = factionID,
|
||||
// edx = char newState)`. Inner setter behind `Script_SetFactionInactive`
|
||||
// (`0x004D69B0`, sets INACTIVE bit) and `Script_SetFactionActive`
|
||||
@@ -3768,6 +3929,22 @@ enum Offsets {
|
||||
// war setter. Polyfilled in `src/faction/UnitFactionPolyfill.cpp`.
|
||||
FUN_FACTION_SET_INACTIVE = 0x004D60F0,
|
||||
|
||||
// `Script_SetFactionInactive` / `Script_SetFactionActive` — the stock
|
||||
// `SetFactionInactive(index)` / `SetFactionActive(index)` globals. Each
|
||||
// resolves its 1-based index through `FUN_RESOLVE_FACTION_INDEX` and,
|
||||
// for a non-zero factionID, calls SET_INACTIVE with 1 / 0 respectively.
|
||||
// Standard Lua C ABI, so both re-register under a namespace table as-is.
|
||||
// Setting by faction id is the same call without the index resolve.
|
||||
FUN_SCRIPT_SET_FACTION_INACTIVE = 0x004D69B0,
|
||||
FUN_SCRIPT_SET_FACTION_ACTIVE = 0x004D6A00,
|
||||
|
||||
// `Script_IsFactionInactive` — the stock `IsFactionInactive(index)`
|
||||
// global (pushes 1 / nil, not a boolean). Its worker at `0x004D6210`
|
||||
// takes the 0-based INDEX rather than a faction id — unlike the at-war
|
||||
// getter — resolving index → factionID → repListID before reading the
|
||||
// INACTIVE bit. Standard Lua C ABI, so it re-registers as-is.
|
||||
FUN_SCRIPT_IS_FACTION_INACTIVE = 0x004D6AF0,
|
||||
|
||||
// SMSG_SET_FACTION_ATWAR handler — `__stdcall(uint32_t opcode,
|
||||
// void *packet)`. Fires when the server force-changes the player's
|
||||
// at-war state on a faction (e.g., a flag the player can't toggle
|
||||
@@ -4102,6 +4279,15 @@ enum Offsets {
|
||||
VAR_PET_SPELLBOOK = 0x00B6F098,
|
||||
SPELLBOOK_MAX_SLOTS = 0x400,
|
||||
|
||||
// Active minimap-tracking spell ID (0 = none). Vanilla tracks exactly
|
||||
// one active tracker as a plain int here, derived from the player's
|
||||
// active Track Creatures/Resources/Stealthed aura by `FUN_004E4170`
|
||||
// (an aura-descriptor observer registered by `FUN_004E4100`). Read by
|
||||
// `Script_GetTrackingTexture` (`0x004E4A20`), `Script_CancelTrackingBuff`
|
||||
// (`0x004E4A80`), and `GameTooltip:SetTrackingSpell` (`0x00532C50`);
|
||||
// `Spell::Tracking::GetTrackingInfo` reads it to report `active`.
|
||||
VAR_ACTIVE_TRACKING_SPELL = 0x00BC6378,
|
||||
|
||||
// Player-spell-knowledge bitmap — `[VAR_PLAYER_SPELL_BITMAP]` is a
|
||||
// pointer to a dword bitmap covering all spellIDs the player has
|
||||
// learned, including talent passives, racials, profession recipes,
|
||||
@@ -4372,6 +4558,15 @@ enum Offsets {
|
||||
// results). Callable directly from C++ — push args on stack, call.
|
||||
FUN_SCRIPT_CAST_SPELL_BY_NAME = 0x004B4AB0,
|
||||
|
||||
// `Script_CastSpell` — engine's Lua wrapper for the slot-based
|
||||
// `CastSpell(spellbookSlot, bookType)` global. `int __fastcall(void *L)`.
|
||||
// Parses (slot, bookType) from stack[1]/[2] via `FUN_004B3EC0`, then
|
||||
// dispatches through `FUN_SPELL_CAST_DISPATCH` with the current target
|
||||
// (or self). Raises "Invalid spell slot in CastSpell" for a bad slot.
|
||||
// Callable from C++ by preparing the stack and tail-calling it (the
|
||||
// `Spell::Tracking::SetTracking` "select = cast" path does this).
|
||||
FUN_SCRIPT_CAST_SPELL = 0x004B42F0,
|
||||
|
||||
// Inner spell-cast dispatcher — `__fastcall(slot, bookType, targetGuidLo,
|
||||
// targetGuidHi)`. `Script_CastSpellByName` calls this after resolving
|
||||
// the spell name to a spellbook slot. For ground-target spells, the
|
||||
@@ -4790,6 +4985,11 @@ enum Offsets {
|
||||
// Verified in docs/LuaCAPI.md and by the getfenv/getmetatable base-lib
|
||||
// functions that call it. Used by the `__environment` env-protection hook.
|
||||
LUA_GET_METATABLE = 0x6F3CF0,
|
||||
// `lua_setmetatable(L, objindex)` — pops the table at the top of the
|
||||
// stack and installs it as the metatable of the value at objindex;
|
||||
// returns 1. Catalogued (★-verified) in docs/LuaCAPI.md. Used by
|
||||
// Table::Length to build its weak-keyed mark table from C.
|
||||
LUA_SET_METATABLE = 0x6F4020,
|
||||
// `lua_getfenv(L, idx)` — pushes the environment table of the function
|
||||
// (or userdata/thread) at `idx`. The getfenv/setfenv base-lib code calls
|
||||
// it to read a function's environment before the protection check.
|
||||
@@ -4869,6 +5069,16 @@ enum Offsets {
|
||||
// (named there since the table.insert trace). Used by the 5.1
|
||||
// `unpack(list, i, j)` upgrade for the default range end.
|
||||
LUAL_GETN = 0x6F5050,
|
||||
// `table.insert` (5.0 `luaB_tinsert`), `int __fastcall(L)`. Found two
|
||||
// independent ways: the table-lib luaL_reg pair in .data (entry
|
||||
// {name 0x008821B4 "insert", func} at 0x00822D70) and the LUAL_SETN
|
||||
// caller trace above. Decompile matches 5.0.2 tinsert exactly:
|
||||
// lua_gettop → luaL_checktype(1, TABLE) → n = luaL_getn(1)+1 →
|
||||
// optional-pos shift-up loop (rawgeti/rawseti) → luaL_setn(1, n) →
|
||||
// lua_pushvalue(value) → lua_rawseti(1, pos). Both `table.insert` and
|
||||
// the FrameXML `tinsert` alias resolve to this one C function. Hooked
|
||||
// by Table::Length to mark deliberate trailing-nil appends (#36/#39).
|
||||
FUN_LUA_TABLE_INSERT = 0x7FB6B0,
|
||||
// `luaV_gettable(L, t, key, loop)` — the VM's generic index resolver.
|
||||
// __fastcall(L /*ecx*/, t /*edx*/, key /*stack*/, loop /*stack*/),
|
||||
// RET 8. Returns the result TValue* in eax (Ghidra types it void; the
|
||||
@@ -5373,8 +5583,12 @@ enum Offsets {
|
||||
//
|
||||
// `VAR_ADDON_LIST_CTRL` is just the address of the control struct
|
||||
// (i.e., `0x00BE1B64`). Pass `&VAR_ADDON_LIST_CTRL` as the `this`
|
||||
// arg to `FUN_INTRUSIVE_LIST_INSERT`.
|
||||
// arg to `FUN_INTRUSIVE_LIST_INSERT`. `VAR_ADDON_LIST_HEAD` is the
|
||||
// control's +0x08 head slot; walk entries via
|
||||
// `next = *(entry + [VAR_ADDON_LIST_CTRL] + 4)` (= `entry+0x10`),
|
||||
// stopping on a low-bit-1 sentinel or NULL.
|
||||
VAR_ADDON_LIST_CTRL = 0x00BE1B64,
|
||||
VAR_ADDON_LIST_HEAD = 0x00BE1B6C,
|
||||
|
||||
// `SStrDup(const char *src, const char *file, int line)`. `__stdcall`.
|
||||
// Storm's string-copy wrapper around `SMemAlloc` — used by the engine
|
||||
@@ -5595,6 +5809,29 @@ enum Offsets {
|
||||
// (0=SECURE, 1=INSECURE, 2=BANNED). Defaults to `1` (INSECURE)
|
||||
// when the entry isn't in the override table.
|
||||
FUN_ADDON_IS_LOADED = 0x0051E6F0,
|
||||
|
||||
// The per-addon loader — `uint __fastcall(const char *name, char flag,
|
||||
// void *progressCtx)`. Returns non-zero (low byte) once the addon is
|
||||
// loaded, including the early-out when it already was.
|
||||
//
|
||||
// **It sets the entry's loaded byte (`+0x18`) BEFORE running anything**,
|
||||
// then loads LoadWith + required deps (clearing the byte again if a
|
||||
// required dep fails), runs the TOC files, `Bindings.xml` and the
|
||||
// SavedVariables, fires `ADDON_LOADED`, and finally loads the entries
|
||||
// that name this one in their LoadWith. So the byte means "load has
|
||||
// begun", i.e. `loadedOrLoading` — NOT "load has finished". The window
|
||||
// is observable from Lua, because the addon's own files (and every
|
||||
// dependency pulled in during them) execute inside it.
|
||||
// `AddOns::LoadState` co-hooks this to tell the two states apart.
|
||||
FUN_ADDON_LOAD_ONE = 0x0051F240,
|
||||
|
||||
// `Script_LoadAddOn` — the stock `LoadAddOn(index or name)` global.
|
||||
// Pushes two values either way: `(1, nil)` on success, or
|
||||
// `(nil, "REASON")` with a locale-independent reason token
|
||||
// ("DISABLED", "MISSING", … / "UNKNOWN_ERROR") on failure — already the
|
||||
// modern `loaded, value` shape apart from `1` standing in for `true`.
|
||||
// Standard Lua C ABI, so it re-registers under a namespace table as-is.
|
||||
FUN_SCRIPT_LOAD_ADDON = 0x0048E980,
|
||||
FUN_ADDON_CAN_LOAD = 0x0051E780,
|
||||
FUN_ADDON_GET_SECURITY_INDEX = 0x0051E990,
|
||||
|
||||
@@ -5646,6 +5883,178 @@ enum Offsets {
|
||||
VAR_ADDON_ARRAY = 0x00BE1B94,
|
||||
VAR_ADDON_COUNT = 0x00BE1B90,
|
||||
|
||||
// AddOnEntry fields shared by `Addons::Embedded` and `Addons::Rescan`
|
||||
// (the full per-field derivation lives in CLAUDE.md's AddOn registry
|
||||
// section):
|
||||
// +0x14 — canonical name pointer (what the load pass passes to the
|
||||
// by-name loader `FUN_0051F240`).
|
||||
// +0x29 — filter-out byte; the flat display-array builder skips
|
||||
// entries where it's non-zero (how `!!!ClassicAPI` hides).
|
||||
// LoadWith desc@+0x58 (count@+0x5C, data@+0x60) — `## LoadWith:`
|
||||
// names, parsed by `FUN_TOC_PARSER` (append site writes
|
||||
// rec[0x16..0x19]).
|
||||
// reverse-LoadWith desc@+0x88 (count@+0x8C, data@+0x90,
|
||||
// quantum@+0x94) — `AddOnEntry *`s of every addon that
|
||||
// declared `## LoadWith: <this>`; built by the scan
|
||||
// `FUN_0051C760`'s tail loop (rec[0x22..0x25]), consumed by
|
||||
// the loader's post-ADDON_LOADED loop.
|
||||
OFF_ADDON_ENTRY_NAME_PTR = 0x14,
|
||||
OFF_ADDON_ENTRY_FILTER_OUT = 0x29,
|
||||
OFF_ADDON_LOADWITH_COUNT = 0x5C,
|
||||
OFF_ADDON_LOADWITH_ARRAY = 0x60,
|
||||
OFF_ADDON_REVLOADWITH_DESC = 0x88,
|
||||
|
||||
// `## Secure:` flag byte. Parser write site verified in
|
||||
// `FUN_TOC_PARSER`'s decompile (`*(bool *)(entry_dwords + 10)` =
|
||||
// `+0x28`); also set by the SMSG_ADDON_INFO secure path at login.
|
||||
// Entries with this set are SMSG-managed (Blizzard addons on stock
|
||||
// clients) — never evict/re-register them: the parser cannot restore
|
||||
// packet-delivered state (and their MPQ TOCs never change anyway).
|
||||
OFF_ADDON_ENTRY_SECURE = 0x28,
|
||||
|
||||
// Complete single-`AddOnEntry` destructor — vtable slot 0 of the
|
||||
// registry's node factory (`[0x00BE1B60]` = `&PTR_FUN_00808c7c`,
|
||||
// installed by the boot init `FUN_0051C360`; the login teardown
|
||||
// `FUN_0051FA40` dispatches per entry through it).
|
||||
// `__stdcall(AddOnEntry *)`, RET 4 (arg read from `[EBP+8]`,
|
||||
// verified by disassembly). Body (`FUN_005200A0` → field cleanup
|
||||
// `FUN_005200D0`, both decompiled):
|
||||
// - frees EVERY owned allocation: name (+0x14), the +0x30 string,
|
||||
// all five dep/SV string arrays AND their buffers
|
||||
// (+0x38/+0x48/+0x58/+0x68/+0x78), the reverse-LoadWith buffer
|
||||
// (+0x88), and the per-entry `##` metadata hash table (+0x98 —
|
||||
// the Title/Notes/Author/X-* nodes `GetAddOnMetadata` reads);
|
||||
// - SELF-UNLINKS from both registry structures: the intrusive
|
||||
// list (link node +0xC via `FUN_00520A60`, which neighbor-
|
||||
// patches from the node alone and no-ops when already unlinked)
|
||||
// and the hash bucket chain (+0x4);
|
||||
// - frees the entry struct itself (`__AUUIADDON__` tag).
|
||||
// What it does NOT clean: THIS entry's pointer inside OTHER
|
||||
// entries' reverse-LoadWith lists — the caller must scrub those
|
||||
// first (`Addons::Rescan`), or the loader's post-ADDON_LOADED loop
|
||||
// (`FUN_0051F240` tail: reads +0x8C/+0x90, derefs each entry's
|
||||
// +0x14) reads freed memory.
|
||||
FUN_ADDON_ENTRY_DESTROY = 0x005200A0,
|
||||
|
||||
// `u8` set to 1 by `FUN_ADDON_INIT` after the login scan completes,
|
||||
// cleared by the registry teardown `FUN_0051FA40`. Gates anything
|
||||
// that touches the registry outside the login path.
|
||||
VAR_ADDON_INITIALIZED = 0x00BE1C08,
|
||||
|
||||
// ── The frozen loose-file index (why new files need a restart) ────
|
||||
//
|
||||
// Every relative-path file open resolves through `FUN_00647E60`,
|
||||
// which consults a HASH TABLE OF LOOSE FILES built ONCE per process:
|
||||
// `FUN_00646EA0` (lazy, latched by `VAR_VFS_INDEX_READY`) walks the
|
||||
// whole game directory tree via `FUN_VFS_INDEX_SUBTREE`, keying each
|
||||
// file by its base-relative path and storing the joined on-disk path.
|
||||
// Index hits open the mapped path LIVE from disk (edits to existing
|
||||
// files take effect on /reload); index misses fall through to the
|
||||
// MPQ search ONLY — the archive layer's live disk probe
|
||||
// (`FUN_00654DD0` → GetFileAttributesA) is dead code at runtime
|
||||
// because its enable global `DAT_00865B44` (ships as 1 in the image)
|
||||
// is unconditionally cleared by early boot (`FUN_00402210` →
|
||||
// `FUN_00648C20`). File WRITES (`FUN_0042A460` = raw CreateFileW)
|
||||
// never register in the index either. Net effect on a stock client:
|
||||
// any file created after boot — a new addon's TOC, a new .lua in an
|
||||
// existing addon, even a freshly written SavedVariables file — is
|
||||
// INVISIBLE to every relative-path read until the client restarts.
|
||||
//
|
||||
// `FUN_VFS_INDEX_SUBTREE` — the recursive indexer itself:
|
||||
// void __fastcall IndexSubtree(
|
||||
// const char *basePath, // ecx — index keys are relative to
|
||||
// // this (the game dir)
|
||||
// const char *relSubdir, // edx — subtree below basePath; ""
|
||||
// // for the boot-time full walk
|
||||
// void *findHandle);// stack — from FUN_VFS_FIND_OPEN on
|
||||
// // join(basePath, relSubdir)
|
||||
// Dedup-safe: every insert is preceded by a hash lookup, so
|
||||
// re-running it on a subtree registers only genuinely new files —
|
||||
// which is exactly what `Addons::Rescan` does per /reload for
|
||||
// `Interface\AddOns` and `WTF\Account`.
|
||||
//
|
||||
// `FUN_VFS_FIND_OPEN` — `__stdcall(const char *dirPath) -> void*`
|
||||
// (RET 4). Allocates a find block, strips trailing backslashes,
|
||||
// requires the path to be an EXISTING DIRECTORY
|
||||
// (GetFileAttributes & 0x10) — returns NULL otherwise — then arms
|
||||
// it with "\*". Release with `FUN_VFS_FIND_CLOSE`
|
||||
// (`__stdcall(void*)`, RET 4; NULL-safe).
|
||||
//
|
||||
// `VAR_VFS_BASE_PATH` — the game base dir recorded at boot
|
||||
// (`FUN_006488B0`); `FUN_00646EA0` uses it as the walk base when
|
||||
// `FUN_VFS_FIND_OPEN` accepts it, else falls back to ".".
|
||||
// `VAR_VFS_INDEX_READY` — `u8` latch; nonzero once the boot walk ran.
|
||||
FUN_VFS_INDEX_SUBTREE = 0x00646910,
|
||||
FUN_VFS_FIND_OPEN = 0x006674A0,
|
||||
FUN_VFS_FIND_CLOSE = 0x006676A0,
|
||||
VAR_VFS_BASE_PATH = 0x00C52418,
|
||||
VAR_VFS_INDEX_READY = 0x00C5251C,
|
||||
|
||||
// ── Login scan walk #2 (loose-addon discovery) — replayed per /reload
|
||||
//
|
||||
// `FUN_ADDON_SCAN_DISK_DIRS` — generic disk directory walker
|
||||
// (resolves to kernel32 FindFirstFileW; the DISK sibling of
|
||||
// `FUN_MPQ_ENUM_FILES`):
|
||||
// int __fastcall ScanDiskDirs(
|
||||
// const char *basePath, // ecx — e.g. "Interface\AddOns\"
|
||||
// const char *pattern, // edx — appended to basePath; the
|
||||
// // scan passes the "*" at
|
||||
// // VAR_ADDON_SCAN_PATTERN
|
||||
// Callback cb, // stack — see below
|
||||
// void *userParam, // stack — passed to cb in edx
|
||||
// int includeHidden); // stack — 0 skips hidden files
|
||||
// RET 0xC. Callback: `int __fastcall(FindInfo * /*ecx*/,
|
||||
// void *userParam /*edx*/)` with FindInfo `{+0x4 attrs (0x10 =
|
||||
// directory), +0x8 char name[]}`; returning nonzero STOPS the walk
|
||||
// (note: OPPOSITE of `FUN_MPQ_ENUM_FILES`' convention).
|
||||
//
|
||||
// `FUN_ADDON_DISK_DIR_CB` — the engine's own per-entry callback for
|
||||
// this walk (defined + verified in Ghidra): for every directory not
|
||||
// starting with '.', calls `FUN_TOC_PARSER` on the name; returns 0.
|
||||
// `Addons::Rescan` replays the scan's exact call —
|
||||
// `ScanDiskDirs(VAR_ADDON_PATH_PREFIX, VAR_ADDON_SCAN_PATTERN,
|
||||
// FUN_ADDON_DISK_DIR_CB, 0, 0)` — so new-folder discovery is 100%
|
||||
// engine code; the parser's dedup guard makes it a no-op per
|
||||
// already-registered addon.
|
||||
FUN_ADDON_SCAN_DISK_DIRS = 0x0042AD10,
|
||||
FUN_ADDON_DISK_DIR_CB = 0x0051D7B0,
|
||||
VAR_ADDON_SCAN_PATTERN = 0x00833180, // "*" (walker config block)
|
||||
VAR_ADDON_PATH_PREFIX = 0x00853724, // "Interface\AddOns\"
|
||||
|
||||
// ── Flat display array + descriptor grow helpers ──────────────────
|
||||
//
|
||||
// The `GetNumAddOns`/`GetAddOnInfo(i)` array (`VAR_ADDON_ARRAY` /
|
||||
// `VAR_ADDON_COUNT` above) is a full `{cap, count, data, quantum}`
|
||||
// descriptor at `VAR_ADDON_ARRAY_CAP` (cap@0x00BE1B8C,
|
||||
// count@0x00BE1B90, data@0x00BE1B94, quantum@0x00BE1B98) holding
|
||||
// NAME POINTERS (entry+0x14), rebuilt from scratch and qsorted by
|
||||
// `FUN_0051DA70` phase 2 whenever SMSG_ADDON_INFO lands.
|
||||
// `Addons::Rescan` mirrors that phase (never calls `FUN_0051DA70`
|
||||
// itself — its phase 1 consumes the packet) so newly appended
|
||||
// registry entries show up in the by-index Lua surface.
|
||||
//
|
||||
// Grow helpers (one template instantiation per desc — use each at
|
||||
// its verified site): `__thiscall(desc /*ecx*/, uint newCap)`,
|
||||
// RET 4 — Storm-reallocs `desc->data` to newCap*4 and writes
|
||||
// `desc->cap = newCap`.
|
||||
// FUN_ADDON_ARRAY_GROW — flat display array (used by
|
||||
// `FUN_0051DA70` phase 2)
|
||||
// FUN_ADDON_REVLOADWITH_GROW — reverse-LoadWith descs (used by the
|
||||
// scan tail loop)
|
||||
// `FUN_DESC_QUANTUM_CALC` — `__thiscall(desc /*ecx*/, uint needed)`,
|
||||
// RET 4: returns the append-rounding quantum (highest power of two
|
||||
// ≤ needed, min 1; ≥ 0x40 caps at 0x40 and stores desc->quantum).
|
||||
// `FUN_CRT_QSORT` — the CRT `qsort` (`__cdecl(base, num, width,
|
||||
// cmp)`); `FUN_ADDON_NAME_COMPARE` is the engine's `__cdecl`
|
||||
// name-pointer comparator `FUN_0051DA70` sorts the array with.
|
||||
VAR_ADDON_ARRAY_CAP = 0x00BE1B8C,
|
||||
VAR_ADDON_ARRAY_QUANTUM = 0x00BE1B98,
|
||||
FUN_ADDON_ARRAY_GROW = 0x004755F0,
|
||||
FUN_ADDON_REVLOADWITH_GROW = 0x005213C0,
|
||||
FUN_DESC_QUANTUM_CALC = 0x00521C50,
|
||||
FUN_CRT_QSORT = 0x0073F727,
|
||||
FUN_ADDON_NAME_COMPARE = 0x0051DEB0,
|
||||
|
||||
// NOTE: an earlier revision defined OFF_PLAYER_FIELD_VIS_BYTES /
|
||||
// PLAYER_VIS_BIT_STEALTH here at 0x17C, believing bit 0x02 was a
|
||||
// "stealth bit". That was wrong: 0x17C is UNIT_FIELD_AURALEVELS
|
||||
@@ -6347,6 +6756,23 @@ enum Offsets {
|
||||
FUN_GET_OBJECT_BY_GUID = 0x00468460,
|
||||
OBJECT_TYPE_ITEM = 2,
|
||||
|
||||
// Melee auto-attack primitives (back StartAttack / StopAttack). 1.12
|
||||
// exposes only the toggling AttackTarget (0x00489B50 -> FUN_006131A0);
|
||||
// these are the non-toggling start/stop it calls internally.
|
||||
// START: __thiscall(player, const uint64_t *targetGuid). Validates
|
||||
// CanAttack on the target, sends CMSG_ATTACKSWING (opcode 0x141), and
|
||||
// stamps the victim GUID at [player+0xC48]. Idempotent / switch-aware
|
||||
// on a valid target (never stops an in-progress attack); an
|
||||
// unattackable target while attacking routes to STOP.
|
||||
// STOP: __fastcall(player). Clears the attack state; self-guards on the
|
||||
// attack flag, so it is a no-op (safe) when not attacking.
|
||||
// RESOLVE_TARGET: __thiscall(player, uint64_t *outGuid) -> int. Validates
|
||||
// the player's CURRENT target (charm/fear/confuse redirect + CanAttack)
|
||||
// and writes its GUID; returns 0 (and posts the UI error) if invalid.
|
||||
FUN_ATTACK_START = 0x005ECB70,
|
||||
FUN_ATTACK_STOP = 0x005ECAC0,
|
||||
FUN_ATTACK_RESOLVE_TARGET = 0x00612DF0,
|
||||
|
||||
// Inbox — array of mail-entry pointers behind a slot indirection.
|
||||
// The address itself holds a heap pointer (`MOV ECX, [imm32]` in
|
||||
// the engine), so reading the array requires one extra deref
|
||||
@@ -6499,6 +6925,12 @@ enum Offsets {
|
||||
// SPELL_ATTR_EX3_IGNORE_CASTER_MODIFIERS (verified via disassembly; the
|
||||
// server enum agrees). NOT AttributesEx2's bit 29 (that's CANT_CRIT).
|
||||
SPELL_ATTR_EX3_IGNORE_CASTER_MODIFIERS = 0x20000000,
|
||||
// AttributesEx3 bit 18 — the paladin judgement-debuff marker. The server's
|
||||
// melee-swing judgement refresh selects auras by SPELLFAMILY_PALADIN +
|
||||
// this bit (tortoise-wow SpellDefines.h SPELL_ATTR_EX3_ALWAYS_HIT,
|
||||
// Unit::DealMeleeDamage). Verified in the client Spell.dbc: all 14
|
||||
// Judgement of Light/Wisdom/Justice/Crusader debuff ranks carry it.
|
||||
SPELL_ATTR_EX3_ALWAYS_HIT = 0x00040000,
|
||||
// AttributesEx channel bits (CHANNELED_1 0x4 | CHANNELED_2 0x40) and the
|
||||
// AttributesEx2 autorepeat flag (bit 5, Auto Shot / Shoot) — each shared
|
||||
// by more than one module, so kept here rather than redefined locally.
|
||||
@@ -6971,11 +7403,13 @@ enum Offsets {
|
||||
// but FUN_004eae10 sets it EXPLICITLY — we mirror that.
|
||||
// * The SECOND vertex UV stream (uv1) DOES feed unit 1 (FUN_004eae10
|
||||
// passes a distinct uv1 = this+0x50), so uv1 = uv0 makes unit 1 sample
|
||||
// the mask at the base's own texcoords — exactly SetMask semantics.
|
||||
// uv1 is the LAST explicit UV slot that exists: the vertex-format
|
||||
// component table at 0x008097A8 (13 formats × 13 components) has no
|
||||
// format with a third UV set, so masks past the first CANNOT ride an
|
||||
// explicit stream — they use texgen (below).
|
||||
// the mask at the base's own texcoords. SetMask originally rode this
|
||||
// recipe but was moved to texgen (below): texcoord-relative sampling
|
||||
// breaks for SetTexCoord'd sprites (the mask gets sampled at the same
|
||||
// sub-rect OF THE MASK IMAGE), and uv1 is the LAST explicit UV slot
|
||||
// that exists — the vertex-format component table at 0x008097A8
|
||||
// (13 formats × 13 components) has no format with a third UV set, so
|
||||
// masks past the first cannot ride an explicit stream anyway.
|
||||
GXRS_TEXTURE0 = 0x17, // texture bind, +unit 0..7 (via FUN_GX_RS_SET_PTR)
|
||||
GXRS_COMBINER0 = 0x1F, // combiner preset, +unit 0..7 (via FUN_GX_RS_SET)
|
||||
GXRS_COMBINE_MODULATE = 1, // preset value: COLOROP=MODULATE, ALPHAOP=MODULATE
|
||||
@@ -7064,6 +7498,192 @@ enum Offsets {
|
||||
FUN_GX_TEXFLAGS_INIT = 0x0058A980,
|
||||
FUN_TEXTURE_GET_RENDERABLE = 0x0044ACF0,
|
||||
|
||||
// --- Texture dimension gates (power-of-two / max size) --------------------
|
||||
// 1.12 rejects a non-power-of-two texture TWICE, and both are plain software
|
||||
// checks — no device caps are consulted for the POT rule at either site:
|
||||
// 1. the allocator/recycler FUN_00448450, which returns NULL outright
|
||||
// (`TEST w,w-1 / JNZ reject` per axis, then `CMP dim,0x400 / JAE`);
|
||||
// 2. the GX create validator FUN_0058AB10, which re-tests POT for every
|
||||
// texture type but 2 (type 0 = 2D, 1 = cubemap — the BLP loader passes
|
||||
// `width == height*6` as the type, and FUN_00542180-style atlas creates
|
||||
// pass 0).
|
||||
// Verified unchanged in both later clients: 3.3.5 FUN_00681d90 and 4.3.4
|
||||
// FUN_00840d30 carry the validator's POT test verbatim, same type-2
|
||||
// exemption. What they DO change is the allocator: its dimension test is
|
||||
// demoted to a pool-eligibility window (`dim - 0x20 < 0x1e1`, i.e. [32,512]),
|
||||
// so an out-of-range dimension skips the recycle pool and allocates fresh
|
||||
// rather than failing. That demotion is only safe there because 3.3.5 clamps
|
||||
// its device-caps max to the scratch size (1024) and lets the validator carry
|
||||
// the bound. 1.12's validator ALSO compares against the per-type caps max
|
||||
// (`caps + 0x60 + type*4`, caps via FUN_0058A230) — but that value is raw
|
||||
// hardware (a 2560x1080 passed it), so in 1.12 the allocator's hardcoded
|
||||
// 0x400 is the ONLY thing protecting the decode scratch described below. It
|
||||
// is not redundant. Do not port the demotion without porting a bound.
|
||||
//
|
||||
// The recycle pool is indexed by log2(dim >> 5) and its hit test compares
|
||||
// only flags/type/miplevels — never width/height — so an NPOT texture must
|
||||
// never reach it or it recycles a bucket of unrelated dimensions. Any patch
|
||||
// relaxing the POT rule therefore has to route the failures through a bound
|
||||
// check and THEN to the pool-skip site — never simply delete them, and never
|
||||
// straight to pool-skip (see CODE ORDER MATTERS below).
|
||||
FUN_GX_TEXTURE_ALLOC = 0x00448450,
|
||||
PATCH_TEXALLOC_POT_W_JMP = 0x004484AE, // 0F 85 rel32 -> VA_TEXALLOC_REJECT
|
||||
PATCH_TEXALLOC_POT_H_JMP = 0x004484B9, // 0F 85 rel32 -> VA_TEXALLOC_REJECT
|
||||
// `CMP EBX,imm32` (81 FB) / `CMP EAX,imm32` (3D) — the size bound per axis,
|
||||
// 0x400 stock. VanillaHelpers.dll leaves these and flips the jumps below
|
||||
// from 0F 83 (JAE) to 0F 87 (JA) so exactly-1024 passes.
|
||||
PATCH_TEXALLOC_SIZE_W_IMM = 0x004484C1,
|
||||
PATCH_TEXALLOC_SIZE_H_IMM = 0x004484CC,
|
||||
PATCH_TEXALLOC_SIZE_W_JMP = 0x004484C5, // 0F 83 rel32 -> VA_TEXALLOC_REJECT
|
||||
PATCH_TEXALLOC_SIZE_H_JMP = 0x004484D0, // 0F 83 rel32 -> VA_TEXALLOC_REJECT
|
||||
VA_TEXALLOC_POOL_SKIP = 0x0044864B, // "skip the pool, create fresh"
|
||||
VA_TEXALLOC_REJECT = 0x00448665, // XOR EAX,EAX; RET 0x14
|
||||
// CODE ORDER MATTERS: the POT jumps sit ABOVE the size CMPs, so a POT
|
||||
// failure retargeted straight to VA_TEXALLOC_POOL_SKIP jumps OVER the size
|
||||
// bound — an NPOT 3000x3000 would reach the decoder unbounded. Any NPOT
|
||||
// relaxation must route POT failures through a bound check first
|
||||
// (Texture::DimensionGate does it with a code cave). At the jump sites EBX =
|
||||
// width and EAX = height, EDI = 0 and the 9-dword descriptor at [EBP-0x2C]
|
||||
// is complete, so the pool-skip tail is enterable from any of them.
|
||||
//
|
||||
// Recycle-pool index arithmetic, `index = h_log + w_log * SIDE`, SIDE = 5
|
||||
// for the stock 5x5 (32..512) table. VanillaHelpers grows the table to 6x6
|
||||
// (its init-count immediate below reads 36 instead of 25) but leaves BOTH of
|
||||
// these at stride 5, so a log of 5 on either axis collides: 32x1024 shares a
|
||||
// bucket with 64x32, 64x1024 with 128x32 — and the bucket match never
|
||||
// compares w/h, so the wrong-size texture is silently recycled. Fix = rewrite
|
||||
// each 5-byte pair to `IMUL r,r,6; ADD r,other` (6B F6 06 03 F3 at the
|
||||
// allocator, 6B DB 06 03 D8 at free), which preserves whichever operand
|
||||
// stock treats as the row and only changes the stride. Stock's alloc and
|
||||
// free agree with each other, so the fixed pair does too.
|
||||
PATCH_TEXPOOL_INDEX_STRIDE_ALLOC = 0x00448529, // 8D 04 B3 03 F0 LEA EAX,[EBX+ESI*4]; ADD ESI,EAX
|
||||
PATCH_TEXPOOL_INDEX_STRIDE_FREE = 0x004487AD, // 8D 0C 98 03 D9 LEA ECX,[EAX+EBX*4]; ADD EBX,ECX
|
||||
PATCH_TEXPOOL_INIT_COUNT = 0x00447C45, // B9 imm32 — bucket count: 25 stock, 36 VanillaHelpers
|
||||
// The stock table lives at 0x00B05BE8 (25 x 12 bytes) and ends EXACTLY at
|
||||
// VAR_TEXTURE_DECODE_SCRATCH — an out-of-range bucket index reads the scratch
|
||||
// pointer as a list head. So the size gate may never admit a dimension the
|
||||
// pool can't index unless that dimension is also routed around the pool.
|
||||
FUN_GX_TEXTURE_VALIDATE = 0x0058AB10,
|
||||
// The recycle-pool FREE path — the mirror of the allocator, and the other
|
||||
// half the size relaxation has to cover. It files a freed texture into a
|
||||
// bucket indexed by `tz(w>>5) + tz(h>>5)*stride` (tz = power-of-two factor),
|
||||
// gated ONLY by `w>31 && h>31 && mips<8` — there is NO upper bound. Stock
|
||||
// never overran because the allocator refused anything >=1024, so an
|
||||
// oversized texture never existed to be freed; the cave breaks that
|
||||
// invariant, so a 2048x2048 (tz 6 per axis) files at bucket 36-42, past the
|
||||
// 36-entry table, and writes through a garbage list head (verified crash:
|
||||
// MOV [EAX],ECX at 0x004487D9, EAX = out-of-table pointer + 4). Fix
|
||||
// (Texture::DimensionGate): route a texture larger than the pool can index to
|
||||
// the engine's own non-pooled free FUN_TEXTURE_FREE_RAW — exactly what the
|
||||
// guard's else-branch already does for too-small / too-many-mip textures.
|
||||
FUN_TEXTURE_POOL_FREE = 0x00448670, // __fastcall(tex); the pool free path
|
||||
FUN_TEXTURE_FREE_RAW = 0x0058AD60, // __fastcall(tex); non-pooled free (device release)
|
||||
FUN_TEXTURE_GET_DESC = 0x0058ADA0, // __fastcall(tex, out*) — fills width@+4, height@+8
|
||||
OFF_TEXDESC_WIDTH = 0x04,
|
||||
OFF_TEXDESC_HEIGHT = 0x08,
|
||||
// THE ACTUAL SIZE CEILING. FUN_00448BD0 runs once at startup and allocates a
|
||||
// SINGLE global decode scratch buffer, sized `FUN_005a4b80(2, 0x200, 0x200)`
|
||||
// — one 512x512 texture's worth. Every loader points the decoder at it
|
||||
// (`texObj + 0x120`; see FUN_0044A260's param_1==0 branch and FUN_0044A560),
|
||||
// and the decoder writes rows with no bounds check, so a texture larger than
|
||||
// this buffer overruns the heap. The allocator's `< 0x400` gate is therefore
|
||||
// NOT redundant with the validator's device-caps max — the caps max runs to
|
||||
// thousands, this buffer does not. VanillaHelpers.dll patches both 0x200
|
||||
// immediates to 0x400 in the same function it relaxes the size limit in;
|
||||
// the pairing is mandatory, not incidental. Verified by crash: a 2560x1080
|
||||
// TGA faulted in the decoder's pixel writer at 0x004492E5 (ACCESS_VIOLATION,
|
||||
// write) 2.6x past a 1024x1024 buffer.
|
||||
// Single caller (boot, 0x00402862); the matching free + NULL is the exit
|
||||
// teardown FUN_00448D30. No mid-session re-init path exists, and the five
|
||||
// readers all re-read the global at use time, so the pointer can be swapped
|
||||
// for a larger engine-allocated buffer without a hook.
|
||||
FUN_TEXTURE_DECODE_SCRATCH_ALLOC = 0x00448BD0,
|
||||
VAR_TEXTURE_DECODE_SCRATCH = 0x00B05D14, // void* — the buffer itself
|
||||
// The two size immediates the boot alloc feeds FUN_GX_FORMAT_IMAGE_BYTES:
|
||||
// `MOV EDX,imm32` is width (BA imm32), `PUSH imm32` is height (68 imm32).
|
||||
// Read them back (+1) to learn what the buffer was ACTUALLY sized to on this
|
||||
// DLL stack — 0x200 stock, 0x400 once VanillaHelpers has run.
|
||||
PATCH_TEXDECODE_SCRATCH_DIM_W = 0x00448BE1,
|
||||
PATCH_TEXDECODE_SCRATCH_DIM_H = 0x00448BDC,
|
||||
// __fastcall(ecx = format, edx = width, [stack] = height) -> total bytes for
|
||||
// the image plus its mip chain plus the per-level row-pointer table.
|
||||
FUN_GX_FORMAT_IMAGE_BYTES = 0x005A4B80,
|
||||
// The engine's own decode-buffer constructor: `__fastcall(ecx = format,
|
||||
// edx = width, [stack] height, file, line) -> void*` (RET 0xC). Exactly
|
||||
// `SMemAlloc(FUN_GX_FORMAT_IMAGE_BYTES(fmt,w,h), file, line, 0)` followed by
|
||||
// the row-pointer-table init FUN_005A4BB0 performs per decode. Both decoders
|
||||
// call it (via the small-texture recycle front FUN_0044AF90, which falls
|
||||
// through to it above 256px) when the scratch is NULL, so a buffer it
|
||||
// returns is precisely what the scratch slot expects — and SMemAlloc-backed,
|
||||
// so the exit teardown's SMemFree on it is valid.
|
||||
FUN_TEXTURE_DECODE_BUFFER_ALLOC = 0x005A4AF0,
|
||||
// Device-relative: the caps block at +0x23C (what FUN_0058A230 returns) plus
|
||||
// the type-0 (2D) max-texture-dimension slot at +0x60 — the exact field the
|
||||
// create validator FUN_0058AB10 compares width and height against
|
||||
// (`CMP EDI,[EAX + ESI*4 + 0x60]`, ESI = type). This is the GPU's own limit,
|
||||
// and the only size ceiling the dimension gate enforces: reading it before
|
||||
// growing the decode scratch means we never allocate for a texture the
|
||||
// validator would refuse a moment later. Raw hardware max on modern cards
|
||||
// (a 2560x1080 passed it) — 3.3.5 clamps the equivalent to 1024, 1.12 does
|
||||
// not.
|
||||
OFF_GXDEV_CAPS_MAX_TEX_DIM = 0x29C,
|
||||
// --- Async texture I/O: the force-load path and its 512 KiB trap ----------
|
||||
// FUN_TEXTURE_GET_RENDERABLE(hTex, force=1) calls this when the GxTex does
|
||||
// not exist yet — i.e. for every UI texture on first draw. `__fastcall(ecx =
|
||||
// HTEXTURE)`. If the pending read at [hTex + OFF_HTEXTURE_ASYNC_REQUEST] was
|
||||
// never dispatched (request byte +0x1D == 0: it didn't fit the 2 MiB shared
|
||||
// buffer at 0x00905BE4, or the buffer was busy), it unlinks the request,
|
||||
// points it at a STATIC fallback buffer, dispatches, and blocks until the
|
||||
// read completes. There is no size check. The fallback spans
|
||||
// 0x00885804..0x00905804 — 0x80000 bytes, bounded exactly by the first live
|
||||
// global after it — so a file larger than 512 KiB read through this path
|
||||
// overruns the async list heads, the recycle-pool table and the shared
|
||||
// buffer. That is VanillaHelpers' "crashes while loading textures bigger
|
||||
// than 2 MiB"; its 32 MiB shared buffer makes the path rarer, not safe. The
|
||||
// stock size gate hid the bug by keeping every BLP under ~1.4 MB. Guard
|
||||
// (Texture::DimensionGate): refuse to force a request larger than the
|
||||
// fallback — the texture stays blank instead of overrunning. Growing the
|
||||
// fallback to fit was tried and reverted: the read runs on the engine's
|
||||
// async reader thread (no crash handler), and a read into a right-sized heap
|
||||
// buffer faulted where the same read into the oversized shared buffer does
|
||||
// not — not yet isolated. TGA reads are synchronous and never on this path.
|
||||
FUN_TEXTURE_FORCE_LOAD = 0x0044AD50,
|
||||
OFF_HTEXTURE_ASYNC_REQUEST = 0x138, // async request node, or 0
|
||||
OFF_ASYNCREQ_SIZE = 0x08, // file size (FUN_006487F0 result)
|
||||
OFF_ASYNCREQ_IN_SHARED = 0x1D, // byte: 1 once assigned a buffer and dispatched
|
||||
VAR_ASYNC_FALLBACK_BUFFER = 0x00885804,
|
||||
ASYNC_FALLBACK_BUFFER_SIZE = 0x80000,
|
||||
// --- BLP decompresses BEFORE it allocates -----------------------------------
|
||||
// The .blp loader FUN_0044A560 copies the shared scratch pointer into
|
||||
// texObj+0x120, calls this decompressor to write the decoded image THROUGH
|
||||
// THAT COPY, and only then calls the allocator FUN_00448450. TGA does it the
|
||||
// other way round (allocate, decode later at upload), which is why the
|
||||
// allocator's gate protects TGA but never sees a BLP's dimensions in time; a
|
||||
// 2048x2048 BLP has always overrun the scratch on this client. The other
|
||||
// caller, the IMG path FUN_00449840 (completion FUN_004497F0), does NOT use
|
||||
// the scratch: it allocates a private buffer sized for the image through
|
||||
// FUN_0044AF90, stores it in the same slot, and its release frees whatever
|
||||
// the slot holds — so a hook here must act only when the slot equals
|
||||
// VAR_TEXTURE_DECODE_SCRATCH, and must repoint that slot after growing. (The BLP pre-scale FUN_00448B60 halves against caps+0x60, the
|
||||
// raw hardware max, so it never fires on a modern GPU and protects nothing.)
|
||||
// `__thiscall(reader, format, int *scratchSlot, mipBase, flag)`, RET 0x10;
|
||||
// the reader carries the level-0 dimensions, `>> mipBase` is the level
|
||||
// actually written. Both callers are completion callbacks that the
|
||||
// main-thread pump FUN_00443E70 runs, so a pre-hook here is main-thread and
|
||||
// never inside another decode. Encoding 3 (uncompressed) only writes the row
|
||||
// table into the scratch and points the rows at the file buffer; DXT and
|
||||
// palettized decompress into it — the crash case.
|
||||
FUN_BLP_DECOMPRESS = 0x005A8430,
|
||||
OFF_BLPREADER_WIDTH = 0x10,
|
||||
OFF_BLPREADER_HEIGHT = 0x14,
|
||||
// The `NEG EAX; SBB EAX,EAX` (F7 D8 1B C0) pair inside the validator's isPOT
|
||||
// idiom `(x & (x-1)) -> NEG/SBB -> INC -> TEST/JZ`, once per axis. The two
|
||||
// axes share the final TEST/JZ with the type-2 branch, so the arithmetic is
|
||||
// the surgical patch point — neutering the shared JZ would also disable the
|
||||
// type-2 flags check.
|
||||
PATCH_TEXVALIDATE_POT_W = 0x0058AB7C,
|
||||
PATCH_TEXVALIDATE_POT_H = 0x0058AB8D,
|
||||
|
||||
// --- Inline-texture positioning (slice 1: measure/emit integration) --------
|
||||
// The 1.12 text pipeline builds a layout as a list of render "nodes" (one
|
||||
// per wrapped line); each node owns per-font-page glyph vertex batches and a
|
||||
@@ -7282,6 +7902,55 @@ enum Offsets {
|
||||
// *(this+0xF0) via SStrDup.
|
||||
OFF_EDITBOX_TEXT_FONTSTRING = 0x328, // CSimpleFontString* — the display text object
|
||||
|
||||
// CSimpleEditBox cursor / selection internals (backs EditBox::Methods'
|
||||
// SetCursorPosition / GetCursorPosition). Verified from the engine's own
|
||||
// HighlightText (FUN_00798870 → FUN_0077cca0) and Insert (FUN_00798400 →
|
||||
// FUN_0077bee0) paths:
|
||||
// +0x31c dirty-flag dword — bit 2 = selection/highlight changed, bit 4 =
|
||||
// cursor changed (both trigger the next-paint relayout).
|
||||
// +0x338 text length in BYTES (the clamp bound below).
|
||||
// +0x35c / +0x360 selection start / end (byte offsets).
|
||||
// +0x36c cursor insertion point, a BYTE offset — matches modern WoW's
|
||||
// byte-based EditBox cursor (GetCursorPosition returns bytes; for
|
||||
// ASCII text byte == character).
|
||||
OFF_EDITBOX_FLAGS = 0x31c,
|
||||
OFF_EDITBOX_TEXT_LENGTH = 0x338, // current text length in bytes
|
||||
OFF_EDITBOX_CURSOR_BYTE = 0x36c,
|
||||
// `void __thiscall(editbox, byteOffset)` — sets the cursor to a byte
|
||||
// offset, clamping to [0, textByteLen] and OR-ing the +0x31c cursor-dirty
|
||||
// bit. The engine's own "restore cursor" primitive (called from the insert
|
||||
// path FUN_0077bee0 to put the caret back after splicing text).
|
||||
FUN_EDITBOX_SET_CURSOR_BYTE = 0x0077e360,
|
||||
// `void __thiscall(editbox)` — collapses the selection onto the cursor
|
||||
// (selStart = selEnd = +0x36c). The engine's own "clear selection to the
|
||||
// caret" helper (FUN_0077ccf0), used before extending a shift-selection.
|
||||
FUN_EDITBOX_COLLAPSE_SELECTION = 0x0077ccf0,
|
||||
// `int __thiscall(editbox, startByteIndex, byteCount)` — counts CHARACTERS
|
||||
// (UTF-8 codepoints) in a byte range, walking the per-byte char-class table
|
||||
// at +0x330 (a byte begins a codepoint when its class is 2/3/6). So
|
||||
// `COUNT_CHARS(eb, 0, cursorByteOffset)` is the cursor's character index —
|
||||
// backs GetUTF8CursorPosition. Verified from the arrow-key mover
|
||||
// FUN_0077cb20's own `FUN_0077bc80(this, byteOff, cursorByte - byteOff)`.
|
||||
FUN_EDITBOX_COUNT_CHARS = 0x0077bc80,
|
||||
// Selection-highlight regions — three consecutive CSimpleTexture pointers
|
||||
// (start-line +0x350, middle-block +0x354, end-line +0x358) the editbox
|
||||
// shows/positions to paint the selection (FUN_0077d950). Their vertex
|
||||
// color IS the highlight color — Set/GetHighlightColor read/write it via
|
||||
// the generic region color accessors. The blinking caret is a SEPARATE
|
||||
// region at +0x3d4, deliberately left alone.
|
||||
OFF_EDITBOX_HIGHLIGHT_REGION = 0x350,
|
||||
// Input-history ring buffer: max lines (ring capacity) at +0x388 (what
|
||||
// GetHistoryLines reads), write position at +0x38c, container
|
||||
// {size@+0x390, alloc@+0x394, data@+0x398} of {char* text, int exec}
|
||||
// entries. AddHistoryLine writes at +0x38c and advances modulo +0x388.
|
||||
OFF_EDITBOX_HISTORY_MAX = 0x388,
|
||||
// `void __thiscall(editbox, int maxLines)` — the SetHistoryLines internal
|
||||
// (FUN_0077cd90). Reallocates the history ring to maxLines; maxLines == 0
|
||||
// frees the whole buffer and zeroes the container. ClearHistory calls it
|
||||
// with 0 then the saved max — empties every entry and resets the write
|
||||
// position while keeping the limit (so recording still works after).
|
||||
FUN_EDITBOX_SET_HISTORY_MAX = 0x0077cd90,
|
||||
|
||||
// --- FontString measure internals (GetStringWidth icon fix + GetStringHeight) ---
|
||||
// CSimpleFontString::GetStringWidthInternal — `float(__fastcall)(fs /*ecx*/)`,
|
||||
// returns in x87 ST0. Lazily computes into the fs+0xFC cache (ANCHOR units,
|
||||
|
||||
+35
-41
@@ -50,6 +50,7 @@
|
||||
#include "addons/EngineIO.h"
|
||||
#include "addons/FlavorBindings.h"
|
||||
#include "addons/FlavorToc.h"
|
||||
#include "addons/Registry.h"
|
||||
#include "addons/Toc.h"
|
||||
#include "addons/TocRewrite.h"
|
||||
#include "bindings/Inject.h"
|
||||
@@ -377,26 +378,22 @@ using ListInsert_t = void(__thiscall *)(void *listCtrl, void *entry,
|
||||
// engine's own insert helper handles remove-then-insert atomically,
|
||||
// so it's safe to call on an entry that's already in the list.
|
||||
//
|
||||
// Entries are pointed to by `[VAR_ADDON_LIST_HEAD]` (`0x00BE1B6C`),
|
||||
// with the next-pointer field at `entry + 0x10` (computed as
|
||||
// `*(value-at-VAR_ADDON_LIST_CTRL) + entry + 4` = `0xC + entry + 4`
|
||||
// = `entry + 0x10`). Low-bit-1 means "sentinel" (end of list).
|
||||
// Name pointer lives at `entry + 0x14`.
|
||||
constexpr uintptr_t VAR_ADDON_LIST_HEAD = 0x00BE1B6C;
|
||||
constexpr int OFF_ADDON_ENTRY_NAME_PTR = 0x14;
|
||||
|
||||
// `entry+0x29` — the "filter-out" byte. The flat display-array builder
|
||||
// `FUN_0051da70` walks the linked list and copies an entry into
|
||||
// `GetNumAddOns`/`GetAddOnInfo`'s array ONLY when this byte is 0, so
|
||||
// setting it hides `!!!ClassicAPI` from the character-select AddOns
|
||||
// list — there's no checkbox, so it can't be toggled off. The builder
|
||||
// only ever *writes* this byte on the secure/SMSG path (`entry+0x28 !=
|
||||
// 0`); our entry is non-secure, so a manual `1` here is stable across
|
||||
// rebuilds. Hiding does NOT stop loading (the load pass `FUN_0051f600`
|
||||
// never reads `+0x29`) and does NOT break dependency resolution or
|
||||
// by-name queries (those use the addon hash table, which keeps the
|
||||
// entry regardless).
|
||||
constexpr int OFF_ADDON_ENTRY_FILTER_OUT = 0x29;
|
||||
// The list is walked via the shared `Addons::ForEachEntry`
|
||||
// (`addons/Registry.h`); the callback stops the walk once it finds and
|
||||
// re-links our entry, since the re-link invalidates the current
|
||||
// next-pointer.
|
||||
//
|
||||
// `entry + OFF_ADDON_ENTRY_FILTER_OUT` is the "filter-out" byte. The
|
||||
// flat display-array builder `FUN_0051da70` walks the linked list and
|
||||
// copies an entry into `GetNumAddOns`/`GetAddOnInfo`'s array ONLY when
|
||||
// this byte is 0, so setting it hides `!!!ClassicAPI` from the
|
||||
// character-select AddOns list — there's no checkbox, so it can't be
|
||||
// toggled off. The builder only ever *writes* this byte on the
|
||||
// secure/SMSG path (`entry+0x28 != 0`); our entry is non-secure, so a
|
||||
// manual `1` here is stable across rebuilds. Hiding does NOT stop
|
||||
// loading (the load pass `FUN_0051f600` never reads `+0x29`) and does
|
||||
// NOT break dependency resolution or by-name queries (those use the
|
||||
// addon hash table, which keeps the entry regardless).
|
||||
|
||||
// `entry+0x2b` — DefaultState (`## DefaultState: enabled/disabled`).
|
||||
// The enable resolver `FUN_ADDON_ENABLE_RESOLVE` falls back to this
|
||||
@@ -411,29 +408,26 @@ constexpr int OFF_ADDON_ENTRY_DEFAULT_STATE = 0x2b;
|
||||
// with the `FUN_ADDON_ENABLE_RESOLVE` co-hook, `!!!ClassicAPI` always
|
||||
// loads and never appears as a toggleable entry.
|
||||
void FinalizeEmbeddedEntry() {
|
||||
uintptr_t entry = *reinterpret_cast<uintptr_t *>(VAR_ADDON_LIST_HEAD);
|
||||
const int linkOffset = *reinterpret_cast<const int *>(
|
||||
static_cast<uintptr_t>(Offsets::VAR_ADDON_LIST_CTRL));
|
||||
while ((entry & 1) == 0 && entry != 0) {
|
||||
Addons::ForEachEntry([](uintptr_t entry) -> bool {
|
||||
const char *name = *reinterpret_cast<const char *const *>(
|
||||
entry + OFF_ADDON_ENTRY_NAME_PTR);
|
||||
if (name != nullptr && std::strcmp(name, kAddonName) == 0) {
|
||||
// Hide from the character-select AddOns list and default it
|
||||
// to enabled.
|
||||
*reinterpret_cast<uint8_t *>(entry + OFF_ADDON_ENTRY_FILTER_OUT) = 1;
|
||||
*reinterpret_cast<uint8_t *>(entry + OFF_ADDON_ENTRY_DEFAULT_STATE) = 1;
|
||||
entry + Offsets::OFF_ADDON_ENTRY_NAME_PTR);
|
||||
if (name == nullptr || std::strcmp(name, kAddonName) != 0)
|
||||
return true; // keep walking
|
||||
|
||||
auto fn = reinterpret_cast<ListInsert_t>(
|
||||
Offsets::FUN_INTRUSIVE_LIST_INSERT);
|
||||
fn(reinterpret_cast<void *>(
|
||||
static_cast<uintptr_t>(Offsets::VAR_ADDON_LIST_CTRL)),
|
||||
reinterpret_cast<void *>(entry),
|
||||
/*position=*/ 1, /*anchor=*/ 0);
|
||||
return;
|
||||
}
|
||||
entry = *reinterpret_cast<const uintptr_t *>(
|
||||
entry + linkOffset + 4);
|
||||
}
|
||||
// Hide from the character-select AddOns list and default it to
|
||||
// enabled, then move it to the head of the load order.
|
||||
*reinterpret_cast<uint8_t *>(
|
||||
entry + Offsets::OFF_ADDON_ENTRY_FILTER_OUT) = 1;
|
||||
*reinterpret_cast<uint8_t *>(entry + OFF_ADDON_ENTRY_DEFAULT_STATE) = 1;
|
||||
|
||||
auto fn = reinterpret_cast<ListInsert_t>(
|
||||
Offsets::FUN_INTRUSIVE_LIST_INSERT);
|
||||
fn(reinterpret_cast<void *>(
|
||||
static_cast<uintptr_t>(Offsets::VAR_ADDON_LIST_CTRL)),
|
||||
reinterpret_cast<void *>(entry),
|
||||
/*position=*/ 1, /*anchor=*/ 0);
|
||||
return false; // stop — the re-link invalidated our next-pointer
|
||||
});
|
||||
}
|
||||
|
||||
void __fastcall AddonInit_h(const char *accountName) {
|
||||
|
||||
+24
-10
@@ -26,6 +26,7 @@
|
||||
|
||||
#include "Game.h"
|
||||
#include "Offsets.h"
|
||||
#include "addons/LoadState.h"
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
@@ -251,19 +252,23 @@ int __fastcall Script_GetAddOnSecurity(void *L) {
|
||||
|
||||
// `C_AddOns.IsAddOnLoaded(indexOrName)` → `(loadedOrLoading, loaded)`.
|
||||
//
|
||||
// Modern WoW distinguishes "load-in-progress" from "fully loaded" —
|
||||
// matters for LoD addons whose load is split across multiple
|
||||
// `LoadAddOn` callbacks. Vanilla 1.12's `FUN_0051F240` is fully
|
||||
// synchronous: the loaded byte at `entry+0x18` flips from 0 → 1
|
||||
// inside a single call, so the "loading" state is never observable
|
||||
// from Lua. We return the same boolean twice — `loadedOrLoading`
|
||||
// and `loaded` are identical here.
|
||||
// Modern WoW distinguishes "load-in-progress" from "fully loaded", and
|
||||
// the two really are distinguishable here: the engine's loaded byte
|
||||
// (`entry+0x18`) is set at the START of a load, before the addon's files
|
||||
// run, so the byte alone answers `loadedOrLoading`, not `loaded`. The
|
||||
// window is reachable from Lua — an addon's own file-scope code, and any
|
||||
// dependency pulled in during it, run while the byte already reads set.
|
||||
//
|
||||
// So `loadedOrLoading` is the engine's byte, and `loaded` additionally
|
||||
// requires the addon not to be mid-load, which `AddOns::LoadState`
|
||||
// tracks from the loader itself.
|
||||
//
|
||||
// Resolves both numeric indices (via `FUN_ADDON_GET_BY_INDEX`) and
|
||||
// string names. `IS_LOADED` accepts an entry pointer as its name arg
|
||||
// because the entry's first 12 bytes are the inline NUL-terminated
|
||||
// directory name (same as the other per-field accessors), so we can
|
||||
// feed it either an entry or a Lua-side string transparently.
|
||||
// feed it either an entry or a Lua-side string transparently — and that
|
||||
// same pointer is the name `LoadState` matches on.
|
||||
int __fastcall Script_IsAddOnLoaded(void *L) {
|
||||
const uint8_t *entry = ResolveAddOnName(L);
|
||||
if (entry == nullptr) {
|
||||
@@ -273,8 +278,11 @@ int __fastcall Script_IsAddOnLoaded(void *L) {
|
||||
}
|
||||
auto isLoaded = reinterpret_cast<EntryByteFn_t>(
|
||||
Offsets::FUN_ADDON_IS_LOADED);
|
||||
const bool loaded = isLoaded(entry) != 0;
|
||||
Game::Lua::PushBool(L, loaded);
|
||||
const bool loadedOrLoading = isLoaded(entry) != 0;
|
||||
const bool loaded =
|
||||
loadedOrLoading &&
|
||||
!AddOns::LoadState::IsLoading(reinterpret_cast<const char *>(entry));
|
||||
Game::Lua::PushBool(L, loadedOrLoading);
|
||||
Game::Lua::PushBool(L, loaded);
|
||||
return 2;
|
||||
}
|
||||
@@ -385,6 +393,12 @@ static void RegisterLuaFunctions() {
|
||||
&Script_DoesAddOnExist);
|
||||
Game::Lua::RegisterTableFunction("C_AddOns", "IsAddOnLoaded",
|
||||
&Script_IsAddOnLoaded);
|
||||
// The stock global, mirrored into the namespace — the engine's own
|
||||
// handler already has the Lua C ABI and the modern return shape.
|
||||
Game::Lua::RegisterTableFunction(
|
||||
"C_AddOns", "LoadAddOn",
|
||||
reinterpret_cast<Game::Lua::CFunction>(
|
||||
static_cast<uintptr_t>(Offsets::FUN_SCRIPT_LOAD_ADDON)));
|
||||
Game::Lua::RegisterTableFunction("C_AddOns", "GetAddOnOptionalDependencies",
|
||||
&Script_GetAddOnOptionalDependencies);
|
||||
Game::Lua::RegisterIntegerEnum(
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
// This file is part of ClassicAPI.
|
||||
//
|
||||
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
|
||||
// of the GNU General Public License as published by the Free Software Foundation, either
|
||||
// version 3 of the License, or (at your option) any later version.
|
||||
//
|
||||
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
|
||||
// PURPOSE. See the GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License along with
|
||||
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
// Tracks in-progress addon loads so `C_AddOns.IsAddOnLoaded` can report
|
||||
// `loadedOrLoading` and `loaded` separately. See `LoadState.h` for why the
|
||||
// engine's own byte can't answer both.
|
||||
//
|
||||
// Loads nest — an addon's dependencies load inside its own call — so this
|
||||
// keeps a stack rather than a single name. The name is copied rather than
|
||||
// stored by pointer: `LoadAddOn` passes a Lua string, which does not
|
||||
// outlive the call.
|
||||
|
||||
#include "addons/LoadState.h"
|
||||
|
||||
#include "Game.h"
|
||||
#include "Offsets.h"
|
||||
|
||||
#include <cstring>
|
||||
|
||||
namespace AddOns::LoadState {
|
||||
|
||||
namespace {
|
||||
|
||||
// Dependency chains are shallow; anything deeper simply isn't tracked
|
||||
// (the depth counter still balances, so the stack can't desync).
|
||||
constexpr int kMaxDepth = 16;
|
||||
constexpr int kMaxName = 64;
|
||||
|
||||
char g_stack[kMaxDepth][kMaxName];
|
||||
int g_depth = 0;
|
||||
|
||||
bool EqualsIgnoreCase(const char *a, const char *b) {
|
||||
for (; *a != '\0' && *b != '\0'; ++a, ++b) {
|
||||
char ca = *a;
|
||||
char cb = *b;
|
||||
if (ca >= 'A' && ca <= 'Z')
|
||||
ca = static_cast<char>(ca - 'A' + 'a');
|
||||
if (cb >= 'A' && cb <= 'Z')
|
||||
cb = static_cast<char>(cb - 'A' + 'a');
|
||||
if (ca != cb)
|
||||
return false;
|
||||
}
|
||||
return *a == *b;
|
||||
}
|
||||
|
||||
using LoadOne_t = unsigned int(__fastcall *)(const char *name, char flag,
|
||||
void *progressCtx);
|
||||
LoadOne_t s_loadOne_o = nullptr;
|
||||
|
||||
unsigned int __fastcall LoadOne_h(const char *name, char flag, void *progressCtx) {
|
||||
const int slot = g_depth;
|
||||
if (slot >= 0 && slot < kMaxDepth) {
|
||||
if (name != nullptr) {
|
||||
std::strncpy(g_stack[slot], name, kMaxName - 1);
|
||||
g_stack[slot][kMaxName - 1] = '\0';
|
||||
} else {
|
||||
g_stack[slot][0] = '\0';
|
||||
}
|
||||
}
|
||||
++g_depth;
|
||||
|
||||
const unsigned int result = s_loadOne_o(name, flag, progressCtx);
|
||||
|
||||
--g_depth;
|
||||
if (g_depth < 0)
|
||||
g_depth = 0; // defensive: never let the stack run negative
|
||||
return result;
|
||||
}
|
||||
|
||||
const Game::HookAutoRegister _hookLoadOne{
|
||||
Offsets::FUN_ADDON_LOAD_ONE,
|
||||
reinterpret_cast<void *>(&LoadOne_h),
|
||||
reinterpret_cast<void **>(&s_loadOne_o)};
|
||||
|
||||
} // namespace
|
||||
|
||||
bool IsLoading(const char *name) {
|
||||
if (name == nullptr || *name == '\0')
|
||||
return false;
|
||||
const int depth = (g_depth < kMaxDepth) ? g_depth : kMaxDepth;
|
||||
for (int i = 0; i < depth; ++i)
|
||||
if (EqualsIgnoreCase(g_stack[i], name))
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
} // namespace AddOns::LoadState
|
||||
@@ -0,0 +1,33 @@
|
||||
// This file is part of ClassicAPI.
|
||||
//
|
||||
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
|
||||
// of the GNU General Public License as published by the Free Software Foundation, either
|
||||
// version 3 of the License, or (at your option) any later version.
|
||||
//
|
||||
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
|
||||
// PURPOSE. See the GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License along with
|
||||
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
#pragma once
|
||||
|
||||
// `AddOns::LoadState` — tracks which addons are mid-load.
|
||||
//
|
||||
// The engine's own "loaded" byte (`entry+0x18`) is set at the START of a
|
||||
// load, before the addon's files run, so on its own it can't tell
|
||||
// "loading" from "loaded". That distinction is observable: an addon's
|
||||
// file-scope code, and every dependency pulled in during it, execute
|
||||
// while the byte already reads set.
|
||||
//
|
||||
// A co-hook on the per-addon loader keeps a stack of the names currently
|
||||
// being loaded, which is the missing half of
|
||||
// `C_AddOns.IsAddOnLoaded`'s `(loadedOrLoading, loaded)` pair.
|
||||
namespace AddOns::LoadState {
|
||||
|
||||
// True while `name` is inside the engine's per-addon load call. Matching
|
||||
// is case-insensitive, as addon-name matching is everywhere else.
|
||||
bool IsLoading(const char *name);
|
||||
|
||||
} // namespace AddOns::LoadState
|
||||
@@ -0,0 +1,50 @@
|
||||
// This file is part of ClassicAPI.
|
||||
//
|
||||
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
|
||||
// of the GNU General Public License as published by the Free Software Foundation, either
|
||||
// version 3 of the License, or (at your option) any later version.
|
||||
//
|
||||
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
|
||||
// PURPOSE. See the GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License along with
|
||||
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "Offsets.h"
|
||||
|
||||
#include <cstdint>
|
||||
#include <type_traits>
|
||||
|
||||
namespace Addons {
|
||||
|
||||
// Walk the engine's intrusive AddOn-registry linked list, calling `fn(entry)`
|
||||
// for each `AddOnEntry *`. Head at `[VAR_ADDON_LIST_HEAD]`; the next-pointer
|
||||
// field is at `entry + [VAR_ADDON_LIST_CTRL] + 4` (= `entry + 0x10`); a
|
||||
// low-bit-1 sentinel or NULL terminates. This is the same traversal the
|
||||
// engine's own load pass (`FUN_0051F600`) and scan tail loop use.
|
||||
//
|
||||
// `fn` may return `void` (always continue) or `bool` (return `false` to STOP
|
||||
// early). Early stop is load-bearing for a visitor that mutates the list —
|
||||
// e.g. re-linking an entry to the head invalidates the current next-pointer,
|
||||
// so the walk must not follow it. Shared by `Addons::Embedded` (find + re-link
|
||||
// the embedded entry) and `Addons::Rescan` (snapshot / diff / fix-up passes).
|
||||
template <typename Fn> void ForEachEntry(Fn fn) {
|
||||
const int linkOffset = *reinterpret_cast<const int *>(
|
||||
static_cast<uintptr_t>(Offsets::VAR_ADDON_LIST_CTRL));
|
||||
uintptr_t entry = *reinterpret_cast<const uintptr_t *>(
|
||||
static_cast<uintptr_t>(Offsets::VAR_ADDON_LIST_HEAD));
|
||||
while ((entry & 1) == 0 && entry != 0) {
|
||||
if constexpr (std::is_void_v<decltype(fn(entry))>) {
|
||||
fn(entry);
|
||||
} else {
|
||||
if (!fn(entry))
|
||||
return;
|
||||
}
|
||||
entry = *reinterpret_cast<const uintptr_t *>(entry + linkOffset + 4);
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace Addons
|
||||
@@ -0,0 +1,523 @@
|
||||
// This file is part of ClassicAPI.
|
||||
//
|
||||
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
|
||||
// of the GNU General Public License as published by the Free Software Foundation, either
|
||||
// version 3 of the License, or (at your option) any later version.
|
||||
//
|
||||
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
|
||||
// PURPOSE. See the GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License along with
|
||||
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
// Retail-like `/reload`: pick up new addon folders and new files without
|
||||
// restarting the client. Two independent engine limitations block that on
|
||||
// a stock 1.12 client, each fixed here with the engine's own machinery:
|
||||
//
|
||||
// 1. FILE VISIBILITY. Every relative-path read resolves through a
|
||||
// loose-file hash index built ONCE at boot (see Offsets.h,
|
||||
// `FUN_VFS_INDEX_SUBTREE`); files created after boot — a new
|
||||
// addon's TOC, a new .lua in an existing addon, a freshly written
|
||||
// SavedVariables file — are invisible until restart. The boot
|
||||
// indexer is dedup-safe and works on subtrees, so we re-run it per
|
||||
// /reload on the only two subtrees whose contents can change what
|
||||
// a /reload loads: `Interface\AddOns` and `WTF\Account`. New files
|
||||
// in EXISTING addons need nothing more — the per-addon loader
|
||||
// `FUN_0051F240` re-reads the TOC from disk on every load pass, so
|
||||
// once the files are visible they load.
|
||||
//
|
||||
// 2. REGISTRY MEMBERSHIP. The addon registry is built once at login;
|
||||
// new folders are never walked. We replay the login scan's disk
|
||||
// walk verbatim (`FUN_ADDON_SCAN_DISK_DIRS` with the engine's own
|
||||
// per-directory callback, which feeds the dedup-safe TOC parser),
|
||||
// so new folders register as completely normal entries — then
|
||||
// mirror the two registry structures the scan's OTHER passes would
|
||||
// have filled: the reverse-LoadWith lists (scan tail loop) and the
|
||||
// flat `GetNumAddOns` display array (`FUN_0051DA70` phase 2).
|
||||
//
|
||||
// 3. METADATA IMMUTABILITY. The parser's dedup guard makes a
|
||||
// registered entry's `##` metadata read-once: an edited
|
||||
// `## SavedVariables:` / `## Dependencies:` / any `##` line never
|
||||
// re-parses. The engine's own complete per-entry destructor
|
||||
// (`FUN_ADDON_ENTRY_DESTROY` — frees every owned allocation and
|
||||
// self-unlinks from both the list and the hash; see Offsets.h)
|
||||
// makes evict + re-register safe: destroy the entry so the dedup
|
||||
// guard MISSES, re-feed the name to `FUN_TOC_PARSER`, and the
|
||||
// entry rebuilds from the edited TOC exactly like a fresh
|
||||
// registration. Gated on an actual `##` change (hash of the TOC's
|
||||
// `##` lines only — file-reference edits load natively and never
|
||||
// trigger the evict path). A TOC that no longer reads at all
|
||||
// (folder deleted) evicts without re-registering — the deletion
|
||||
// analog; a folder the scan still finds simply re-registers.
|
||||
//
|
||||
// Runs from the `ModuleAutoRegister` callback — LoadScriptFunctions
|
||||
// post-hook, which fires inside FrameXML init (`FUN_0048FBF0`) BEFORE
|
||||
// the AddOns.txt enable-state re-read, the load-progress count, and the
|
||||
// addon load pass, on both login and every /reload. The engine then
|
||||
// loads new entries natively: dep ordering, Bindings.xml, flavor TOCs,
|
||||
// SavedVariables, ADDON_LOADED, the load-screen "Loading add-on %s".
|
||||
// The window is also what makes eviction safe: the unload pass already
|
||||
// ran (SavedVariables written from the OLD entry's SV lists, loaded
|
||||
// bytes cleared), and the load pass hasn't — nothing holds entry state.
|
||||
//
|
||||
// Excluded from the evict path (everything else is fair game):
|
||||
// - `!!!ClassicAPI` — embedded; `Addons::Embedded` owns its entry
|
||||
// (head re-link + filter byte);
|
||||
// - `## Secure:` / SMSG-managed entries — the parser cannot restore
|
||||
// packet-delivered state (see OFF_ADDON_ENTRY_SECURE).
|
||||
//
|
||||
// NOTE the surgical discipline. A previous attempt re-invoked the
|
||||
// login-only teardown+rescan (`FUN_ADDON_INIT`) mid-/reload and
|
||||
// corrupted the registry (duplicate loads + Lua memory explosion on the
|
||||
// NEXT login). This module never runs bulk teardown; the only entry
|
||||
// mutation beyond what a login scan produces is the per-entry evict
|
||||
// above, which is (a) the engine's own complete destructor, (b) always
|
||||
// preceded by the reverse-LoadWith scrub (the one reference the dtor
|
||||
// can't clean), and (c) always followed by the same fix-ups a new
|
||||
// registration gets. New entries come from the same parser call
|
||||
// `Addons::Embedded` has exercised every login.
|
||||
//
|
||||
// Thread note: the loose-file index has no lock, but the engine itself
|
||||
// builds it lazily on first access and we mutate it only during the
|
||||
// reload loading screen on the main thread — the same context in which
|
||||
// the engine does all its own file work.
|
||||
|
||||
#include "Game.h"
|
||||
#include "Offsets.h"
|
||||
#include "addons/EngineIO.h"
|
||||
#include "addons/Registry.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstdint>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <string>
|
||||
#include <unordered_map>
|
||||
#include <vector>
|
||||
|
||||
namespace Addons::Rescan {
|
||||
|
||||
namespace {
|
||||
|
||||
// ── Engine entry points (all verified by disassembly; see Offsets.h) ──
|
||||
|
||||
using FindOpen_t = void *(__stdcall *)(const char *dirPath);
|
||||
using FindClose_t = void(__stdcall *)(void *findBlock);
|
||||
using IndexSubtree_t = void(__fastcall *)(const char *basePath,
|
||||
const char *relSubdir,
|
||||
void *findHandle);
|
||||
using ScanDiskDirs_t = int(__fastcall *)(const char *basePath,
|
||||
const char *pattern, void *callback,
|
||||
void *userParam, int includeHidden);
|
||||
using ResolveByName_t = void *(__fastcall *)(const char *name);
|
||||
using DescGrow_t = void(__thiscall *)(void *desc, uint32_t newCap);
|
||||
using QuantumCalc_t = uint32_t(__thiscall *)(void *desc, uint32_t needed);
|
||||
using Qsort_t = void(__cdecl *)(void *base, uint32_t num, uint32_t width,
|
||||
void *compare);
|
||||
using TocParser_t = void(__fastcall *)(const char *name);
|
||||
using EntryDestroy_t = void(__stdcall *)(void *entry);
|
||||
|
||||
// The engine's ubiquitous growable-array descriptor.
|
||||
struct Desc {
|
||||
uint32_t cap;
|
||||
uint32_t count;
|
||||
uint32_t *data;
|
||||
uint32_t quantum;
|
||||
};
|
||||
|
||||
// Append one dword to a descriptor, mirroring the engine's inline
|
||||
// grow-and-append (the scan tail loop and `FUN_0051DA70` phase 2 share
|
||||
// it): round the needed cap up to the quantum — computed by the
|
||||
// engine's own `FUN_DESC_QUANTUM_CALC` when the desc has none — grow
|
||||
// via the site's grow instantiation (which reallocs `data` and writes
|
||||
// `cap`), then `data[count++] = value`.
|
||||
void DescAppend(Desc *desc, uint32_t value, DescGrow_t grow) {
|
||||
uint32_t needed = desc->count + 1;
|
||||
if (desc->cap < needed) {
|
||||
uint32_t quantum = desc->quantum;
|
||||
if (quantum == 0) {
|
||||
auto calc =
|
||||
reinterpret_cast<QuantumCalc_t>(Offsets::FUN_DESC_QUANTUM_CALC);
|
||||
quantum = calc(desc, needed);
|
||||
}
|
||||
if (needed % quantum != 0)
|
||||
needed += quantum - needed % quantum;
|
||||
grow(desc, needed);
|
||||
}
|
||||
desc->data[desc->count++] = value;
|
||||
}
|
||||
|
||||
// The registry linked-list walk lives in `addons/Registry.h`
|
||||
// (`Addons::ForEachEntry`), shared with `Addons::Embedded`.
|
||||
|
||||
// By-name registry lookup via the engine's own hash resolver (returns
|
||||
// the entry's RequiredDeps desc, so subtract its offset — same recovery
|
||||
// `Addons::Info` uses).
|
||||
uintptr_t ResolveEntryByName(const char *name) {
|
||||
auto resolve =
|
||||
reinterpret_cast<ResolveByName_t>(Offsets::FUN_ADDON_RESOLVE_REQ_DEPS);
|
||||
const uintptr_t desc = reinterpret_cast<uintptr_t>(resolve(name));
|
||||
return desc != 0 ? desc - Offsets::OFF_ADDON_REQDEPS_DESC : 0;
|
||||
}
|
||||
|
||||
// ── Step 1: loose-file index refresh ──────────────────────────────────
|
||||
|
||||
// Register new on-disk files under `relSubdir` with the frozen index,
|
||||
// exactly as the boot walk would have (the indexer skips every file
|
||||
// already present). No-op when the directory doesn't exist —
|
||||
// `FUN_VFS_FIND_OPEN` requires an existing directory and returns NULL.
|
||||
void ReindexSubtree(const char *base, const char *relSubdir) {
|
||||
char dir[260];
|
||||
std::snprintf(dir, sizeof(dir), "%s\\%s", base, relSubdir);
|
||||
auto findOpen = reinterpret_cast<FindOpen_t>(Offsets::FUN_VFS_FIND_OPEN);
|
||||
auto findClose = reinterpret_cast<FindClose_t>(Offsets::FUN_VFS_FIND_CLOSE);
|
||||
void *handle = findOpen(dir);
|
||||
if (handle == nullptr)
|
||||
return;
|
||||
auto index =
|
||||
reinterpret_cast<IndexSubtree_t>(Offsets::FUN_VFS_INDEX_SUBTREE);
|
||||
index(base, relSubdir, handle);
|
||||
findClose(handle);
|
||||
}
|
||||
|
||||
void RefreshLooseFileIndex() {
|
||||
// The boot walk's base choice (`FUN_00646EA0`): the recorded game
|
||||
// dir if it opens as a directory, else ".". Index keys are relative
|
||||
// to this, so it must match or the new keys would never be hit.
|
||||
auto findOpen = reinterpret_cast<FindOpen_t>(Offsets::FUN_VFS_FIND_OPEN);
|
||||
auto findClose = reinterpret_cast<FindClose_t>(Offsets::FUN_VFS_FIND_CLOSE);
|
||||
const char *base =
|
||||
reinterpret_cast<const char *>(Offsets::VAR_VFS_BASE_PATH);
|
||||
void *probe = findOpen(base);
|
||||
if (probe != nullptr)
|
||||
findClose(probe);
|
||||
else
|
||||
base = ".";
|
||||
|
||||
// The only subtrees whose post-boot changes affect what a /reload
|
||||
// loads: addon content, and the SavedVariables the unload pass
|
||||
// wrote moments ago (a first-ever SV file is otherwise invisible —
|
||||
// the stock client loses a freshly installed addon's settings on
|
||||
// /reload for exactly this reason).
|
||||
ReindexSubtree(base, "Interface\\AddOns");
|
||||
ReindexSubtree(base, "WTF\\Account");
|
||||
}
|
||||
|
||||
// ── Step 1.5: `##` metadata refresh (evict + re-register on change) ──
|
||||
|
||||
// The embedded addon's name — its entry is owned by `Addons::Embedded`
|
||||
// (head re-link + filter byte) and never refreshed here.
|
||||
constexpr const char *kEmbeddedAddon = "!!!ClassicAPI";
|
||||
|
||||
// name → FNV-1a hash of the addon's `##` lines, as last parsed.
|
||||
// Per-process on purpose: the registry persists across /reload, and a
|
||||
// stale hash after a full re-login scan only causes one harmless
|
||||
// re-parse of already-current metadata.
|
||||
std::unordered_map<std::string, uint32_t> g_metaHash;
|
||||
|
||||
// Read `<name>`'s base TOC exactly as the parser does — through the
|
||||
// (hooked) `FUN_FILE_READ`, so flavor selection and TOC rewriting are
|
||||
// reflected in what gets hashed. Caller frees `*buf` via Storm.
|
||||
bool ReadToc(const char *name, void **buf, size_t *size) {
|
||||
char path[260];
|
||||
std::snprintf(path, sizeof(path), "Interface\\AddOns\\%s\\%s.toc", name,
|
||||
name);
|
||||
auto read = reinterpret_cast<AddOns::EngineIO::FileReadFn>(
|
||||
static_cast<uintptr_t>(Offsets::FUN_FILE_READ));
|
||||
*buf = nullptr;
|
||||
*size = 0;
|
||||
return read(0, path, buf, size, 1, 1, 0) != 0 && *buf != nullptr;
|
||||
}
|
||||
|
||||
// FNV-1a over the TOC's `##` lines only. File-reference lines are
|
||||
// excluded on purpose — the load pass re-reads those from disk every
|
||||
// /reload anyway, so an edit there must not trigger the evict path.
|
||||
// Line terminators are excluded (a CRLF↔LF conversion is not a
|
||||
// metadata change); a per-line separator keeps adjacent lines from
|
||||
// concatenating into the same hash. The BOM skip mirrors the parser.
|
||||
uint32_t HashTocMetadata(const char *buf, size_t size) {
|
||||
uint32_t h = 2166136261u;
|
||||
size_t i = 0;
|
||||
if (size >= 3 && static_cast<uint8_t>(buf[0]) == 0xEF &&
|
||||
static_cast<uint8_t>(buf[1]) == 0xBB &&
|
||||
static_cast<uint8_t>(buf[2]) == 0xBF)
|
||||
i = 3;
|
||||
while (i < size) {
|
||||
size_t end = i;
|
||||
while (end < size && buf[end] != '\n')
|
||||
++end;
|
||||
size_t stop = end;
|
||||
while (stop > i && buf[stop - 1] == '\r')
|
||||
--stop;
|
||||
if (stop - i >= 2 && buf[i] == '#' && buf[i + 1] == '#') {
|
||||
for (size_t k = i; k < stop; ++k) {
|
||||
h ^= static_cast<uint8_t>(buf[k]);
|
||||
h *= 16777619u;
|
||||
}
|
||||
h ^= '\n';
|
||||
h *= 16777619u;
|
||||
}
|
||||
i = end + 1;
|
||||
}
|
||||
return h;
|
||||
}
|
||||
|
||||
// True when the entry never takes the evict path: the embedded addon
|
||||
// (owned by `Addons::Embedded`) and SMSG-managed secure entries (the
|
||||
// parser cannot restore packet-delivered state).
|
||||
bool IsRefreshExempt(uintptr_t entry, const char *name) {
|
||||
return name == nullptr || std::strcmp(name, kEmbeddedAddon) == 0 ||
|
||||
*reinterpret_cast<const uint8_t *>(
|
||||
entry + Offsets::OFF_ADDON_ENTRY_SECURE) != 0;
|
||||
}
|
||||
|
||||
// Read + hash `<name>`'s `##` lines. False when the TOC doesn't read.
|
||||
bool TryHashToc(const char *name, uint32_t *outHash) {
|
||||
void *buf = nullptr;
|
||||
size_t size = 0;
|
||||
if (!ReadToc(name, &buf, &size))
|
||||
return false;
|
||||
*outHash = HashTocMetadata(static_cast<const char *>(buf), size);
|
||||
reinterpret_cast<AddOns::EngineIO::SMemFreeFn>(
|
||||
static_cast<uintptr_t>(Offsets::FUN_STORM_SMEM_FREE))(
|
||||
buf, __FILE__, __LINE__, 0);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Remove every pointer to `victim` from the OTHER entries'
|
||||
// reverse-LoadWith lists — the one reference `FUN_ADDON_ENTRY_DESTROY`
|
||||
// cannot clean (it frees the victim's own list, not the victim's
|
||||
// presence in others'). Without this, the loader's post-ADDON_LOADED
|
||||
// loop dereferences freed memory.
|
||||
void ScrubReverseLoadWith(uintptr_t victim) {
|
||||
ForEachEntry([victim](uintptr_t entry) {
|
||||
if (entry == victim)
|
||||
return;
|
||||
auto *desc = reinterpret_cast<Desc *>(
|
||||
entry + Offsets::OFF_ADDON_REVLOADWITH_DESC);
|
||||
uint32_t w = 0;
|
||||
for (uint32_t r = 0; r < desc->count; ++r)
|
||||
if (desc->data[r] != static_cast<uint32_t>(victim))
|
||||
desc->data[w++] = desc->data[r];
|
||||
desc->count = w;
|
||||
});
|
||||
}
|
||||
|
||||
// The refresh pass. Walks the registry comparing each entry's current
|
||||
// `##` hash against the last-parsed one; on change (or an unreadable
|
||||
// TOC — deleted folder), evicts via the engine's own destructor and,
|
||||
// for changes, re-feeds the name to the parser. Re-registered entries
|
||||
// are appended to `refreshed` so they join the same reverse-LoadWith /
|
||||
// display-array fix-ups a new folder gets. Returns true when anything
|
||||
// was actually evicted (the display array holds a freed name pointer
|
||||
// until it's rebuilt).
|
||||
//
|
||||
// Victims are collected during the walk and processed after it — the
|
||||
// destructor unlinks the entry the iterator stands on (its next-pointer
|
||||
// is zeroed), so evicting mid-walk would silently truncate the walk.
|
||||
// Names are copied for the same reason: the dtor frees the entry's
|
||||
// name string. `g_metaHash` is committed only once the re-registration
|
||||
// actually happened — updating it during the walk would absorb the edit
|
||||
// forever if any later step failed, with no retry on the next /reload.
|
||||
bool RefreshChangedMetadata(std::vector<uintptr_t> &refreshed) {
|
||||
struct Victim {
|
||||
std::string name;
|
||||
uint32_t newHash;
|
||||
bool reRegister; // false: TOC unreadable — evict only
|
||||
};
|
||||
std::vector<Victim> victims;
|
||||
ForEachEntry([&victims](uintptr_t entry) {
|
||||
const char *name = *reinterpret_cast<const char *const *>(
|
||||
entry + Offsets::OFF_ADDON_ENTRY_NAME_PTR);
|
||||
if (IsRefreshExempt(entry, name))
|
||||
return;
|
||||
uint32_t h = 0;
|
||||
if (!TryHashToc(name, &h)) {
|
||||
victims.push_back({name, 0, false});
|
||||
return;
|
||||
}
|
||||
auto it = g_metaHash.find(name);
|
||||
if (it == g_metaHash.end())
|
||||
g_metaHash.emplace(name, h); // first sighting — seed only
|
||||
else if (it->second != h)
|
||||
victims.push_back({name, h, true});
|
||||
});
|
||||
|
||||
bool anyEvicted = false;
|
||||
auto destroy =
|
||||
reinterpret_cast<EntryDestroy_t>(Offsets::FUN_ADDON_ENTRY_DESTROY);
|
||||
auto parse = reinterpret_cast<TocParser_t>(Offsets::FUN_TOC_PARSER);
|
||||
for (const Victim &v : victims) {
|
||||
const uintptr_t entry = ResolveEntryByName(v.name.c_str());
|
||||
if (entry == 0)
|
||||
continue;
|
||||
ScrubReverseLoadWith(entry);
|
||||
destroy(reinterpret_cast<void *>(entry));
|
||||
anyEvicted = true;
|
||||
if (!v.reRegister) {
|
||||
g_metaHash.erase(v.name);
|
||||
continue;
|
||||
}
|
||||
// The parser appends the fresh entry at the list TAIL, so a
|
||||
// refreshed addon's position in the load pass changes for this
|
||||
// reload. Declared dependencies still force-load first; only
|
||||
// undeclared load-order assumptions between unrelated addons
|
||||
// could observe the difference.
|
||||
parse(v.name.c_str());
|
||||
const uintptr_t fresh = ResolveEntryByName(v.name.c_str());
|
||||
if (fresh != 0) {
|
||||
refreshed.push_back(fresh);
|
||||
g_metaHash[v.name] = v.newHash;
|
||||
} else {
|
||||
// Evicted but the parse didn't restore it (TOC became
|
||||
// unreadable between hash and parse) — treat as deleted so
|
||||
// a reappearing folder re-seeds instead of diffing against
|
||||
// a hash the registry never absorbed.
|
||||
g_metaHash.erase(v.name);
|
||||
}
|
||||
}
|
||||
return anyEvicted;
|
||||
}
|
||||
|
||||
// ── Step 2: registry rescan + mirrored fix-ups ────────────────────────
|
||||
|
||||
// The scan tail loop's reverse-LoadWith build, restricted to pairs
|
||||
// involving a newly registered entry. Login already linked old→old
|
||||
// pairs; a pair is missing exactly when one side is new (the login
|
||||
// walk skipped `## LoadWith:` names that didn't resolve, and new
|
||||
// entries were never walked at all) — so appending just those pairs
|
||||
// reproduces what a login scan would have built, with no duplicates in
|
||||
// the engine's no-dedup lists.
|
||||
void FixupReverseLoadWith(const std::vector<uintptr_t> &added) {
|
||||
auto isNew = [&added](uintptr_t e) {
|
||||
return std::find(added.begin(), added.end(), e) != added.end();
|
||||
};
|
||||
auto grow =
|
||||
reinterpret_cast<DescGrow_t>(Offsets::FUN_ADDON_REVLOADWITH_GROW);
|
||||
ForEachEntry([&](uintptr_t entry) {
|
||||
const bool entryIsNew = isNew(entry);
|
||||
const uint32_t count = *reinterpret_cast<const uint32_t *>(
|
||||
entry + Offsets::OFF_ADDON_LOADWITH_COUNT);
|
||||
auto names = *reinterpret_cast<const char *const *const *>(
|
||||
entry + Offsets::OFF_ADDON_LOADWITH_ARRAY);
|
||||
for (uint32_t i = 0; i < count; ++i) {
|
||||
const uintptr_t target = ResolveEntryByName(names[i]);
|
||||
if (target == 0 || (!entryIsNew && !isNew(target)))
|
||||
continue;
|
||||
DescAppend(reinterpret_cast<Desc *>(
|
||||
target + Offsets::OFF_ADDON_REVLOADWITH_DESC),
|
||||
static_cast<uint32_t>(entry), grow);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// `FUN_0051DA70` phase 2, mirrored (never call that function itself —
|
||||
// its phase 1 consumes an SMSG_ADDON_INFO packet): rebuild the
|
||||
// `GetNumAddOns`/`GetAddOnInfo(i)` name-pointer array from the linked
|
||||
// list, skipping filtered entries (keeps `!!!ClassicAPI` hidden), then
|
||||
// sort with the engine's own comparator to restore alphabetical order.
|
||||
void RebuildDisplayArray() {
|
||||
auto desc = reinterpret_cast<Desc *>(
|
||||
static_cast<uintptr_t>(Offsets::VAR_ADDON_ARRAY_CAP));
|
||||
auto grow = reinterpret_cast<DescGrow_t>(Offsets::FUN_ADDON_ARRAY_GROW);
|
||||
desc->count = 0;
|
||||
ForEachEntry([&](uintptr_t entry) {
|
||||
if (*reinterpret_cast<const uint8_t *>(
|
||||
entry + Offsets::OFF_ADDON_ENTRY_FILTER_OUT) != 0)
|
||||
return;
|
||||
DescAppend(desc,
|
||||
*reinterpret_cast<const uint32_t *>(
|
||||
entry + Offsets::OFF_ADDON_ENTRY_NAME_PTR),
|
||||
grow);
|
||||
});
|
||||
auto qsort = reinterpret_cast<Qsort_t>(Offsets::FUN_CRT_QSORT);
|
||||
qsort(desc->data, desc->count, 4,
|
||||
reinterpret_cast<void *>(Offsets::FUN_ADDON_NAME_COMPARE));
|
||||
}
|
||||
|
||||
void Run() {
|
||||
// Registry populated (login scan ran) and loose index built — both
|
||||
// always true by the first in-world LoadScriptFunctions, but these
|
||||
// are the states the steps below mutate, so gate explicitly.
|
||||
if (*reinterpret_cast<const uint8_t *>(
|
||||
static_cast<uintptr_t>(Offsets::VAR_ADDON_INITIALIZED)) == 0 ||
|
||||
*reinterpret_cast<const uint8_t *>(
|
||||
static_cast<uintptr_t>(Offsets::VAR_VFS_INDEX_READY)) == 0)
|
||||
return;
|
||||
|
||||
RefreshLooseFileIndex();
|
||||
|
||||
// Metadata refresh runs BEFORE the snapshot: a re-registered entry
|
||||
// is alive by snapshot time, so it lands in `before` and can never
|
||||
// double-count in `added` — even if the allocator reuses the old
|
||||
// entry's address, `refreshed` tracks it independently of the diff.
|
||||
std::vector<uintptr_t> refreshed;
|
||||
const bool evicted = RefreshChangedMetadata(refreshed);
|
||||
|
||||
std::vector<uintptr_t> before;
|
||||
ForEachEntry([&before](uintptr_t entry) { before.push_back(entry); });
|
||||
|
||||
// Replay login scan walk #2 verbatim: engine walker + engine
|
||||
// callback + engine parser. The parser's dedup guard makes this a
|
||||
// hash lookup per already-registered addon; new folders register
|
||||
// as complete, normal entries (their TOC is readable now — and it
|
||||
// goes through the FlavorToc/TocRewrite read hooks like any other).
|
||||
//
|
||||
// The dedup guard is why this replay can NEVER refresh or corrupt
|
||||
// an existing entry's `##` metadata (verified in the parser's
|
||||
// decompile): the guard is the function's FIRST block — hash
|
||||
// lookup + name compare, early `return` on match BEFORE the TOC
|
||||
// path is even built, so no file read and no entry write happen
|
||||
// for a registered name. Both the Storm hash (`FUN_0064B3F0`,
|
||||
// uppercases a–z and folds '/'→'\\' before hashing) and the
|
||||
// compare (`SStrCmpI`) are case-insensitive, so a case-renamed
|
||||
// folder still matches its existing entry rather than registering
|
||||
// a duplicate.
|
||||
auto scan =
|
||||
reinterpret_cast<ScanDiskDirs_t>(Offsets::FUN_ADDON_SCAN_DISK_DIRS);
|
||||
scan(reinterpret_cast<const char *>(Offsets::VAR_ADDON_PATH_PREFIX),
|
||||
reinterpret_cast<const char *>(Offsets::VAR_ADDON_SCAN_PATTERN),
|
||||
reinterpret_cast<void *>(Offsets::FUN_ADDON_DISK_DIR_CB),
|
||||
/*userParam=*/nullptr, /*includeHidden=*/0);
|
||||
|
||||
std::vector<uintptr_t> added;
|
||||
ForEachEntry([&](uintptr_t entry) {
|
||||
if (std::find(before.begin(), before.end(), entry) == before.end())
|
||||
added.push_back(entry);
|
||||
});
|
||||
|
||||
// Seed the metadata map for entries this reload registered, so the
|
||||
// next reload diffs a `##` edit against what was actually parsed
|
||||
// (waiting for the next pass to seed would silently absorb an edit
|
||||
// made between now and then).
|
||||
for (uintptr_t entry : added) {
|
||||
const char *name = *reinterpret_cast<const char *const *>(
|
||||
entry + Offsets::OFF_ADDON_ENTRY_NAME_PTR);
|
||||
uint32_t h = 0;
|
||||
if (!IsRefreshExempt(entry, name) && TryHashToc(name, &h))
|
||||
g_metaHash[name] = h;
|
||||
}
|
||||
|
||||
if (added.empty() && refreshed.empty() && !evicted)
|
||||
return;
|
||||
|
||||
// Refreshed entries need the same link fix-ups a new folder gets:
|
||||
// their old reverse-LoadWith pairs died with the old entry (scrub +
|
||||
// dtor), and the parser doesn't build reverse links.
|
||||
std::vector<uintptr_t> linked = added;
|
||||
linked.insert(linked.end(), refreshed.begin(), refreshed.end());
|
||||
if (!linked.empty())
|
||||
FixupReverseLoadWith(linked);
|
||||
|
||||
// Rebuild whenever membership OR an entry identity changed — after
|
||||
// any eviction (including a pure deletion) the display array still
|
||||
// holds the dead entry's freed name pointer.
|
||||
RebuildDisplayArray();
|
||||
}
|
||||
|
||||
const Game::ModuleAutoRegister _autoreg{&Run};
|
||||
|
||||
} // namespace
|
||||
|
||||
} // namespace Addons::Rescan
|
||||
@@ -68,12 +68,9 @@ namespace AuctionHouse::PostItem {
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr const char *kEvtStart = "AUCTION_MULTISELL_START";
|
||||
constexpr const char *kEvtUpdate = "AUCTION_MULTISELL_UPDATE";
|
||||
constexpr const char *kEvtFailure = "AUCTION_MULTISELL_FAILURE";
|
||||
const Event::Custom::AutoReserve _r1{kEvtStart};
|
||||
const Event::Custom::AutoReserve _r2{kEvtUpdate};
|
||||
const Event::Custom::AutoReserve _r3{kEvtFailure};
|
||||
const Event::Custom::AutoReserve _evtStart{"AUCTION_MULTISELL_START"};
|
||||
const Event::Custom::AutoReserve _evtUpdate{"AUCTION_MULTISELL_UPDATE"};
|
||||
const Event::Custom::AutoReserve _evtFailure{"AUCTION_MULTISELL_FAILURE"};
|
||||
|
||||
// Generous per-step abort bound. A split or a post is one server round-trip
|
||||
// (~tens of frames); 600 frames (~10s at 60fps) means "something's wrong"
|
||||
@@ -206,7 +203,7 @@ bool FindFreeGeneralSlot(void *L, int *outBag, int *outSlot) {
|
||||
void Finish(bool success) {
|
||||
g_job.active = false;
|
||||
if (!success)
|
||||
Event::Custom::Fire(Event::Custom::Lookup(kEvtFailure), "");
|
||||
Event::Custom::Fire(_evtFailure.Slot(), "");
|
||||
// success needs no event: the last PostWait already fired
|
||||
// AUCTION_MULTISELL_UPDATE(total, total).
|
||||
}
|
||||
@@ -279,7 +276,7 @@ void Tick() {
|
||||
// Post confirmed once the server pulls the item out of the slot.
|
||||
if (w.item == nullptr || w.guid != g_job.postGuid) {
|
||||
++g_job.postedStacks;
|
||||
Event::Custom::Fire(Event::Custom::Lookup(kEvtUpdate), "%d%d",
|
||||
Event::Custom::Fire(_evtUpdate.Slot(), "%d%d",
|
||||
g_job.postedStacks, g_job.totalStacks);
|
||||
g_job.phase = Phase::Idle;
|
||||
g_job.waitTicks = 0;
|
||||
@@ -400,7 +397,7 @@ int __fastcall Script_C_AuctionHouse_PostItem(void *L) {
|
||||
g_job.runTime = runTime;
|
||||
g_job.phase = Phase::Idle;
|
||||
|
||||
Event::Custom::Fire(Event::Custom::Lookup(kEvtStart), "");
|
||||
Event::Custom::Fire(_evtStart.Slot(), "");
|
||||
Game::Lua::PushBool(L, true);
|
||||
return 1;
|
||||
}
|
||||
|
||||
+136
-20
@@ -21,7 +21,8 @@
|
||||
// tokens separated by `|` and/or whitespace, each optionally negated with a
|
||||
// leading `!`. Whole-token matching, so `RAID_PLAYER_DISPELLABLE` is not
|
||||
// mistaken for `PLAYER` and `!PLAYER` is a negation rather than a match.
|
||||
// Honored: `HELPFUL` / `HARMFUL` (descriptor slot ranges), `PLAYER` /
|
||||
// Honored: `HELPFUL` / `HARMFUL` (the aura's real polarity, read however this
|
||||
// server encodes it — see `Aura::Data::IsSlotHarmful`), `PLAYER` /
|
||||
// `!PLAYER` (caster == / != the local player, from the Aura::Source cache),
|
||||
// `DISPELLABLE` / `!DISPELLABLE` (dispel type is / isn't one a
|
||||
// dispel/purge/steal can remove — Spell.dbc Dispel ∈ Magic/Curse/Disease/
|
||||
@@ -46,14 +47,10 @@ namespace Aura::Api {
|
||||
|
||||
namespace {
|
||||
|
||||
using ResolveUnitToken_t = void *(__fastcall *)(const char *token);
|
||||
|
||||
const uint8_t *ResolveUnit(const char *token) {
|
||||
if (token == nullptr)
|
||||
return nullptr;
|
||||
auto fn = reinterpret_cast<ResolveUnitToken_t>(
|
||||
static_cast<uintptr_t>(Offsets::FUN_RESOLVE_UNIT_TOKEN));
|
||||
return static_cast<const uint8_t *>(fn(token));
|
||||
return static_cast<const uint8_t *>(Game::ResolveUnitToken(token));
|
||||
}
|
||||
|
||||
// Resolves a token to its GUID for the out-of-range (no live CGUnit) path.
|
||||
@@ -356,22 +353,135 @@ int __fastcall Script_GetPlayerAuraBySpellID(void *L) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Iterates one slot range and pushes AuraData tables into `outer` at
|
||||
// sequential keys starting from `nextKey`. Updates `nextKey` so a
|
||||
// follow-up call can append to the same outer table.
|
||||
// C_UnitAuras.GetAuraSlots(unit [, filter [, maxSlots [, continuationToken]]])
|
||||
// -> continuationToken, slot1, slot2, ...
|
||||
//
|
||||
// Enumerates the opaque slot ids of every aura on `unit` matching `filter`, in
|
||||
// the order the by-index getters visit them, `maxSlots` at a time (nil / 0 =
|
||||
// all). The first return is the token to pass back for the next batch, or nil
|
||||
// when this batch reached the end — retail's batching contract, and what makes
|
||||
// AuraUtil.ForEachAura linear: one enumeration per batch plus a direct by-slot
|
||||
// fetch per aura, instead of a fresh from-slot-0 walk per index. The token is
|
||||
// the 1-based ordinal to resume at; opaque to callers. Encoding of the slot ids
|
||||
// is documented at `Data::OPAQUE_STRIDE`.
|
||||
int __fastcall Script_GetAuraSlots(void *L) {
|
||||
const char *unitToken = ArgUnit(L, 1);
|
||||
const char *filterStr = ArgOptString(L, 2);
|
||||
const int maxSlots = ArgInt(L, 3);
|
||||
const int token = ArgInt(L, 4);
|
||||
// ClassicAPI extension: a table as the 5th argument selects FILL mode —
|
||||
// the slot ids are written into it (t[1..n], stale tail cleared, t.n = n)
|
||||
// and the call returns `continuationToken, n` instead of the vararg list.
|
||||
// Lua 5.0 builds an `arg` table for every vararg CALL, so a Lua helper
|
||||
// that receives the retail multi-return allocates one table per batch;
|
||||
// filling the caller's table here is what keeps a per-frame scan
|
||||
// allocation-free (AuraUtil and pfUI's ScanAuraSlots use this form).
|
||||
const bool fill = Game::Lua::Type(L, 5) == Game::Lua::TYPE_TABLE;
|
||||
|
||||
int slots[Data::OPAQUE_SLOTS_MAX];
|
||||
int total = 0;
|
||||
if (unitToken != nullptr) {
|
||||
const uint8_t *unit = ResolveUnit(unitToken);
|
||||
const uint64_t guid = (unit == nullptr) ? GuidForOutOfRange(unitToken) : 0;
|
||||
const ParsedFilter pf = ParseFilters(filterStr);
|
||||
total = Data::CollectSlots(unit, guid, RangeFilter(pf), pf.ToMatch(), slots,
|
||||
Data::OPAQUE_SLOTS_MAX);
|
||||
}
|
||||
int start = (token > 0) ? token - 1 : 0;
|
||||
if (start > total)
|
||||
start = total;
|
||||
int n = total - start;
|
||||
if (maxSlots > 0 && n > maxSlots)
|
||||
n = maxSlots;
|
||||
const bool more = start + n < total;
|
||||
|
||||
if (fill) {
|
||||
for (int i = 0; i < n; ++i) {
|
||||
Game::Lua::PushNumber(L, static_cast<double>(i + 1));
|
||||
Game::Lua::PushNumber(L, static_cast<double>(slots[start + i]));
|
||||
Game::Lua::RawSet(L, 5);
|
||||
}
|
||||
// Clear what a previous, longer fill left past n. The array is dense
|
||||
// by construction, so stop at the first hole.
|
||||
for (int k = n + 1;; ++k) {
|
||||
Game::Lua::PushNumber(L, static_cast<double>(k));
|
||||
Game::Lua::RawGet(L, 5);
|
||||
const bool hole = Game::Lua::Type(L, -1) == Game::Lua::TYPE_NIL;
|
||||
Game::Lua::SetTop(L, -2);
|
||||
if (hole)
|
||||
break;
|
||||
Game::Lua::PushNumber(L, static_cast<double>(k));
|
||||
Game::Lua::PushNil(L);
|
||||
Game::Lua::RawSet(L, 5);
|
||||
}
|
||||
Game::Lua::PushString(L, "n");
|
||||
Game::Lua::PushNumber(L, static_cast<double>(n));
|
||||
Game::Lua::RawSet(L, 5);
|
||||
Game::Lua::SetTop(L, 0);
|
||||
if (more)
|
||||
Game::Lua::PushNumber(L, static_cast<double>(start + n + 1));
|
||||
else
|
||||
Game::Lua::PushNil(L);
|
||||
Game::Lua::PushNumber(L, static_cast<double>(n));
|
||||
return 2;
|
||||
}
|
||||
|
||||
// Vararg form. Everything below only pushes, so the args can go; a full
|
||||
// batch is up to OPAQUE_SLOTS_MAX + 1 values, past Lua's guaranteed C-call
|
||||
// headroom.
|
||||
Game::Lua::SetTop(L, 0);
|
||||
if (Game::Lua::CheckStack(L, n + 1) == 0) {
|
||||
Game::Lua::PushNil(L);
|
||||
return 1;
|
||||
}
|
||||
if (more)
|
||||
Game::Lua::PushNumber(L, static_cast<double>(start + n + 1));
|
||||
else
|
||||
Game::Lua::PushNil(L);
|
||||
for (int i = 0; i < n; ++i)
|
||||
Game::Lua::PushNumber(L, static_cast<double>(slots[start + i]));
|
||||
return 1 + n;
|
||||
}
|
||||
|
||||
// Shared body of GetAuraDataBySlot (table) / UnitAuraBySlot (positional):
|
||||
// pushes the aura an opaque slot id from GetAuraSlots names, or a single nil
|
||||
// for an id that no longer names one.
|
||||
int PushAuraBySlot(void *L, Data::Emit emit) {
|
||||
const char *unitToken = ArgUnit(L, 1);
|
||||
if (unitToken == nullptr || !Game::Lua::IsNumber(L, 2)) {
|
||||
Game::Lua::PushNil(L);
|
||||
return 1;
|
||||
}
|
||||
const int slot = ArgInt(L, 2);
|
||||
const uint8_t *unit = ResolveUnit(unitToken);
|
||||
const uint64_t guid = (unit == nullptr) ? GuidForOutOfRange(unitToken) : 0;
|
||||
if (Data::PushBySlot(L, unit, guid, slot, emit))
|
||||
return (emit == Data::Emit::Positional) ? 15 : 1;
|
||||
Game::Lua::PushNil(L);
|
||||
return 1;
|
||||
}
|
||||
|
||||
// C_UnitAuras.GetAuraDataBySlot(unit, slot) -> AuraData | nil
|
||||
int __fastcall Script_GetAuraDataBySlot(void *L) {
|
||||
return PushAuraBySlot(L, Data::Emit::Table);
|
||||
}
|
||||
|
||||
// C_UnitAuras.UnitAuraBySlot(unit, slot) -> the 15 positional UnitAura values | nil
|
||||
// Zero-allocation sibling of GetAuraDataBySlot, namespaced under C_UnitAuras
|
||||
// like C_UnitAuras.UnitAura. What AuraUtil's non-packed iteration reads.
|
||||
int __fastcall Script_UnitAuraBySlot(void *L) {
|
||||
return PushAuraBySlot(L, Data::Emit::Positional);
|
||||
}
|
||||
|
||||
// Iterates the auras of one polarity (in `SlotInFilterOrder`, the same order
|
||||
// the by-index getters use) and pushes AuraData tables into `outer` at
|
||||
// sequential keys starting from `nextKey`. Updates `nextKey` so a follow-up
|
||||
// call can append to the same outer table.
|
||||
void AppendRangeToArray(void *L, const uint8_t *unit, int outerIdx,
|
||||
Data::Filter filter, int &nextKey, Data::Match match) {
|
||||
const int start = (filter == Data::Filter::Harmful)
|
||||
? Offsets::UNIT_AURA_BUFF_COUNT
|
||||
: 0;
|
||||
const int end = (filter == Data::Filter::Harmful)
|
||||
? Offsets::UNIT_AURA_TOTAL
|
||||
: Offsets::UNIT_AURA_BUFF_COUNT;
|
||||
for (int slot = start; slot < end; ++slot) {
|
||||
if (!Data::IsSlotPopulated(unit, slot))
|
||||
continue;
|
||||
if (!Data::MatchesAura(match, Data::IsPlayerCast(unit, slot),
|
||||
Data::ReadSpellID(unit, slot)))
|
||||
for (int i = 0; i < Offsets::UNIT_AURA_TOTAL; ++i) {
|
||||
const int slot = Data::SlotInFilterOrder(filter, i);
|
||||
if (!Data::SlotMatchesFilter(unit, slot, filter, match))
|
||||
continue;
|
||||
Game::Lua::PushNumber(L, static_cast<double>(nextKey++));
|
||||
Data::Push(L, unit, slot);
|
||||
@@ -501,6 +611,12 @@ static void RegisterLuaFunctions() {
|
||||
&Script_UnitBuff);
|
||||
Game::Lua::RegisterTableFunction("C_UnitAuras", "UnitDebuff",
|
||||
&Script_UnitDebuff);
|
||||
Game::Lua::RegisterTableFunction("C_UnitAuras", "GetAuraSlots",
|
||||
&Script_GetAuraSlots);
|
||||
Game::Lua::RegisterTableFunction("C_UnitAuras", "GetAuraDataBySlot",
|
||||
&Script_GetAuraDataBySlot);
|
||||
Game::Lua::RegisterTableFunction("C_UnitAuras", "UnitAuraBySlot",
|
||||
&Script_UnitAuraBySlot);
|
||||
Game::Lua::RegisterTableFunction("C_UnitAuras", "GetUnitAuraBySpellID",
|
||||
&Script_GetUnitAuraBySpellID);
|
||||
Game::Lua::RegisterTableFunction("C_UnitAuras", "GetPlayerAuraBySpellID",
|
||||
|
||||
+381
-158
@@ -22,6 +22,7 @@
|
||||
#include "spell/CrowdControl.h"
|
||||
#include "spell/IsSelfBuff.h"
|
||||
#include "time/Clock.h"
|
||||
#include "turtle/Detect.h"
|
||||
#include "unit/Identity.h"
|
||||
|
||||
#include <cstdint>
|
||||
@@ -77,13 +78,9 @@ const char *SpellIconPath(const uint8_t *spellRecord) {
|
||||
Offsets::OFF_SPELLICON_PATH);
|
||||
}
|
||||
|
||||
using ResolveUnitToken_t = void *(__fastcall *)(const char *);
|
||||
|
||||
// Returns the local player's CGUnit pointer, or nullptr pre-login.
|
||||
const uint8_t *LocalPlayer() {
|
||||
auto fn = reinterpret_cast<ResolveUnitToken_t>(
|
||||
static_cast<uintptr_t>(Offsets::FUN_RESOLVE_UNIT_TOKEN));
|
||||
return static_cast<const uint8_t *>(fn("player"));
|
||||
return static_cast<const uint8_t *>(Game::ResolveUnitToken("player"));
|
||||
}
|
||||
|
||||
// Looks up the player-buff-table entry for a given spellID. The table
|
||||
@@ -276,6 +273,94 @@ bool IsSlotPopulated(const uint8_t *unit, int slot) {
|
||||
return IsVisible(SpellRecord(spellID));
|
||||
}
|
||||
|
||||
// Every nibble Turtle's SetAuraFlag can write for an occupied slot: HELPFUL,
|
||||
// HELPFUL|CANCELABLE, or HARMFUL alone. Nothing else is reachable there — a
|
||||
// negative aura gets exactly HARMFUL, and a positive one always carries HELPFUL.
|
||||
bool IsTurtlePolarityNibble(uint8_t nibble) {
|
||||
return nibble == Offsets::UNIT_AURA_FLAG_HELPFUL ||
|
||||
nibble == (Offsets::UNIT_AURA_FLAG_HELPFUL | Offsets::UNIT_AURA_FLAG_CANCELABLE) ||
|
||||
nibble == Offsets::UNIT_AURA_FLAG_HARMFUL;
|
||||
}
|
||||
|
||||
// Latched for the process once a nibble proves the flags are not polarity here.
|
||||
// A server does not change its encoding mid-session, so this never resets.
|
||||
bool g_nibbleContradicted = false;
|
||||
|
||||
bool IsSlotHarmful(const uint8_t *unit, int slot) {
|
||||
if (slot < 0 || slot >= Offsets::UNIT_AURA_TOTAL)
|
||||
return false;
|
||||
|
||||
// Normally the slot range IS the polarity: an aura is written to 0..31 when
|
||||
// helpful and 32..47 when harmful, and nothing ever crosses over. The flag
|
||||
// nibble cannot be used here, because in a stock descriptor those bits mean
|
||||
// something else entirely — one bit per spell effect index that carries an
|
||||
// aura, plus a cancelable bit. Reading bit 0x08 as "harmful" would then be
|
||||
// reading "effect index 0 has an aura", which is true of nearly every aura,
|
||||
// and would report almost every BUFF as a debuff.
|
||||
if (!Turtle::Detected())
|
||||
return slot >= Offsets::UNIT_AURA_BUFF_COUNT;
|
||||
|
||||
// Turtle lets a debuff spill into a buff slot once the harmful range is
|
||||
// full, so the range stops being reliable — and it repurposes those same
|
||||
// nibble bits to carry the real polarity (0x04 helpful, 0x08 harmful) so
|
||||
// the information survives the spill. There, the nibble is the answer.
|
||||
if (g_nibbleContradicted)
|
||||
return slot >= Offsets::UNIT_AURA_BUFF_COUNT;
|
||||
|
||||
auto *desc = Descriptor(unit);
|
||||
if (desc == nullptr)
|
||||
return false;
|
||||
const uint8_t byte = *(desc + Offsets::OFF_UNIT_FIELD_AURAFLAGS + slot / 2);
|
||||
const uint8_t nibble = (byte >> ((slot & 1) * 4)) & 0xF;
|
||||
|
||||
// Fail safe. Detection says this server SHOULD be writing polarity here, so
|
||||
// a nibble it could not have produced means that assumption is stale — the
|
||||
// server changed how it writes the flags. Fall back to the slot range for
|
||||
// good rather than keep reading bits whose meaning we no longer know: the
|
||||
// range is merely incomplete for spilled debuffs, whereas a misread nibble
|
||||
// reports nearly every buff as a debuff. Revocation only, never promotion —
|
||||
// nothing here can turn nibble reading ON, because the agreeing case is not
|
||||
// provable (an all-0x08 run is equally consistent with single-effect auras
|
||||
// under the other encoding).
|
||||
if (nibble != 0 && !IsTurtlePolarityNibble(nibble)) {
|
||||
g_nibbleContradicted = true;
|
||||
return slot >= Offsets::UNIT_AURA_BUFF_COUNT;
|
||||
}
|
||||
return (nibble & Offsets::UNIT_AURA_FLAG_HARMFUL) != 0;
|
||||
}
|
||||
|
||||
bool IsSlotTooltipVisible(const uint8_t *unit, int slot) {
|
||||
if (slot < 0 || slot >= Offsets::UNIT_AURA_TOTAL)
|
||||
return false;
|
||||
auto *desc = Descriptor(unit);
|
||||
if (desc == nullptr)
|
||||
return false;
|
||||
const int spellID = *reinterpret_cast<const int *>(
|
||||
desc + Offsets::OFF_UNIT_FIELD_AURA + slot * 4);
|
||||
if (spellID <= 0) // the engine's own `0 < id` test (signed)
|
||||
return false;
|
||||
const uint8_t byte = *(desc + Offsets::OFF_UNIT_FIELD_AURAFLAGS + slot / 2);
|
||||
const uint8_t nibble = (byte >> ((slot & 1) * 4)) & 0xF;
|
||||
if ((nibble & Offsets::UNIT_AURA_VISIBLE_MASK) == 0)
|
||||
return false;
|
||||
const uint8_t *rec = SpellRecord(static_cast<uint32_t>(spellID));
|
||||
if (rec == nullptr)
|
||||
return false;
|
||||
using Fn = char(__fastcall *)(const uint8_t *);
|
||||
auto fn = reinterpret_cast<Fn>(
|
||||
static_cast<uintptr_t>(Offsets::FUN_GAMETOOLTIP_AURA_VISIBLE));
|
||||
return fn(rec) != 0;
|
||||
}
|
||||
|
||||
int SlotInFilterOrder(Filter filter, int i) {
|
||||
// Harmful: 32..47 first, then 0..31 (where the server parks debuffs once
|
||||
// the 16 are full). Helpful: 0..31 then 32..47 — positive auras never
|
||||
// spill downward in practice, so that tail is a no-op safety net.
|
||||
if (filter == Filter::Harmful)
|
||||
return (i + Offsets::UNIT_AURA_BUFF_COUNT) % Offsets::UNIT_AURA_TOTAL;
|
||||
return i;
|
||||
}
|
||||
|
||||
bool IsPlayerCast(const uint8_t *unit, int slot) {
|
||||
const uint32_t spellID = ReadSpellID(unit, slot);
|
||||
if (spellID == 0)
|
||||
@@ -331,6 +416,30 @@ bool MatchesAura(const Match &match, bool isPlayerCast, uint32_t spellID) {
|
||||
CcMatches(match.cc, spellID);
|
||||
}
|
||||
|
||||
// `MatchesAura` for a descriptor slot the caller has ALREADY established is
|
||||
// populated. The caster lookup is the expensive predicate (a scan of the
|
||||
// Aura::Source cache per slot), and `CasterMatches` ignores its answer when the
|
||||
// match has no caster restriction — so skip the lookup entirely in that case.
|
||||
static bool PopulatedSlotMatches(const uint8_t *unit, int slot,
|
||||
const Match &match) {
|
||||
const bool castByPlayer =
|
||||
match.caster != CasterMode::Any && IsPlayerCast(unit, slot);
|
||||
return MatchesAura(match, castByPlayer, ReadSpellID(unit, slot));
|
||||
}
|
||||
|
||||
bool SlotMatches(const uint8_t *unit, int slot, const Match &match) {
|
||||
return IsSlotPopulated(unit, slot) && PopulatedSlotMatches(unit, slot, match);
|
||||
}
|
||||
|
||||
bool SlotMatchesFilter(const uint8_t *unit, int slot, Filter filter,
|
||||
const Match &match) {
|
||||
if (!IsSlotPopulated(unit, slot))
|
||||
return false;
|
||||
if (IsSlotHarmful(unit, slot) != (filter == Filter::Harmful))
|
||||
return false;
|
||||
return PopulatedSlotMatches(unit, slot, match);
|
||||
}
|
||||
|
||||
// Group-array analog of IsPlayerCast: the member has no descriptor, so there is
|
||||
// no slot to attribute by and the cache is consulted by (guid, spellID) alone.
|
||||
// A miss counts as "not the player" (same as IsPlayerCast).
|
||||
@@ -344,17 +453,10 @@ int FindNthSlot(const uint8_t *unit, int oneBasedIndex, Filter filter,
|
||||
Match match) {
|
||||
if (unit == nullptr || oneBasedIndex < 1)
|
||||
return -1;
|
||||
const int start = (filter == Filter::Harmful)
|
||||
? Offsets::UNIT_AURA_BUFF_COUNT
|
||||
: 0;
|
||||
const int end = (filter == Filter::Harmful)
|
||||
? Offsets::UNIT_AURA_TOTAL
|
||||
: Offsets::UNIT_AURA_BUFF_COUNT;
|
||||
int matches = 0;
|
||||
for (int slot = start; slot < end; ++slot) {
|
||||
if (!IsSlotPopulated(unit, slot))
|
||||
continue;
|
||||
if (!MatchesAura(match, IsPlayerCast(unit, slot), ReadSpellID(unit, slot)))
|
||||
for (int i = 0; i < Offsets::UNIT_AURA_TOTAL; ++i) {
|
||||
const int slot = SlotInFilterOrder(filter, i);
|
||||
if (!SlotMatchesFilter(unit, slot, filter, match))
|
||||
continue;
|
||||
if (++matches == oneBasedIndex)
|
||||
return slot;
|
||||
@@ -366,18 +468,16 @@ int FindSlotBySpellID(const uint8_t *unit, uint32_t spellID,
|
||||
const Filter *filter, Match match) {
|
||||
if (unit == nullptr || spellID == 0)
|
||||
return -1;
|
||||
const int start = (filter != nullptr && *filter == Filter::Harmful)
|
||||
? Offsets::UNIT_AURA_BUFF_COUNT
|
||||
: 0;
|
||||
const int end = (filter != nullptr && *filter == Filter::Helpful)
|
||||
? Offsets::UNIT_AURA_BUFF_COUNT
|
||||
: Offsets::UNIT_AURA_TOTAL;
|
||||
for (int slot = start; slot < end; ++slot) {
|
||||
for (int i = 0; i < Offsets::UNIT_AURA_TOTAL; ++i) {
|
||||
const int slot = (filter != nullptr) ? SlotInFilterOrder(*filter, i) : i;
|
||||
if (!IsSlotPopulated(unit, slot))
|
||||
continue;
|
||||
if (ReadSpellID(unit, slot) != spellID)
|
||||
continue;
|
||||
if (!MatchesAura(match, IsPlayerCast(unit, slot), ReadSpellID(unit, slot)))
|
||||
if (filter != nullptr &&
|
||||
IsSlotHarmful(unit, slot) != (*filter == Filter::Harmful))
|
||||
continue;
|
||||
if (!PopulatedSlotMatches(unit, slot, match))
|
||||
continue;
|
||||
return slot;
|
||||
}
|
||||
@@ -388,19 +488,17 @@ int FindSlotBySpellName(const uint8_t *unit, const char *spellName,
|
||||
const Filter *filter, Match match) {
|
||||
if (unit == nullptr || spellName == nullptr || *spellName == '\0')
|
||||
return -1;
|
||||
const int start = (filter != nullptr && *filter == Filter::Harmful)
|
||||
? Offsets::UNIT_AURA_BUFF_COUNT
|
||||
: 0;
|
||||
const int end = (filter != nullptr && *filter == Filter::Helpful)
|
||||
? Offsets::UNIT_AURA_BUFF_COUNT
|
||||
: Offsets::UNIT_AURA_TOTAL;
|
||||
for (int slot = start; slot < end; ++slot) {
|
||||
for (int i = 0; i < Offsets::UNIT_AURA_TOTAL; ++i) {
|
||||
const int slot = (filter != nullptr) ? SlotInFilterOrder(*filter, i) : i;
|
||||
if (!IsSlotPopulated(unit, slot))
|
||||
continue;
|
||||
const char *name = LocalizedSpellName(SpellRecord(ReadSpellID(unit, slot)));
|
||||
if (name == nullptr || std::strcmp(name, spellName) != 0)
|
||||
continue;
|
||||
if (!MatchesAura(match, IsPlayerCast(unit, slot), ReadSpellID(unit, slot)))
|
||||
if (filter != nullptr &&
|
||||
IsSlotHarmful(unit, slot) != (*filter == Filter::Harmful))
|
||||
continue;
|
||||
if (!PopulatedSlotMatches(unit, slot, match))
|
||||
continue;
|
||||
return slot;
|
||||
}
|
||||
@@ -451,9 +549,15 @@ const char *DispelName(uint32_t dispelTypeID) {
|
||||
|
||||
// The display fields resolved from a spell record + caster, shared by the table
|
||||
// emitter (`BuildTable`) and the positional emitter (`PushPositional`) so the
|
||||
// name / icon / dispel-name lookups live in exactly one place. `castByPlayer` is
|
||||
// the positional tuple's field #13; the table uses `isFromPlayerOrPlayerPet`
|
||||
// instead, mirroring retail's own divergence between the two aura shapes.
|
||||
// name / icon / dispel-name lookups live in exactly one place. `castByPlayer`
|
||||
// is the positional tuple's field #13 and the table's
|
||||
// `isFromPlayerOrPlayerPet` — same value, retail's two names for the two aura
|
||||
// shapes. Both mean "applied by ANY player or a player's pet" (Era
|
||||
// semantics), not the local player — that's the `PLAYER` filter's job. The
|
||||
// answer is the caster GUID's HIGHGUID prefix: player 0x0000 / pet 0xF140
|
||||
// (server-confirmed in tortoise-wow ObjectGuid.h). Vanilla totems carry the
|
||||
// creature prefix, so totem buffs read false. False on a cache miss (cast not
|
||||
// observed), since a GUID of 0 classifies as neither.
|
||||
struct Display {
|
||||
const char *name;
|
||||
const char *icon;
|
||||
@@ -472,8 +576,7 @@ static Display ResolveDisplay(uint32_t spellID, uint64_t casterGuid) {
|
||||
d.name = LocalizedSpellName(spellRecord);
|
||||
d.icon = SpellIconPath(spellRecord);
|
||||
d.dispelName = DispelName(dispelTypeID);
|
||||
d.castByPlayer =
|
||||
casterGuid != 0 && casterGuid == Unit::Identity::PlayerGuid();
|
||||
d.castByPlayer = Guid::IsPlayer(casterGuid) || Guid::IsPet(casterGuid);
|
||||
return d;
|
||||
}
|
||||
|
||||
@@ -523,10 +626,11 @@ static void BuildTable(void *L, uint32_t spellID, int applications,
|
||||
Game::Lua::SetFieldNumber(L, "maxCharges", 0);
|
||||
Game::Lua::SetFieldNumber(L, "timeMod", 1);
|
||||
|
||||
Game::Lua::SetFieldBool(L, "isFromPlayerOrPlayerPet", disp.castByPlayer);
|
||||
|
||||
// Boolean fields whose modern semantics don't apply in vanilla.
|
||||
Game::Lua::SetFieldBool(L, "isStealable", false);
|
||||
Game::Lua::SetFieldBool(L, "isBossAura", false);
|
||||
Game::Lua::SetFieldBool(L, "isFromPlayerOrPlayerPet", false);
|
||||
Game::Lua::SetFieldBool(L, "isNameplateOnly", false);
|
||||
Game::Lua::SetFieldBool(L, "nameplateShowAll", false);
|
||||
Game::Lua::SetFieldBool(L, "nameplateShowPersonal", false);
|
||||
@@ -655,7 +759,7 @@ static Attribution CachedAttribution(const Aura::Source::CachedAura &c) {
|
||||
|
||||
void Push(void *L, const uint8_t *unit, int slot, Emit emit) {
|
||||
const uint32_t spellID = ReadSpellID(unit, slot);
|
||||
const bool isHelpful = slot < Offsets::UNIT_AURA_BUFF_COUNT;
|
||||
const bool isHelpful = !IsSlotHarmful(unit, slot); // nibble, not slot range
|
||||
const int unitLevel = (unit != nullptr) ? PlayerLevel(unit) : 0;
|
||||
PushEnriched(L, UnitGuid(unit), spellID, isHelpful, ReadStacks(unit, slot),
|
||||
unitLevel, unit != nullptr && unit == LocalPlayer(), slot,
|
||||
@@ -720,36 +824,16 @@ bool DescriptorHasVisibleAura(const uint8_t *unit) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Counts populated descriptor slots matching `filter` (and `caster`).
|
||||
int CountSlots(const uint8_t *unit, Filter filter, Match match) {
|
||||
if (unit == nullptr)
|
||||
return 0;
|
||||
const int start = (filter == Filter::Harmful)
|
||||
? Offsets::UNIT_AURA_BUFF_COUNT
|
||||
: 0;
|
||||
const int end = (filter == Filter::Harmful) ? Offsets::UNIT_AURA_TOTAL
|
||||
: Offsets::UNIT_AURA_BUFF_COUNT;
|
||||
int n = 0;
|
||||
for (int slot = start; slot < end; ++slot) {
|
||||
if (!IsSlotPopulated(unit, slot))
|
||||
continue;
|
||||
if (!MatchesAura(match, IsPlayerCast(unit, slot), ReadSpellID(unit, slot)))
|
||||
continue;
|
||||
++n;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
constexpr int kFallbackMax = Offsets::UNIT_AURA_TOTAL;
|
||||
|
||||
// Whether cache entry `c` should be surfaced as a fallback for `unit` under
|
||||
// `filter`/`caster`: the spell must be a user-visible aura, it must not
|
||||
// already be in a populated descriptor slot (no double-listing the same live
|
||||
// aura) and, when player-filtered, must have been cast by us. Superseded /
|
||||
// dispelled auras are kept out of the cache by `Aura::Source`'s removal
|
||||
// eviction, not filtered here.
|
||||
// Whether cache entry `c` may surface as a fallback for `unit` under `filter`:
|
||||
// the spell must be a user-visible aura and must not already be in a populated
|
||||
// descriptor slot (no double-listing the same live aura). The caller's
|
||||
// caster/dispel/CC predicates are applied separately by `CachedMatches`.
|
||||
// Superseded / dispelled auras are kept out of the cache by `Aura::Source`'s
|
||||
// removal eviction, not filtered here.
|
||||
bool FallbackEligible(const uint8_t *unit, const Aura::Source::CachedAura &c,
|
||||
Filter filter, Match match) {
|
||||
Filter filter) {
|
||||
if (!IsVisible(SpellRecord(c.spellId)))
|
||||
return false; // hidden/internal aura (e.g. SPELL_ATTR_HIDDEN_CLIENTSIDE):
|
||||
// the descriptor path drops these via IsSlotPopulated, so
|
||||
@@ -757,37 +841,58 @@ bool FallbackEligible(const uint8_t *unit, const Aura::Source::CachedAura &c,
|
||||
// they leak through as tooltip-less phantom buffs.
|
||||
if (FindSlotBySpellID(unit, c.spellId, &filter, Match{}) >= 0)
|
||||
return false; // still in the descriptor — returned via the slot path
|
||||
if (!MatchesAura(match, c.casterGuid == Unit::Identity::PlayerGuid(), c.spellId))
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Applies `match` to a cache entry. The entry carries its caster, so the caster
|
||||
// test is a plain GUID compare — no scan, nothing to defer.
|
||||
bool CachedMatches(const Aura::Source::CachedAura &c, const Match &match) {
|
||||
return MatchesAura(match, c.casterGuid == Unit::Identity::PlayerGuid(),
|
||||
c.spellId);
|
||||
}
|
||||
|
||||
// The eligible cache-fallback entries for `unit` under `filter`, in `Enumerate`
|
||||
// order, unfiltered by any `Match` (callers apply `CachedMatches`). Applies the
|
||||
// fallback gates: reconcile the cache against the descriptor, then yield nothing
|
||||
// while the descriptor shows any visible aura (it is authoritative then — see
|
||||
// DescriptorHasVisibleAura). `buf` must hold kFallbackMax entries. Returns the
|
||||
// count. An entry's ordinal here is what an opaque cache slot id encodes, which
|
||||
// is why the sequence must not depend on the caller's match.
|
||||
int EligibleFallbacks(const uint8_t *unit, Filter filter,
|
||||
Aura::Source::CachedAura *buf) {
|
||||
if (unit == nullptr)
|
||||
return 0;
|
||||
ReconcileCache(unit); // drop entries the (synced) descriptor contradicts
|
||||
if (DescriptorHasVisibleAura(unit))
|
||||
return 0; // descriptor authoritative — see DescriptorHasVisibleAura
|
||||
const uint64_t guid = UnitGuid(unit);
|
||||
if (guid == 0)
|
||||
return 0;
|
||||
Aura::Source::CachedAura raw[kFallbackMax];
|
||||
const int n = Aura::Source::Enumerate(
|
||||
guid, filter == Filter::Harmful, raw, kFallbackMax);
|
||||
int m = 0;
|
||||
for (int i = 0; i < n; ++i) {
|
||||
if (FallbackEligible(unit, raw[i], filter))
|
||||
buf[m++] = raw[i];
|
||||
}
|
||||
return m;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
bool PushNthCacheFallback(void *L, const uint8_t *unit, int oneBasedIndex,
|
||||
Filter filter, Match match, Emit emit) {
|
||||
if (unit == nullptr)
|
||||
return false;
|
||||
ReconcileCache(unit); // drop entries the (synced) descriptor contradicts
|
||||
if (DescriptorHasVisibleAura(unit))
|
||||
return false; // descriptor authoritative — see DescriptorHasVisibleAura
|
||||
// The descriptor held `descCount` matches; the caller already found fewer
|
||||
// than `oneBasedIndex` there, so the cache supplies index
|
||||
// `oneBasedIndex - descCount` (1-based) of the entries it didn't.
|
||||
const int target = oneBasedIndex - CountSlots(unit, filter, match);
|
||||
if (target < 1)
|
||||
return false;
|
||||
const uint64_t guid = UnitGuid(unit);
|
||||
if (guid == 0)
|
||||
return false;
|
||||
// The fallback yields only while the descriptor shows no visible aura, so
|
||||
// the descriptor held none of the caller's matches: the cache supplies index
|
||||
// `oneBasedIndex` (1-based) of the entries passing `match` directly.
|
||||
Aura::Source::CachedAura buf[kFallbackMax];
|
||||
const int n = Aura::Source::Enumerate(
|
||||
guid, filter == Filter::Harmful, buf, kFallbackMax);
|
||||
const int n = EligibleFallbacks(unit, filter, buf);
|
||||
int seen = 0;
|
||||
for (int i = 0; i < n; ++i) {
|
||||
if (!FallbackEligible(unit, buf[i], filter, match))
|
||||
if (!CachedMatches(buf[i], match))
|
||||
continue;
|
||||
if (++seen == target) {
|
||||
if (++seen == oneBasedIndex) {
|
||||
PushFromCache(L, unit, buf[i], filter == Filter::Helpful, emit);
|
||||
return true;
|
||||
}
|
||||
@@ -797,19 +902,10 @@ bool PushNthCacheFallback(void *L, const uint8_t *unit, int oneBasedIndex,
|
||||
|
||||
void AppendCacheFallbacks(void *L, const uint8_t *unit, Filter filter,
|
||||
Match match, int outerIdx, int &nextKey) {
|
||||
if (unit == nullptr)
|
||||
return;
|
||||
ReconcileCache(unit); // drop entries the (synced) descriptor contradicts
|
||||
if (DescriptorHasVisibleAura(unit))
|
||||
return; // descriptor authoritative — see DescriptorHasVisibleAura
|
||||
const uint64_t guid = UnitGuid(unit);
|
||||
if (guid == 0)
|
||||
return;
|
||||
Aura::Source::CachedAura buf[kFallbackMax];
|
||||
const int n = Aura::Source::Enumerate(
|
||||
guid, filter == Filter::Harmful, buf, kFallbackMax);
|
||||
const int n = EligibleFallbacks(unit, filter, buf);
|
||||
for (int i = 0; i < n; ++i) {
|
||||
if (!FallbackEligible(unit, buf[i], filter, match))
|
||||
if (!CachedMatches(buf[i], match))
|
||||
continue;
|
||||
Game::Lua::PushNumber(L, static_cast<double>(nextKey++));
|
||||
PushFromCache(L, unit, buf[i], filter == Filter::Helpful);
|
||||
@@ -821,8 +917,10 @@ void AppendCacheFallbacks(void *L, const uint8_t *unit, Filter filter,
|
||||
|
||||
namespace {
|
||||
|
||||
// Absolute slot range [start, end) for a filter, matching the descriptor
|
||||
// convention (helpful 0..31, harmful 32..47).
|
||||
// Absolute slot range [start, end) for a filter in the GROUP ARRAY (helpful
|
||||
// 0..31, harmful 32..47). The array carries spell IDs only — no flag nibble —
|
||||
// so the seating range is the only polarity the out-of-range path has. (The
|
||||
// descriptor path reads the nibble instead; see SlotInFilterOrder.)
|
||||
void GroupRange(Filter filter, int &start, int &end) {
|
||||
start = (filter == Filter::Harmful) ? Offsets::UNIT_AURA_BUFF_COUNT : 0;
|
||||
end = (filter == Filter::Harmful) ? Offsets::UNIT_AURA_TOTAL
|
||||
@@ -836,21 +934,27 @@ uint32_t GroupSpellID(const uint16_t *arr, int slot) {
|
||||
return arr[slot];
|
||||
}
|
||||
|
||||
// Whether the group-array slot holds a surfacable aura for this query. Mirrors
|
||||
// the engine's per-slot test in Script_UnitBuff/UnitDebuff — a non-zero spell
|
||||
// ID with a real Spell.dbc record that passes the visibility predicate (the
|
||||
// server only transmits *applied* auras, so there is no flag nibble to check).
|
||||
// `caster` additionally requires the `Aura::Source` cache to attribute the
|
||||
// cast to the local player (a miss excludes it — we can't confirm the caster
|
||||
// from the group array alone).
|
||||
bool GroupSlotEligible(uint64_t guid, const uint16_t *arr, int slot,
|
||||
Match match) {
|
||||
// Whether the group-array slot holds a surfacable aura. Mirrors the engine's
|
||||
// per-slot test in Script_UnitBuff/UnitDebuff — a non-zero spell ID with a real
|
||||
// Spell.dbc record that passes the visibility predicate (the server only
|
||||
// transmits *applied* auras, so there is no flag nibble to check).
|
||||
bool GroupSlotVisible(const uint16_t *arr, int slot) {
|
||||
const uint32_t id = GroupSpellID(arr, slot);
|
||||
if (id == 0 || !IsVisible(SpellRecord(id)))
|
||||
return id != 0 && IsVisible(SpellRecord(id));
|
||||
}
|
||||
|
||||
// `GroupSlotVisible` plus the caller's `match` predicates. The caster answer
|
||||
// (`GroupIsPlayerCast` — a cache scan; a miss counts as not-player, since the
|
||||
// array alone can't confirm the caster) is computed only when `match` restricts
|
||||
// on caster.
|
||||
bool GroupSlotMatches(uint64_t guid, const uint16_t *arr, int slot,
|
||||
const Match &match) {
|
||||
if (!GroupSlotVisible(arr, slot))
|
||||
return false;
|
||||
if (!MatchesAura(match, GroupIsPlayerCast(guid, id), id))
|
||||
return false;
|
||||
return true;
|
||||
const uint32_t id = GroupSpellID(arr, slot);
|
||||
const bool castByPlayer =
|
||||
match.caster != CasterMode::Any && GroupIsPlayerCast(guid, id);
|
||||
return MatchesAura(match, castByPlayer, id);
|
||||
}
|
||||
|
||||
// The groupmate's level, for Spell.dbc duration scaling (0 = skip). Cheap
|
||||
@@ -870,28 +974,52 @@ int GroupMemberLevel(uint64_t guid) {
|
||||
// still sit in the cache from when we observed them in range.
|
||||
bool GroupArrayHasVisibleAura(const uint16_t *arr) {
|
||||
for (int slot = 0; slot < Offsets::UNIT_AURA_TOTAL; ++slot) {
|
||||
const uint32_t id = GroupSpellID(arr, slot);
|
||||
if (id != 0 && IsVisible(SpellRecord(id)))
|
||||
if (GroupSlotVisible(arr, slot))
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Whether cache entry `c` may supplement the (empty) group array: a visible
|
||||
// aura, not already present in the array, and — when player-filtered — cast by
|
||||
// us. See GroupArrayHasVisibleAura for when this path runs.
|
||||
bool GroupFallbackEligible(const uint16_t *arr, const Aura::Source::CachedAura &c,
|
||||
Match match) {
|
||||
// aura not already present in the array. The caller's `match` is applied
|
||||
// separately by `CachedMatches`. See EligibleGroupFallbacks for when this path
|
||||
// runs.
|
||||
bool GroupFallbackEligible(const uint16_t *arr,
|
||||
const Aura::Source::CachedAura &c) {
|
||||
if (!IsVisible(SpellRecord(c.spellId)))
|
||||
return false;
|
||||
for (int slot = 0; slot < Offsets::UNIT_AURA_TOTAL; ++slot)
|
||||
if (GroupSpellID(arr, slot) == c.spellId)
|
||||
return false;
|
||||
if (!MatchesAura(match, c.casterGuid == Unit::Identity::PlayerGuid(), c.spellId))
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
// The eligible group cache-fallback entries for out-of-range member `guid`, in
|
||||
// `Enumerate` order, unfiltered by any `Match` (callers apply `CachedMatches`).
|
||||
// Yields nothing while the group array exposes ANY visible aura: the server's
|
||||
// out-of-range aura data is then present and authoritative, mirroring how the
|
||||
// descriptor path treats a populated descriptor (DescriptorHasVisibleAura).
|
||||
// Only an entirely empty array falls back to the cache — the server resends
|
||||
// out-of-range stats as deltas only, so a member seen in range and then moved
|
||||
// away again leaves an empty party-stats aura array (the array is cleared when
|
||||
// they become visible and never re-sent), yet their auras still sit in the
|
||||
// cache from when we observed them in range. `buf` must hold kFallbackMax
|
||||
// entries. An entry's ordinal here is what an opaque group-cache slot id
|
||||
// encodes, which is why the sequence must not depend on the caller's match.
|
||||
int EligibleGroupFallbacks(const uint16_t *arr, uint64_t guid, bool harmful,
|
||||
Aura::Source::CachedAura *buf) {
|
||||
if (arr == nullptr || GroupArrayHasVisibleAura(arr))
|
||||
return 0;
|
||||
Aura::Source::CachedAura raw[kFallbackMax];
|
||||
const int n = Aura::Source::Enumerate(guid, harmful, raw, kFallbackMax);
|
||||
int m = 0;
|
||||
for (int i = 0; i < n; ++i) {
|
||||
if (GroupFallbackEligible(arr, raw[i]))
|
||||
buf[m++] = raw[i];
|
||||
}
|
||||
return m;
|
||||
}
|
||||
|
||||
// Nth (1-based) cache-fallback aura of `filter`'s kind for an out-of-range
|
||||
// member whose group array is empty. Pushes the AuraData table on a hit.
|
||||
bool PushNthGroupCacheFallback(void *L, uint64_t guid, const uint16_t *arr,
|
||||
@@ -899,10 +1027,10 @@ bool PushNthGroupCacheFallback(void *L, uint64_t guid, const uint16_t *arr,
|
||||
Emit emit = Emit::Table) {
|
||||
Aura::Source::CachedAura buf[kFallbackMax];
|
||||
const int n =
|
||||
Aura::Source::Enumerate(guid, filter == Filter::Harmful, buf, kFallbackMax);
|
||||
EligibleGroupFallbacks(arr, guid, filter == Filter::Harmful, buf);
|
||||
int seen = 0;
|
||||
for (int i = 0; i < n; ++i) {
|
||||
if (!GroupFallbackEligible(arr, buf[i], match))
|
||||
if (!CachedMatches(buf[i], match))
|
||||
continue;
|
||||
if (++seen == oneBasedIndex) {
|
||||
const Attribution a = CachedAttribution(buf[i]);
|
||||
@@ -920,10 +1048,10 @@ void AppendGroupCacheFallbacks(void *L, uint64_t guid, const uint16_t *arr,
|
||||
int &nextKey) {
|
||||
Aura::Source::CachedAura buf[kFallbackMax];
|
||||
const int n =
|
||||
Aura::Source::Enumerate(guid, filter == Filter::Harmful, buf, kFallbackMax);
|
||||
EligibleGroupFallbacks(arr, guid, filter == Filter::Harmful, buf);
|
||||
const int level = GroupMemberLevel(guid);
|
||||
for (int i = 0; i < n; ++i) {
|
||||
if (!GroupFallbackEligible(arr, buf[i], match))
|
||||
if (!CachedMatches(buf[i], match))
|
||||
continue;
|
||||
Game::Lua::PushNumber(L, static_cast<double>(nextKey++));
|
||||
const Attribution a = CachedAttribution(buf[i]);
|
||||
@@ -939,7 +1067,7 @@ bool PushGroupCacheFallbackMatch(void *L, uint64_t guid, const uint16_t *arr,
|
||||
bool harmful, uint32_t spellID,
|
||||
const char *spellName, Match match) {
|
||||
Aura::Source::CachedAura buf[kFallbackMax];
|
||||
const int n = Aura::Source::Enumerate(guid, harmful, buf, kFallbackMax);
|
||||
const int n = EligibleGroupFallbacks(arr, guid, harmful, buf);
|
||||
for (int i = 0; i < n; ++i) {
|
||||
if (spellID != 0 && buf[i].spellId != spellID)
|
||||
continue;
|
||||
@@ -948,7 +1076,7 @@ bool PushGroupCacheFallbackMatch(void *L, uint64_t guid, const uint16_t *arr,
|
||||
if (nm == nullptr || std::strcmp(nm, spellName) != 0)
|
||||
continue;
|
||||
}
|
||||
if (!GroupFallbackEligible(arr, buf[i], match))
|
||||
if (!CachedMatches(buf[i], match))
|
||||
continue;
|
||||
const Attribution a = CachedAttribution(buf[i]);
|
||||
PushEnriched(L, guid, buf[i].spellId, !harmful, 1, GroupMemberLevel(guid),
|
||||
@@ -972,7 +1100,7 @@ bool PushNthGroupAura(void *L, uint64_t guid, int oneBasedIndex, Filter filter,
|
||||
GroupRange(filter, start, end);
|
||||
int matches = 0;
|
||||
for (int slot = start; slot < end; ++slot) {
|
||||
if (!GroupSlotEligible(guid, arr, slot, match))
|
||||
if (!GroupSlotMatches(guid, arr, slot, match))
|
||||
continue;
|
||||
if (++matches == oneBasedIndex) {
|
||||
PushEnriched(L, guid, GroupSpellID(arr, slot),
|
||||
@@ -983,10 +1111,8 @@ bool PushNthGroupAura(void *L, uint64_t guid, int oneBasedIndex, Filter filter,
|
||||
}
|
||||
}
|
||||
// Empty array (server delta-resend gap): supplement from the cache.
|
||||
if (!GroupArrayHasVisibleAura(arr))
|
||||
return PushNthGroupCacheFallback(L, guid, arr, oneBasedIndex, filter,
|
||||
match, emit);
|
||||
return false;
|
||||
return PushNthGroupCacheFallback(L, guid, arr, oneBasedIndex, filter, match,
|
||||
emit);
|
||||
}
|
||||
|
||||
bool PushGroupAuraBySpellID(void *L, uint64_t guid, uint32_t spellID,
|
||||
@@ -1006,23 +1132,20 @@ bool PushGroupAuraBySpellID(void *L, uint64_t guid, uint32_t spellID,
|
||||
for (int slot = start; slot < end; ++slot) {
|
||||
if (GroupSpellID(arr, slot) != spellID)
|
||||
continue;
|
||||
if (!GroupSlotEligible(guid, arr, slot, match))
|
||||
if (!GroupSlotMatches(guid, arr, slot, match))
|
||||
continue;
|
||||
PushEnriched(L, guid, spellID, slot < Offsets::UNIT_AURA_BUFF_COUNT, 1,
|
||||
GroupMemberLevel(guid), false, Aura::Source::SLOT_UNBOUND);
|
||||
return true;
|
||||
}
|
||||
if (!GroupArrayHasVisibleAura(arr)) {
|
||||
const bool both = (filter == nullptr);
|
||||
if ((both || *filter == Filter::Helpful) &&
|
||||
PushGroupCacheFallbackMatch(L, guid, arr, false, spellID, nullptr,
|
||||
match))
|
||||
return true;
|
||||
if ((both || *filter == Filter::Harmful) &&
|
||||
PushGroupCacheFallbackMatch(L, guid, arr, true, spellID, nullptr,
|
||||
match))
|
||||
return true;
|
||||
}
|
||||
// Empty array (server delta-resend gap): supplement from the cache.
|
||||
const bool both = (filter == nullptr);
|
||||
if ((both || *filter == Filter::Helpful) &&
|
||||
PushGroupCacheFallbackMatch(L, guid, arr, false, spellID, nullptr, match))
|
||||
return true;
|
||||
if ((both || *filter == Filter::Harmful) &&
|
||||
PushGroupCacheFallbackMatch(L, guid, arr, true, spellID, nullptr, match))
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -1047,23 +1170,20 @@ bool PushGroupAuraBySpellName(void *L, uint64_t guid, const char *spellName,
|
||||
const char *name = LocalizedSpellName(SpellRecord(id));
|
||||
if (name == nullptr || std::strcmp(name, spellName) != 0)
|
||||
continue;
|
||||
if (!GroupSlotEligible(guid, arr, slot, match))
|
||||
if (!GroupSlotMatches(guid, arr, slot, match))
|
||||
continue;
|
||||
PushEnriched(L, guid, id, slot < Offsets::UNIT_AURA_BUFF_COUNT, 1,
|
||||
GroupMemberLevel(guid), false, Aura::Source::SLOT_UNBOUND);
|
||||
return true;
|
||||
}
|
||||
if (!GroupArrayHasVisibleAura(arr)) {
|
||||
const bool both = (filter == nullptr);
|
||||
if ((both || *filter == Filter::Helpful) &&
|
||||
PushGroupCacheFallbackMatch(L, guid, arr, false, 0, spellName,
|
||||
match))
|
||||
return true;
|
||||
if ((both || *filter == Filter::Harmful) &&
|
||||
PushGroupCacheFallbackMatch(L, guid, arr, true, 0, spellName,
|
||||
match))
|
||||
return true;
|
||||
}
|
||||
// Empty array (server delta-resend gap): supplement from the cache.
|
||||
const bool both = (filter == nullptr);
|
||||
if ((both || *filter == Filter::Helpful) &&
|
||||
PushGroupCacheFallbackMatch(L, guid, arr, false, 0, spellName, match))
|
||||
return true;
|
||||
if ((both || *filter == Filter::Harmful) &&
|
||||
PushGroupCacheFallbackMatch(L, guid, arr, true, 0, spellName, match))
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -1079,7 +1199,7 @@ void AppendGroupAuras(void *L, uint64_t guid, Filter filter, Match match,
|
||||
GroupRange(filter, start, end);
|
||||
const int level = GroupMemberLevel(guid);
|
||||
for (int slot = start; slot < end; ++slot) {
|
||||
if (!GroupSlotEligible(guid, arr, slot, match))
|
||||
if (!GroupSlotMatches(guid, arr, slot, match))
|
||||
continue;
|
||||
Game::Lua::PushNumber(L, static_cast<double>(nextKey++));
|
||||
PushEnriched(L, guid, GroupSpellID(arr, slot),
|
||||
@@ -1088,9 +1208,112 @@ void AppendGroupAuras(void *L, uint64_t guid, Filter filter, Match match,
|
||||
Game::Lua::SetTable(L, outerIdx);
|
||||
}
|
||||
// Empty array (server delta-resend gap): supplement from the cache.
|
||||
if (!GroupArrayHasVisibleAura(arr))
|
||||
AppendGroupCacheFallbacks(L, guid, arr, filter, match, outerIdx,
|
||||
nextKey);
|
||||
AppendGroupCacheFallbacks(L, guid, arr, filter, match, outerIdx, nextKey);
|
||||
}
|
||||
|
||||
// ── Opaque aura slots ──────────────────────────────────────────────────────
|
||||
|
||||
int CollectSlots(const uint8_t *unit, uint64_t guid, Filter filter, Match match,
|
||||
int *out, int cap) {
|
||||
int n = 0;
|
||||
if (unit != nullptr) {
|
||||
for (int i = 0; i < Offsets::UNIT_AURA_TOTAL && n < cap; ++i) {
|
||||
const int slot = SlotInFilterOrder(filter, i);
|
||||
if (SlotMatchesFilter(unit, slot, filter, match))
|
||||
out[n++] = slot;
|
||||
}
|
||||
// The cache fallback yields only while the descriptor shows no visible
|
||||
// aura, so these never interleave with live slots — they follow them,
|
||||
// exactly as the by-index getters order them.
|
||||
Aura::Source::CachedAura buf[kFallbackMax];
|
||||
const int m = EligibleFallbacks(unit, filter, buf);
|
||||
const int base = (filter == Filter::Harmful) ? OPAQUE_CACHE_HARMFUL
|
||||
: OPAQUE_CACHE_HELPFUL;
|
||||
for (int k = 0; k < m && n < cap; ++k) {
|
||||
if (CachedMatches(buf[k], match))
|
||||
out[n++] = base + k;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
// No live CGUnit — the out-of-range group path.
|
||||
if (guid == 0)
|
||||
return 0;
|
||||
const uint16_t *arr = Group::MemberStats::AuraArray(guid);
|
||||
if (arr == nullptr)
|
||||
return 0;
|
||||
Aura::Source::ObserveGroupAuras(guid, arr);
|
||||
int start, end;
|
||||
GroupRange(filter, start, end);
|
||||
for (int slot = start; slot < end && n < cap; ++slot) {
|
||||
if (GroupSlotMatches(guid, arr, slot, match))
|
||||
out[n++] = OPAQUE_GROUP + slot;
|
||||
}
|
||||
Aura::Source::CachedAura buf[kFallbackMax];
|
||||
const int m = EligibleGroupFallbacks(arr, guid, filter == Filter::Harmful, buf);
|
||||
const int base = (filter == Filter::Harmful) ? OPAQUE_GROUP_CACHE_HARMFUL
|
||||
: OPAQUE_GROUP_CACHE_HELPFUL;
|
||||
for (int k = 0; k < m && n < cap; ++k) {
|
||||
if (CachedMatches(buf[k], match))
|
||||
out[n++] = base + k;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
bool PushBySlot(void *L, const uint8_t *unit, uint64_t guid, int slot, Emit emit) {
|
||||
if (slot < 0)
|
||||
return false;
|
||||
const int kind = slot / OPAQUE_STRIDE;
|
||||
const int k = slot % OPAQUE_STRIDE;
|
||||
switch (kind) {
|
||||
case 0: // live descriptor slot
|
||||
if (unit == nullptr || !IsSlotPopulated(unit, k))
|
||||
return false;
|
||||
Push(L, unit, k, emit);
|
||||
return true;
|
||||
case 1: // helpful cache fallback
|
||||
case 2: { // harmful cache fallback
|
||||
if (unit == nullptr || k >= kFallbackMax)
|
||||
return false;
|
||||
const Filter filter = (kind == 2) ? Filter::Harmful : Filter::Helpful;
|
||||
Aura::Source::CachedAura buf[kFallbackMax];
|
||||
if (k >= EligibleFallbacks(unit, filter, buf))
|
||||
return false;
|
||||
PushFromCache(L, unit, buf[k], filter == Filter::Helpful, emit);
|
||||
return true;
|
||||
}
|
||||
case 3: { // out-of-range group-array slot
|
||||
if (guid == 0 || k >= Offsets::UNIT_AURA_TOTAL)
|
||||
return false;
|
||||
const uint16_t *arr = Group::MemberStats::AuraArray(guid);
|
||||
if (arr == nullptr)
|
||||
return false;
|
||||
Aura::Source::ObserveGroupAuras(guid, arr);
|
||||
if (!GroupSlotVisible(arr, k))
|
||||
return false;
|
||||
PushEnriched(L, guid, GroupSpellID(arr, k),
|
||||
k < Offsets::UNIT_AURA_BUFF_COUNT, 1, GroupMemberLevel(guid),
|
||||
false, Aura::Source::SLOT_UNBOUND, /*known*/ nullptr, emit);
|
||||
return true;
|
||||
}
|
||||
case 4: // helpful group cache fallback
|
||||
case 5: { // harmful group cache fallback
|
||||
if (guid == 0 || k >= kFallbackMax)
|
||||
return false;
|
||||
const uint16_t *arr = Group::MemberStats::AuraArray(guid);
|
||||
if (arr == nullptr)
|
||||
return false;
|
||||
const bool harmful = (kind == 5);
|
||||
Aura::Source::CachedAura buf[kFallbackMax];
|
||||
if (k >= EligibleGroupFallbacks(arr, guid, harmful, buf))
|
||||
return false;
|
||||
const Attribution a = CachedAttribution(buf[k]);
|
||||
PushEnriched(L, guid, buf[k].spellId, !harmful, 1, GroupMemberLevel(guid),
|
||||
false, buf[k].slot, &a, emit);
|
||||
return true;
|
||||
}
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace Aura::Data
|
||||
|
||||
+104
-13
@@ -10,6 +10,8 @@
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "Offsets.h"
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
// Shared aura-table primitives consumed by `C_UnitAuras.*` (and any
|
||||
@@ -18,19 +20,38 @@
|
||||
// `unit + OFF_CGUNIT_OBJECT_FIELDS`; the layout is documented in
|
||||
// `Offsets.h` under `OFF_UNIT_FIELD_AURA*`.
|
||||
//
|
||||
// Slot indexing convention used here matches the engine's:
|
||||
// Slots are absolute 0..47, and how polarity is read out of them DEPENDS ON THE
|
||||
// SERVER. `IsSlotHarmful` owns that decision; nothing else should look at either
|
||||
// signal directly.
|
||||
//
|
||||
// slot 0..31 → buffs (helpful)
|
||||
// slot 32..47 → debuffs (harmful)
|
||||
// Normally the slot range IS the polarity: positive auras are written to 0..31
|
||||
// and negative ones to 32..47 by a strict either/or, so nothing crosses over.
|
||||
// The flag nibble is no help — in a stock descriptor those bits record which
|
||||
// spell effect indices carry an aura, not whether the aura is good or bad.
|
||||
//
|
||||
// Callers that take a 1-based Lua index into "buffs" or "debuffs"
|
||||
// translate to the absolute 0..47 slot before calling in.
|
||||
// Turtle changes both halves. Once the 16 harmful slots are full it spills
|
||||
// further debuffs into 0..31 (setting UNIT_FLAG_AURAS_VISIBLE so the client
|
||||
// still renders them), which makes the range unreliable — and it repurposes the
|
||||
// nibble to carry the true polarity so the information survives the spill.
|
||||
// There, the nibble is authoritative. See `Offsets::UNIT_AURA_FLAG_HARMFUL`.
|
||||
//
|
||||
// Either way `Filter` selects on the aura's REAL polarity, so a spilled debuff
|
||||
// is reported as a debuff. The engine's own UnitBuff/UnitDebuff split purely by
|
||||
// slot range and would call it a buff; that is a 1.12 UI limitation we
|
||||
// deliberately do not mirror, since retail's contract is actual polarity. A
|
||||
// polarity walk visits its home range first and then the other range, so
|
||||
// indices are unchanged wherever no spill has happened — which is everywhere,
|
||||
// on a server that does not spill.
|
||||
//
|
||||
// Callers that take a 1-based Lua index into "buffs" or "debuffs" translate
|
||||
// to the absolute 0..47 slot before calling in.
|
||||
|
||||
namespace Aura::Data {
|
||||
|
||||
// Filter for slot iteration. The engine's `UnitBuff` / `UnitDebuff`
|
||||
// pick one direction; modern `C_UnitAuras.GetAuraDataByIndex` defaults
|
||||
// to helpful when no filter is specified.
|
||||
// Filter for slot iteration: the aura's polarity per its flag nibble (see the
|
||||
// file header). The engine's `UnitBuff` / `UnitDebuff` pick one direction;
|
||||
// modern `C_UnitAuras.GetAuraDataByIndex` defaults to helpful when no filter
|
||||
// is specified.
|
||||
enum class Filter { Helpful, Harmful };
|
||||
|
||||
// How a resolved aura is emitted onto the Lua stack by the single-aura push
|
||||
@@ -83,11 +104,33 @@ uint32_t ReadSpellID(const uint8_t *unit, int slot);
|
||||
// to decide whether to surface an aura through Lua.
|
||||
bool IsSlotPopulated(const uint8_t *unit, int slot);
|
||||
|
||||
// Finds the absolute slot of the `oneBasedIndex`-th populated aura
|
||||
// matching `filter`. Returns -1 if no such aura. Walks
|
||||
// 0..UNIT_AURA_BUFF_COUNT-1 for Helpful, UNIT_AURA_BUFF_COUNT..
|
||||
// UNIT_AURA_TOTAL-1 for Harmful. When `playerOnly` is true, only auras
|
||||
// the local player cast (per `IsPlayerCast`) are counted/returned.
|
||||
// The aura's real polarity, read the way this server encodes it: the slot range
|
||||
// normally, the flag nibble on Turtle (see the file header). False for an empty
|
||||
// slot or a null unit. This is what `Filter` selects on, and the ONE place the
|
||||
// two encodings are chosen between.
|
||||
bool IsSlotHarmful(const uint8_t *unit, int slot);
|
||||
|
||||
// The engine's TOOLTIP visibility gate for a slot: occupied, visible nibble,
|
||||
// and the spell passes `FUN_GAMETOOLTIP_AURA_VISIBLE` — exactly what
|
||||
// `SetUnitBuff` / `SetUnitDebuff` count while turning an index into a slot.
|
||||
// Differs from `IsSlotPopulated` (the UnitBuff gate), so use THIS to compute
|
||||
// the index the engine's tooltip methods expect for a given slot.
|
||||
bool IsSlotTooltipVisible(const uint8_t *unit, int slot);
|
||||
|
||||
// The i-th slot (0..UNIT_AURA_TOTAL-1) in the order a `filter` walk visits the
|
||||
// descriptor: the polarity's home range first (helpful 0..31, harmful 32..47),
|
||||
// then the other range, where spilled debuffs live. Every enumeration that
|
||||
// hands out indices or slot lists uses this so they agree on order.
|
||||
int SlotInFilterOrder(Filter filter, int i);
|
||||
|
||||
// `SlotMatches` plus the polarity test: populated, of `filter`'s polarity, and
|
||||
// passing `match`.
|
||||
bool SlotMatchesFilter(const uint8_t *unit, int slot, Filter filter,
|
||||
const Match &match);
|
||||
|
||||
// Finds the absolute slot of the `oneBasedIndex`-th populated aura of
|
||||
// `filter`'s polarity that passes `match`. Returns -1 if no such aura. Walks
|
||||
// all 48 slots in `SlotInFilterOrder`.
|
||||
int FindNthSlot(const uint8_t *unit, int oneBasedIndex, Filter filter,
|
||||
Match match = {});
|
||||
|
||||
@@ -124,6 +167,12 @@ bool IsDispellable(uint32_t spellID);
|
||||
// per-aura "was cast by the local player" answer and its spell ID.
|
||||
bool MatchesAura(const Match &match, bool isPlayerCast, uint32_t spellID);
|
||||
|
||||
// True iff descriptor `slot` on `unit` is populated (per `IsSlotPopulated`) AND
|
||||
// passes `match`. The caster answer (`IsPlayerCast` — a scan of the
|
||||
// `Aura::Source` cache, ~1000 entries in a raid) is computed ONLY when `match`
|
||||
// restricts on caster, so the common filter-less walk never pays for it.
|
||||
bool SlotMatches(const uint8_t *unit, int slot, const Match &match);
|
||||
|
||||
// Display stack count for the given slot (engine stores stacks-1,
|
||||
// we add 1). Returns 0 if the unit is null.
|
||||
int ReadStacks(const uint8_t *unit, int slot);
|
||||
@@ -230,4 +279,46 @@ bool PushGroupAuraBySpellName(void *L, uint64_t guid, const char *spellName,
|
||||
void AppendGroupAuras(void *L, uint64_t guid, Filter filter, Match match,
|
||||
int outerIdx, int &nextKey);
|
||||
|
||||
// ── Opaque aura slots (GetAuraSlots / GetAuraDataBySlot) ────────────────────
|
||||
//
|
||||
// The by-index getters re-walk the descriptor from slot 0 on every call, so a
|
||||
// full scan through them is quadratic in the aura count. `CollectSlots`
|
||||
// enumerates once into opaque slot ids and `PushBySlot` fetches one aura by id
|
||||
// with no walk — the modern `C_UnitAuras.GetAuraSlots` / `GetAuraDataBySlot`
|
||||
// batching contract. An id is valid for the frame it was enumerated in.
|
||||
//
|
||||
// Encoding (kind = id / OPAQUE_STRIDE, k = id % OPAQUE_STRIDE):
|
||||
// kind 0 live descriptor slot k (0..UNIT_AURA_TOTAL-1)
|
||||
// kind 1 k-th eligible helpful cache-fallback entry (OPAQUE_CACHE_HELPFUL)
|
||||
// kind 2 k-th eligible harmful cache-fallback entry (OPAQUE_CACHE_HARMFUL)
|
||||
// kind 3 out-of-range group-array slot k (0..47) (OPAQUE_GROUP)
|
||||
// kind 4 k-th helpful group cache-fallback entry (OPAQUE_GROUP_CACHE_HELPFUL)
|
||||
// kind 5 k-th harmful group cache-fallback entry (OPAQUE_GROUP_CACHE_HARMFUL)
|
||||
// Fallback ordinals count the UNFILTERED eligible sequence (visible, not
|
||||
// already in the live array), so `PushBySlot` needs no filter; `CollectSlots`
|
||||
// applies the caller's `match` only when choosing which ids to emit.
|
||||
constexpr int OPAQUE_STRIDE = 0x100;
|
||||
constexpr int OPAQUE_CACHE_HELPFUL = 1 * OPAQUE_STRIDE;
|
||||
constexpr int OPAQUE_CACHE_HARMFUL = 2 * OPAQUE_STRIDE;
|
||||
constexpr int OPAQUE_GROUP = 3 * OPAQUE_STRIDE;
|
||||
constexpr int OPAQUE_GROUP_CACHE_HELPFUL = 4 * OPAQUE_STRIDE;
|
||||
constexpr int OPAQUE_GROUP_CACHE_HARMFUL = 5 * OPAQUE_STRIDE;
|
||||
|
||||
// Upper bound on ids one `CollectSlots` call can yield: the live array (or the
|
||||
// group array) plus its cache fallback, each at most UNIT_AURA_TOTAL entries.
|
||||
constexpr int OPAQUE_SLOTS_MAX = 2 * Offsets::UNIT_AURA_TOTAL;
|
||||
|
||||
// Writes the opaque ids of every aura on the unit matching `filter` + `match`
|
||||
// into `out` (at most `cap`), in the order the by-index getters visit them:
|
||||
// live slots (or, with no live CGUnit, the group array) first, then the cache
|
||||
// fallback. `unit` null selects the out-of-range group path for `guid`. Returns
|
||||
// the count written.
|
||||
int CollectSlots(const uint8_t *unit, uint64_t guid, Filter filter, Match match,
|
||||
int *out, int cap);
|
||||
|
||||
// Pushes the aura an opaque id names — table or positional per `emit` — and
|
||||
// returns true; pushes nothing and returns false for an id that names nothing
|
||||
// (stale, malformed, or the wrong path for the unit's current state).
|
||||
bool PushBySlot(void *L, const uint8_t *unit, uint64_t guid, int slot, Emit emit);
|
||||
|
||||
} // namespace Aura::Data
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
// This file is part of ClassicAPI.
|
||||
//
|
||||
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
|
||||
// of the GNU General Public License as published by the Free Software Foundation, either
|
||||
// version 3 of the License, or (at your option) any later version.
|
||||
//
|
||||
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
|
||||
// PURPOSE. See the GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License along with
|
||||
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
// Paladin judgement melee refresh — client mirror of the server's white-swing
|
||||
// rule (pfUI#45: the judgement debuff's timer ran to zero while the debuff
|
||||
// stayed up through the paladin's auto-attacks).
|
||||
//
|
||||
// The server rule (tortoise-wow Unit::DealMeleeDamage, vmangos lineage): a
|
||||
// melee swing that dealt ANY damage (`totalDamage > 0` — misses/dodges/parries
|
||||
// deal none) refreshes every aura on the victim that the attacker cast whose
|
||||
// spell is SPELLFAMILY_PALADIN with SPELL_ATTR_EX3_ALWAYS_HIT — the judgement
|
||||
// marker (all 14 Judgement of Light/Wisdom/Justice/Crusader debuff ranks carry
|
||||
// it, verified in the client Spell.dbc). RefreshHolder resets the holder to
|
||||
// its own full duration.
|
||||
//
|
||||
// The edit itself is packet-silent (UpdateAuraDuration is self-scoped — the
|
||||
// same blindness every Turtle::DurationMods rule works around), but its
|
||||
// TRIGGER is not: DealMeleeDamage's only caller is Unit::AttackerStateUpdate
|
||||
// (white swings), which then broadcasts SMSG_ATTACKERSTATEUPDATE to everyone
|
||||
// in range — attacker, victim, and totalDamage are its first fields (wire
|
||||
// format verified against the server's own SendAttackStateUpdate writer; see
|
||||
// Offsets.h at SMSG_ATTACKERSTATEUPDATE). So the swing packet is an exact
|
||||
// client-side stand-in for the refresh, for ANY paladin's judgements — ours
|
||||
// or a raid member's.
|
||||
//
|
||||
// Ability damage (Crusader/Holy Strike) does NOT reach that server block —
|
||||
// melee-class spell damage never routes through DealMeleeDamage on this core
|
||||
// — so no rule is registered for it. Re-judging is covered separately: the
|
||||
// judgement debuff is cast as a real triggered spell, its SMSG_SPELL_GO
|
||||
// refreshes the cache entry via StoreFromCast.
|
||||
//
|
||||
// Not gated on Turtle::Detected(): the block is vmangos-lineage core (the
|
||||
// classic "judgements are refreshed by the paladin's melee attacks"
|
||||
// mechanic), and the refresh only ever touches existing judgement entries
|
||||
// with a matching known caster — on a server without the mechanic the debuff
|
||||
// would genuinely expire, removing the entry via OnAuraRemoved before drift
|
||||
// could show.
|
||||
|
||||
#include "Offsets.h"
|
||||
#include "aura/Source.h"
|
||||
#include "net/PacketDispatch.h"
|
||||
#include "net/PacketReader.h"
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
namespace Aura::JudgementRefresh {
|
||||
|
||||
namespace {
|
||||
|
||||
void OnPacket(uint32_t opcode, Net::CDataStore *packet) {
|
||||
if (opcode != Offsets::SMSG_ATTACKERSTATEUPDATE || packet == nullptr)
|
||||
return;
|
||||
Net::Read<uint32_t>(packet); // hitInfo — the damage gate below subsumes it
|
||||
const uint64_t attacker = Net::ReadPackedGuid(packet);
|
||||
const uint64_t victim = Net::ReadPackedGuid(packet);
|
||||
const uint32_t totalDamage = Net::Read<uint32_t>(packet);
|
||||
// The server's exact gate: any nonzero melee damage refreshes; a fully
|
||||
// avoided/absorbed swing (totalDamage 0) does not.
|
||||
if (totalDamage == 0 || attacker == 0 || victim == 0)
|
||||
return;
|
||||
Aura::Source::RefreshJudgements(victim, attacker);
|
||||
}
|
||||
|
||||
const Net::PacketDispatch::AutoSubscribe _sub{&OnPacket};
|
||||
|
||||
} // namespace
|
||||
|
||||
} // namespace Aura::JudgementRefresh
|
||||
+770
-30
@@ -238,14 +238,28 @@ Entry *FindBySlot(uint64_t targetGuid, uint32_t spellId, int slot) {
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
// The newest cast capture for this aura still awaiting a descriptor slot —
|
||||
// what an `OnAuraAdded` for `(target, spell)` is seating. Newest wins because
|
||||
// SpellGo precedes the application by a packet, so the freshest unbound entry
|
||||
// is the cast that just landed; an older one is a capture that never seated
|
||||
// (fully resisted, immune) and is left to expire on its own. "Newest" is the
|
||||
// smallest `Time::Clock::Elapsed(stamp, now)` — a wrap-safe signed difference;
|
||||
// a raw `stampMs >` compare inverts across the 2^32 ms tick wrap.
|
||||
Entry *FindFreshestUnbound(uint64_t targetGuid, uint32_t spellId, uint32_t now) {
|
||||
// A capture older than this is not the one an arriving application seats. An
|
||||
// instant aura's application follows its SpellGo within a server tick plus
|
||||
// latency (a few hundred ms); a projectile's (Fireball's DoT) follows at
|
||||
// impact — up to ~1.5s of flight at max range. 3s absorbs both plus lag, yet
|
||||
// stays far below any DoT's duration, so a capture whose application never
|
||||
// came (hit but aura-rejected: death race, effect-level immunity on a mixed
|
||||
// spell) can't be seated by a later same-spell cast's application. Fully
|
||||
// resisted/immune casts never create captures at all — they sit in
|
||||
// SMSG_SPELL_GO's miss list, and ParseSpellGo reads only the hit list.
|
||||
constexpr uint32_t kSeatWindowMs = 3000;
|
||||
|
||||
// The cast capture an `OnAuraAdded` for `(target, spell)` is seating: the
|
||||
// OLDEST unbound capture still inside the seat window — FIFO. When two
|
||||
// casters' same-spellID DoTs land in one descriptor-update batch, the server
|
||||
// assigned their slots in cast order (`_AddSpellAuraHolder`'s ascending
|
||||
// first-free-slot scan) and the client's diff dispatcher (`FUN_00604d00`)
|
||||
// fires OnAuraAdded in ascending slot order — verified: both its loops are
|
||||
// plain `slot = 0..0x2F` walks. So the FIRST application belongs to the
|
||||
// EARLIEST cast; seating newest-first paired the two casters' timers and
|
||||
// attribution backwards. Elapsed times use `Time::Clock::Elapsed` — a raw
|
||||
// `stampMs` compare inverts across the 2^32 ms tick wrap.
|
||||
Entry *FindOldestUnbound(uint64_t targetGuid, uint32_t spellId, uint32_t now) {
|
||||
Entry *best = nullptr;
|
||||
uint32_t bestElapsed = 0;
|
||||
for (int i = 0; i < g_usedHigh; ++i) {
|
||||
@@ -254,7 +268,9 @@ Entry *FindFreshestUnbound(uint64_t targetGuid, uint32_t spellId, uint32_t now)
|
||||
e.slot != SLOT_UNBOUND)
|
||||
continue;
|
||||
const uint32_t elapsed = Time::Clock::Elapsed(e.stampMs, now);
|
||||
if (best == nullptr || elapsed < bestElapsed) {
|
||||
if (elapsed > kSeatWindowMs)
|
||||
continue;
|
||||
if (best == nullptr || elapsed > bestElapsed) {
|
||||
best = &e;
|
||||
bestElapsed = elapsed;
|
||||
}
|
||||
@@ -340,6 +356,16 @@ Entry *Claim(uint32_t now) {
|
||||
|
||||
// ---- Writes --------------------------------------------------------------
|
||||
|
||||
// Defined in the duration-modifier section below; used by StoreFromCast's
|
||||
// refresh path too.
|
||||
void SignalAuraChanged(uint64_t guid);
|
||||
|
||||
// Defined with the duration-modifier machinery below. A trigger can CREATE the
|
||||
// aura it then edits, in which case the edit is armed at trigger time and
|
||||
// applied here, when the aura it was waiting for actually lands. See PendingMod.
|
||||
struct Entry;
|
||||
void ConsumePendingMods(Entry &e, uint32_t now);
|
||||
|
||||
// The SpellGo hook: authoritative caster + caster-modified (talented) timing.
|
||||
// Identity is `(target, spell, caster)`, so a second caster of the same spell
|
||||
// opens its own entry instead of overwriting the first's, and a recast by the
|
||||
@@ -367,12 +393,20 @@ void StoreFromCast(uint64_t targetGuid, uint32_t spellId, uint64_t casterGuid,
|
||||
e = Claim(now);
|
||||
*e = {targetGuid, casterGuid, spellId, expirationMs, durationMs,
|
||||
now, SLOT_UNBOUND, KIND_UNKNOWN, true};
|
||||
ConsumePendingMods(*e, now);
|
||||
return;
|
||||
}
|
||||
e->stampMs = now; // refresh EvictAbsent grace on any touch
|
||||
e->casterGuid = casterGuid;
|
||||
e->expirationMs = expirationMs;
|
||||
e->durationMs = durationMs;
|
||||
// An in-place recast refresh changes no descriptor field, so the engine
|
||||
// fires no UNIT_AURA — event-driven consumers (aura-bar addons counting
|
||||
// down a once-read duration) would never re-read the new timing. Signal it
|
||||
// ourselves (deferred to the world tick, coalesced). A NEW application
|
||||
// doesn't need this: its descriptor write fires the engine's own event.
|
||||
SignalAuraChanged(targetGuid);
|
||||
ConsumePendingMods(*e, now);
|
||||
}
|
||||
|
||||
// The OnAuraAdded / OnAuraStacksChanged application hooks: a descriptor slot,
|
||||
@@ -396,13 +430,14 @@ void StoreFromApplication(uint64_t targetGuid, uint32_t spellId,
|
||||
|
||||
Entry *e = FindBySlot(targetGuid, spellId, slot);
|
||||
if (e == nullptr)
|
||||
e = FindFreshestUnbound(targetGuid, spellId, now);
|
||||
e = FindOldestUnbound(targetGuid, spellId, now);
|
||||
if (e == nullptr && slot < 0)
|
||||
e = FindSole(targetGuid, spellId);
|
||||
if (e == nullptr) {
|
||||
e = Claim(now);
|
||||
*e = {targetGuid, casterGuid, spellId, expirationMs, durationMs, now,
|
||||
static_cast<int16_t>(slot), kind, true};
|
||||
ConsumePendingMods(*e, now);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -414,6 +449,11 @@ void StoreFromApplication(uint64_t targetGuid, uint32_t spellId,
|
||||
// back to unknown.
|
||||
if (kind != KIND_UNKNOWN)
|
||||
e->kind = kind;
|
||||
// An armed server edit applies whoever owns the timing — the server made it
|
||||
// AFTER the cast this entry came from, so it is the newer truth. It also
|
||||
// adopts the entry, which makes the ownership guard below keep the edited
|
||||
// value instead of the application's base-duration guess.
|
||||
ConsumePendingMods(*e, now);
|
||||
if (e->casterGuid != 0)
|
||||
return; // SpellGo owns this entry; keep its caster + talented timing
|
||||
if (casterGuid != 0)
|
||||
@@ -497,8 +537,10 @@ void Evict(uint64_t targetGuid, uint32_t spellId, int slot) {
|
||||
// Molten Blast -> Flame Shock: refresh (RefreshHolder → reset to max)
|
||||
// A trigger is matched either by exact spellID (from the server's script
|
||||
// binding, stable across ranks) or by SpellFamilyName + school; the affected
|
||||
// aura by SpellFamilyName + a family-flag overlap (+ optional icon) — rank-proof,
|
||||
// exactly how the server's scripts find it. Conflagrate's *full*-consume path
|
||||
// aura by SpellFamilyName plus a family-flag overlap and/or a SpellIconID —
|
||||
// rank-proof, exactly how the server's scripts find it, and the icon half is
|
||||
// what reaches a custom spell with no family flags at all (see
|
||||
// AffectedMatchesRaw). Conflagrate's *full*-consume path
|
||||
// removes Immolate, which clears
|
||||
// the descriptor slot → OnAuraRemoved already handles it; the reduce rule
|
||||
// covers the keep-ticking case. Probabilistic refreshes (Carnage's roll) are
|
||||
@@ -523,9 +565,23 @@ int g_modCount = 0;
|
||||
|
||||
// The affected-aura selector alone, shared with the Lua-facing by-family
|
||||
// refresh so the two cannot disagree about what they match.
|
||||
//
|
||||
// SpellFamilyName always has to match. Past that a rule discriminates by a
|
||||
// SpellFamilyFlags overlap, by SpellIconID, or by both; a zero mask or a zero
|
||||
// icon drops that half of the test. The icon-only form is what names an aura
|
||||
// whose SpellFamilyFlags are ZERO — every one of Turtle's custom spells is
|
||||
// (308 in the hunter family alone), so the mask cannot select them while the
|
||||
// icon still can, and an icon is shared by a spell's whole rank ladder, which
|
||||
// keeps it rank-proof exactly like a family flag. Registrars reject the
|
||||
// degenerate mask-0 + icon-0 rule, which would match a whole class family.
|
||||
bool AffectedMatchesRaw(const uint8_t *rec, uint32_t family, uint64_t mask,
|
||||
uint32_t icon) {
|
||||
if (!Spell::Lookup::IsFitToFamily(rec, family, mask))
|
||||
if (rec == nullptr)
|
||||
return false;
|
||||
if (*reinterpret_cast<const uint32_t *>(
|
||||
rec + Offsets::OFF_SPELL_RECORD_FAMILY_NAME) != family)
|
||||
return false;
|
||||
if (mask != 0 && !Spell::Lookup::IsFitToFamily(rec, family, mask))
|
||||
return false;
|
||||
if (icon != 0 &&
|
||||
*reinterpret_cast<const uint32_t *>(
|
||||
@@ -590,8 +646,8 @@ void FlushAuraSignals() {
|
||||
g_pendingSignalCount = 0;
|
||||
}
|
||||
|
||||
void ApplyMod(Entry &e, const DurationMod &m, uint32_t now) {
|
||||
switch (m.op) {
|
||||
void ApplyModOp(Entry &e, int32_t op, int32_t valueMs, uint32_t now) {
|
||||
switch (op) {
|
||||
case MOD_REFRESH:
|
||||
if (e.durationMs > 0) {
|
||||
e.expirationMs = now + e.durationMs; // RefreshHolder → reset to max
|
||||
@@ -599,14 +655,14 @@ void ApplyMod(Entry &e, const DurationMod &m, uint32_t now) {
|
||||
}
|
||||
break;
|
||||
case MOD_SET:
|
||||
e.durationMs = static_cast<uint32_t>(m.valueMs);
|
||||
e.expirationMs = now + static_cast<uint32_t>(m.valueMs);
|
||||
e.durationMs = static_cast<uint32_t>(valueMs);
|
||||
e.expirationMs = now + static_cast<uint32_t>(valueMs);
|
||||
SignalAuraChanged(e.targetGuid);
|
||||
break;
|
||||
case MOD_REDUCE:
|
||||
if (e.expirationMs != 0) {
|
||||
if (e.expirationMs > now + static_cast<uint32_t>(m.valueMs)) {
|
||||
e.expirationMs -= static_cast<uint32_t>(m.valueMs);
|
||||
if (e.expirationMs > now + static_cast<uint32_t>(valueMs)) {
|
||||
e.expirationMs -= static_cast<uint32_t>(valueMs);
|
||||
SignalAuraChanged(e.targetGuid);
|
||||
} else {
|
||||
e.used = false; // shaved to/past now → server removes it
|
||||
@@ -619,6 +675,88 @@ void ApplyMod(Entry &e, const DurationMod &m, uint32_t now) {
|
||||
}
|
||||
}
|
||||
|
||||
void ApplyMod(Entry &e, const DurationMod &m, uint32_t now) {
|
||||
ApplyModOp(e, m.op, m.valueMs, now);
|
||||
}
|
||||
|
||||
// ---- Deferred duration edits (the trigger creates the aura it edits) ------
|
||||
//
|
||||
// A trigger does not always act on an aura that is ALREADY there. Bestial
|
||||
// Wrath's effect 0 is TRIGGER_SPELL(Scent of Blood), and the server's script
|
||||
// then stretches the very holder that trigger just created (tortoise-wow
|
||||
// spell_hunter_bestial_wrath). The trigger's own SMSG_SPELL_GO reaches us
|
||||
// first, so at trigger time either nothing matches yet, or something matches
|
||||
// and is overwritten moments later when the triggered cast stores its own base
|
||||
// duration. Either way the edit is lost.
|
||||
//
|
||||
// So a trigger also ARMS its targets, and the next matching store inside the
|
||||
// window re-applies the edit — from the cast path or the application path,
|
||||
// whichever the triggered aura arrives on.
|
||||
//
|
||||
// Only the IDEMPOTENT ops arm. Re-running refresh or set lands on the same
|
||||
// value, while reduce would shave a second time and remove would kill an aura
|
||||
// that was legitimately re-applied. That keeps Conflagrate (reduce) behaving
|
||||
// exactly as before.
|
||||
bool OpIsIdempotent(int32_t op) {
|
||||
return op == MOD_REFRESH || op == MOD_SET;
|
||||
}
|
||||
|
||||
struct PendingMod {
|
||||
uint64_t target;
|
||||
uint64_t caster;
|
||||
uint32_t affectedFamily;
|
||||
uint64_t affectedMask;
|
||||
uint32_t affectedIcon;
|
||||
int32_t op;
|
||||
int32_t valueMs;
|
||||
uint32_t untilMs;
|
||||
bool used;
|
||||
};
|
||||
// The triggered cast follows its trigger within one packet burst, so the window
|
||||
// only has to cover network jitter. Sized like the other arm tables.
|
||||
constexpr int kPendingModMax = 8;
|
||||
constexpr uint32_t kPendingModTtlMs = 2000;
|
||||
PendingMod g_pendingMods[kPendingModMax];
|
||||
|
||||
void ArmPendingMod(uint64_t target, uint64_t caster, const DurationMod &m,
|
||||
uint32_t now) {
|
||||
PendingMod *slot = nullptr;
|
||||
for (auto &p : g_pendingMods) {
|
||||
// Re-arming the same rule on the same target refreshes it in place.
|
||||
if (p.used && p.target == target && p.affectedFamily == m.affectedFamily &&
|
||||
p.affectedMask == m.affectedMask && p.affectedIcon == m.affectedIcon) {
|
||||
slot = &p;
|
||||
break;
|
||||
}
|
||||
if (slot == nullptr && (!p.used || Time::Clock::Reached(now, p.untilMs)))
|
||||
slot = &p;
|
||||
}
|
||||
if (slot == nullptr)
|
||||
slot = &g_pendingMods[0]; // all live — steal the first
|
||||
*slot = {target, caster, m.affectedFamily, m.affectedMask,
|
||||
m.affectedIcon, m.op, m.valueMs, now + kPendingModTtlMs, true};
|
||||
}
|
||||
|
||||
void ConsumePendingMods(Entry &e, uint32_t now) {
|
||||
for (auto &p : g_pendingMods) {
|
||||
if (!p.used || p.target != e.targetGuid ||
|
||||
Time::Clock::Reached(now, p.untilMs))
|
||||
continue;
|
||||
// Caster-scoped exactly like the immediate path, adoption included.
|
||||
if (e.casterGuid != 0 && e.casterGuid != p.caster)
|
||||
continue;
|
||||
if (!AffectedMatchesRaw(
|
||||
Spell::Lookup::RecordForID(static_cast<int>(e.spellId)),
|
||||
p.affectedFamily, p.affectedMask, p.affectedIcon))
|
||||
continue;
|
||||
if (e.casterGuid == 0)
|
||||
e.casterGuid = p.caster;
|
||||
ApplyModOp(e, p.op, p.valueMs, now);
|
||||
p.used = false; // one-shot, like the server's single-holder edit
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// On a trigger cast landing on its hit targets, mirror the server's duration
|
||||
// edit on the caster's own matching cached aura. Called from SpellGo_h before
|
||||
// the aura gate — triggers (Conflagrate, Molten Blast) apply no aura of their
|
||||
@@ -655,6 +793,11 @@ void ApplyDurationModifiers(uint32_t triggerSpellId, uint64_t caster,
|
||||
ApplyMod(e, m, now);
|
||||
break; // one matching aura per (rule, target), like the server
|
||||
}
|
||||
// The aura may not be here yet, or may be about to be overwritten by
|
||||
// the cast this trigger sets off. Arm the idempotent ops so the edit
|
||||
// lands when it arrives — see PendingMod.
|
||||
if (OpIsIdempotent(m.op))
|
||||
ArmPendingMod(targets[t], caster, m, now);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -663,8 +806,11 @@ bool RegisterDurationMod(uint32_t triggerSpellId, uint32_t triggerFamily,
|
||||
int32_t triggerSchool, uint32_t affectedFamily,
|
||||
uint64_t affectedMask, uint32_t affectedIcon, int op,
|
||||
int32_t valueMs) {
|
||||
// Trigger must be identified one way or the other.
|
||||
if ((triggerSpellId == 0 && triggerFamily == 0) || affectedMask == 0 ||
|
||||
// Trigger must be identified one way or the other, and the affected aura
|
||||
// needs at least one discriminator past its family — a flag overlap, an
|
||||
// icon, or both. Neither would match every aura of a class.
|
||||
if ((triggerSpellId == 0 && triggerFamily == 0) ||
|
||||
(affectedMask == 0 && affectedIcon == 0) ||
|
||||
op < MOD_REFRESH || op > MOD_REMOVE)
|
||||
return false;
|
||||
for (int i = 0; i < g_modCount; ++i) { // replace an identical rule
|
||||
@@ -742,6 +888,168 @@ void RegisterDurationModLua() {
|
||||
|
||||
const Game::ModuleAutoRegister _autoregDurationMod{&RegisterDurationModLua};
|
||||
|
||||
// ---- Tick-speed compression (Turtle: Dark Harvest) -------------------------
|
||||
//
|
||||
// A channel that accelerates the caster's periodic auras on its target
|
||||
// consumes them FASTER on the live server: each dot keeps its tick count, so
|
||||
// ticking pct% faster drains its real remaining time at (100+pct)% rate while
|
||||
// the trigger aura is up, and it falls off early by pct% of the overlap — with
|
||||
// no client-visible aura change whatsoever. (tortoise-wow's recreation only
|
||||
// re-times the tick timers and leaves holder duration alone, which does NOT
|
||||
// reproduce the observed early fall-off; the compression model here matches
|
||||
// Cursive's community calibration — `remaining -= 0.30 × overlap` — and the
|
||||
// reported ~2.4–3 s loss over a full 8 s Dark Harvest.)
|
||||
//
|
||||
// The math is anchored, not incremental: each compressed entry remembers the
|
||||
// expiration it had when compression began and is set to
|
||||
// `anchor − elapsed × pct/100` every tick, so frame pacing can't drift it. An
|
||||
// external write to the entry (dot recast → SpellGo re-stamp, a duration mod)
|
||||
// is detected by comparing against the last value WE wrote and re-anchors from
|
||||
// the fresh expiration — matching the server, where a recast dot is a new
|
||||
// holder that compresses from its own full duration.
|
||||
struct TickCompression {
|
||||
uint32_t triggerFamily;
|
||||
uint64_t triggerMask;
|
||||
uint32_t affectedFamily;
|
||||
uint64_t affectedMask;
|
||||
int32_t pct;
|
||||
};
|
||||
constexpr int kCompressionMax = 4;
|
||||
TickCompression g_compressions[kCompressionMax];
|
||||
int g_compressionCount = 0;
|
||||
|
||||
// Live per-entry compression state, keyed the way the cache identifies an
|
||||
// aura instance — (target, spell, caster) — so two warlocks' same-spell dots
|
||||
// on one target compress independently. `lastWrittenMs` is the expiration this
|
||||
// module wrote last tick — a mismatch means someone else re-stamped the entry.
|
||||
struct CompressState {
|
||||
uint64_t targetGuid;
|
||||
uint64_t casterGuid;
|
||||
uint32_t spellId;
|
||||
uint32_t startMs; // compression (re-)anchor time
|
||||
uint32_t anchorMs; // entry expiration at the anchor
|
||||
uint32_t lastWrittenMs;
|
||||
uint32_t lastSignalMs; // event-consumer nudge cadence (~1/s)
|
||||
bool touched; // trigger still live this tick
|
||||
bool used;
|
||||
};
|
||||
constexpr int kCompressStateMax = 16;
|
||||
CompressState g_compressStates[kCompressStateMax];
|
||||
|
||||
CompressState *FindOrClaimCompressState(uint64_t targetGuid,
|
||||
uint64_t casterGuid,
|
||||
uint32_t spellId) {
|
||||
CompressState *spare = nullptr;
|
||||
for (auto &s : g_compressStates) {
|
||||
if (s.used && s.targetGuid == targetGuid &&
|
||||
s.casterGuid == casterGuid && s.spellId == spellId)
|
||||
return &s;
|
||||
if (!s.used && spare == nullptr)
|
||||
spare = &s;
|
||||
}
|
||||
return spare; // unused slot for the caller to claim; nullptr = table full
|
||||
}
|
||||
|
||||
// The server's affected-aura test is on the RUNTIME aura type (PERIODIC_DAMAGE
|
||||
// / PERIODIC_LEECH), which the record mirrors as EffectApplyAuraName 3 / 53 on
|
||||
// some effect. Load-bearing beyond the family mask: Turtle's Atrocity (45904)
|
||||
// shares Corruption's 0x2 bit but is a proc-trigger aura (42) — the mask alone
|
||||
// would compress it, this gate excludes it exactly as the server's does.
|
||||
// (Drain Soul passes via its effect-2 periodic damage: aura=[86,3,0].)
|
||||
bool HasPeriodicDamageOrLeech(const uint8_t *rec) {
|
||||
if (rec == nullptr)
|
||||
return false;
|
||||
constexpr int32_t kPeriodicDamage = 3, kPeriodicLeech = 53;
|
||||
for (int i = 0; i < 3; ++i) {
|
||||
const int32_t aura = *reinterpret_cast<const int32_t *>(
|
||||
rec + Offsets::OFF_SPELL_RECORD_EFFECT_APPLY_AURA_NAME + i * 4);
|
||||
if (aura == kPeriodicDamage || aura == kPeriodicLeech)
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Compression pairs by CASTER, mirroring the server (each warlock's Dark
|
||||
// Harvest accelerates that warlock's own dots): the trigger entry's caster
|
||||
// must own the affected entry too. Another warlock's DH + dots are both
|
||||
// broadcast SPELL_GO, so their timing compresses for us as well; caster-less
|
||||
// entries (application seen, cast unobserved) can't be paired and stay
|
||||
// untouched, same as every other caster-strict mechanic here.
|
||||
void CompressTarget(const TickCompression &c, uint64_t targetGuid,
|
||||
uint64_t casterGuid, uint32_t now) {
|
||||
for (int i = 0; i < g_usedHigh; ++i) {
|
||||
Entry &e = g_cache[i];
|
||||
if (!e.used || e.targetGuid != targetGuid ||
|
||||
e.casterGuid != casterGuid || e.expirationMs == 0)
|
||||
continue;
|
||||
const uint8_t *rec =
|
||||
Spell::Lookup::RecordForID(static_cast<int>(e.spellId));
|
||||
if (!AffectedMatchesRaw(rec, c.affectedFamily, c.affectedMask,
|
||||
/*icon*/ 0) ||
|
||||
!HasPeriodicDamageOrLeech(rec))
|
||||
continue;
|
||||
CompressState *s =
|
||||
FindOrClaimCompressState(e.targetGuid, e.casterGuid, e.spellId);
|
||||
if (s == nullptr)
|
||||
continue;
|
||||
if (!s->used) {
|
||||
*s = {e.targetGuid, e.casterGuid, e.spellId, now,
|
||||
e.expirationMs, e.expirationMs, 0, false, true};
|
||||
} else if (e.expirationMs != s->lastWrittenMs) {
|
||||
s->startMs = now; // externally re-stamped — restart from fresh
|
||||
s->anchorMs = e.expirationMs;
|
||||
}
|
||||
s->touched = true;
|
||||
const uint32_t cut = static_cast<uint32_t>(
|
||||
static_cast<uint64_t>(now - s->startMs) *
|
||||
static_cast<uint32_t>(c.pct) / 100);
|
||||
e.expirationMs = s->anchorMs - cut;
|
||||
s->lastWrittenMs = e.expirationMs;
|
||||
// Event-driven consumers (aura bars that read once and count down
|
||||
// locally) need periodic nudges to re-read the moving expiration;
|
||||
// pollers pick it up on their own. First write nudges immediately.
|
||||
if (s->lastSignalMs == 0 || now - s->lastSignalMs >= 1000) {
|
||||
s->lastSignalMs = now;
|
||||
SignalAuraChanged(e.targetGuid);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void ApplyTickCompressions(uint32_t now) {
|
||||
if (g_compressionCount == 0)
|
||||
return;
|
||||
for (auto &s : g_compressStates)
|
||||
s.touched = false;
|
||||
for (int r = 0; r < g_compressionCount; ++r) {
|
||||
const TickCompression &c = g_compressions[r];
|
||||
for (int i = 0; i < g_usedHigh; ++i) {
|
||||
const Entry &t = g_cache[i];
|
||||
if (!t.used || t.casterGuid == 0)
|
||||
continue;
|
||||
// The trigger aura is live exactly while its entry is: unexpired
|
||||
// (channel pushback already folded in via RestampPlayerChannel)
|
||||
// and not yet evicted by OnAuraRemoved (interrupt / early end /
|
||||
// target death).
|
||||
if (t.expirationMs != 0 &&
|
||||
Time::Clock::Reached(now, t.expirationMs))
|
||||
continue;
|
||||
if (!Spell::Lookup::IsFitToFamily(
|
||||
Spell::Lookup::RecordForID(static_cast<int>(t.spellId)),
|
||||
c.triggerFamily, c.triggerMask))
|
||||
continue;
|
||||
CompressTarget(c, t.targetGuid, t.casterGuid, now);
|
||||
}
|
||||
}
|
||||
// Trigger gone → the entry keeps whatever expiration it drained to; one
|
||||
// last nudge publishes the final position to event-driven consumers.
|
||||
for (auto &s : g_compressStates) {
|
||||
if (s.used && !s.touched) {
|
||||
SignalAuraChanged(s.targetGuid);
|
||||
s.used = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Wipe the whole cache. Used on a map transition (see OnWorldTick).
|
||||
void FlushAll() {
|
||||
for (int i = 0; i < g_usedHigh; ++i)
|
||||
@@ -751,6 +1059,9 @@ void FlushAll() {
|
||||
// should be treated as first-sight (unknown age), not diffed as new.
|
||||
for (auto &s : g_groupSnaps)
|
||||
s.used = false;
|
||||
// Compressed entries are gone with the cache; their states go too.
|
||||
for (auto &s : g_compressStates)
|
||||
s.used = false;
|
||||
}
|
||||
|
||||
// -1 = no map seen yet (never a valid Map.dbc id), so the first tick just
|
||||
@@ -782,6 +1093,9 @@ void OnWorldTick() {
|
||||
}
|
||||
|
||||
const uint32_t now = NowMs();
|
||||
// Tick-speed compression first, so a drained expiration is visible to the
|
||||
// same tick's eviction sweep and signal flush.
|
||||
ApplyTickCompressions(now);
|
||||
for (int i = 0; i < g_usedHigh; ++i) {
|
||||
Entry &e = g_cache[i];
|
||||
if (!e.used || e.expirationMs == 0 || !Time::Clock::Reached(now, e.expirationMs))
|
||||
@@ -810,6 +1124,279 @@ void OnWorldTick() {
|
||||
|
||||
const Tick::WorldTick::AutoSubscribe _tickSub{&OnWorldTick};
|
||||
|
||||
// ---- Paladin judgement identity + refresh (pfUI#45) ----------------------
|
||||
//
|
||||
// The judgement debuff (Judgement of Light/Wisdom/Justice/Crusader) is cast
|
||||
// SERVER-side as a triggered spell whose SMSG_SPELL_GO never reaches the
|
||||
// client, so its cache entry is seated caster-less by OnAuraAdded — and the
|
||||
// server's refresh mechanics are keyed by caster. The one attributable
|
||||
// signal is the Judgement CAST itself (SPELL_PALADIN_JUDGEMENT, the single
|
||||
// spell every seal judges through): its SPELL_GO names caster and victim.
|
||||
// HandleSpellGo uses it to (a) refresh existing judgement entries WITH
|
||||
// adoption — a re-judge is an in-place RefreshHolder server-side, no
|
||||
// descriptor change, no OnAuraAdded — and (b) arm a short window so the
|
||||
// fresh debuff's application gets attributed to the judging caster. From
|
||||
// then on the caster-STRICT swing refresh (Aura::JudgementRefresh's
|
||||
// SMSG_ATTACKERSTATEUPDATE subscriber) can do its job.
|
||||
|
||||
constexpr uint32_t kSpellJudgement = 20271; // server SPELL_PALADIN_JUDGEMENT
|
||||
|
||||
// The server's judgement-debuff selector, verbatim from its melee-refresh
|
||||
// block (tortoise-wow Unit::DealMeleeDamage): SPELLFAMILY_PALADIN +
|
||||
// SPELL_ATTR_EX3_ALWAYS_HIT. All 14 debuff ranks carry it (client DBC
|
||||
// verified); no shared family-flag mask exists (each seal's debuff has its
|
||||
// own bit), which is why this isn't an AddDurationMod rule.
|
||||
bool IsJudgementSpell(const uint8_t *rec) {
|
||||
constexpr uint32_t kPaladinFamily = 10; // SPELLFAMILY_PALADIN
|
||||
if (rec == nullptr)
|
||||
return false;
|
||||
if (*reinterpret_cast<const uint32_t *>(
|
||||
rec + Offsets::OFF_SPELL_RECORD_FAMILY_NAME) != kPaladinFamily)
|
||||
return false;
|
||||
return (*reinterpret_cast<const uint32_t *>(
|
||||
rec + Offsets::OFF_SPELL_RECORD_ATTRIBUTES_EX3) &
|
||||
Offsets::SPELL_ATTR_EX3_ALWAYS_HIT) != 0;
|
||||
}
|
||||
|
||||
// Armed attribution: a Judgement cast went off from `caster` at `victim`;
|
||||
// the triggered debuff's OnAuraAdded lands ~a server tick later (SPELL_GO
|
||||
// precedes the aura's SMSG_UPDATE_OBJECT — the same ordering kEvictGraceMs
|
||||
// exists for). One-shot, short TTL so a judge whose debuff never lands
|
||||
// can't mis-attribute a later application.
|
||||
struct JudgeArm {
|
||||
uint64_t victim;
|
||||
uint64_t caster;
|
||||
uint32_t untilMs;
|
||||
};
|
||||
constexpr int kJudgeArmMax = 4; // simultaneous judging paladins in view
|
||||
constexpr uint32_t kJudgeArmTtlMs = 2000;
|
||||
JudgeArm g_judgeArms[kJudgeArmMax];
|
||||
|
||||
void ArmJudgementAttribution(uint64_t victim, uint64_t caster) {
|
||||
const uint32_t now = NowMs();
|
||||
JudgeArm *slot = nullptr;
|
||||
for (auto &a : g_judgeArms) {
|
||||
if (a.victim == victim && a.caster == caster) {
|
||||
slot = &a; // re-judge inside the TTL — refresh in place
|
||||
break;
|
||||
}
|
||||
if (slot == nullptr &&
|
||||
(a.victim == 0 || Time::Clock::Reached(now, a.untilMs)))
|
||||
slot = &a;
|
||||
}
|
||||
if (slot == nullptr)
|
||||
slot = &g_judgeArms[0]; // all live (>4 paladins?) — steal the first
|
||||
*slot = {victim, caster, now + kJudgeArmTtlMs};
|
||||
}
|
||||
|
||||
// The caster armed for `victim`, or 0. One-shot: consumed on hit.
|
||||
uint64_t ConsumeJudgementAttribution(uint64_t victim, uint32_t now) {
|
||||
for (auto &a : g_judgeArms) {
|
||||
if (a.victim != victim || a.victim == 0 ||
|
||||
Time::Clock::Reached(now, a.untilMs))
|
||||
continue;
|
||||
const uint64_t caster = a.caster;
|
||||
a = {};
|
||||
return caster;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Shared body of the two refresh triggers. `adopt` lets the Judgement-cast
|
||||
// path claim a caster-less entry (the cast is proof of ownership); the swing
|
||||
// path stays caster-strict — a swing gives no proof, and adopting there
|
||||
// would let any tank's white swings pin another paladin's judgement timer.
|
||||
int RefreshJudgementsImpl(uint64_t unitGuid, uint64_t attackerGuid, bool adopt) {
|
||||
if (unitGuid == 0 || attackerGuid == 0)
|
||||
return 0;
|
||||
const uint32_t now = NowMs();
|
||||
int refreshed = 0;
|
||||
for (int i = 0; i < g_usedHigh; ++i) {
|
||||
Entry &e = g_cache[i];
|
||||
if (!e.used || e.targetGuid != unitGuid)
|
||||
continue;
|
||||
if (e.casterGuid != attackerGuid && !(adopt && e.casterGuid == 0))
|
||||
continue;
|
||||
if (e.durationMs == 0)
|
||||
continue; // never invent a duration
|
||||
if (!IsJudgementSpell(
|
||||
Spell::Lookup::RecordForID(static_cast<int>(e.spellId))))
|
||||
continue;
|
||||
if (e.casterGuid == 0)
|
||||
e.casterGuid = attackerGuid; // adoption claim
|
||||
e.expirationMs = now + e.durationMs; // RefreshHolder → own duration
|
||||
e.stampMs = now;
|
||||
SignalAuraChanged(e.targetGuid);
|
||||
++refreshed;
|
||||
}
|
||||
return refreshed;
|
||||
}
|
||||
|
||||
// ---- Triggered applications (server AddAura inference) --------------------
|
||||
//
|
||||
// Some server mechanics APPLY an aura as a side effect of another spell via a
|
||||
// direct AddAura — no cast, no SMSG_SPELL_GO for the applied aura, and a
|
||||
// duration the packet stream never carries. The application itself reaches
|
||||
// the client only as a descriptor change (OnAuraAdded → caster-less, base
|
||||
// duration). But the TRIGGER's SMSG_SPELL_GO is visible, so — same shape as
|
||||
// the judgement attribution above — a registered trigger cast by the local
|
||||
// player arms its hit targets for a short window, and the caster-less
|
||||
// application that follows consumes the arm: player attribution + the rule's
|
||||
// percentage of the aura's own (player-modified) base duration, mirroring
|
||||
// the server's `max(1, CalculateDuration(caster) * pct / 100)`.
|
||||
//
|
||||
// Registered from src/turtle (Stinging Nettle: Mongoose Bite / fire-trap
|
||||
// effects apply the highest known Serpent Sting rank at 20/40%). Local
|
||||
// player only — a rule may be gated on a talent-granted passive
|
||||
// (`gateSpellId`), and other players' talents aren't client-knowable.
|
||||
|
||||
struct TriggeredApplication {
|
||||
uint32_t triggerSpellId; // exact trigger spell; 0 = match by family+mask below
|
||||
uint32_t triggerFamily; // (triggerSpellId==0) trigger's SpellFamilyName
|
||||
uint64_t triggerMask; // (triggerSpellId==0) family-flag overlap — rank-proof
|
||||
uint32_t gateSpellId; // rule live only while the player knows this; 0 = always
|
||||
uint32_t affectedFamily; // applied aura's SpellFamilyName
|
||||
uint64_t affectedMask; // + family-flag overlap
|
||||
int32_t durationPct; // applied duration = pct% of its own base
|
||||
};
|
||||
constexpr int kTrigAppMax = 32;
|
||||
TriggeredApplication g_trigApps[kTrigAppMax];
|
||||
int g_trigAppCount = 0;
|
||||
|
||||
// Talent gate: does the player currently know `spellID`? The engine's
|
||||
// spell-knowledge bitmap (same store IsPlayerSpell reads — covers talent
|
||||
// passives), checked live at trigger time so respecs are honored.
|
||||
bool PlayerKnowsSpell(uint32_t spellID) {
|
||||
if (spellID == 0)
|
||||
return false;
|
||||
auto *bitmap = Game::Read<const uint32_t *>(
|
||||
static_cast<uintptr_t>(Offsets::VAR_PLAYER_SPELL_BITMAP));
|
||||
if (bitmap == nullptr)
|
||||
return false;
|
||||
const uint32_t spellCount = Game::Read<uint32_t>(
|
||||
static_cast<uintptr_t>(Offsets::VAR_SPELL_RECORD_COUNT));
|
||||
if (spellID > spellCount)
|
||||
return false;
|
||||
return (bitmap[spellID >> 5] & (1u << (spellID & 31))) != 0;
|
||||
}
|
||||
|
||||
// Armed rule instance awaiting its application on `victim`. Caster is
|
||||
// implicitly the local player (only player triggers arm).
|
||||
struct TrigAppArm {
|
||||
uint64_t victim; // 0 = empty
|
||||
uint32_t affectedFamily;
|
||||
uint64_t affectedMask;
|
||||
int32_t durationPct;
|
||||
uint32_t untilMs;
|
||||
};
|
||||
constexpr int kTrigAppArmMax = 8;
|
||||
constexpr uint32_t kTrigAppArmTtlMs = 2000;
|
||||
TrigAppArm g_trigAppArms[kTrigAppArmMax];
|
||||
|
||||
// Mirror the server's re-apply on an EXISTING matching aura at trigger time
|
||||
// (tortoise-wow ApplyStingingNettle): an existing holder from this caster
|
||||
// whose remaining time exceeds the scaled duration is kept; otherwise the
|
||||
// server re-AddAura's at the scaled duration — a remove+add of the same
|
||||
// spellID that reuses the descriptor slot, so NO OnAuraAdded fires and the
|
||||
// armed window is never consumed (verified in-game: chained Mongoose Bites
|
||||
// left the first application's timer running out). The trigger is the only
|
||||
// audible signal, so the refresh happens here. A caster-less matching entry
|
||||
// is adopted and stamped unconditionally: it's a nettle application whose
|
||||
// arm was missed, seated by the application hook at the WRONG base duration,
|
||||
// so its remaining time can't gate the mirror — and the player's trigger is
|
||||
// proof of ownership, same as the judgement-cast adoption.
|
||||
void RefreshTriggeredExisting(uint64_t victim, const TriggeredApplication &r,
|
||||
uint32_t now) {
|
||||
for (int i = 0; i < g_usedHigh; ++i) {
|
||||
Entry &e = g_cache[i];
|
||||
if (!e.used || e.targetGuid != victim)
|
||||
continue;
|
||||
const bool mine = e.casterGuid == Unit::Identity::PlayerGuid();
|
||||
if (!mine && e.casterGuid != 0)
|
||||
continue; // another caster's aura — not ours to touch
|
||||
const uint8_t *rec =
|
||||
Spell::Lookup::RecordForID(static_cast<int>(e.spellId));
|
||||
if (!Spell::Lookup::IsFitToFamily(rec, r.affectedFamily,
|
||||
r.affectedMask))
|
||||
continue;
|
||||
const uint32_t base = SpellDurationMs(rec, /*casterIsPlayer*/ true);
|
||||
if (base == 0)
|
||||
continue;
|
||||
uint32_t newMs = base * static_cast<uint32_t>(r.durationPct) / 100;
|
||||
if (newMs == 0)
|
||||
newMs = 1;
|
||||
// The server's keep-the-longer guard, applied only to trusted
|
||||
// (player-stamped) timing. An elapsed timer yields Remaining 0 and
|
||||
// refreshes — the invisible server re-adds are exactly what let it
|
||||
// elapse while the aura stayed up.
|
||||
if (mine && e.expirationMs != 0 &&
|
||||
Time::Clock::Remaining(now, e.expirationMs) > newMs)
|
||||
continue;
|
||||
e.casterGuid = Unit::Identity::PlayerGuid();
|
||||
e.durationMs = newMs;
|
||||
e.expirationMs = now + newMs;
|
||||
e.stampMs = now;
|
||||
SignalAuraChanged(victim);
|
||||
break; // one aura instance per (target, spell, caster) — server touches one holder
|
||||
}
|
||||
}
|
||||
|
||||
// A player trigger cast landed on `victim`: arm the first live rule for this
|
||||
// trigger (rules sharing a trigger are registration-ordered — the higher
|
||||
// talent rank registers first, so its gate wins while known). Also refreshes
|
||||
// an existing matching aura in place (see RefreshTriggeredExisting) — the arm
|
||||
// only covers applications that reach the descriptor as a change.
|
||||
void ArmTriggeredApplications(uint64_t victim, uint32_t triggerSpellId,
|
||||
const uint8_t *triggerRec) {
|
||||
for (int i = 0; i < g_trigAppCount; ++i) {
|
||||
const TriggeredApplication &r = g_trigApps[i];
|
||||
if (r.triggerSpellId != 0
|
||||
? r.triggerSpellId != triggerSpellId
|
||||
: !Spell::Lookup::IsFitToFamily(triggerRec, r.triggerFamily,
|
||||
r.triggerMask))
|
||||
continue;
|
||||
if (r.gateSpellId != 0 && !PlayerKnowsSpell(r.gateSpellId))
|
||||
continue;
|
||||
const uint32_t now = NowMs();
|
||||
RefreshTriggeredExisting(victim, r, now);
|
||||
TrigAppArm *slot = nullptr;
|
||||
for (auto &a : g_trigAppArms) {
|
||||
if (a.victim == victim && a.affectedFamily == r.affectedFamily &&
|
||||
a.affectedMask == r.affectedMask) {
|
||||
slot = &a; // re-trigger inside the TTL — refresh in place
|
||||
break;
|
||||
}
|
||||
if (slot == nullptr &&
|
||||
(a.victim == 0 || Time::Clock::Reached(now, a.untilMs)))
|
||||
slot = &a;
|
||||
}
|
||||
if (slot == nullptr)
|
||||
slot = &g_trigAppArms[0]; // all live — steal the first
|
||||
*slot = {victim, r.affectedFamily, r.affectedMask, r.durationPct,
|
||||
now + kTrigAppArmTtlMs};
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// The armed duration percent for a caster-less application of `rec` on
|
||||
// `victim`, or 0. One-shot: consumed on hit.
|
||||
int32_t ConsumeTriggeredApplication(uint64_t victim, const uint8_t *rec,
|
||||
uint32_t now) {
|
||||
if (victim == 0 || rec == nullptr)
|
||||
return 0;
|
||||
for (auto &a : g_trigAppArms) {
|
||||
if (a.victim != victim || Time::Clock::Reached(now, a.untilMs))
|
||||
continue;
|
||||
if (!Spell::Lookup::IsFitToFamily(rec, a.affectedFamily,
|
||||
a.affectedMask))
|
||||
continue;
|
||||
const int32_t pct = a.durationPct;
|
||||
a = {};
|
||||
return pct;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
// ---- SMSG_SPELL_GO co-hook ----------------------------------------------
|
||||
|
||||
// The hit-target list is all we need: each entry is a unit the cast landed
|
||||
@@ -847,7 +1434,27 @@ void HandleSpellGo(uint64_t caster, uint32_t spellId, const uint64_t *targets,
|
||||
// before the aura gate below: the trigger spell applies no aura itself.
|
||||
ApplyDurationModifiers(spellId, caster, targets, numTargets);
|
||||
|
||||
// Judgement: refresh-with-adoption for a re-judge (in-place server
|
||||
// refresh, no aura event) and arm attribution for the fresh debuff that
|
||||
// follows. Before the aura gate — Judgement itself applies no aura.
|
||||
if (spellId == kSpellJudgement) {
|
||||
for (int i = 0; i < numTargets; ++i) {
|
||||
RefreshJudgementsImpl(targets[i], caster, /*adopt*/ true);
|
||||
ArmJudgementAttribution(targets[i], caster);
|
||||
}
|
||||
}
|
||||
|
||||
const uint8_t *rec = Spell::Lookup::RecordForID(static_cast<int>(spellId));
|
||||
|
||||
// Triggered-application rules (Stinging Nettle): a registered trigger
|
||||
// cast by the local player arms its hit targets so the server-added aura
|
||||
// that follows (no SPELL_GO of its own) gets player attribution + the
|
||||
// rule's scaled duration. Before the aura gate — Mongoose Bite applies
|
||||
// no aura of its own.
|
||||
if (g_trigAppCount != 0 && caster == Unit::Identity::PlayerGuid())
|
||||
for (int i = 0; i < numTargets; ++i)
|
||||
ArmTriggeredApplications(targets[i], spellId, rec);
|
||||
|
||||
if (!SpellAppliesAura(rec))
|
||||
return;
|
||||
|
||||
@@ -908,10 +1515,14 @@ const Net::PacketDispatch::AutoSubscribe _spellGoSub{&SpellGoSub};
|
||||
|
||||
// ---- Aura-application co-hooks (timing for proc / triggered auras) -------
|
||||
|
||||
// Classify by the absolute aura slot: 0..BUFF_COUNT-1 = buff (helpful),
|
||||
// BUFF_COUNT..TOTAL-1 = debuff (harmful).
|
||||
int8_t KindForSlot(int slot) {
|
||||
return slot >= Offsets::UNIT_AURA_BUFF_COUNT ? KIND_HARMFUL : KIND_HELPFUL;
|
||||
// Defer to `Aura::Data::IsSlotHarmful` rather than testing the range here — how
|
||||
// polarity is encoded differs between servers, and that function is the one
|
||||
// place that decides. The descriptor write pass has already landed both the
|
||||
// slot and its flags by the time the application hooks fire.
|
||||
int8_t KindForSlot(const void *unit, int slot) {
|
||||
return Aura::Data::IsSlotHarmful(static_cast<const uint8_t *>(unit), slot)
|
||||
? KIND_HARMFUL
|
||||
: KIND_HELPFUL;
|
||||
}
|
||||
|
||||
// Stamp expiration for an aura that just landed/refreshed in `slot` on `unit`.
|
||||
@@ -933,12 +1544,41 @@ void StampApplication(void *unit, uint32_t spellId, int slot) {
|
||||
// (and attribute the caster) — SpellGo couldn't always land the talented
|
||||
// value on this target's entry. Otherwise base (we lack other casters'
|
||||
// mods). See WasRecentPlayerCast.
|
||||
const bool byPlayer = WasRecentPlayerCast(spellId);
|
||||
const uint32_t durationMs = SpellDurationMs(rec, byPlayer);
|
||||
const uint64_t caster = byPlayer ? Unit::Identity::PlayerGuid() : 0;
|
||||
uint64_t caster = 0;
|
||||
uint32_t durationMs = 0;
|
||||
// A triggered-application arm (Stinging Nettle): the server AddAura'd
|
||||
// this aura off the player's trigger cast at a scaled duration —
|
||||
// attribute the player and mirror the server's max(1, base × pct / 100),
|
||||
// player-modified base since the player is the caster. Checked BEFORE
|
||||
// the recent-cast window below: the arm names this exact TARGET, while
|
||||
// WasRecentPlayerCast is spell-scoped only — a real Serpent Sting cast
|
||||
// at unit A must not stamp its full duration onto a nettle application
|
||||
// landing on unit B moments later. The order is safe for a real cast's
|
||||
// own application: its entry is already cast-owned (StoreFromCast), and
|
||||
// StoreFromApplication never overwrites an owned entry's timing.
|
||||
if (const int32_t pct = ConsumeTriggeredApplication(unitGuid, rec, NowMs())) {
|
||||
const uint32_t playerBase = SpellDurationMs(rec, /*player*/ true);
|
||||
if (playerBase > 0) {
|
||||
caster = Unit::Identity::PlayerGuid();
|
||||
durationMs = playerBase * static_cast<uint32_t>(pct) / 100;
|
||||
if (durationMs == 0)
|
||||
durationMs = 1;
|
||||
}
|
||||
}
|
||||
if (caster == 0) {
|
||||
const bool byPlayer = WasRecentPlayerCast(spellId);
|
||||
durationMs = SpellDurationMs(rec, byPlayer);
|
||||
caster = byPlayer ? Unit::Identity::PlayerGuid() : 0;
|
||||
// A judgement debuff is cast server-side (no SPELL_GO, so neither
|
||||
// path above can name its caster) — attribute it to the paladin
|
||||
// whose Judgement cast just armed this victim. See the judgement
|
||||
// section.
|
||||
if (caster == 0 && IsJudgementSpell(rec))
|
||||
caster = ConsumeJudgementAttribution(unitGuid, NowMs());
|
||||
}
|
||||
const uint32_t expirationMs = durationMs > 0 ? NowMs() + durationMs : 0;
|
||||
StoreFromApplication(unitGuid, spellId, caster, expirationMs, durationMs,
|
||||
slot, KindForSlot(slot));
|
||||
slot, KindForSlot(unit, slot));
|
||||
}
|
||||
|
||||
// Bump the player-stat-inputs signal when an aura change hits the LOCAL
|
||||
@@ -1043,10 +1683,91 @@ bool AddDurationMod(uint32_t triggerSpellId, uint32_t affectedFamily,
|
||||
affectedIcon, op, valueMs);
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
||||
bool RegisterTriggeredApplication(uint32_t triggerSpellId,
|
||||
uint32_t triggerFamily, uint64_t triggerMask,
|
||||
uint32_t gateSpellId, uint32_t affectedFamily,
|
||||
uint64_t affectedMask, int32_t durationPct) {
|
||||
// Trigger must be identified one way or the other.
|
||||
if ((triggerSpellId == 0 && (triggerFamily == 0 || triggerMask == 0)) ||
|
||||
affectedMask == 0 || durationPct <= 0)
|
||||
return false;
|
||||
for (int i = 0; i < g_trigAppCount; ++i) { // replace an identical rule
|
||||
TriggeredApplication &r = g_trigApps[i];
|
||||
if (r.triggerSpellId == triggerSpellId &&
|
||||
r.triggerFamily == triggerFamily && r.triggerMask == triggerMask &&
|
||||
r.gateSpellId == gateSpellId && r.affectedFamily == affectedFamily &&
|
||||
r.affectedMask == affectedMask) {
|
||||
r.durationPct = durationPct;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if (g_trigAppCount >= kTrigAppMax)
|
||||
return false;
|
||||
g_trigApps[g_trigAppCount++] = {triggerSpellId, triggerFamily, triggerMask,
|
||||
gateSpellId, affectedFamily,
|
||||
affectedMask, durationPct};
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
bool AddTriggeredApplication(uint32_t triggerSpellId, uint32_t gateSpellId,
|
||||
uint32_t affectedFamily, uint64_t affectedMask,
|
||||
int32_t durationPct) {
|
||||
return RegisterTriggeredApplication(triggerSpellId, /*triggerFamily*/ 0,
|
||||
/*triggerMask*/ 0, gateSpellId,
|
||||
affectedFamily, affectedMask,
|
||||
durationPct);
|
||||
}
|
||||
|
||||
bool AddTriggeredApplicationByFamily(uint32_t triggerFamily,
|
||||
uint64_t triggerMask, uint32_t gateSpellId,
|
||||
uint32_t affectedFamily,
|
||||
uint64_t affectedMask,
|
||||
int32_t durationPct) {
|
||||
return RegisterTriggeredApplication(/*triggerSpellId*/ 0, triggerFamily,
|
||||
triggerMask, gateSpellId,
|
||||
affectedFamily, affectedMask,
|
||||
durationPct);
|
||||
}
|
||||
|
||||
bool AddTickCompression(uint32_t triggerFamily, uint64_t triggerMask,
|
||||
uint32_t affectedFamily, uint64_t affectedMask,
|
||||
int32_t pct) {
|
||||
if (triggerFamily == 0 || triggerMask == 0 || affectedMask == 0 ||
|
||||
pct <= 0 || pct > 100)
|
||||
return false;
|
||||
for (int i = 0; i < g_compressionCount; ++i) { // replace an identical rule
|
||||
TickCompression &r = g_compressions[i];
|
||||
if (r.triggerFamily == triggerFamily && r.triggerMask == triggerMask &&
|
||||
r.affectedFamily == affectedFamily &&
|
||||
r.affectedMask == affectedMask) {
|
||||
r.pct = pct;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if (g_compressionCount >= kCompressionMax)
|
||||
return false;
|
||||
g_compressions[g_compressionCount++] = {triggerFamily, triggerMask,
|
||||
affectedFamily, affectedMask, pct};
|
||||
return true;
|
||||
}
|
||||
|
||||
int RefreshJudgements(uint64_t unitGuid, uint64_t attackerGuid) {
|
||||
// Caster-strict (no adoption) — the Judgement-cast path inside
|
||||
// HandleSpellGo is the one with proof of ownership; see
|
||||
// RefreshJudgementsImpl.
|
||||
return RefreshJudgementsImpl(unitGuid, attackerGuid, /*adopt*/ false);
|
||||
}
|
||||
|
||||
uint32_t RefreshDurationByFamily(uint64_t unitGuid, uint32_t family,
|
||||
uint64_t mask, uint32_t icon,
|
||||
uint64_t casterGuid) {
|
||||
if (unitGuid == 0 || mask == 0 || casterGuid == 0)
|
||||
// Same selector invariant the rule registrar enforces: family plus at
|
||||
// least one of flag-overlap / icon.
|
||||
if (unitGuid == 0 || (mask == 0 && icon == 0) || casterGuid == 0)
|
||||
return 0;
|
||||
const uint32_t now = NowMs();
|
||||
for (int i = 0; i < g_usedHigh; ++i) {
|
||||
@@ -1077,6 +1798,25 @@ uint32_t RefreshDurationByFamily(uint64_t unitGuid, uint32_t family,
|
||||
return 0;
|
||||
}
|
||||
|
||||
void RestampPlayerChannel(uint32_t spellId, uint32_t remainingMs) {
|
||||
if (spellId == 0 || remainingMs == 0)
|
||||
return;
|
||||
const uint64_t player = Unit::Identity::PlayerGuid();
|
||||
if (player == 0)
|
||||
return;
|
||||
const uint32_t now = NowMs();
|
||||
// Every hit target's entry, matching DelaySpellAuraHolder's loop over the
|
||||
// channel's m_UniqueTargetInfo (an AoE channel shortens them all).
|
||||
for (int i = 0; i < g_usedHigh; ++i) {
|
||||
Entry &e = g_cache[i];
|
||||
if (!e.used || e.casterGuid != player || e.spellId != spellId)
|
||||
continue;
|
||||
e.expirationMs = now + remainingMs;
|
||||
e.stampMs = now;
|
||||
SignalAuraChanged(e.targetGuid);
|
||||
}
|
||||
}
|
||||
|
||||
void EvictAbsent(uint64_t unitGuid, const uint32_t *slotSpellIds) {
|
||||
if (unitGuid == 0 || slotSpellIds == nullptr)
|
||||
return;
|
||||
|
||||
+91
-6
@@ -61,14 +61,45 @@ bool Get(uint64_t unitGuid, uint32_t spellId, int slot, uint64_t *outCaster,
|
||||
uint32_t *outExpirationMs, uint32_t *outDurationMs);
|
||||
|
||||
// Refreshes `casterGuid`'s aura on `unitGuid` matching the same selector the
|
||||
// duration rules use — SpellFamilyName + family-flag overlap + optional icon
|
||||
// (0 = any) — to a full duration from now. Returns the spellID refreshed, or 0
|
||||
// if nothing matched. For a mechanic whose duration edit reaches the client
|
||||
// too late to be attributed to a cast packet, so no rule can express it.
|
||||
// duration rules use — SpellFamilyName plus a family-flag overlap and/or a
|
||||
// SpellIconID, at least one of the two — to a full duration from now. Returns
|
||||
// the spellID refreshed, or 0 if nothing matched. For a mechanic whose
|
||||
// duration edit reaches the client too late to be attributed to a cast packet,
|
||||
// so no rule can express it.
|
||||
uint32_t RefreshDurationByFamily(uint64_t unitGuid, uint32_t family,
|
||||
uint64_t mask, uint32_t icon,
|
||||
uint64_t casterGuid);
|
||||
|
||||
// Mirrors the server's melee-swing judgement refresh (tortoise-wow
|
||||
// Unit::DealMeleeDamage): a white swing from `attackerGuid` that dealt any
|
||||
// damage refreshes every aura it cast on `unitGuid` whose spell is
|
||||
// SPELLFAMILY_PALADIN with SPELL_ATTR_EX3_ALWAYS_HIT (the judgement-debuff
|
||||
// marker — all Judgement of Light/Wisdom/Justice/Crusader ranks) back to its
|
||||
// own applied duration. Caster-strict: entries with an unknown caster are
|
||||
// left alone (a swing gives no proof of ownership). The caster comes from
|
||||
// the Judgement-cast attribution inside the SpellGo path — the debuff itself
|
||||
// is cast server-side with no client-visible SPELL_GO, so the Judgement cast
|
||||
// (20271) is what names the owner: it refresh-with-adoption's existing
|
||||
// entries (re-judge = in-place server refresh, no aura event) and arms the
|
||||
// fresh application's attribution. Returns the number refreshed. Fed by
|
||||
// Aura::JudgementRefresh's SMSG_ATTACKERSTATEUPDATE subscriber.
|
||||
int RefreshJudgements(uint64_t unitGuid, uint64_t attackerGuid);
|
||||
|
||||
// Re-anchors the expiration of every cached aura the local player cast with
|
||||
// `spellId` to `now + remainingMs`. Mirrors the server's channel pushback
|
||||
// (Spell::DelayedChannel → Unit::DelaySpellAuraHolder): a hit shortens the
|
||||
// channel AND every hit target's holder of the channel spell by the same
|
||||
// amount, then tells the caster the new remaining time via MSG_CHANNEL_UPDATE
|
||||
// — the packet Spell::Cast feeds this from. Without it a pushed-back channel's
|
||||
// target-side aura (Dark Harvest, the drains) keeps its full-length SpellGo
|
||||
// expiration and reads late by the accumulated pushback. `durationMs` (the
|
||||
// applied total, the bar's full width) is deliberately untouched. Self-target
|
||||
// holders are also corrected natively by the server (it re-sends
|
||||
// SMSG_UPDATE_AURA_DURATION), so restamping those entries just keeps the two
|
||||
// sources agreeing. Caster-only packet ⇒ only the player's channels are
|
||||
// correctable; remote pushback stays invisible (see spell/Cast.cpp).
|
||||
void RestampPlayerChannel(uint32_t spellId, uint32_t remainingMs);
|
||||
|
||||
// Op codes for AddDurationMod (mirror the Lua op strings).
|
||||
enum DurationModOp {
|
||||
DURATION_MOD_REFRESH = 0,
|
||||
@@ -78,8 +109,10 @@ enum DurationModOp {
|
||||
};
|
||||
|
||||
// Register a server duration-modifier rule from C++. The trigger is matched by
|
||||
// exact `triggerSpellId`; the affected aura by SpellFamilyName + a family-flag
|
||||
// overlap (+ optional `affectedIcon`, 0 = any). `valueMs` is the reduce/set
|
||||
// exact `triggerSpellId`; the affected aura by SpellFamilyName plus an
|
||||
// `affectedMask` family-flag overlap and/or an `affectedIcon`, at least one of
|
||||
// the two (0 drops that half of the test; a custom spell with no family flags
|
||||
// is named by its icon alone). `valueMs` is the reduce/set
|
||||
// amount in milliseconds (ignored by refresh/remove). Used by src/turtle
|
||||
// modules for the server's built-in mods (the family/school-matched variant is
|
||||
// still Lua-registerable via C_UnitAuras.RegisterAuraDurationModifierByTrigger).
|
||||
@@ -88,6 +121,58 @@ bool AddDurationMod(uint32_t triggerSpellId, uint32_t affectedFamily,
|
||||
uint64_t affectedMask, uint32_t affectedIcon, int op,
|
||||
int32_t valueMs);
|
||||
|
||||
// Register a server "triggered application" rule from C++: `triggerSpellId`'s
|
||||
// SMSG_SPELL_GO — local player casts only — arms its hit targets for a short
|
||||
// window, and a caster-less aura application on an armed target matching
|
||||
// `affectedFamily` + a family-flag overlap with `affectedMask` is attributed
|
||||
// to the player at `durationPct` percent of the aura's own player-modified
|
||||
// base duration (the server's `max(1, CalculateDuration(caster) * pct / 100)`).
|
||||
// For server mechanics that AddAura an aura off another spell's hit with no
|
||||
// cast packet of its own (Turtle's Stinging Nettle: Mongoose Bite / fire-trap
|
||||
// effects apply the highest known Serpent Sting rank at 20/40% duration).
|
||||
// A re-trigger over an EXISTING matching aura is also handled: the server's
|
||||
// re-add reuses the descriptor slot (no application event), so the trigger
|
||||
// additionally refreshes the player's existing cached entry in place,
|
||||
// honoring the server's keep-the-longer-remaining guard.
|
||||
// `gateSpellId` (0 = none) keeps the rule live only while the player knows
|
||||
// that spell (a talent-granted passive), read from the spell-knowledge bitmap
|
||||
// at trigger time so respecs are honored; rules matching the same trigger are
|
||||
// tried in registration order and the first live one arms (register the
|
||||
// higher talent rank first). Returns false on bad input or a full table.
|
||||
bool AddTriggeredApplication(uint32_t triggerSpellId, uint32_t gateSpellId,
|
||||
uint32_t affectedFamily, uint64_t affectedMask,
|
||||
int32_t durationPct);
|
||||
|
||||
// Family-mask trigger form of the above: the trigger matches any cast whose
|
||||
// spell is SpellFamilyName `triggerFamily` with a family-flag overlap of
|
||||
// `triggerMask` — rank-proof when the bit selects exactly the trigger set
|
||||
// (verify against the DBC that no unrelated spell shares it first: hunter
|
||||
// 0x4 is exactly the fire-trap effects, but 0x2 is Mongoose Bite AND Raptor
|
||||
// Strike, so that trigger needs the exact-ID form).
|
||||
bool AddTriggeredApplicationByFamily(uint32_t triggerFamily,
|
||||
uint64_t triggerMask, uint32_t gateSpellId,
|
||||
uint32_t affectedFamily,
|
||||
uint64_t affectedMask, int32_t durationPct);
|
||||
|
||||
// Register a tick-speed compression rule from C++: while an aura matching
|
||||
// (triggerFamily, triggerMask) is live on a target, every cached aura on that
|
||||
// target CAST BY THE SAME CASTER matching (affectedFamily, affectedMask) — and
|
||||
// whose record carries a periodic damage/leech effect, the server's aura-type
|
||||
// gate — drains an extra `pct`% of real time: its expiration moves earlier by
|
||||
// pct% of the elapsed overlap. Mirrors a server that preserves an accelerated
|
||||
// dot's tick COUNT, so ticking pct% faster consumes it pct% faster with no
|
||||
// client-visible aura change (Turtle's Dark Harvest; see
|
||||
// src/turtle/DarkHarvest.cpp for the live-behavior derivation). Caster-paired
|
||||
// like the server's own check, so another warlock's observed DH compresses
|
||||
// THEIR dots' cached timing too; caster-less entries never compress. The
|
||||
// trigger's cache-entry lifetime is the compression window — created by its
|
||||
// SpellGo, shortened by channel pushback (RestampPlayerChannel), evicted by
|
||||
// OnAuraRemoved on interrupt / early end — so the window tracks every edge the
|
||||
// server's has. Returns false on bad input or a full table.
|
||||
bool AddTickCompression(uint32_t triggerFamily, uint64_t triggerMask,
|
||||
uint32_t affectedFamily, uint64_t affectedMask,
|
||||
int32_t pct);
|
||||
|
||||
// One cached aura, as returned by `Enumerate`.
|
||||
struct CachedAura {
|
||||
uint32_t spellId;
|
||||
|
||||
@@ -143,7 +143,7 @@ void OnWorldTick() {
|
||||
static_cast<uintptr_t>(Offsets::VAR_IN_WORLD)) == 0)
|
||||
return;
|
||||
g_pending = false;
|
||||
const int slot = Event::Custom::Lookup(kEventName);
|
||||
const int slot = _reserve.Slot();
|
||||
if (slot >= 0)
|
||||
Event::Custom::Fire(slot, "");
|
||||
// Notify C++ subscribers of the bag-change frame (see UpdateDelayed.h).
|
||||
|
||||
Vendored
+7
-7
@@ -61,8 +61,8 @@ constexpr int kMaxWaitTicks = 1200; // give up and fire failure after this many
|
||||
|
||||
struct Registered {
|
||||
void *cache;
|
||||
const char *eventName;
|
||||
uintptr_t getRecord; // this cache class's _GetRecord
|
||||
const Event::Custom::AutoReserve *reserve; // completion event's reservation
|
||||
uintptr_t getRecord; // this cache class's _GetRecord
|
||||
Pending pending[kMaxPending];
|
||||
int pendingCount;
|
||||
};
|
||||
@@ -79,8 +79,7 @@ Registered *Find(void *cache) {
|
||||
}
|
||||
|
||||
void Fire(const Registered *r, uint32_t id, bool success) {
|
||||
Event::Custom::FireIdSuccess(Event::Custom::Lookup(r->eventName),
|
||||
static_cast<int>(id), success);
|
||||
Event::Custom::FireIdSuccess(r->reserve->Slot(), static_cast<int>(id), success);
|
||||
}
|
||||
|
||||
int FindPending(const Registered *r, uint32_t id) {
|
||||
@@ -176,15 +175,16 @@ const Tick::WorldTick::AutoSubscribe _tickSub{&OnWorldTick};
|
||||
|
||||
} // namespace
|
||||
|
||||
void Register(void *cacheInstance, const char *eventName, uintptr_t getRecordAddr) {
|
||||
if (cacheInstance == nullptr || eventName == nullptr || getRecordAddr == 0)
|
||||
void Register(void *cacheInstance, const Event::Custom::AutoReserve *reserve,
|
||||
uintptr_t getRecordAddr) {
|
||||
if (cacheInstance == nullptr || reserve == nullptr || getRecordAddr == 0)
|
||||
return;
|
||||
if (Find(cacheInstance) != nullptr) // idempotent (safe across /reload)
|
||||
return;
|
||||
if (g_cacheCount >= kMaxCaches)
|
||||
return;
|
||||
g_caches[g_cacheCount].cache = cacheInstance;
|
||||
g_caches[g_cacheCount].eventName = eventName;
|
||||
g_caches[g_cacheCount].reserve = reserve;
|
||||
g_caches[g_cacheCount].getRecord = getRecordAddr;
|
||||
g_caches[g_cacheCount].pendingCount = 0;
|
||||
++g_cacheCount;
|
||||
|
||||
Vendored
+14
-10
@@ -15,6 +15,10 @@
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
namespace Event::Custom {
|
||||
struct AutoReserve;
|
||||
}
|
||||
|
||||
// Shared async-load machinery for the client query caches that aren't
|
||||
// item/quest — creature, gameobject, and (potentially) the other
|
||||
// SMSG_*_QUERY caches. Modeled on `Item::Data`'s pending/timeout/event
|
||||
@@ -30,10 +34,9 @@
|
||||
// functions, so hooking creature's and gameobject's separately is fine);
|
||||
// item/quest have their own parsers and aren't touched.
|
||||
//
|
||||
// static const char *kEvent = "CREATURE_DATA_LOAD_RESULT";
|
||||
// static const Event::Custom::AutoReserve _r{kEvent}; // reserve the event
|
||||
// static const Event::Custom::AutoReserve _r{"CREATURE_DATA_LOAD_RESULT"};
|
||||
// ...
|
||||
// Cache::QueryLoad::Register(cacheInstance, kEvent,
|
||||
// Cache::QueryLoad::Register(cacheInstance, &_r,
|
||||
// Offsets::FUN_CREATURE_GET_RECORD); // at init
|
||||
// Cache::QueryLoad::RequestLoad(cacheInstance, id); // fire a load
|
||||
//
|
||||
@@ -44,13 +47,14 @@ namespace Cache::QueryLoad {
|
||||
|
||||
// Register a query cache for async-load tracking. `cacheInstance` is the
|
||||
// cache global (e.g. `Offsets::VAR_CREATURE_CACHE` cast to void*);
|
||||
// `eventName` is a Custom event the caller has reserved via
|
||||
// `Event::Custom::AutoReserve`, fired `(id, success)` on completion;
|
||||
// `getRecordAddr` is the cache class's `_GetRecord` (e.g.
|
||||
// `Offsets::FUN_CREATURE_GET_RECORD`). `cacheInstance`/`eventName` must
|
||||
// outlive the process (pass static values). Idempotent — calling again
|
||||
// for the same cache is a no-op (safe across /reload).
|
||||
void Register(void *cacheInstance, const char *eventName, uintptr_t getRecordAddr);
|
||||
// `reserve` is the caller's `Event::Custom::AutoReserve` for the
|
||||
// completion event, fired `(id, success)`; `getRecordAddr` is the cache
|
||||
// class's `_GetRecord` (e.g. `Offsets::FUN_CREATURE_GET_RECORD`).
|
||||
// `cacheInstance`/`reserve` must outlive the process (pass statics).
|
||||
// Idempotent — calling again for the same cache is a no-op (safe across
|
||||
// /reload).
|
||||
void Register(void *cacheInstance, const Event::Custom::AutoReserve *reserve,
|
||||
uintptr_t getRecordAddr);
|
||||
|
||||
// True if `id` is already in the cache (pure peek, no network query).
|
||||
bool IsCached(void *cacheInstance, uint32_t id);
|
||||
|
||||
+30
-20
@@ -11,28 +11,32 @@
|
||||
// You should have received a copy of the GNU General Public License along with
|
||||
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
// Inline-texture (`|T`) chat anti-spoof.
|
||||
// Inline-texture (`|T`, `|A`) chat anti-spoof.
|
||||
//
|
||||
// Vanilla neutralizes player-*typed* escapes in chat (a typed `|cff…|r`,
|
||||
// `|Hitem…|h` or `|T…|t` shows as raw text) — only *trusted* text renders them
|
||||
// (addon `AddMessage`, shift-clicked links). Our Text::InlineTexture backport
|
||||
// breaks that parity for `|T` alone: it detects the neutralized `||T` form and
|
||||
// draws the icon anyway, so a player could `/say` `|Tpath|t` and have a real
|
||||
// icon appear in everyone's chat AND in the speech bubble over their head.
|
||||
// breaks that parity for the two texture markers: it detects the neutralized
|
||||
// `||T` / `||A` form and draws the art anyway, so a player could `/say`
|
||||
// `|Tpath|t` and have a real icon appear in everyone's chat AND in the speech
|
||||
// bubble over their head.
|
||||
//
|
||||
// This restores vanilla's behavior for `|T` by defanging it in the chat path,
|
||||
// while leaving `|c`/`|r`/`|H`/`|h` exactly as vanilla (already raw for typed
|
||||
// input). It CANNOT be done in the text emitter: addon `|T` and player `|T`
|
||||
// reach the emitter as byte-identical `||T` (vanilla doubles the unrecognized
|
||||
// `|T` for both), so the only place they're distinguishable is the source —
|
||||
// server-delivered chat flows through the chat dispatcher, addon `print` does
|
||||
// not. Chat::Dispatch owns that hook and calls this on the message.
|
||||
// This restores vanilla's behavior for both markers by defanging them in the
|
||||
// chat path, while leaving `|c`/`|r`/`|H`/`|h` exactly as vanilla (already raw
|
||||
// for typed input). It CANNOT be done in the text emitter: addon `|T` and player
|
||||
// `|T` reach the emitter as byte-identical `||T` (vanilla doubles the
|
||||
// unrecognized escape for both), so the only place they're distinguishable is
|
||||
// the source — server-delivered chat flows through the chat dispatcher, addon
|
||||
// `print` does not. Chat::Dispatch owns that hook and calls this on the message.
|
||||
//
|
||||
// Defang = replace the `|` of each `|T` with a space, breaking the pipe→T
|
||||
// Defang = replace the `|` of each opener with a space, breaking the pipe→letter
|
||||
// adjacency the inline-texture detector keys on (`|T` and `||T` both contain the
|
||||
// `|T` substring, so this catches the doubled form too). The path text stays
|
||||
// readable, the closer `|t` is inert without an opener, and every other escape
|
||||
// is untouched.
|
||||
// `|T` substring, so this catches the doubled form too). The payload text stays
|
||||
// readable, the closer is inert without an opener, and every other escape is
|
||||
// untouched.
|
||||
//
|
||||
// ANY new marker the emitter learns MUST be added here in the same change, or it
|
||||
// becomes a chat-spoof vector.
|
||||
|
||||
#include "IconFilter.h"
|
||||
|
||||
@@ -40,10 +44,16 @@ namespace Chat::IconFilter {
|
||||
|
||||
namespace {
|
||||
|
||||
// True if `s` contains an inline-texture opener (`|T`) anywhere.
|
||||
// True if `s` contains an inline-texture opener anywhere. Both markers count:
|
||||
// `|T` names a texture path and `|A` names an atlas, and either one would render
|
||||
// art from player-typed text if it survived to the fontstring.
|
||||
bool IsIconOpener(const char *s) {
|
||||
return s[0] == '|' && (s[1] == 'T' || s[1] == 'A');
|
||||
}
|
||||
|
||||
bool HasIconEscape(const char *s) {
|
||||
for (; *s != '\0'; ++s)
|
||||
if (s[0] == '|' && s[1] == 'T')
|
||||
if (IsIconOpener(s))
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
@@ -68,12 +78,12 @@ const char *Sanitize(const char *msg, char *buf, size_t bufSize) {
|
||||
// opener survives for the inline-texture detector to fire on.
|
||||
size_t j = 0;
|
||||
for (size_t i = 0; msg[i] != '\0' && j + 1 < bufSize;) {
|
||||
if (msg[i] == '|' && msg[i + 1] == 'T') {
|
||||
if (IsIconOpener(msg + i)) {
|
||||
const bool doubledPipe = (i > 0 && msg[i - 1] == '|');
|
||||
buf[j++] = doubledPipe ? '|' : ' '; // keep pipe only if it completes `||`
|
||||
if (j + 1 < bufSize)
|
||||
buf[j++] = ' '; // blank the `T`
|
||||
i += 2; // consumed `|T`
|
||||
buf[j++] = ' '; // blank the `T` / `A`
|
||||
i += 2; // consumed the opener
|
||||
} else {
|
||||
buf[j++] = msg[i++];
|
||||
}
|
||||
|
||||
@@ -86,13 +86,18 @@ std::string StripMarkup(const char *s, unsigned len) {
|
||||
++i;
|
||||
break; // the |h is dropped by the 'h' case on the next iteration
|
||||
case 't': // stray texture closer — drop
|
||||
case 'a': // stray atlas closer — drop
|
||||
i += 2;
|
||||
break;
|
||||
case 'T': // texture opener — skip through to the next |t
|
||||
case 'A': // atlas opener — skip through to the next |a
|
||||
{
|
||||
const char closer = (s[i + 1] == 'A') ? 'a' : 't';
|
||||
i += 2;
|
||||
while (i < len && !(s[i] == '|' && i + 1 < len && s[i + 1] == 't'))
|
||||
while (i < len && !(s[i] == '|' && i + 1 < len && s[i + 1] == closer))
|
||||
++i;
|
||||
break; // the |t is dropped by the 't' case on the next iteration
|
||||
break; // the closer is dropped by the 't'/'a' case next iteration
|
||||
}
|
||||
default: // unknown sequence — keep the pipe literally
|
||||
out.push_back('|');
|
||||
++i;
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
// This file is part of ClassicAPI.
|
||||
//
|
||||
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
|
||||
// of the GNU General Public License as published by the Free Software Foundation, either
|
||||
// version 3 of the License, or (at your option) any later version.
|
||||
//
|
||||
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
|
||||
// PURPOSE. See the GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License along with
|
||||
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
// `StartAttack([target])` / `StopAttack()` — the non-toggling melee
|
||||
// auto-attack verbs. Vanilla 1.12 ships only `AttackTarget()`, which
|
||||
// *toggles* (start <-> stop each call), so a `/startattack` can't guarantee
|
||||
// it won't cancel an in-progress swing. These split the engine's own attack
|
||||
// machinery into the two explicit, idempotent verbs modern clients expose.
|
||||
//
|
||||
// 1.12's `AttackTarget` (`0x00489B50`) is `FUN_006131A0`: resolve the target,
|
||||
// then `isAttacking ? stop : start`. We call the same start/stop primitives
|
||||
// directly (see the `FUN_ATTACK_*` notes in Offsets.h), skipping the toggle:
|
||||
// StartAttack -> the "start" leg only (idempotent; switches or no-ops on a
|
||||
// valid target, never stops it).
|
||||
// StopAttack -> the "stop" leg only (self-guards, so a no-op when idle).
|
||||
|
||||
#include "Game.h"
|
||||
#include "Offsets.h"
|
||||
#include "unit/Identity.h"
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
namespace Combat::Attack {
|
||||
|
||||
namespace {
|
||||
|
||||
using Start_t = void(__thiscall *)(void *player, const uint64_t *targetGuid);
|
||||
using Stop_t = void(__fastcall *)(void *player);
|
||||
using ResolveTarget_t = int(__thiscall *)(void *player, uint64_t *outGuid);
|
||||
|
||||
// Resolve an optional unit-token argument to a GUID without raising.
|
||||
// `Unit::Identity::GuidForToken` raises for non-token strings (a name or
|
||||
// garbage — 1.12 has no name->GUID resolver), so run it under `pcall`; a raise
|
||||
// or an empty token yields 0, which the caller treats as "use current target".
|
||||
int __fastcall ResolveTokenThunk(void *L) {
|
||||
using namespace Game::Lua;
|
||||
const char *token = ToString(L, 1);
|
||||
const uint64_t g = (token && *token) ? Unit::Identity::GuidForToken(token) : 0;
|
||||
PushNumber(L, static_cast<double>(static_cast<uint32_t>(g)));
|
||||
PushNumber(L, static_cast<double>(static_cast<uint32_t>(g >> 32)));
|
||||
return 2;
|
||||
}
|
||||
|
||||
uint64_t SafeResolveToken(void *L, const char *token) {
|
||||
using namespace Game::Lua;
|
||||
if (token == nullptr || *token == '\0')
|
||||
return 0;
|
||||
|
||||
const int base = GetTop(L);
|
||||
PushCClosure(L, &ResolveTokenThunk, 0);
|
||||
PushString(L, token);
|
||||
uint64_t guid = 0;
|
||||
if (PCall(L, 1, 2, 0) == 0) {
|
||||
const uint32_t lo = static_cast<uint32_t>(ToNumber(L, -2));
|
||||
const uint32_t hi = static_cast<uint32_t>(ToNumber(L, -1));
|
||||
guid = (static_cast<uint64_t>(hi) << 32) | lo;
|
||||
}
|
||||
SetTop(L, base); // drop the results (or the caught error message)
|
||||
return guid;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
static int __fastcall Script_StartAttack(void *L) {
|
||||
auto *player = const_cast<uint8_t *>(Unit::Identity::PlayerObject());
|
||||
if (player == nullptr)
|
||||
return 0;
|
||||
|
||||
uint64_t target = SafeResolveToken(L, Game::Lua::ToString(L, 1));
|
||||
if (target == 0) {
|
||||
// No explicit target given (or unresolvable): validate and take the
|
||||
// current target. Returns 0 — and posts the engine's own UI error —
|
||||
// when there's nothing valid to attack.
|
||||
auto resolve =
|
||||
reinterpret_cast<ResolveTarget_t>(Offsets::FUN_ATTACK_RESOLVE_TARGET);
|
||||
if (resolve(player, &target) == 0)
|
||||
return 0;
|
||||
}
|
||||
|
||||
reinterpret_cast<Start_t>(Offsets::FUN_ATTACK_START)(player, &target);
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int __fastcall Script_StopAttack(void *L) {
|
||||
(void)L;
|
||||
auto *player = const_cast<uint8_t *>(Unit::Identity::PlayerObject());
|
||||
if (player == nullptr)
|
||||
return 0;
|
||||
|
||||
reinterpret_cast<Stop_t>(Offsets::FUN_ATTACK_STOP)(player);
|
||||
return 0;
|
||||
}
|
||||
|
||||
static void RegisterLuaFunctions() {
|
||||
Game::Lua::RegisterGlobalFunction("StartAttack", &Script_StartAttack);
|
||||
Game::Lua::RegisterGlobalFunction("StopAttack", &Script_StopAttack);
|
||||
}
|
||||
|
||||
static const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
|
||||
|
||||
} // namespace Combat::Attack
|
||||
@@ -0,0 +1,135 @@
|
||||
// This file is part of ClassicAPI.
|
||||
//
|
||||
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
|
||||
// of the GNU General Public License as published by the Free Software Foundation, either
|
||||
// version 3 of the License, or (at your option) any later version.
|
||||
//
|
||||
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
|
||||
// PURPOSE. See the GNU General Public License for more details.
|
||||
|
||||
// `-config <name>` — a launch switch that swaps which settings file the client
|
||||
// uses, so one install can hold several profiles.
|
||||
//
|
||||
// The engine already names the config file through a single pointer: boot passes
|
||||
// `"Config.wtf"` to the config initializer, which keeps it and uses it to both
|
||||
// read the file at startup and write it at exit. Supplying a different string is
|
||||
// therefore the whole feature — no second code path, and no way for reading and
|
||||
// writing to disagree. See `Offsets::PATCH_CONFIG_FILENAME_PTR`.
|
||||
|
||||
#include "FileSwitch.h"
|
||||
|
||||
#include "Offsets.h"
|
||||
|
||||
#include <windows.h>
|
||||
|
||||
#include <cstddef>
|
||||
|
||||
namespace Config::FileSwitch {
|
||||
|
||||
namespace {
|
||||
|
||||
// The engine holds this pointer for the life of the process and reads through it
|
||||
// again at exit to save, so the storage has to outlive everything — hence a
|
||||
// file-static buffer rather than anything owned by a scope.
|
||||
char g_configName[64];
|
||||
|
||||
bool IsSpace(char c) { return c == ' ' || c == '\t'; }
|
||||
|
||||
bool EqualsIgnoreCase(const char *a, const char *b, size_t len) {
|
||||
for (size_t i = 0; i < len; ++i) {
|
||||
char ca = a[i];
|
||||
char cb = b[i];
|
||||
if (ca >= 'A' && ca <= 'Z')
|
||||
ca = static_cast<char>(ca - 'A' + 'a');
|
||||
if (cb >= 'A' && cb <= 'Z')
|
||||
cb = static_cast<char>(cb - 'A' + 'a');
|
||||
if (ca != cb)
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// A filename is required to be a plain name. The engine builds the save path as
|
||||
// `WTF\` + this, and a subdirectory that does not exist makes the save fail
|
||||
// silently, which would throw away a session's settings with nothing to show for
|
||||
// it. Refusing the switch is the loud failure that a lost config file is not.
|
||||
bool IsPlainFilename(const char *name) {
|
||||
if (*name == '\0')
|
||||
return false;
|
||||
for (const char *p = name; *p != '\0'; ++p) {
|
||||
if (*p == '\\' || *p == '/' || *p == ':')
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Copies the token at `p` into `out`, honoring double quotes so a name with a
|
||||
// space still arrives whole. Returns false when the token is empty or too long.
|
||||
bool ReadToken(const char *p, char *out, size_t outSize) {
|
||||
const bool quoted = (*p == '"');
|
||||
if (quoted)
|
||||
++p;
|
||||
|
||||
size_t n = 0;
|
||||
while (*p != '\0' && (quoted ? (*p != '"') : !IsSpace(*p))) {
|
||||
if (n + 1 >= outSize)
|
||||
return false; // longer than we will accept
|
||||
out[n++] = *p++;
|
||||
}
|
||||
out[n] = '\0';
|
||||
return n > 0;
|
||||
}
|
||||
|
||||
// Finds `-config <name>` (or `-config=<name>`) on the command line and copies the
|
||||
// name into `out`. Also accepts a `/` switch prefix, which Windows tools allow.
|
||||
bool ParseSwitch(char *out, size_t outSize) {
|
||||
const char *cmdLine = ::GetCommandLineA();
|
||||
if (cmdLine == nullptr)
|
||||
return false;
|
||||
|
||||
static const char kSwitch[] = "config";
|
||||
const size_t kSwitchLen = sizeof(kSwitch) - 1;
|
||||
|
||||
for (const char *p = cmdLine; *p != '\0'; ++p) {
|
||||
if (*p != '-' && *p != '/')
|
||||
continue;
|
||||
// Only at a token boundary, so a path containing "-config" cannot match.
|
||||
if (p != cmdLine && !IsSpace(p[-1]) && p[-1] != '"')
|
||||
continue;
|
||||
|
||||
const char *name = p + 1;
|
||||
if (!EqualsIgnoreCase(name, kSwitch, kSwitchLen))
|
||||
continue;
|
||||
|
||||
const char *after = name + kSwitchLen;
|
||||
if (*after == '=') {
|
||||
++after;
|
||||
} else if (IsSpace(*after)) {
|
||||
while (IsSpace(*after))
|
||||
++after;
|
||||
} else {
|
||||
continue; // a longer switch that merely starts with "config"
|
||||
}
|
||||
return ReadToken(after, out, outSize);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
void Apply() {
|
||||
if (!ParseSwitch(g_configName, sizeof g_configName))
|
||||
return;
|
||||
if (!IsPlainFilename(g_configName))
|
||||
return;
|
||||
|
||||
void *target = reinterpret_cast<void *>(Offsets::PATCH_CONFIG_FILENAME_PTR);
|
||||
DWORD previous = 0;
|
||||
if (!::VirtualProtect(target, sizeof(const char *), PAGE_EXECUTE_READWRITE, &previous))
|
||||
return;
|
||||
*reinterpret_cast<const char **>(target) = g_configName;
|
||||
::VirtualProtect(target, sizeof(const char *), previous, &previous);
|
||||
}
|
||||
|
||||
} // namespace Config::FileSwitch
|
||||
@@ -0,0 +1,37 @@
|
||||
// This file is part of ClassicAPI.
|
||||
//
|
||||
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
|
||||
// of the GNU General Public License as published by the Free Software Foundation, either
|
||||
// version 3 of the License, or (at your option) any later version.
|
||||
//
|
||||
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
|
||||
// PURPOSE. See the GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License along with
|
||||
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
#pragma once
|
||||
|
||||
// `-config <name>` — makes the client read and write `WTF\<name>` in place of
|
||||
// `WTF\Config.wtf`, so several settings profiles can live side by side.
|
||||
namespace Config::FileSwitch {
|
||||
|
||||
// Reads the switch from the process command line and, when present, points the
|
||||
// engine's config filename at it.
|
||||
//
|
||||
// MUST be called from DllMain, and is the one thing in this project that is.
|
||||
// The engine loads the config during boot, several calls before the point where
|
||||
// VanillaFixes invokes our `Load` export, so by the time the normal
|
||||
// initialization path runs the file has already been read — see
|
||||
// `Offsets::PATCH_CONFIG_FILENAME_PTR` for the full call ordering. DllMain is
|
||||
// early enough because VanillaFixes injects every DLL while the game process is
|
||||
// still suspended and resumes it only afterwards.
|
||||
//
|
||||
// This does not install a hook. It is a single 4-byte store into an immediate
|
||||
// operand, so it costs none of what makes hooking from DllMain a problem: no
|
||||
// trampoline, no thread suspension, no loader-lock work, and nothing for another
|
||||
// DLL's hook to collide with.
|
||||
void Apply();
|
||||
|
||||
} // namespace Config::FileSwitch
|
||||
@@ -361,7 +361,7 @@ void RegisterLuaFunctions() {
|
||||
Game::Lua::RegisterTableFunction("C_CreatureInfo", "GetCreatureTypeIDs",
|
||||
&Script_GetCreatureTypeIDs);
|
||||
Cache::QueryLoad::Register(reinterpret_cast<void *>(Offsets::VAR_CREATURE_CACHE),
|
||||
kCreatureDataLoadResult, Offsets::FUN_CREATURE_GET_RECORD);
|
||||
&_reserveLoadResult, Offsets::FUN_CREATURE_GET_RECORD);
|
||||
}
|
||||
|
||||
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
|
||||
|
||||
+1
-4
@@ -64,11 +64,8 @@ namespace Debug::Probe {
|
||||
|
||||
namespace {
|
||||
|
||||
using ResolveUnitToken_t = void *(__fastcall *)(const char *token);
|
||||
|
||||
const uint8_t *ResolvePlayer() {
|
||||
auto fn = reinterpret_cast<ResolveUnitToken_t>(Offsets::FUN_RESOLVE_UNIT_TOKEN);
|
||||
return static_cast<const uint8_t *>(fn("player"));
|
||||
return static_cast<const uint8_t *>(Game::ResolveUnitToken("player"));
|
||||
}
|
||||
|
||||
const uint8_t *ResolvePlayerDescriptor() {
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
// This file is part of ClassicAPI.
|
||||
//
|
||||
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
|
||||
// of the GNU General Public License as published by the Free Software Foundation, either
|
||||
// version 3 of the License, or (at your option) any later version.
|
||||
//
|
||||
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
|
||||
// PURPOSE. See the GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License along with
|
||||
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
// Modern EditBox method backports — cursor, selection, focus and text-state
|
||||
// accessors that vanilla's 48-method EditBox registry lacks but modern addons
|
||||
// call unconditionally (the caret bug that motivated this: TwitchEmotes'
|
||||
// autocomplete calls SetCursorPosition after replacing the text).
|
||||
//
|
||||
// SetCursorPosition / GetCursorPosition — caret as a byte offset
|
||||
// GetUTF8CursorPosition — caret as a character index
|
||||
// ClearHighlightText — drop the selection, keep the caret
|
||||
// HasFocus — does this box own keyboard focus
|
||||
// HasText — is the box non-empty
|
||||
// Set/GetHighlightColor — the text-selection highlight color
|
||||
// ClearHistory — empty the up/down input history
|
||||
//
|
||||
// The engine already stores and manipulates all of this; we just expose it.
|
||||
// Every method is registered on the EditBox method registry, so — like any
|
||||
// per-type frame method — only EditBox frames dispatch to them (a Button or
|
||||
// Frame still gets "method not found"), so the resolved `this` is always a
|
||||
// real CSimpleEditBox.
|
||||
//
|
||||
// The caret is a BYTE offset into the UTF-8 text buffer (field +0x36c), which
|
||||
// is exactly what modern WoW's byte-based EditBox cursor uses — for ASCII text
|
||||
// a byte offset equals a character index, so callers passing `#text` land at
|
||||
// the end either way.
|
||||
|
||||
#include "Game.h"
|
||||
#include "Offsets.h"
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
namespace EditBox::Methods {
|
||||
|
||||
namespace {
|
||||
|
||||
using SetCursorByte_t = void(__thiscall *)(void *editbox, int byteOffset);
|
||||
using CollapseSelection_t = void(__thiscall *)(void *editbox);
|
||||
using CountChars_t = int(__thiscall *)(void *editbox, int startByte, int byteCount);
|
||||
using RegionSetColor_t = void(__thiscall *)(void *region, const uint32_t *colorBGRA);
|
||||
using RegionGetColor_t = void(__thiscall *)(void *region, uint32_t *outBGRA);
|
||||
using SetHistoryMax_t = void(__thiscall *)(void *editbox, int maxLines);
|
||||
|
||||
// A Lua color arg (0..1) clamped and scaled to a 0..255 byte, matching the
|
||||
// engine's own float->byte color conversion (truncating, like Texture's
|
||||
// SetVertexColor). `dflt` is used when the arg is absent.
|
||||
uint32_t ColorByte(void *L, int idx, double dflt) {
|
||||
double v = Game::Lua::IsNumber(L, idx) ? Game::Lua::ToNumber(L, idx) : dflt;
|
||||
if (v < 0.0)
|
||||
v = 0.0;
|
||||
else if (v > 1.0)
|
||||
v = 1.0;
|
||||
return static_cast<uint32_t>(v * 255.0);
|
||||
}
|
||||
|
||||
int __fastcall Script_SetCursorPosition(void *L) {
|
||||
void *eb = Game::Lua::ResolveObject(L, 1);
|
||||
if (eb == nullptr || !Game::Lua::IsNumber(L, 2)) {
|
||||
Game::Lua::Error(L, "Usage: EditBox:SetCursorPosition(position)");
|
||||
return 0;
|
||||
}
|
||||
const int pos = static_cast<int>(Game::Lua::ToNumber(L, 2));
|
||||
reinterpret_cast<SetCursorByte_t>(
|
||||
Offsets::FUN_EDITBOX_SET_CURSOR_BYTE)(eb, pos);
|
||||
reinterpret_cast<CollapseSelection_t>(
|
||||
Offsets::FUN_EDITBOX_COLLAPSE_SELECTION)(eb);
|
||||
// COLLAPSE_SELECTION moves selStart/selEnd but doesn't flag them; set the
|
||||
// selection-dirty bit (2) so any highlight from before is cleared on the
|
||||
// next paint. SET_CURSOR_BYTE already set the cursor-dirty bit (4).
|
||||
Game::Ref<uint32_t>(eb, Offsets::OFF_EDITBOX_FLAGS) |= 2;
|
||||
return 0;
|
||||
}
|
||||
|
||||
int __fastcall Script_GetCursorPosition(void *L) {
|
||||
void *eb = Game::Lua::ResolveObject(L, 1);
|
||||
if (eb == nullptr) {
|
||||
Game::Lua::Error(L, "Usage: EditBox:GetCursorPosition()");
|
||||
return 0;
|
||||
}
|
||||
Game::Lua::PushNumber(L, static_cast<double>(
|
||||
Game::Read<int>(eb, Offsets::OFF_EDITBOX_CURSOR_BYTE)));
|
||||
return 1;
|
||||
}
|
||||
|
||||
// The caret as a 0-based CHARACTER index (UTF-8 codepoints before the caret),
|
||||
// vs GetCursorPosition's byte offset. Equal for ASCII; differs once the text
|
||||
// holds multibyte characters.
|
||||
int __fastcall Script_GetUTF8CursorPosition(void *L) {
|
||||
void *eb = Game::Lua::ResolveObject(L, 1);
|
||||
if (eb == nullptr) {
|
||||
Game::Lua::Error(L, "Usage: EditBox:GetUTF8CursorPosition()");
|
||||
return 0;
|
||||
}
|
||||
const int cursorByte = Game::Read<int>(eb, Offsets::OFF_EDITBOX_CURSOR_BYTE);
|
||||
const int chars = reinterpret_cast<CountChars_t>(
|
||||
Offsets::FUN_EDITBOX_COUNT_CHARS)(eb, 0, cursorByte);
|
||||
Game::Lua::PushNumber(L, static_cast<double>(chars));
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Drop the current selection without moving the caret. Collapsing selection to
|
||||
// the caret (selStart = selEnd = cursor) is exactly what the engine's own
|
||||
// helper does; vanilla can only clear a selection via HighlightText(0, 0),
|
||||
// which yanks it to offset 0 instead of leaving the caret in place.
|
||||
int __fastcall Script_ClearHighlightText(void *L) {
|
||||
void *eb = Game::Lua::ResolveObject(L, 1);
|
||||
if (eb == nullptr) {
|
||||
Game::Lua::Error(L, "Usage: EditBox:ClearHighlightText()");
|
||||
return 0;
|
||||
}
|
||||
reinterpret_cast<CollapseSelection_t>(
|
||||
Offsets::FUN_EDITBOX_COLLAPSE_SELECTION)(eb);
|
||||
Game::Ref<uint32_t>(eb, Offsets::OFF_EDITBOX_FLAGS) |= 2;
|
||||
return 0;
|
||||
}
|
||||
|
||||
// True when this edit box currently owns keyboard focus. The engine tracks the
|
||||
// focused box in one global (written by SetFocus / cleared by ClearFocus), so
|
||||
// focus is just pointer identity against it.
|
||||
int __fastcall Script_HasFocus(void *L) {
|
||||
void *eb = Game::Lua::ResolveObject(L, 1);
|
||||
if (eb == nullptr) {
|
||||
Game::Lua::PushBool(L, false);
|
||||
return 1;
|
||||
}
|
||||
Game::Lua::PushBool(L,
|
||||
eb == Game::Read<void *>(Offsets::VAR_FOCUSED_EDITBOX));
|
||||
return 1;
|
||||
}
|
||||
|
||||
// True when the box holds any text (its byte length is non-zero).
|
||||
int __fastcall Script_HasText(void *L) {
|
||||
void *eb = Game::Lua::ResolveObject(L, 1);
|
||||
if (eb == nullptr) {
|
||||
Game::Lua::PushBool(L, false);
|
||||
return 1;
|
||||
}
|
||||
Game::Lua::PushBool(L,
|
||||
Game::Read<int>(eb, Offsets::OFF_EDITBOX_TEXT_LENGTH) > 0);
|
||||
return 1;
|
||||
}
|
||||
|
||||
// SetHighlightColor(r, g, b [, a]) — the color of the text-selection highlight.
|
||||
// The engine paints the selection with three Texture regions (start-line,
|
||||
// middle-block, end-line); their vertex color is the highlight color, so set
|
||||
// it on all three via the engine's own region color setter. Colors are 0..1;
|
||||
// alpha defaults to 1. Takes effect on the next selection paint.
|
||||
int __fastcall Script_SetHighlightColor(void *L) {
|
||||
void *eb = Game::Lua::ResolveObject(L, 1);
|
||||
if (eb == nullptr || !Game::Lua::IsNumber(L, 2) ||
|
||||
!Game::Lua::IsNumber(L, 3) || !Game::Lua::IsNumber(L, 4)) {
|
||||
Game::Lua::Error(L, "Usage: EditBox:SetHighlightColor(r, g, b [, a])");
|
||||
return 0;
|
||||
}
|
||||
const uint32_t r = ColorByte(L, 2, 1.0);
|
||||
const uint32_t g = ColorByte(L, 3, 1.0);
|
||||
const uint32_t b = ColorByte(L, 4, 1.0);
|
||||
const uint32_t a = ColorByte(L, 5, 1.0);
|
||||
const uint32_t packed = b | (g << 8) | (r << 16) | (a << 24); // {b,g,r,a}
|
||||
auto setColor = reinterpret_cast<RegionSetColor_t>(
|
||||
Offsets::FUN_FONTSTRING_SET_COLOR);
|
||||
for (int off = Offsets::OFF_EDITBOX_HIGHLIGHT_REGION;
|
||||
off <= Offsets::OFF_EDITBOX_HIGHLIGHT_REGION + 8; off += 4) {
|
||||
void *region = Game::Read<void *>(eb, off);
|
||||
if (region != nullptr)
|
||||
setColor(region, &packed);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
// GetHighlightColor() -> r, g, b, a (each 0..1). Reads the color off the first
|
||||
// highlight region; a region with no explicit color reads back white (opaque).
|
||||
int __fastcall Script_GetHighlightColor(void *L) {
|
||||
void *eb = Game::Lua::ResolveObject(L, 1);
|
||||
if (eb == nullptr) {
|
||||
Game::Lua::Error(L, "Usage: EditBox:GetHighlightColor()");
|
||||
return 0;
|
||||
}
|
||||
uint32_t packed = 0xFFFFFFFF;
|
||||
void *region = Game::Read<void *>(eb, Offsets::OFF_EDITBOX_HIGHLIGHT_REGION);
|
||||
if (region != nullptr)
|
||||
reinterpret_cast<RegionGetColor_t>(Offsets::FUN_REGION_GET_COLOR)(
|
||||
region, &packed);
|
||||
const double inv = 1.0 / 255.0;
|
||||
Game::Lua::PushNumber(L, ((packed >> 16) & 0xff) * inv); // r
|
||||
Game::Lua::PushNumber(L, ((packed >> 8) & 0xff) * inv); // g
|
||||
Game::Lua::PushNumber(L, (packed & 0xff) * inv); // b
|
||||
Game::Lua::PushNumber(L, ((packed >> 24) & 0xff) * inv); // a
|
||||
return 4;
|
||||
}
|
||||
|
||||
// Empties the up/down input history, keeping the line limit so the box keeps
|
||||
// recording afterward. The engine's SetHistoryLines internal frees the whole
|
||||
// ring at max 0, so clear = free (0) then reallocate at the saved max — which
|
||||
// drops every entry and resets the write position. No-op when history was
|
||||
// never enabled (max 0).
|
||||
int __fastcall Script_ClearHistory(void *L) {
|
||||
void *eb = Game::Lua::ResolveObject(L, 1);
|
||||
if (eb == nullptr) {
|
||||
Game::Lua::Error(L, "Usage: EditBox:ClearHistory()");
|
||||
return 0;
|
||||
}
|
||||
const int max = Game::Read<int>(eb, Offsets::OFF_EDITBOX_HISTORY_MAX);
|
||||
if (max > 0) {
|
||||
auto setMax = reinterpret_cast<SetHistoryMax_t>(
|
||||
Offsets::FUN_EDITBOX_SET_HISTORY_MAX);
|
||||
setMax(eb, 0);
|
||||
setMax(eb, max);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
const Game::Lua::FrameMethodEntry g_methods[] = {
|
||||
{"SetCursorPosition", &Script_SetCursorPosition},
|
||||
{"GetCursorPosition", &Script_GetCursorPosition},
|
||||
{"GetUTF8CursorPosition", &Script_GetUTF8CursorPosition},
|
||||
{"ClearHighlightText", &Script_ClearHighlightText},
|
||||
{"HasFocus", &Script_HasFocus},
|
||||
{"HasText", &Script_HasText},
|
||||
{"SetHighlightColor", &Script_SetHighlightColor},
|
||||
{"GetHighlightColor", &Script_GetHighlightColor},
|
||||
{"ClearHistory", &Script_ClearHistory},
|
||||
};
|
||||
|
||||
void RegisterLuaFunctions() {
|
||||
Game::Lua::RegisterFrameMethods(
|
||||
reinterpret_cast<void *>(Offsets::VAR_EDITBOX_METHOD_REGISTRY),
|
||||
g_methods,
|
||||
static_cast<int>(sizeof(g_methods) / sizeof(g_methods[0])));
|
||||
}
|
||||
|
||||
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
|
||||
|
||||
} // namespace
|
||||
|
||||
} // namespace EditBox::Methods
|
||||
@@ -386,7 +386,7 @@ int __fastcall Script_UseEquipmentSet(void *L) {
|
||||
const uint32_t setID = ArgSetID(L, 1);
|
||||
const Set *s = Data::FindByID(setID);
|
||||
if (s == nullptr) {
|
||||
const int evt = Event::Custom::Lookup(kSwapFinishedEvent);
|
||||
const int evt = kEvtSwapFinished.Slot();
|
||||
if (evt >= 0)
|
||||
Event::Custom::Fire(evt, "%d%d", 0, static_cast<int>(setID));
|
||||
Game::Lua::PushBoolean(L, 0);
|
||||
@@ -396,7 +396,7 @@ int __fastcall Script_UseEquipmentSet(void *L) {
|
||||
// Fire PENDING right after the set-exists check, before any of
|
||||
// the swap work. Addon UI can use this to gate swap-in-progress
|
||||
// visuals.
|
||||
const int pendingEvt = Event::Custom::Lookup(kSwapPendingEvent);
|
||||
const int pendingEvt = kEvtSwapPending.Slot();
|
||||
if (pendingEvt >= 0)
|
||||
Event::Custom::Fire(pendingEvt, "%d", static_cast<int>(setID));
|
||||
|
||||
@@ -569,7 +569,7 @@ int __fastcall Script_UseEquipmentSet(void *L) {
|
||||
virtualPaperdoll[targetSlot - 1] = 0;
|
||||
}
|
||||
|
||||
const int evt = Event::Custom::Lookup(kSwapFinishedEvent);
|
||||
const int evt = kEvtSwapFinished.Slot();
|
||||
if (evt >= 0)
|
||||
Event::Custom::Fire(evt, "%d%d", 1, static_cast<int>(setID));
|
||||
|
||||
@@ -620,8 +620,5 @@ static void RegisterLuaFunctions() {
|
||||
}
|
||||
|
||||
static const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
|
||||
static const Event::Custom::AutoReserve _reserve{kEventName};
|
||||
static const Event::Custom::AutoReserve _reservePending{kSwapPendingEvent};
|
||||
static const Event::Custom::AutoReserve _reserveFinished{kSwapFinishedEvent};
|
||||
|
||||
} // namespace EquipmentSet::Api
|
||||
|
||||
@@ -75,7 +75,7 @@ uint32_t NextAvailableID() {
|
||||
}
|
||||
|
||||
void FireChanged() {
|
||||
const int slot = Event::Custom::Lookup(kEventName);
|
||||
const int slot = kEvtSetsChanged.Slot();
|
||||
if (slot >= 0)
|
||||
Event::Custom::Fire(slot, "");
|
||||
}
|
||||
|
||||
@@ -13,6 +13,8 @@
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "event/Custom.h"
|
||||
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
|
||||
@@ -109,8 +111,9 @@ constexpr int LOC_MISSING = -1;
|
||||
// coincidence, not load-bearing).
|
||||
constexpr int LOC_IGNORED = 1;
|
||||
|
||||
// Custom event names. All three are reserved at module-init time
|
||||
// via `Event::Custom::AutoReserve` in `Api.cpp`.
|
||||
// Custom event reservations, shared by the Data and Api TUs (C++17 inline
|
||||
// variables — one instance each; the AutoReserve ctor dedups by name anyway).
|
||||
// Fire via `kEvt*.Slot()`.
|
||||
//
|
||||
// EQUIPMENT_SETS_CHANGED — no payload; fires on any mutation
|
||||
// (create / save / modify / delete /
|
||||
@@ -130,8 +133,8 @@ constexpr int LOC_IGNORED = 1;
|
||||
// pickup→equip pair actually completed,
|
||||
// matching modern's "we dispatched"
|
||||
// definition of success).
|
||||
constexpr const char *kEventName = "EQUIPMENT_SETS_CHANGED";
|
||||
constexpr const char *kSwapPendingEvent = "EQUIPMENT_SWAP_PENDING";
|
||||
constexpr const char *kSwapFinishedEvent = "EQUIPMENT_SWAP_FINISHED";
|
||||
inline const Event::Custom::AutoReserve kEvtSetsChanged{"EQUIPMENT_SETS_CHANGED"};
|
||||
inline const Event::Custom::AutoReserve kEvtSwapPending{"EQUIPMENT_SWAP_PENDING"};
|
||||
inline const Event::Custom::AutoReserve kEvtSwapFinished{"EQUIPMENT_SWAP_FINISHED"};
|
||||
|
||||
} // namespace EquipmentSet
|
||||
|
||||
@@ -13,8 +13,7 @@
|
||||
|
||||
#include "Storage.h"
|
||||
|
||||
#include "Game.h"
|
||||
#include "Offsets.h"
|
||||
#include "settings/Paths.h"
|
||||
|
||||
#include <cstdint>
|
||||
#include <cstdio>
|
||||
@@ -25,22 +24,6 @@ namespace EquipmentSet::Storage {
|
||||
|
||||
namespace {
|
||||
|
||||
const char *ReadAccountName() {
|
||||
return Game::Read<const char *>(Offsets::VAR_ACCOUNT_NAME_PTR);
|
||||
}
|
||||
|
||||
const char *ReadCharacterName() {
|
||||
auto *p = reinterpret_cast<const char *>(Offsets::VAR_CHARACTER_NAME);
|
||||
return (p[0] == '\0') ? nullptr : p;
|
||||
}
|
||||
|
||||
const char *ReadRealmName() {
|
||||
auto *info = Game::Read<uint8_t *>(Offsets::VAR_REALM_INFO_PTR);
|
||||
if (info == nullptr)
|
||||
return nullptr;
|
||||
return Game::Read<const char *>(info, Offsets::OFF_REALM_INFO_NAME);
|
||||
}
|
||||
|
||||
// Removes trailing CR/LF/whitespace. Names from the engine are ASCII
|
||||
// only (the chat input filter rejects high bytes) so we don't have to
|
||||
// think about multi-byte trailing whitespace.
|
||||
@@ -74,23 +57,7 @@ bool ParseField(const std::string &line, const std::string &key, std::string *va
|
||||
} // namespace
|
||||
|
||||
std::string ResolveFilePath() {
|
||||
const char *account = ReadAccountName();
|
||||
if (account == nullptr || account[0] == '\0')
|
||||
return {};
|
||||
const char *realm = ReadRealmName();
|
||||
if (realm == nullptr || realm[0] == '\0')
|
||||
return {};
|
||||
const char *character = ReadCharacterName();
|
||||
if (character == nullptr)
|
||||
return {};
|
||||
std::string out = "WTF\\Account\\";
|
||||
out += account;
|
||||
out += '\\';
|
||||
out += realm;
|
||||
out += '\\';
|
||||
out += character;
|
||||
out += "\\ClassicAPI_EquipmentSets.txt";
|
||||
return out;
|
||||
return Settings::Paths::CharacterFile("ClassicAPI_EquipmentSets.txt");
|
||||
}
|
||||
|
||||
bool Load(const std::string &path, std::vector<Set> *outSets) {
|
||||
|
||||
+12
-10
@@ -244,25 +244,25 @@ void Grow() {
|
||||
} // namespace
|
||||
|
||||
AutoReserve::AutoReserve(const char *name) {
|
||||
if (name == nullptr || g_reservedCount >= MAX_RESERVED)
|
||||
if (name == nullptr)
|
||||
return;
|
||||
// Dedup: a name reserved twice resolves both instances to the one entry.
|
||||
for (int i = 0; i < g_reservedCount; ++i) {
|
||||
if (std::strcmp(g_reserved[i].name, name) == 0)
|
||||
if (std::strcmp(g_reserved[i].name, name) == 0) {
|
||||
index_ = i;
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (g_reservedCount >= MAX_RESERVED)
|
||||
return; // overflow — index_ stays -1, Slot() reports unclaimed
|
||||
g_reserved[g_reservedCount].name = name;
|
||||
g_reserved[g_reservedCount].slot = -1;
|
||||
index_ = g_reservedCount;
|
||||
++g_reservedCount;
|
||||
}
|
||||
|
||||
int Lookup(const char *name) {
|
||||
if (name == nullptr)
|
||||
return -1;
|
||||
for (int i = 0; i < g_reservedCount; ++i) {
|
||||
if (std::strcmp(g_reserved[i].name, name) == 0)
|
||||
return g_reserved[i].slot;
|
||||
}
|
||||
return -1;
|
||||
int AutoReserve::Slot() const {
|
||||
return index_ >= 0 ? g_reserved[index_].slot : -1;
|
||||
}
|
||||
|
||||
int LookupByName(const char *name) {
|
||||
@@ -320,6 +320,8 @@ void PrepareForReload() {
|
||||
g_growLatched = false; // re-arm the one-shot grow for the new table
|
||||
}
|
||||
|
||||
static const Game::ReloadAutoRegister _reloadReg{&PrepareForReload};
|
||||
|
||||
// Diagnostic Lua functions — registered via the auto-register pattern
|
||||
// at the bottom of this file. Not on any hot path.
|
||||
namespace {
|
||||
|
||||
+22
-17
@@ -37,31 +37,36 @@ namespace Event::Custom {
|
||||
// DLL operate on the table independently of the others.
|
||||
//
|
||||
// The name pointer must outlive the engine (a string literal does).
|
||||
// Same name reserved twice is deduped; reserving more than `MAX_RESERVED`
|
||||
// names total (see Custom.cpp) silently drops the overflow — keep that cap
|
||||
// above the codebase-wide `AutoReserve` count.
|
||||
// Same name reserved twice is deduped (both instances resolve to the same
|
||||
// reservation); reserving more than `MAX_RESERVED` names total (see
|
||||
// Custom.cpp) silently drops the overflow — keep that cap above the
|
||||
// codebase-wide `AutoReserve` count.
|
||||
//
|
||||
// Fire through the instance: `Fire(_r.Slot(), …)`. `Slot()` is the O(1)
|
||||
// read of the reservation's current slot id — it resolves through the
|
||||
// shared reservation table, so reload invalidation (`PrepareForReload`)
|
||||
// and re-claiming (`RetryClaims`) stay centralized and the value is never
|
||||
// stale. Returns -1 while unclaimed (before the first Lua-side
|
||||
// `RegisterEvent` triggers `RetryClaims`), which `Fire` treats as a no-op.
|
||||
// Do NOT cache the return across frames — read it at fire time (the read
|
||||
// is two loads; there is nothing to save).
|
||||
struct AutoReserve {
|
||||
explicit AutoReserve(const char *name);
|
||||
};
|
||||
int Slot() const;
|
||||
|
||||
// Returns the slot id currently assigned to `name`, or -1 if not yet
|
||||
// claimed (e.g. before the first Lua-side `RegisterEvent` has
|
||||
// triggered `RetryClaims`). Slot indices may change across `/reload`,
|
||||
// so call this at fire time rather than caching the value.
|
||||
//
|
||||
// Only consults our reservation cache — for engine-defined events
|
||||
// (UNIT_FACTION, BAG_UPDATE, etc.) use `LookupByName`.
|
||||
int Lookup(const char *name);
|
||||
private:
|
||||
int index_ = -1; // into the reservation table; -1 = dropped (overflow)
|
||||
};
|
||||
|
||||
// Walks the live engine event table at `[VAR_EVENT_TABLE_BASE_PTR]`
|
||||
// looking for an entry whose name strcmps equal to `name`. Returns
|
||||
// the slot index, or -1 if no entry matches.
|
||||
//
|
||||
// Works for both engine-defined events AND our `AutoReserve`-claimed
|
||||
// custom events (both populate the same table). Slightly slower than
|
||||
// `Lookup` (O(table size) vs O(reservations)) — prefer `Lookup` for
|
||||
// names we've reserved. Use this for engine-defined events we want to
|
||||
// fire ourselves (e.g., polyfilling missing event dispatches).
|
||||
// custom events (both populate the same table). O(table size) — for
|
||||
// names we've reserved, prefer the O(1) `AutoReserve::Slot()`. Use this
|
||||
// for engine-defined events we want to fire ourselves (e.g., polyfilling
|
||||
// missing event dispatches).
|
||||
int LookupByName(const char *name);
|
||||
|
||||
// True iff at least one frame is currently registered for the event in
|
||||
@@ -88,7 +93,7 @@ bool HasListeners(int slot);
|
||||
// Fire(slot, "%s%d", keyName, down); // MODIFIER_STATE_CHANGED(key, down)
|
||||
//
|
||||
// No-op for `eventID < 0`, which lets callers cheaply guard on the
|
||||
// `Lookup()` result without an explicit if.
|
||||
// `Slot()` result without an explicit if.
|
||||
template <typename... Args>
|
||||
inline void Fire(int eventID, const char *format, Args... args) {
|
||||
if (eventID < 0)
|
||||
|
||||
+237
-4
@@ -69,10 +69,7 @@ void PushFlag(void *L, bool value) {
|
||||
// `[player + 0xE68]`. Returns nullptr if "player" isn't resolvable
|
||||
// or the sub-struct is uninitialized (pre-login / glue).
|
||||
const uint8_t *PlayerInfo() {
|
||||
using ResolveUnitToken_t = void *(__fastcall *)(const char *token);
|
||||
auto resolve = reinterpret_cast<ResolveUnitToken_t>(
|
||||
Offsets::FUN_RESOLVE_UNIT_TOKEN);
|
||||
auto *player = static_cast<const uint8_t *>(resolve("player"));
|
||||
auto *player = static_cast<const uint8_t *>(Game::ResolveUnitToken("player"));
|
||||
if (player == nullptr)
|
||||
return nullptr;
|
||||
return *reinterpret_cast<const uint8_t *const *>(
|
||||
@@ -410,6 +407,30 @@ static int __fastcall Script_C_Reputation_GetWatchedFactionData(void *L) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
// `C_Reputation.GetFactionDataByID(factionID)` — modern table-shaped
|
||||
// accessor keyed by faction ID rather than by displayed-list position.
|
||||
// Returns nil when the ID has no `Faction.dbc` record.
|
||||
//
|
||||
// Unlike the index form, this doesn't need the faction to be in the
|
||||
// player's displayed reputation list: `ReadFactionData` fills an
|
||||
// unencountered faction cleanly (currentStanding 0, not at war), so a
|
||||
// lookup works for any real faction.
|
||||
static int __fastcall Script_C_Reputation_GetFactionDataByID(void *L) {
|
||||
if (!Game::Lua::IsNumber(L, 1)) {
|
||||
Game::Lua::Error(L, "Usage: C_Reputation.GetFactionDataByID(factionID)");
|
||||
return 0;
|
||||
}
|
||||
const int factionID = static_cast<int>(Game::Lua::ToNumber(L, 1));
|
||||
|
||||
FactionData d;
|
||||
if (!ReadFactionData(factionID, &d))
|
||||
return 0; // nil — no such faction
|
||||
|
||||
Game::Lua::SetTop(L, 0);
|
||||
PushFactionDataTable(L, d);
|
||||
return 1;
|
||||
}
|
||||
|
||||
// `C_Reputation.GetFactionDataByIndex(factionSortIndex)` — modern
|
||||
// table-shaped accessor over the displayed reputation list. 1-based
|
||||
// index covering the same range as vanilla's `GetFactionInfo(index)`
|
||||
@@ -443,6 +464,182 @@ static int __fastcall Script_C_Reputation_GetFactionDataByIndex(void *L) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
// `C_Reputation.ToggleFactionAtWarByID(factionID)` — ClassicAPI
|
||||
// extension. Flips a faction's at-war state by ID rather than by
|
||||
// displayed-list position.
|
||||
//
|
||||
// Mirrors the stock `FactionToggleAtWar(index)` body exactly, minus the
|
||||
// index resolve: read the current at-war flag, negate it, hand it to the
|
||||
// engine's own setter. Every rule therefore stays with the engine —
|
||||
// refusing to make peace below -3000 standing, honouring the
|
||||
// peace-forced flag, the loot-session bail, and sending
|
||||
// `CMSG_SET_FACTION_ATWAR`. Going through the engine's setter also means
|
||||
// `Faction::UnitFactionPolyfill`'s hook on it fires `UNIT_FACTION`
|
||||
// ("any future C++ caller of the setter inherits the polyfill"), and its
|
||||
// change-detection keeps that silent when the engine refuses the toggle.
|
||||
//
|
||||
// Non-positive IDs are ignored (the stock path likewise skips
|
||||
// factionID 0, which is what its resolver returns for header rows).
|
||||
static int __fastcall Script_C_Reputation_ToggleFactionAtWarByID(void *L) {
|
||||
if (!Game::Lua::IsNumber(L, 1)) {
|
||||
Game::Lua::Error(L,
|
||||
"Usage: C_Reputation.ToggleFactionAtWarByID(factionID)");
|
||||
return 0;
|
||||
}
|
||||
const int factionID = static_cast<int>(Game::Lua::ToNumber(L, 1));
|
||||
if (factionID <= 0)
|
||||
return 0;
|
||||
|
||||
// `newState` is declared `int`, not `char`, to match the detour
|
||||
// `Faction::UnitFactionPolyfill` installs on this setter: a `char`
|
||||
// argument only defines the low byte of EDX, leaving the detour's
|
||||
// `int` parameter with undefined high bits.
|
||||
using GetAtWar_t = unsigned int(__fastcall *)(int factionID);
|
||||
using SetAtWar_t = void(__fastcall *)(int factionID, int newState);
|
||||
auto getAtWar = reinterpret_cast<GetAtWar_t>(
|
||||
static_cast<uintptr_t>(Offsets::FUN_FACTION_GET_AT_WAR));
|
||||
auto setAtWar = reinterpret_cast<SetAtWar_t>(
|
||||
static_cast<uintptr_t>(Offsets::FUN_FACTION_SET_AT_WAR));
|
||||
|
||||
setAtWar(factionID, (getAtWar(factionID) != 0) ? 0 : 1);
|
||||
return 0;
|
||||
}
|
||||
|
||||
// `factionID` → its 0..63 reputation-slot index, or -1 when the faction
|
||||
// has no slot (no record, or a header category). Same `Faction.dbc`
|
||||
// `RepListIndex` field `ReadFactionData` reads.
|
||||
static int RepSlotForFaction(int factionID) {
|
||||
if (factionID <= 0)
|
||||
return -1;
|
||||
const uint8_t *record = FactionRecord(factionID);
|
||||
if (record == nullptr)
|
||||
return -1;
|
||||
const int repListIndex = *reinterpret_cast<const int32_t *>(
|
||||
record + Offsets::OFF_FACTION_REP_LIST_INDEX);
|
||||
if (repListIndex < 0 || repListIndex >= Offsets::MAX_REP_SLOTS)
|
||||
return -1;
|
||||
return repListIndex;
|
||||
}
|
||||
|
||||
// Whether a reputation slot carries the INACTIVE flag.
|
||||
static bool RepSlotIsInactive(int repSlot) {
|
||||
auto *slot = reinterpret_cast<const uint8_t *>(
|
||||
static_cast<uintptr_t>(Offsets::VAR_PLAYER_REP_SLOTS) +
|
||||
static_cast<uintptr_t>(repSlot) * Offsets::REP_SLOT_STRIDE);
|
||||
return (*(slot + Offsets::OFF_REP_SLOT_FLAGS) &
|
||||
Offsets::REP_SLOT_FLAG_INACTIVE) != 0;
|
||||
}
|
||||
|
||||
// `C_Reputation.IsFactionActive(factionSortIndex)` — whether the faction
|
||||
// at a 1-based displayed-list position is NOT filed under "Inactive".
|
||||
//
|
||||
// Runs the same chain as the engine's own inactive check (resolve the
|
||||
// index to a faction id, then read the slot's INACTIVE bit) and returns
|
||||
// the inverse as a real boolean. A position that names no faction — out
|
||||
// of range, or a category header — reports `false`.
|
||||
static int __fastcall Script_C_Reputation_IsFactionActive(void *L) {
|
||||
if (!Game::Lua::IsNumber(L, 1)) {
|
||||
Game::Lua::Error(L,
|
||||
"Usage: C_Reputation.IsFactionActive(factionSortIndex)");
|
||||
return 0;
|
||||
}
|
||||
const int idx = static_cast<int>(Game::Lua::ToNumber(L, 1)) - 1;
|
||||
const int maxIdx = *reinterpret_cast<const int *>(
|
||||
static_cast<uintptr_t>(Offsets::VAR_FACTION_VISIBLE_MAX_INDEX));
|
||||
|
||||
const int factionID = (idx >= 0 && idx <= maxIdx) ? Resolver()(idx) : 0;
|
||||
const int repSlot = RepSlotForFaction(factionID);
|
||||
|
||||
Game::Lua::SetTop(L, 0);
|
||||
Game::Lua::PushBool(L, repSlot >= 0 && !RepSlotIsInactive(repSlot));
|
||||
return 1;
|
||||
}
|
||||
|
||||
// `C_Reputation.IsFactionActiveByID(factionID)` — ClassicAPI extension.
|
||||
// The same answer keyed by faction id. Reports `false` for a faction
|
||||
// with no reputation slot.
|
||||
static int __fastcall Script_C_Reputation_IsFactionActiveByID(void *L) {
|
||||
if (!Game::Lua::IsNumber(L, 1)) {
|
||||
Game::Lua::Error(L,
|
||||
"Usage: C_Reputation.IsFactionActiveByID(factionID)");
|
||||
return 0;
|
||||
}
|
||||
const int repSlot =
|
||||
RepSlotForFaction(static_cast<int>(Game::Lua::ToNumber(L, 1)));
|
||||
|
||||
Game::Lua::SetTop(L, 0);
|
||||
Game::Lua::PushBool(L, repSlot >= 0 && !RepSlotIsInactive(repSlot));
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Shared body for the two by-ID inactive setters below. Hands the id to
|
||||
// the engine's own inactive setter, which flips the rep slot's INACTIVE
|
||||
// bit, tells the server, and rebuilds the displayed faction list so the
|
||||
// faction moves into or out of the "Inactive" category. That setter is
|
||||
// also what `Faction::UnitFactionPolyfill` hooks, so `UNIT_FACTION`
|
||||
// fires when the flag actually changes.
|
||||
//
|
||||
// `newState` is `int` rather than `char` to match that detour's
|
||||
// signature — see the note in ToggleFactionAtWarByID.
|
||||
static int SetFactionInactiveByID(void *L, int newState, const char *usage) {
|
||||
if (!Game::Lua::IsNumber(L, 1)) {
|
||||
Game::Lua::Error(L, usage);
|
||||
return 0;
|
||||
}
|
||||
const int factionID = static_cast<int>(Game::Lua::ToNumber(L, 1));
|
||||
if (factionID <= 0)
|
||||
return 0;
|
||||
|
||||
using SetInactive_t = void(__fastcall *)(int factionID, int newState);
|
||||
auto fn = reinterpret_cast<SetInactive_t>(
|
||||
static_cast<uintptr_t>(Offsets::FUN_FACTION_SET_INACTIVE));
|
||||
fn(factionID, newState);
|
||||
return 0;
|
||||
}
|
||||
|
||||
// `C_Reputation.SetFactionInactiveByID(factionID)` — ClassicAPI
|
||||
// extension. Moves a faction into the "Inactive" category by ID rather
|
||||
// than by displayed-list position.
|
||||
static int __fastcall Script_C_Reputation_SetFactionInactiveByID(void *L) {
|
||||
return SetFactionInactiveByID(
|
||||
L, 1, "Usage: C_Reputation.SetFactionInactiveByID(factionID)");
|
||||
}
|
||||
|
||||
// `C_Reputation.SetFactionActiveByID(factionID)` — the inverse: takes a
|
||||
// faction back out of the "Inactive" category.
|
||||
static int __fastcall Script_C_Reputation_SetFactionActiveByID(void *L) {
|
||||
return SetFactionInactiveByID(
|
||||
L, 0, "Usage: C_Reputation.SetFactionActiveByID(factionID)");
|
||||
}
|
||||
|
||||
// `C_Reputation.SetSelectedFactionByID(factionID)` — ClassicAPI
|
||||
// extension. Selects a faction in the reputation pane by ID rather
|
||||
// than by displayed-list position, the same convenience
|
||||
// `SetWatchedFactionByID` gives over `SetWatchedFactionIndex`.
|
||||
//
|
||||
// The engine already stores the selection AS a factionID
|
||||
// (`VAR_FACTION_SELECTED_ID`) — the stock index form only resolves its
|
||||
// argument to an id before storing it — so this writes the id straight
|
||||
// through, with no list walk and no dependence on the faction being
|
||||
// currently listed. `GetSelectedFaction()` maps it back to an index,
|
||||
// reporting 0 while the faction isn't in the displayed list.
|
||||
//
|
||||
// Negative IDs are ignored; 0 clears the selection.
|
||||
static int __fastcall Script_C_Reputation_SetSelectedFactionByID(void *L) {
|
||||
if (!Game::Lua::IsNumber(L, 1)) {
|
||||
Game::Lua::Error(L,
|
||||
"Usage: C_Reputation.SetSelectedFactionByID(factionID)");
|
||||
return 0;
|
||||
}
|
||||
const int factionID = static_cast<int>(Game::Lua::ToNumber(L, 1));
|
||||
if (factionID < 0)
|
||||
return 0;
|
||||
|
||||
*reinterpret_cast<int32_t *>(
|
||||
static_cast<uintptr_t>(Offsets::VAR_FACTION_SELECTED_ID)) = factionID;
|
||||
return 0;
|
||||
}
|
||||
|
||||
// `C_Reputation.SetWatchedFactionByID(factionID)` — sets the faction
|
||||
// shown above the XP bar by ID rather than by displayed-list index.
|
||||
// Modern API; vanilla 1.12 only exposes `SetWatchedFactionIndex(idx)`,
|
||||
@@ -478,10 +675,46 @@ static void RegisterLuaFunctions() {
|
||||
Game::Lua::RegisterGlobalFunction("GetFactionParentID", &Script_GetFactionParentID);
|
||||
Game::Lua::RegisterTableFunction("C_Reputation", "SetWatchedFactionByID",
|
||||
&Script_C_Reputation_SetWatchedFactionByID);
|
||||
Game::Lua::RegisterTableFunction("C_Reputation", "SetSelectedFactionByID",
|
||||
&Script_C_Reputation_SetSelectedFactionByID);
|
||||
Game::Lua::RegisterTableFunction("C_Reputation", "ToggleFactionAtWarByID",
|
||||
&Script_C_Reputation_ToggleFactionAtWarByID);
|
||||
Game::Lua::RegisterTableFunction("C_Reputation", "IsFactionActive",
|
||||
&Script_C_Reputation_IsFactionActive);
|
||||
Game::Lua::RegisterTableFunction("C_Reputation", "IsFactionActiveByID",
|
||||
&Script_C_Reputation_IsFactionActiveByID);
|
||||
Game::Lua::RegisterTableFunction(
|
||||
"C_Reputation", "IsFactionInactive",
|
||||
reinterpret_cast<Game::Lua::CFunction>(
|
||||
static_cast<uintptr_t>(Offsets::FUN_SCRIPT_IS_FACTION_INACTIVE)));
|
||||
Game::Lua::RegisterTableFunction("C_Reputation", "SetFactionInactiveByID",
|
||||
&Script_C_Reputation_SetFactionInactiveByID);
|
||||
Game::Lua::RegisterTableFunction("C_Reputation", "SetFactionActiveByID",
|
||||
&Script_C_Reputation_SetFactionActiveByID);
|
||||
Game::Lua::RegisterTableFunction(
|
||||
"C_Reputation", "SetFactionInactive",
|
||||
reinterpret_cast<Game::Lua::CFunction>(
|
||||
static_cast<uintptr_t>(Offsets::FUN_SCRIPT_SET_FACTION_INACTIVE)));
|
||||
Game::Lua::RegisterTableFunction(
|
||||
"C_Reputation", "SetFactionActive",
|
||||
reinterpret_cast<Game::Lua::CFunction>(
|
||||
static_cast<uintptr_t>(Offsets::FUN_SCRIPT_SET_FACTION_ACTIVE)));
|
||||
Game::Lua::RegisterTableFunction(
|
||||
"C_Reputation", "ToggleFactionAtWar",
|
||||
reinterpret_cast<Game::Lua::CFunction>(
|
||||
static_cast<uintptr_t>(Offsets::FUN_SCRIPT_FACTION_TOGGLE_AT_WAR)));
|
||||
// The stock global, mirrored into the namespace — the engine's own
|
||||
// handler already has the Lua C ABI, so it registers as-is.
|
||||
Game::Lua::RegisterTableFunction(
|
||||
"C_Reputation", "SetSelectedFaction",
|
||||
reinterpret_cast<Game::Lua::CFunction>(
|
||||
static_cast<uintptr_t>(Offsets::FUN_SCRIPT_SET_SELECTED_FACTION)));
|
||||
Game::Lua::RegisterTableFunction("C_Reputation", "GetWatchedFactionData",
|
||||
&Script_C_Reputation_GetWatchedFactionData);
|
||||
Game::Lua::RegisterTableFunction("C_Reputation", "GetFactionStandings",
|
||||
&Script_C_Reputation_GetFactionStandings);
|
||||
Game::Lua::RegisterTableFunction("C_Reputation", "GetFactionDataByID",
|
||||
&Script_C_Reputation_GetFactionDataByID);
|
||||
Game::Lua::RegisterTableFunction("C_Reputation", "GetFactionDataByIndex",
|
||||
&Script_C_Reputation_GetFactionDataByIndex);
|
||||
}
|
||||
|
||||
@@ -98,7 +98,7 @@ void __fastcall FireNotify_h(int factionID, int delta) {
|
||||
// any addon relying on the chat event firing first would expect.
|
||||
FireNotify_o(factionID, delta);
|
||||
|
||||
const int slot = Event::Custom::Lookup(kEventName);
|
||||
const int slot = _reserve.Slot();
|
||||
if (slot >= 0)
|
||||
Event::Custom::Fire(slot, "%d%d%d", factionID, newStanding, delta);
|
||||
|
||||
|
||||
@@ -103,6 +103,8 @@
|
||||
// (`FUN_FRAME_SCRIPT_RESOLVER`) and handing out an external per-frame cell for
|
||||
// that name — the `Tooltip::SetEvents` analog, applied to every frame.
|
||||
|
||||
#include "frame/Attributes.h"
|
||||
|
||||
#include "Game.h"
|
||||
#include "Offsets.h"
|
||||
#include "baselib/Ascii.h"
|
||||
@@ -945,11 +947,30 @@ void FireAttributeChanged(void *L, void *frame, const char *name) {
|
||||
// ---- the methods -----------------------------------------------------------
|
||||
|
||||
// Enables the frame's mouse so it can become the mouse-focus (a bare frame
|
||||
// otherwise never registers as hovered). Operates on self at index 1.
|
||||
// otherwise never registers as hovered).
|
||||
//
|
||||
// Two properties, both deliberate:
|
||||
// * STACK-NEUTRAL. The old form did SetTop(L, 1) to set up (self, true) for
|
||||
// the engine setter, which DESTROYED the (name, value) at stack[2]/[3] that
|
||||
// FireAttributeChanged reads right after — handing any OnAttributeChanged
|
||||
// handler a nil `arg2`. We instead push the call at the top and restore the
|
||||
// caller's stack, so DoSet's (self, name, value) survives intact.
|
||||
// * PROTECTED. SetAttribute is Frame-scoped (VAR_FRAME_METHOD_REGISTRY), so
|
||||
// `self` is a real Frame and the setter's type check normally passes;
|
||||
// invoking through lua_pcall is belt-and-suspenders so a best-effort side
|
||||
// effect can never surface an error in the addon's SetAttribute. (Calling
|
||||
// via pcall is also what lets us pass the args at the callee's own stack
|
||||
// base without clobbering ours.)
|
||||
void EnableFrameMouse(void *L) {
|
||||
Game::Lua::SetTop(L, 1); // (self)
|
||||
Game::Lua::PushBoolean(L, 1);
|
||||
CallScript(Offsets::FUN_SCRIPT_FRAME_ENABLEMOUSE, L);
|
||||
const int top = Game::Lua::GetTop(L);
|
||||
Game::Lua::PushCClosure(
|
||||
L,
|
||||
reinterpret_cast<Game::Lua::CFunction>(Offsets::FUN_SCRIPT_FRAME_ENABLEMOUSE),
|
||||
0);
|
||||
Game::Lua::PushValue(L, 1); // self (the frame)
|
||||
Game::Lua::PushBoolean(L, 1); // enable = true
|
||||
Game::Lua::PCall(L, 2, 0, 0); // best-effort; swallow a transient type error
|
||||
Game::Lua::SetTop(L, top); // restore the caller's stack
|
||||
}
|
||||
|
||||
int DoSet(void *L, bool fireHandler) { // (self, name, value)
|
||||
@@ -1088,4 +1109,25 @@ const Tick::WorldTick::AutoSubscribe _tick{&MouseoverTick};
|
||||
|
||||
} // namespace
|
||||
|
||||
// Drop every per-frame map before a /reload (or logout) resets the Lua state.
|
||||
// Both are keyed by the frame's C object pointer, which the allocator recycles
|
||||
// across a reload:
|
||||
// * g_attrHandlers holds OnAttributeChanged handler refs. The refs die with
|
||||
// the Lua reset, so a stale entry whose address a new frame reuses would
|
||||
// fire a DEAD ref from FireAttributeChanged — the invoker's protected pcall
|
||||
// catches it, but the engine error handler still prints it as an error "in
|
||||
// SetAttribute" (SecureStateDriverManager sets OnAttributeChanged every
|
||||
// session, so there's always at least one entry to go stale). This was the
|
||||
// "SetAttribute occasionally errors after /reload" report; same class of
|
||||
// bug as Tooltip::SetEvents issue #33.
|
||||
// * g_unitByFrame maps a frame to its `unit` token for the mouse-focus poll;
|
||||
// a stale entry at a reused address would point the mouseover at the wrong
|
||||
// unit and mislead the isNew mouse-enable gate.
|
||||
void PrepareForReload() {
|
||||
g_attrHandlers.clear();
|
||||
g_unitByFrame.clear();
|
||||
}
|
||||
|
||||
static const Game::ReloadAutoRegister _reloadReg{&PrepareForReload};
|
||||
|
||||
} // namespace Frame::Attributes
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
// This file is part of ClassicAPI.
|
||||
//
|
||||
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
|
||||
// of the GNU General Public License as published by the Free Software Foundation, either
|
||||
// version 3 of the License, or (at your option) any later version.
|
||||
//
|
||||
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
|
||||
// PURPOSE. See the GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License along with
|
||||
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
#pragma once
|
||||
|
||||
namespace Frame::Attributes {
|
||||
|
||||
// Drop the per-frame attribute-handler and unit-token maps before a /reload (or
|
||||
// logout) resets the Lua state and recycles frame object pointers. Without it, a
|
||||
// stale OnAttributeChanged ref fires from a new frame that reused an old
|
||||
// address (surfaces as "SetAttribute occasionally errors after /reload"). Wired
|
||||
// from `FrameScript_Initialize_h` in DllMain.cpp alongside the other cleaners.
|
||||
void PrepareForReload();
|
||||
|
||||
} // namespace Frame::Attributes
|
||||
@@ -0,0 +1,181 @@
|
||||
// This file is part of ClassicAPI.
|
||||
//
|
||||
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
|
||||
// of the GNU General Public License as published by the Free Software Foundation, either
|
||||
// version 3 of the License, or (at your option) any later version.
|
||||
//
|
||||
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
|
||||
// PURPOSE. See the GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License along with
|
||||
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
// `button:SetScript("PreClick"|"PostClick", fn)` — backport of the modern
|
||||
// button scripts that fire immediately before and after OnClick, as real frame
|
||||
// scripts settable via the standard SetScript / GetScript / HookScript. Vanilla
|
||||
// 1.12 has only OnClick and OnDoubleClick on buttons.
|
||||
//
|
||||
// 1.12 already has the script-dispatch machinery, just not these two names.
|
||||
// Two co-hooks (same shape as Tooltip::SetEvents):
|
||||
//
|
||||
// - The Button script resolver FUN_BUTTON_SCRIPT_RESOLVER maps a script name
|
||||
// to its handler slot (OnClick -> button+0x4CC, …). SetScript/GetScript/
|
||||
// HookScript all go through it. We co-hook it: for PreClick/PostClick
|
||||
// (which the engine doesn't know) we hand back a pointer to an external
|
||||
// per-button cell — a button object has no free slot for two new scripts,
|
||||
// and the resolver's only contract is "return the address the script lives
|
||||
// at", so external storage works exactly like an in-object slot.
|
||||
//
|
||||
// - The engine fires a button's OnClick through the arg'd script runner
|
||||
// FUN_FRAME_RUN_SCRIPT_WITH_CONTEXT(frame, frame+0x4CC, "%s", &buttonName).
|
||||
// We co-hook that runner and, when the fired slot IS the OnClick slot,
|
||||
// re-invoke the runner for our PreClick cell first, then let OnClick run,
|
||||
// then re-invoke for PostClick — reusing the SAME (fmt, varargs) so
|
||||
// PreClick/PostClick receive arg1 = the button name exactly like OnClick.
|
||||
//
|
||||
// Why the runner and not the button click vmethod (FUN_00779540): SuperWoW
|
||||
// inline-hooks that vmethod for click-casting, and a second MinHook there
|
||||
// corrupts the trampoline (ERROR #132 — see Offsets.h). The runner is
|
||||
// uncontested, fires at the same top-level dispatch level (so its exec-context
|
||||
// stamp is valid), and the OnClick slot address is an exact gate.
|
||||
//
|
||||
// Limitation: because the runner only runs when a slot is non-empty, PreClick/
|
||||
// PostClick fire only when the button also has an OnClick handler set. Modern
|
||||
// fires them regardless; on this client that would require hooking the click
|
||||
// vmethod, which SuperWoW owns. Bracketing an existing OnClick is the common
|
||||
// case and the one this backport serves.
|
||||
|
||||
#include "frame/ClickEvents.h"
|
||||
|
||||
#include "Game.h"
|
||||
#include "Offsets.h"
|
||||
|
||||
#include <cstdint>
|
||||
#include <unordered_map>
|
||||
|
||||
namespace Frame::ClickEvents {
|
||||
|
||||
namespace {
|
||||
|
||||
enum ScriptKind { SK_PRECLICK = 0, SK_POSTCLICK, SK_COUNT };
|
||||
|
||||
// Matched case-insensitively against the resolver's input name.
|
||||
constexpr const char *kNamesLower[SK_COUNT] = {"preclick", "postclick"};
|
||||
|
||||
// Per-button storage: one 8-byte {handler, context} slot per script kind. Only
|
||||
// buttons that actually had SetScript("PreClick"/"PostClick", …) called get an
|
||||
// entry, so the map stays small. unordered_map nodes are pointer-stable, so a
|
||||
// cell address handed to the engine stays valid until PrepareForReload clears
|
||||
// the map (buttons and their handler refs die on /reload).
|
||||
struct Cell {
|
||||
uint32_t slot[SK_COUNT][2]; // [kind] = {handler ref, exec context}
|
||||
};
|
||||
std::unordered_map<void *, Cell> g_cells;
|
||||
|
||||
uint32_t *SlotFor(void *button, int kind, bool create) {
|
||||
auto it = g_cells.find(button);
|
||||
if (it != g_cells.end())
|
||||
return it->second.slot[kind];
|
||||
if (!create)
|
||||
return nullptr;
|
||||
Cell &c = g_cells[button];
|
||||
for (auto &s : c.slot) {
|
||||
s[0] = 0;
|
||||
s[1] = 0;
|
||||
}
|
||||
return c.slot[kind];
|
||||
}
|
||||
|
||||
bool EqualsIgnoreCase(const char *s, const char *literal) {
|
||||
if (s == nullptr)
|
||||
return false;
|
||||
for (;; ++s, ++literal) {
|
||||
unsigned char a = static_cast<unsigned char>(*s);
|
||||
const unsigned char b = static_cast<unsigned char>(*literal);
|
||||
if (a >= 'A' && a <= 'Z')
|
||||
a = static_cast<unsigned char>(a + 32);
|
||||
if (a != b)
|
||||
return false;
|
||||
if (b == 0)
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Recursion guard: a PreClick/OnClick/PostClick handler can programmatically
|
||||
// click a button again. While bracketing, further OnClick fires pass straight
|
||||
// through (the nested click still runs — it just doesn't get its own nested
|
||||
// PreClick/PostClick), which prevents runaway from a self-clicking handler.
|
||||
int g_firing = 0;
|
||||
|
||||
// --- Resolver co-hook (Button script-name -> slot) ---------------------
|
||||
using Resolver_t = int(__fastcall *)(void *self, void *edx, const char *name);
|
||||
Resolver_t g_resolverOriginal = nullptr;
|
||||
|
||||
int __fastcall Resolver_h(void *self, void *edx, const char *name) {
|
||||
const int engineSlot = g_resolverOriginal(self, edx, name);
|
||||
if (engineSlot != 0) // a base-frame or existing button script — leave it
|
||||
return engineSlot;
|
||||
for (int k = 0; k < SK_COUNT; ++k)
|
||||
if (EqualsIgnoreCase(name, kNamesLower[k]))
|
||||
return reinterpret_cast<int>(SlotFor(self, k, /*create*/ true));
|
||||
return 0;
|
||||
}
|
||||
|
||||
// --- Runner co-hook (fire PreClick before / PostClick after OnClick) ---
|
||||
using RunScript_t = void(__cdecl *)(void *frame, uint32_t *slotPtr,
|
||||
const char *fmt, void *varargs);
|
||||
RunScript_t g_runOriginal = nullptr;
|
||||
|
||||
// Fire the handler (if any) for `kind` on `frame`, reusing the OnClick fire's
|
||||
// own (fmt, varargs) so arg1 = the button name. We call the original runner
|
||||
// directly (its exec-context stamp is valid at this dispatch level). The runner
|
||||
// balances the Lua stack itself; we snapshot/restore the top as cheap insurance
|
||||
// against any imbalance leaking to the still-running button click code.
|
||||
void FireCell(void *frame, int kind, const char *fmt, void *varargs) {
|
||||
uint32_t *slot = SlotFor(frame, kind, /*create*/ false);
|
||||
if (slot == nullptr || slot[0] == 0)
|
||||
return;
|
||||
void *L = Game::Lua::State();
|
||||
const int savedTop = (L != nullptr) ? Game::Lua::GetTop(L) : 0;
|
||||
g_runOriginal(frame, slot, fmt, varargs);
|
||||
if (L != nullptr)
|
||||
Game::Lua::SetTop(L, savedTop);
|
||||
}
|
||||
|
||||
void __cdecl RunScript_h(void *frame, uint32_t *slotPtr, const char *fmt,
|
||||
void *varargs) {
|
||||
const auto onClickSlot = reinterpret_cast<uint32_t *>(
|
||||
reinterpret_cast<char *>(frame) + Offsets::OFF_BUTTON_ONCLICK_HANDLER);
|
||||
|
||||
if (slotPtr != onClickSlot || g_firing > 0) {
|
||||
g_runOriginal(frame, slotPtr, fmt, varargs);
|
||||
return;
|
||||
}
|
||||
|
||||
++g_firing;
|
||||
FireCell(frame, SK_PRECLICK, fmt, varargs);
|
||||
g_runOriginal(frame, slotPtr, fmt, varargs); // the button's OnClick
|
||||
FireCell(frame, SK_POSTCLICK, fmt, varargs);
|
||||
--g_firing;
|
||||
}
|
||||
|
||||
static const Game::HookAutoRegister _resolverHook{
|
||||
Offsets::FUN_BUTTON_SCRIPT_RESOLVER,
|
||||
reinterpret_cast<void *>(&Resolver_h),
|
||||
reinterpret_cast<void **>(&g_resolverOriginal)};
|
||||
|
||||
static const Game::HookAutoRegister _runnerHook{
|
||||
Offsets::FUN_FRAME_RUN_SCRIPT_WITH_CONTEXT,
|
||||
reinterpret_cast<void *>(&RunScript_h),
|
||||
reinterpret_cast<void **>(&g_runOriginal)};
|
||||
|
||||
} // namespace
|
||||
|
||||
void PrepareForReload() {
|
||||
g_cells.clear();
|
||||
}
|
||||
|
||||
static const Game::ReloadAutoRegister _reloadReg{&PrepareForReload};
|
||||
|
||||
} // namespace Frame::ClickEvents
|
||||
@@ -0,0 +1,25 @@
|
||||
// This file is part of ClassicAPI.
|
||||
//
|
||||
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
|
||||
// of the GNU General Public License as published by the Free Software Foundation, either
|
||||
// version 3 of the License, or (at your option) any later version.
|
||||
//
|
||||
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
|
||||
// PURPOSE. See the GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License along with
|
||||
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
#pragma once
|
||||
|
||||
namespace Frame::ClickEvents {
|
||||
|
||||
// Drop every per-button PreClick/PostClick handler cell before a `/reload` (or
|
||||
// logout) tears down the button objects and resets the Lua state. The handler
|
||||
// refs die with the Lua reset, and a recycled button address must not fire a
|
||||
// stale ref. Wired from `FrameScript_Initialize_h` in DllMain.cpp, alongside
|
||||
// the other per-reload cleaners.
|
||||
void PrepareForReload();
|
||||
|
||||
} // namespace Frame::ClickEvents
|
||||
@@ -0,0 +1,99 @@
|
||||
// This file is part of ClassicAPI.
|
||||
//
|
||||
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
|
||||
// of the GNU General Public License as published by the Free Software Foundation, either
|
||||
// version 3 of the License, or (at your option) any later version.
|
||||
//
|
||||
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
|
||||
// PURPOSE. See the GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License along with
|
||||
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
// `GetClickFrame(name)` — backport of the C global WoW registers for the
|
||||
// `/click` handler to resolve a frame by its global name. Vanilla 1.12
|
||||
// never shipped it; 3.3.5's `Script_GetClickFrame` (`0x00564130`) is the
|
||||
// reference.
|
||||
//
|
||||
// It lives in the DLL, not addon Lua, because Blizzard shipped it as an
|
||||
// engine-registered C global — the backport mirrors where the real
|
||||
// function lives, not the fact that Lua could fake it.
|
||||
//
|
||||
// Behaviour, matching the 3.3.5 resolver `FUN_00562990` (minus its private
|
||||
// name→frame memoization cache, which is only a perf optimization over the
|
||||
// global lookup):
|
||||
// 1. `_G[name]` — a plain global-table lookup.
|
||||
// 2. The value must be a frame wrapper table backed by a
|
||||
// `CFrameScriptObject`.
|
||||
// 3. Its real `GetName()` must equal `name`. This is the load-bearing
|
||||
// check: it stops a reassigned global (`_G["Foo"] = someOtherFrame`)
|
||||
// from resolving to a differently-named frame.
|
||||
// Return the frame on a match, otherwise nil.
|
||||
//
|
||||
// `GetName()` is the object's vtable slot +4 (`__thiscall`, returns
|
||||
// `const char *`) — verified from 1.12's own `Script_GetName`
|
||||
// (`0x0079FF60` / `0x007A1390`), which resolves self the same way and reads
|
||||
// the name from that slot. It's a base virtual present on every
|
||||
// `CFrameScriptObject` subtype, so calling it after `ResolveObject` is
|
||||
// safe without the RTTI subtype gate `Script_GetName` does first.
|
||||
|
||||
#include "Game.h"
|
||||
#include "Offsets.h"
|
||||
|
||||
#include <cstring>
|
||||
|
||||
namespace Frame::ClickFrame {
|
||||
|
||||
namespace {
|
||||
// CFrameScriptObject::GetName — vtable slot +4, thiscall, no args.
|
||||
using GetName_t = const char *(__thiscall *)(void *self);
|
||||
|
||||
const char *ObjectName(void *obj) {
|
||||
auto vtable = *reinterpret_cast<void ***>(obj);
|
||||
auto getName = reinterpret_cast<GetName_t>(vtable[1]);
|
||||
return getName(obj);
|
||||
}
|
||||
} // namespace
|
||||
|
||||
static int __fastcall Script_GetClickFrame(void *L) {
|
||||
using namespace Game::Lua;
|
||||
|
||||
if (GetTop(L) < 1) {
|
||||
Error(L, "Usage: GetClickFrame(\"name\")");
|
||||
return 0;
|
||||
}
|
||||
const char *name = ToString(L, 1);
|
||||
if (name == nullptr) {
|
||||
PushNil(L);
|
||||
return 1;
|
||||
}
|
||||
|
||||
// getglobal(name): push _G[name].
|
||||
PushString(L, name);
|
||||
GetTable(L, GLOBALS_INDEX);
|
||||
|
||||
if (Type(L, -1) != TYPE_TABLE) {
|
||||
PushNil(L);
|
||||
return 1;
|
||||
}
|
||||
|
||||
void *obj = ResolveObject(L, -1);
|
||||
if (obj != nullptr) {
|
||||
const char *objName = ObjectName(obj);
|
||||
if (objName != nullptr && std::strcmp(objName, name) == 0)
|
||||
// The wrapper table is still on top — return it as-is.
|
||||
return 1;
|
||||
}
|
||||
|
||||
PushNil(L);
|
||||
return 1;
|
||||
}
|
||||
|
||||
static void RegisterLuaFunctions() {
|
||||
Game::Lua::RegisterGlobalFunction("GetClickFrame", &Script_GetClickFrame);
|
||||
}
|
||||
|
||||
static const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
|
||||
|
||||
} // namespace Frame::ClickFrame
|
||||
@@ -18,8 +18,7 @@
|
||||
|
||||
#include "Notes.h"
|
||||
|
||||
#include "Game.h"
|
||||
#include "Offsets.h"
|
||||
#include "settings/Paths.h"
|
||||
|
||||
#include <cstdint>
|
||||
#include <cstdio>
|
||||
@@ -41,43 +40,11 @@ std::unordered_map<uint64_t, Entry> g_notes;
|
||||
std::string g_path;
|
||||
bool g_loaded = false;
|
||||
|
||||
const char *ReadAccountName() {
|
||||
return Game::Read<const char *>(Offsets::VAR_ACCOUNT_NAME_PTR);
|
||||
}
|
||||
|
||||
const char *ReadRealmName() {
|
||||
auto *info = Game::Read<uint8_t *>(Offsets::VAR_REALM_INFO_PTR);
|
||||
if (info == nullptr)
|
||||
return nullptr;
|
||||
return Game::Read<const char *>(info, Offsets::OFF_REALM_INFO_NAME);
|
||||
}
|
||||
|
||||
const char *ReadCharacterName() {
|
||||
auto *p = reinterpret_cast<const char *>(Offsets::VAR_CHARACTER_NAME);
|
||||
return (p[0] == '\0') ? nullptr : p;
|
||||
}
|
||||
|
||||
// WTF\Account\<acct>\<realm>\<char>\ClassicAPI_FriendNotes.txt, or empty until
|
||||
// the account + realm + character session globals are populated. Per-character
|
||||
// because the friends list is per-character in vanilla.
|
||||
std::string ResolveFilePath() {
|
||||
const char *account = ReadAccountName();
|
||||
if (account == nullptr || account[0] == '\0')
|
||||
return {};
|
||||
const char *realm = ReadRealmName();
|
||||
if (realm == nullptr || realm[0] == '\0')
|
||||
return {};
|
||||
const char *character = ReadCharacterName();
|
||||
if (character == nullptr)
|
||||
return {};
|
||||
std::string out = "WTF\\Account\\";
|
||||
out += account;
|
||||
out += '\\';
|
||||
out += realm;
|
||||
out += '\\';
|
||||
out += character;
|
||||
out += "\\ClassicAPI_FriendNotes.txt";
|
||||
return out;
|
||||
return Settings::Paths::CharacterFile("ClassicAPI_FriendNotes.txt");
|
||||
}
|
||||
|
||||
// Notes are a single line each; the file is tab-delimited, so a note must
|
||||
|
||||
@@ -107,7 +107,7 @@ void RegisterLuaFunctions() {
|
||||
Game::Lua::RegisterTableFunction("C_GameObjectInfo", "RequestLoadGameObjectByID",
|
||||
&Script_RequestLoadGameObjectByID);
|
||||
Cache::QueryLoad::Register(reinterpret_cast<void *>(Offsets::VAR_GAMEOBJECT_CACHE),
|
||||
kGameObjectDataLoadResult, Offsets::FUN_GAMEOBJECT_GET_RECORD);
|
||||
&_reserveLoadResult, Offsets::FUN_GAMEOBJECT_GET_RECORD);
|
||||
}
|
||||
|
||||
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
|
||||
|
||||
@@ -64,8 +64,8 @@ namespace Input::GlobalMouse {
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr const char *kDownEvent = "GLOBAL_MOUSE_DOWN";
|
||||
constexpr const char *kUpEvent = "GLOBAL_MOUSE_UP";
|
||||
const Event::Custom::AutoReserve _reserveDown{"GLOBAL_MOUSE_DOWN"};
|
||||
const Event::Custom::AutoReserve _reserveUp{"GLOBAL_MOUSE_UP"};
|
||||
|
||||
// Held-state bitmap — bit `i` set means the button with 1-based
|
||||
// `IsMouseButtonDown` ID `i+1` is currently held. Modern API IDs:
|
||||
@@ -154,7 +154,7 @@ void ProcessMouseMessage(UINT msg, WPARAM wParam) {
|
||||
else g_mouseButtonMask &= ~bit;
|
||||
}
|
||||
|
||||
const int slot = Event::Custom::Lookup(down ? kDownEvent : kUpEvent);
|
||||
const int slot = (down ? _reserveDown : _reserveUp).Slot();
|
||||
if (slot >= 0)
|
||||
Event::Custom::Fire(slot, "%s", name);
|
||||
}
|
||||
@@ -241,8 +241,6 @@ void RegisterLuaFunctions() {
|
||||
InstallHook();
|
||||
}
|
||||
|
||||
const Event::Custom::AutoReserve _reserveDown{kDownEvent};
|
||||
const Event::Custom::AutoReserve _reserveUp{kUpEvent};
|
||||
const Tick::WorldTick::AutoSubscribe _evict{&EvictTick};
|
||||
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
|
||||
|
||||
|
||||
@@ -102,11 +102,9 @@ void SetBit(int bitIdx, bool down) {
|
||||
if (down) g_modifierMask |= bit;
|
||||
else g_modifierMask &= ~bit;
|
||||
|
||||
// Slot was assigned when the engine populated its event table; our
|
||||
// hook on `RebuildEventTable` recorded the index. Lookup at fire
|
||||
// time rather than caching because `/reload` re-runs the rebuild
|
||||
// and the index may shift.
|
||||
const int slot = Event::Custom::Lookup(kModifierStateChanged);
|
||||
// Read the slot at fire time (never cache it) — `/reload` rebuilds the
|
||||
// event table and the reservation re-claims, which may shift the index.
|
||||
const int slot = _reserveModifierStateChanged.Slot();
|
||||
if (slot >= 0)
|
||||
Event::Custom::Fire(slot, "%s%d", kKeyName[bitIdx], static_cast<int>(down));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
// This file is part of ClassicAPI.
|
||||
//
|
||||
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
|
||||
// of the GNU General Public License as published by the Free Software Foundation, either
|
||||
// version 3 of the License, or (at your option) any later version.
|
||||
//
|
||||
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
|
||||
// PURPOSE. See the GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License along with
|
||||
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
// `INTERFACE_VERSION` global — the client's interface (TOC) version number,
|
||||
// 11200 on this client. Read from the engine's own accessor (the same value
|
||||
// the addon loader compares each `## Interface:` against) rather than a
|
||||
// literal, and re-published on every `/reload` via the standard
|
||||
// `ModuleAutoRegister` flow. It is a true fixed client constant, so it is a
|
||||
// global number like `CLASSIC_API_VERSION`, not a function.
|
||||
|
||||
#include "Game.h"
|
||||
#include "Offsets.h"
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
namespace Interface::Version {
|
||||
|
||||
namespace {
|
||||
|
||||
using ClientIfaceVer_t = uint32_t(__cdecl *)();
|
||||
|
||||
void RegisterLuaFunctions() {
|
||||
void *L = Game::Lua::State();
|
||||
if (L == nullptr)
|
||||
return;
|
||||
const uint32_t version = reinterpret_cast<ClientIfaceVer_t>(
|
||||
static_cast<uintptr_t>(Offsets::FUN_ADDON_CLIENT_INTERFACE_VERSION))();
|
||||
Game::Lua::SetGlobalNumber(L, "INTERFACE_VERSION",
|
||||
static_cast<double>(version));
|
||||
}
|
||||
|
||||
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
|
||||
|
||||
} // namespace
|
||||
|
||||
} // namespace Interface::Version
|
||||
+4
-4
@@ -58,13 +58,13 @@ static const Event::Custom::AutoReserve _reserveItemDataLoadResult{kItemDataLoad
|
||||
static const Event::Custom::AutoReserve _reserveGetItemInfoReceived{kGetItemInfoReceived};
|
||||
|
||||
static void FireGetItemInfoReceived(int itemID, bool success) {
|
||||
Event::Custom::FireIdSuccess(Event::Custom::Lookup(kGetItemInfoReceived),
|
||||
itemID, success);
|
||||
Event::Custom::FireIdSuccess(_reserveGetItemInfoReceived.Slot(), itemID,
|
||||
success);
|
||||
}
|
||||
|
||||
static void FireItemDataLoadResult(int itemID, bool success) {
|
||||
Event::Custom::FireIdSuccess(Event::Custom::Lookup(kItemDataLoadResult),
|
||||
itemID, success);
|
||||
Event::Custom::FireIdSuccess(_reserveItemDataLoadResult.Slot(), itemID,
|
||||
success);
|
||||
}
|
||||
|
||||
// Item-load callbacks are __stdcall, 2 args / 8 bytes — matching the
|
||||
|
||||
@@ -200,11 +200,8 @@ int __fastcall Script_C_Item_EquipItemByName(void *L) {
|
||||
if (!Item::Cursor::PickupBagItem(L, found.bagID, found.slotIndex))
|
||||
return 0;
|
||||
|
||||
using ResolveUnitToken_t = void *(__fastcall *)(const char *token);
|
||||
using AutoEquipCursor_t = void (__thiscall *)(void *player, int flag);
|
||||
auto resolve = reinterpret_cast<ResolveUnitToken_t>(
|
||||
Offsets::FUN_RESOLVE_UNIT_TOKEN);
|
||||
if (auto *player = resolve("player")) {
|
||||
if (auto *player = Game::ResolveUnitToken("player")) {
|
||||
auto equip = reinterpret_cast<AutoEquipCursor_t>(
|
||||
Offsets::FUN_AUTO_EQUIP_CURSOR_ITEM);
|
||||
equip(player, 0);
|
||||
|
||||
@@ -24,11 +24,9 @@ namespace {
|
||||
|
||||
using GetItemBySlot_t = void *(__thiscall *)(void *invMgr, int slot);
|
||||
using GetVisibleItem_t = void *(__thiscall *)(void *unit, int slot);
|
||||
using ResolveUnitToken_t = void *(__fastcall *)(const char *token);
|
||||
|
||||
void *ResolveUnit(const char *token) {
|
||||
auto fn = reinterpret_cast<ResolveUnitToken_t>(Offsets::FUN_RESOLVE_UNIT_TOKEN);
|
||||
return fn(token);
|
||||
return Game::ResolveUnitToken(token);
|
||||
}
|
||||
|
||||
// Walks the local player's private inventory manager — the same path
|
||||
|
||||
@@ -318,7 +318,7 @@ void AppendNonBagOwned(uint64_t *out, int *count) {
|
||||
}
|
||||
|
||||
void FireChanged() {
|
||||
Event::Custom::Fire(Event::Custom::Lookup(kEvtNewItems), "");
|
||||
Event::Custom::Fire(_r.Slot(), "");
|
||||
}
|
||||
|
||||
// Login-baseline handler. Runs every frame but does no work once the
|
||||
|
||||
+1
-3
@@ -50,12 +50,10 @@ namespace Item::Usable {
|
||||
|
||||
namespace {
|
||||
|
||||
using ResolveUnitToken_t = void *(__fastcall *)(const char *token);
|
||||
using SpellIsUsable_t = int(__fastcall *)(const uint8_t *spellRecord, int *outNoMana);
|
||||
|
||||
const uint8_t *PlayerDescriptor() {
|
||||
auto resolve = reinterpret_cast<ResolveUnitToken_t>(Offsets::FUN_RESOLVE_UNIT_TOKEN);
|
||||
auto *player = static_cast<const uint8_t *>(resolve("player"));
|
||||
auto *player = static_cast<const uint8_t *>(Game::ResolveUnitToken("player"));
|
||||
if (player == nullptr)
|
||||
return nullptr;
|
||||
return *reinterpret_cast<const uint8_t *const *>(
|
||||
|
||||
@@ -344,7 +344,7 @@ StartFn_t g_origStart = nullptr;
|
||||
// structurally: a new item appears or the ring evicts an old one (shifting
|
||||
// every index).
|
||||
void FireFullUpdate() {
|
||||
Event::Custom::Fire(Event::Custom::Lookup(kFullUpdate), "");
|
||||
Event::Custom::Fire(_evFullUpdate.Slot(), "");
|
||||
}
|
||||
|
||||
// START_ROLL: let the original build the store node, then read the item's
|
||||
@@ -373,8 +373,7 @@ void __fastcall Roll_h(void *msg, void *ctx, int a3) {
|
||||
if (structural)
|
||||
FireFullUpdate();
|
||||
if (itemIdx > 0)
|
||||
Event::Custom::Fire(Event::Custom::Lookup(kRollChanged), "%d%d", itemIdx,
|
||||
playerIdx);
|
||||
Event::Custom::Fire(_evRollChanged.Slot(), "%d%d", itemIdx, playerIdx);
|
||||
}
|
||||
|
||||
void __fastcall Won_h(void *ctx, void *msg, int a3) {
|
||||
@@ -388,7 +387,7 @@ void __fastcall Won_h(void *ctx, void *msg, int a3) {
|
||||
if (structural)
|
||||
FireFullUpdate();
|
||||
if (itemIdx > 0)
|
||||
Event::Custom::Fire(Event::Custom::Lookup(kRollComplete), "%d", itemIdx);
|
||||
Event::Custom::Fire(_evRollComplete.Slot(), "%d", itemIdx);
|
||||
}
|
||||
|
||||
void __fastcall Passed_h(void *ctx, void *msg) {
|
||||
@@ -402,7 +401,7 @@ void __fastcall Passed_h(void *ctx, void *msg) {
|
||||
if (structural)
|
||||
FireFullUpdate();
|
||||
if (itemIdx > 0)
|
||||
Event::Custom::Fire(Event::Custom::Lookup(kRollComplete), "%d", itemIdx);
|
||||
Event::Custom::Fire(_evRollComplete.Slot(), "%d", itemIdx);
|
||||
}
|
||||
|
||||
const Game::HookAutoRegister _hookRoll{
|
||||
|
||||
+1
-5
@@ -49,8 +49,6 @@ using ClntObjMgrEnumVisibleObjectsCallback_t = int(__fastcall *)(void *ctx,
|
||||
using ClntObjMgrEnumVisibleObjects_t =
|
||||
int(__fastcall *)(ClntObjMgrEnumVisibleObjectsCallback_t cb, void *ctx);
|
||||
|
||||
using ResolveUnitToken_t = void *(__fastcall *)(const char *token);
|
||||
|
||||
// `__thiscall(self, outBuf) → const C3Vector *` at vtable slot 5
|
||||
// (byte offset `0x14`). Both CGUnit_C and CGPlayer_C populate the
|
||||
// caller-provided 12-byte buffer with the unit's world position and
|
||||
@@ -182,9 +180,7 @@ int __fastcall Script_GetNearbyLootableUnits(void *L) {
|
||||
// Resolve the local player once — the canonical CGPlayer_C the
|
||||
// engine's own interact-range checks use, distinct from the
|
||||
// `VAR_LOCAL_PLAYER_PTR` global. Bail if unavailable.
|
||||
auto ResolveUnitToken = reinterpret_cast<ResolveUnitToken_t>(
|
||||
Offsets::FUN_RESOLVE_UNIT_TOKEN);
|
||||
const void *player = ResolveUnitToken("player");
|
||||
const void *player = Game::ResolveUnitToken("player");
|
||||
if (player == nullptr)
|
||||
return 1;
|
||||
|
||||
|
||||
+2
-5
@@ -105,7 +105,6 @@ using ClntObjMgrEnumVisibleObjectsCallback_t = int(__fastcall *)(void *ctx,
|
||||
uint64_t guid);
|
||||
using ClntObjMgrEnumVisibleObjects_t =
|
||||
int(__fastcall *)(ClntObjMgrEnumVisibleObjectsCallback_t cb, void *ctx);
|
||||
using ResolveUnitToken_t = void *(__fastcall *)(const char *token);
|
||||
using LootUnit_t = void(__thiscall *)(void *player, void *target,
|
||||
char useDistanceCheck);
|
||||
using CloseLootInner_t = void(__fastcall *)(int sendRelease,
|
||||
@@ -267,7 +266,7 @@ void Complete() {
|
||||
s_scan.currentGuid = 0;
|
||||
s_scan.suppressEvents = false;
|
||||
s_scan.lootMode = false;
|
||||
Event::Custom::Fire(Event::Custom::Lookup(kEventCompleted), "");
|
||||
Event::Custom::Fire(_reserve.Slot(), "");
|
||||
}
|
||||
|
||||
void TryStartNext() {
|
||||
@@ -443,9 +442,7 @@ bool BeginWalk(bool lootMode, size_t maxCount) {
|
||||
return false;
|
||||
if (*reinterpret_cast<void *volatile *>(Offsets::VAR_LOCAL_PLAYER_PTR) == nullptr)
|
||||
return false;
|
||||
auto Resolve = reinterpret_cast<ResolveUnitToken_t>(
|
||||
Offsets::FUN_RESOLVE_UNIT_TOKEN);
|
||||
void *player = Resolve("player");
|
||||
void *player = Game::ResolveUnitToken("player");
|
||||
if (player == nullptr)
|
||||
return false;
|
||||
|
||||
|
||||
+1
-5
@@ -22,8 +22,6 @@ namespace Loot::Unit {
|
||||
|
||||
namespace {
|
||||
|
||||
using ResolveUnitToken_t = void *(__fastcall *)(const char *token);
|
||||
|
||||
// `FUN_CMSG_LOOT_UNIT` is `__thiscall(player, target, useDistanceCheck)`.
|
||||
// MSVC `__thiscall` puts `player` in ECX and the rest on the stack —
|
||||
// matching the engine's own callsite at `FUN_0060FA20`. The trailing
|
||||
@@ -74,9 +72,7 @@ int __fastcall Script_LootUnit(void *L) {
|
||||
// `ResolveUnitToken("player")` gives the canonical CGPlayer_C the
|
||||
// engine uses for `__thiscall` calls — distinct from the global
|
||||
// at `VAR_LOCAL_PLAYER_PTR` (see the comment on that offset).
|
||||
auto ResolveUnitToken = reinterpret_cast<ResolveUnitToken_t>(
|
||||
Offsets::FUN_RESOLVE_UNIT_TOKEN);
|
||||
void *player = ResolveUnitToken("player");
|
||||
void *player = Game::ResolveUnitToken("player");
|
||||
if (player == nullptr)
|
||||
return 0;
|
||||
|
||||
|
||||
@@ -39,7 +39,6 @@ enum class State {
|
||||
WaitingOpen,
|
||||
};
|
||||
|
||||
using ResolveUnitToken_t = void *(__fastcall *)(const char *token);
|
||||
using LootUnit_t = void(__thiscall *)(void *player, void *target,
|
||||
char useDistanceCheck);
|
||||
// `FUN_005E1AD0` is `__stdcall(uint8_t)`, not `__fastcall` — the
|
||||
@@ -164,9 +163,7 @@ int __fastcall Script_LootUnitItem(void *L) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
auto Resolve = reinterpret_cast<ResolveUnitToken_t>(
|
||||
Offsets::FUN_RESOLVE_UNIT_TOKEN);
|
||||
void *player = Resolve("player");
|
||||
void *player = Game::ResolveUnitToken("player");
|
||||
if (player == nullptr) {
|
||||
Game::Lua::PushBool(L, false);
|
||||
return 1;
|
||||
|
||||
@@ -223,8 +223,8 @@ int BuildList(LocEntry *out, int maxOut) {
|
||||
// lesson). The scan is bounded (16 debuff slots) and early-outs when idle, so
|
||||
// the per-frame cost is negligible.
|
||||
|
||||
constexpr const char *kEventAdded = "LOSS_OF_CONTROL_ADDED";
|
||||
constexpr const char *kEventUpdate = "LOSS_OF_CONTROL_UPDATE";
|
||||
const Event::Custom::AutoReserve _reserveAdded{"LOSS_OF_CONTROL_ADDED"};
|
||||
const Event::Custom::AutoReserve _reserveUpdate{"LOSS_OF_CONTROL_UPDATE"};
|
||||
|
||||
// Identity of an active effect for frame-to-frame diffing: a CC is keyed by
|
||||
// spellID, a school interrupt by its school mask (spellID 0).
|
||||
@@ -255,7 +255,7 @@ void OnWorldTick() {
|
||||
for (int i = 0; i < n; ++i) {
|
||||
if (!ContainsKey(g_prev, g_prevCount, cur[i])) {
|
||||
// A newly-applied effect -> LOSS_OF_CONTROL_ADDED(eventIndex).
|
||||
Event::Custom::Fire(Event::Custom::Lookup(kEventAdded), "%d", i + 1);
|
||||
Event::Custom::Fire(_reserveAdded.Slot(), "%d", i + 1);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
@@ -269,15 +269,13 @@ void OnWorldTick() {
|
||||
// forward-compatible with per-unit tracking and matches modern WoW, whose
|
||||
// LOSS_OF_CONTROL_UPDATE carries the unit token.
|
||||
if (changed)
|
||||
Event::Custom::Fire(Event::Custom::Lookup(kEventUpdate), "%s", "player");
|
||||
Event::Custom::Fire(_reserveUpdate.Slot(), "%s", "player");
|
||||
|
||||
for (int i = 0; i < n; ++i)
|
||||
g_prev[i] = cur[i];
|
||||
g_prevCount = n;
|
||||
}
|
||||
|
||||
static const Event::Custom::AutoReserve _reserveAdded{kEventAdded};
|
||||
static const Event::Custom::AutoReserve _reserveUpdate{kEventUpdate};
|
||||
static const Tick::WorldTick::AutoSubscribe _tick{&OnWorldTick};
|
||||
|
||||
// `C_LossOfControl.GetActiveLossOfControlDataCount()` -> number.
|
||||
|
||||
@@ -202,4 +202,6 @@ void PrepareForReload() {
|
||||
g_gameSlot.tv.value = nullptr;
|
||||
}
|
||||
|
||||
static const Game::ReloadAutoRegister _reloadReg{&PrepareForReload};
|
||||
|
||||
} // namespace LuaSyntax::StringMethods
|
||||
|
||||
+39
-20
@@ -79,11 +79,12 @@
|
||||
// `...` yields the name + a per-addon shared table. `__addonns` returns the
|
||||
// same table for every file of an addon (registry-backed). The preamble is
|
||||
// newline-free (line numbers preserved).
|
||||
// * Non-addon vararg chunks (RunScript `/run`, XML <OnLoad>, loadstring) get a
|
||||
// `local arg = {n=0}` fallback: their main chunk is not vararg, so a
|
||||
// top-level `...` would otherwise be `unpack(nil)` and throw where 5.1
|
||||
// yields nothing. A chunk called with args (`loadstring(...)(a,b)`) can't
|
||||
// recover them — this build doesn't populate `arg` for called chunks.
|
||||
// * Non-addon vararg chunks (RunScript `/run`, XML handler bodies,
|
||||
// `loadstring`) compile as a REAL 5.0 vararg closure: the body is wrapped
|
||||
// `return function(...) … end` and the main is called once at load, so
|
||||
// the caller receives the inner function whose own `arg` carries real
|
||||
// call args — `loadstring("return ...")(a, b)` yields a, b, matching 5.1
|
||||
// called-chunk semantics (the old `local arg={n=0}` fallback lost them).
|
||||
// The `__addonns` global is CONTEXT-GATED (see `Script_AddonNS`): it only
|
||||
// answers for the addon mid-load, for its own name — so it cannot be used
|
||||
// at runtime to read another addon's private table. Cross-addon access is
|
||||
@@ -1300,6 +1301,7 @@ int __fastcall LoadBuffer_h(void *L, const char *buff, unsigned size, const char
|
||||
pre += "local unpack=unpack;";
|
||||
|
||||
bool armed = false;
|
||||
bool wrapped = false;
|
||||
char addon[128];
|
||||
if (didVararg) {
|
||||
if (fromFileFunnel && AddonNameFromChunk(name, addon, sizeof addon)) {
|
||||
@@ -1311,20 +1313,26 @@ int __fastcall LoadBuffer_h(void *L, const char *buff, unsigned size, const char
|
||||
armed = true;
|
||||
} else {
|
||||
// Top-level `...` OUTSIDE the addon file funnel — RunScript (`/run`),
|
||||
// XML <OnLoad>, or loadstring. Vanilla's main / RunScript chunk is
|
||||
// NOT vararg, so `arg` is nil there and the `unpack(arg)` we emit
|
||||
// would throw where 5.1's `...` yields nothing. Bind a chunk-local
|
||||
// empty vararg table so those chunks match 5.1 (top-level `...`
|
||||
// yields nothing). A vararg function's own `arg` local shadows this
|
||||
// at its own scope, so nested `function(...)` bodies are unaffected.
|
||||
//
|
||||
// Limitation (verified in-game): a loadstring'd chunk CALLED with
|
||||
// args — `loadstring("return ...")(a,b)` — can't recover them. This
|
||||
// build does not populate `arg` for a called chunk, and `...` is not
|
||||
// a real 5.0 expression, so there is no source for the call args.
|
||||
// Not a regression: without the rewrite that chunk fails to compile
|
||||
// outright. This case is vanishingly rare in addons.
|
||||
pre += "local arg={n=0};";
|
||||
// XML handler bodies, or loadstring. Vanilla's main chunk is NOT
|
||||
// vararg (verified in-game): `arg` is nil there, and no rewrite can
|
||||
// read call args the VM never captured. So compile the chunk as a
|
||||
// REAL 5.0 vararg closure instead: wrap the body in
|
||||
// `return function(...) … end` and, after a successful compile,
|
||||
// call the main once so the caller receives the inner function.
|
||||
// The inner's own 5.0 `arg` then carries whatever the caller
|
||||
// passes — `loadstring("return ...")(a, b)` yields a, b — matching
|
||||
// 5.1 called-chunk semantics. Chunks the engine calls with zero
|
||||
// args (RunScript, OnLoad) see an empty `arg` exactly as the old
|
||||
// `local arg={n=0}` fallback gave them; XML handlers invoked
|
||||
// through Frame::ScriptArgs' positional push now receive those
|
||||
// args through `...` as well — the modern handler shape. The
|
||||
// helper captures stay in the OUTER main, which runs once at load
|
||||
// in the default environment, so the sandbox rationale above is
|
||||
// preserved (the inner binds them as upvalues). The prefix is
|
||||
// newline-free and the synthetic `end` rides on its own appended
|
||||
// final line, so existing line numbers are preserved.
|
||||
pre += "return function(...) ";
|
||||
wrapped = true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1341,10 +1349,15 @@ int __fastcall LoadBuffer_h(void *L, const char *buff, unsigned size, const char
|
||||
? 3
|
||||
: 0;
|
||||
std::string full;
|
||||
full.reserve(bodyLen + pre.size() + 4);
|
||||
full.reserve(bodyLen + pre.size() + 8);
|
||||
full.append(body, bom); // keep a leading BOM ahead of the preamble
|
||||
full += pre;
|
||||
full.append(body + bom, bodyLen - bom);
|
||||
// Close the vararg wrap on its OWN line: a body ending in a line comment
|
||||
// (`-- foo` with no trailing newline) would otherwise swallow the `end`.
|
||||
// Appending a final line shifts no existing line numbers.
|
||||
if (wrapped)
|
||||
full += "\nend";
|
||||
|
||||
// Grant this chunk (and only this chunk) the right to fetch its own
|
||||
// namespace via `__addonns`; the preamble consumes the grant as the chunk's
|
||||
@@ -1357,6 +1370,12 @@ int __fastcall LoadBuffer_h(void *L, const char *buff, unsigned size, const char
|
||||
g_origLoadBuffer(L, full.data(), static_cast<unsigned>(full.size()), name);
|
||||
if (armed && rc != 0)
|
||||
g_loadingAddon[0] = '\0';
|
||||
// Materialize the wrapped chunk's inner function. The main is helper-local
|
||||
// binds plus `return function(...) … end` — closure creation only, it
|
||||
// cannot raise — so a plain lua_call is safe, and it leaves the inner
|
||||
// function exactly where the engine expects the compiled chunk.
|
||||
if (wrapped && rc == 0)
|
||||
Game::Lua::Call(L, 0, 1);
|
||||
return rc;
|
||||
}
|
||||
|
||||
|
||||
+100
-4
@@ -54,10 +54,10 @@ double FloatField(const uint8_t *rec, int off) {
|
||||
return *reinterpret_cast<const float *>(rec + off);
|
||||
}
|
||||
|
||||
// Vanilla zone world-map canvas size (WorldMapDetailFrame); WorldMapOverlay
|
||||
// hit rects live in this pixel space.
|
||||
constexpr double kZoneCanvasW = 1002.0;
|
||||
constexpr double kZoneCanvasH = 668.0;
|
||||
// Zone world-map canvas size (WorldMapDetailFrame); WorldMapOverlay hit
|
||||
// rects live in this pixel space. Shared with the map-art readers.
|
||||
constexpr double kZoneCanvasW = kMapCanvasWidth;
|
||||
constexpr double kZoneCanvasH = kMapCanvasHeight;
|
||||
|
||||
// How deep inside its containing WorldMapOverlay hit rect the map-percent point
|
||||
// (0..100) sits on WMA row `wmaRow` — the max, over that zone's overlays whose
|
||||
@@ -211,6 +211,102 @@ bool PercentInZone(int areaID, float x, float y, double *outMapX, double *outMap
|
||||
return false;
|
||||
}
|
||||
|
||||
int RowForUiMapID(int uiMapID) {
|
||||
if (uiMapID > 0)
|
||||
return RowForAreaID(static_cast<uint32_t>(uiMapID));
|
||||
if (uiMapID < 0) {
|
||||
const int row = -uiMapID;
|
||||
if (DBC::Record(Offsets::VAR_WORLDMAP_AREA_RECORDS,
|
||||
Offsets::VAR_WORLDMAP_AREA_COUNT,
|
||||
static_cast<uint32_t>(row)) != nullptr)
|
||||
return row;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
int UiMapIDForRow(int row) {
|
||||
if (row <= 0)
|
||||
return 0;
|
||||
const uint8_t *rec = DBC::Record(Offsets::VAR_WORLDMAP_AREA_RECORDS,
|
||||
Offsets::VAR_WORLDMAP_AREA_COUNT,
|
||||
static_cast<uint32_t>(row));
|
||||
if (rec == nullptr)
|
||||
return 0;
|
||||
const int areaID = Game::Read<int>(rec, Offsets::OFF_WMA_AREA_ID);
|
||||
return (areaID != 0) ? areaID : -row;
|
||||
}
|
||||
|
||||
int ContinentRowForMapID(int mapID) {
|
||||
const int count = Game::Read<int>(Offsets::VAR_WORLDMAP_AREA_COUNT);
|
||||
for (int id = 1; id <= count; ++id) {
|
||||
const uint8_t *rec = DBC::Record(Offsets::VAR_WORLDMAP_AREA_RECORDS,
|
||||
Offsets::VAR_WORLDMAP_AREA_COUNT,
|
||||
static_cast<uint32_t>(id));
|
||||
if (rec == nullptr)
|
||||
continue;
|
||||
if (Game::Read<int>(rec, Offsets::OFF_WMA_MAP_ID) != mapID)
|
||||
continue;
|
||||
if (Game::Read<int>(rec, Offsets::OFF_WMA_AREA_ID) != 0)
|
||||
continue; // a zone row, not the continent-spanning one
|
||||
const double left = FloatField(rec, Offsets::OFF_WMA_LOC_LEFT);
|
||||
const double right = FloatField(rec, Offsets::OFF_WMA_LOC_RIGHT);
|
||||
const double top = FloatField(rec, Offsets::OFF_WMA_LOC_TOP);
|
||||
const double bottom = FloatField(rec, Offsets::OFF_WMA_LOC_BOTTOM);
|
||||
if (left - right <= 0.0 || top - bottom <= 0.0)
|
||||
continue; // stray zero-rect continent row (the "World" row)
|
||||
return id;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
bool RowRect(int row, double *outLeft, double *outRight, double *outTop,
|
||||
double *outBottom, int *outMapID) {
|
||||
if (row <= 0)
|
||||
return false;
|
||||
const uint8_t *rec = DBC::Record(Offsets::VAR_WORLDMAP_AREA_RECORDS,
|
||||
Offsets::VAR_WORLDMAP_AREA_COUNT,
|
||||
static_cast<uint32_t>(row));
|
||||
if (rec == nullptr)
|
||||
return false;
|
||||
if (outLeft != nullptr)
|
||||
*outLeft = FloatField(rec, Offsets::OFF_WMA_LOC_LEFT);
|
||||
if (outRight != nullptr)
|
||||
*outRight = FloatField(rec, Offsets::OFF_WMA_LOC_RIGHT);
|
||||
if (outTop != nullptr)
|
||||
*outTop = FloatField(rec, Offsets::OFF_WMA_LOC_TOP);
|
||||
if (outBottom != nullptr)
|
||||
*outBottom = FloatField(rec, Offsets::OFF_WMA_LOC_BOTTOM);
|
||||
if (outMapID != nullptr)
|
||||
*outMapID = Game::Read<int>(rec, Offsets::OFF_WMA_MAP_ID);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool PercentInRow(int row, float x, float y, double *outPx, double *outPy) {
|
||||
double left, right, top, bottom;
|
||||
if (!RowRect(row, &left, &right, &top, &bottom, nullptr))
|
||||
return false;
|
||||
const double spanY = left - right; // horizontal (world Y)
|
||||
const double spanX = top - bottom; // vertical (world X)
|
||||
if (spanX <= 0.0 || spanY <= 0.0)
|
||||
return false;
|
||||
*outPx = (left - y) / spanY;
|
||||
*outPy = (top - x) / spanX;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool WorldFromRow(int row, double px, double py, double *outX, double *outY) {
|
||||
double left, right, top, bottom;
|
||||
if (!RowRect(row, &left, &right, &top, &bottom, nullptr))
|
||||
return false;
|
||||
const double spanY = left - right;
|
||||
const double spanX = top - bottom;
|
||||
if (spanX <= 0.0 || spanY <= 0.0)
|
||||
return false;
|
||||
*outY = left - px * spanY;
|
||||
*outX = top - py * spanX;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool ContinentPercent(int mapID, float x, float y, double *outPx, double *outPy) {
|
||||
const int count = Game::Read<int>(Offsets::VAR_WORLDMAP_AREA_COUNT);
|
||||
for (int id = 1; id <= count; ++id) {
|
||||
|
||||
@@ -25,6 +25,32 @@
|
||||
// place.
|
||||
namespace Map::Area {
|
||||
|
||||
// The world map's detail canvas in pixels (`WorldMapDetailFrame`) and the
|
||||
// size of one background tile — both taken from the client's own
|
||||
// `WorldMapFrame.xml`, where the frame is 1002x668 and each of the twelve
|
||||
// `WorldMapDetailTile` textures is 256x256 (tile 5 anchors below tile 1, so
|
||||
// the grid is 4 wide by 3 tall). A map's background is that grid, and
|
||||
// `WorldMapOverlay` placement/hit rects are authored in the same space.
|
||||
//
|
||||
// These are deliberately CONSTANTS rather than a live read of
|
||||
// `WorldMapDetailFrame`, which would look like the more "engine-driven"
|
||||
// choice and is in fact the wrong one, twice over:
|
||||
//
|
||||
// - The frame is addon-mutable. Map addons resize and rescale it, but the
|
||||
// DBC coordinates these normalize don't move — they were authored
|
||||
// against the shipped size. Reading the frame live would silently skew
|
||||
// every overlay the moment someone scaled the map.
|
||||
// - They are LOGICAL sizes, not on-disk ones. An HD map patch ships
|
||||
// larger tile files; `Map::Overlays` detects that from the real BLP
|
||||
// dimensions and scales. Reporting a patched tile's true size here
|
||||
// would break the `canvas / tile` division that yields the 4x3 grid.
|
||||
//
|
||||
// So the authored value is the truth, and the runtime one is the mutable
|
||||
// approximation — the reverse of the usual case.
|
||||
constexpr double kMapCanvasWidth = 1002.0;
|
||||
constexpr double kMapCanvasHeight = 668.0;
|
||||
constexpr int kMapTileSize = 256;
|
||||
|
||||
// WorldMapArea row for an AreaTable `areaID` (the stable zone identity
|
||||
// `C_Map.GetBestMapForUnit` returns). Linear walk of the ~175-row table.
|
||||
// Returns -1 when no row carries that areaID.
|
||||
@@ -76,4 +102,45 @@ bool PercentInZone(int areaID, float x, float y, double *outMapX, double *outMap
|
||||
// projection retail's TaxiNodeInfo.position uses.
|
||||
bool ContinentPercent(int mapID, float x, float y, double *outPx, double *outPy);
|
||||
|
||||
// --- uiMapID identity ------------------------------------------------------
|
||||
//
|
||||
// A ClassicAPI `uiMapID` names either a zone or a whole map:
|
||||
// positive = an `AreaTable.dbc` area id (a zone) — what
|
||||
// `C_Map.GetBestMapForUnit` returns.
|
||||
// negative = -(WorldMapArea row) — continent / world / instance maps,
|
||||
// which carry no AreaTable id (their rows have areaID 0).
|
||||
// The negative half mirrors the engine's own two-namespaces-in-one-int idiom
|
||||
// (a quest's `zoneOrSort`: positive = AreaTable zone, negative = -QuestSort
|
||||
// row), so the two id spaces can never collide.
|
||||
|
||||
// WorldMapArea row for a `uiMapID` in either namespace. Returns -1 when the
|
||||
// id resolves to no row.
|
||||
int RowForUiMapID(int uiMapID);
|
||||
|
||||
// The `uiMapID` that names WorldMapArea row `row` — its areaID when it has
|
||||
// one, else -(row). Returns 0 for an unreadable row.
|
||||
int UiMapIDForRow(int row);
|
||||
|
||||
// The continent-level WorldMapArea row for a `Map.dbc` map: the areaID == 0
|
||||
// row with a non-degenerate rect (the same selection `ContinentPercent`
|
||||
// makes, so the stray zero-rect "World" row never wins). -1 when none.
|
||||
int ContinentRowForMapID(int mapID);
|
||||
|
||||
// Reads WorldMapArea row `row`'s placement rect and its `Map.dbc` map id.
|
||||
// Every out-parameter is optional (pass null to skip it). False when the row
|
||||
// is unreadable.
|
||||
bool RowRect(int row, double *outLeft, double *outRight, double *outTop,
|
||||
double *outBottom, int *outMapID);
|
||||
|
||||
// Projects world (x, y) into row `row`'s rect as 0..1 (`outPx` horizontal off
|
||||
// world Y, `outPy` vertical off world X). Unlike `PercentInZone` there is NO
|
||||
// containment gate — a point outside the rect yields a value outside 0..1, and
|
||||
// the caller decides whether that is meaningful. False for a missing or
|
||||
// degenerate rect.
|
||||
bool PercentInRow(int row, float x, float y, double *outPx, double *outPy);
|
||||
|
||||
// Inverse of `PercentInRow`: a 0..1 position on row `row` back to
|
||||
// continent-space world coordinates. False for a missing or degenerate rect.
|
||||
bool WorldFromRow(int row, double px, double py, double *outX, double *outY);
|
||||
|
||||
} // namespace Map::Area
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
// This file is part of ClassicAPI.
|
||||
//
|
||||
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
|
||||
// of the GNU General Public License as published by the Free Software Foundation, either
|
||||
// version 3 of the License, or (at your option) any later version.
|
||||
//
|
||||
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
|
||||
// PURPOSE. See the GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License along with
|
||||
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
// `C_Map` coordinate transforms — the world↔map projection layer that sits on
|
||||
// top of `WorldMapArea.dbc`'s per-zone placement rects:
|
||||
//
|
||||
// GetPlayerMapPosition(uiMapID, unitToken) -> position(vector2)
|
||||
// GetWorldPosFromMapPos(uiMapID, mapPos) -> continentID, worldPos(vector2)
|
||||
// GetMapPosFromWorldPos(continentID, worldPos [, overrideUiMapID])
|
||||
// -> uiMapID, mapPos(vector2)
|
||||
// GetMapRectOnMap(uiMapID, topUiMapID) -> minX, maxX, minY, maxY
|
||||
//
|
||||
// ## The identity scheme (shared with the rest of `Map::*`)
|
||||
//
|
||||
// `uiMapID` is a vanilla `AreaTable.dbc` area id for a zone — the same
|
||||
// identity `C_Map.GetBestMapForUnit` returns and `GetMapWorldSize` /
|
||||
// `GetMapOverlays` accept — or a NEGATIVE `-(WorldMapArea row)` for a
|
||||
// continent / world / instance map (see `Map::Area`'s uiMapID section), so a
|
||||
// continent map works anywhere a zone does. `continentID` is a `Map.dbc` map
|
||||
// id (0 = Eastern Kingdoms, 1 = Kalimdor, or an instance map). World
|
||||
// coordinates are continent-space
|
||||
// (WoW world axes: +X north, +Y west), exactly what `AreaTrigger.dbc` stores
|
||||
// and what the object `GetPosition` virtual returns. All four functions reuse
|
||||
// `Map::Area`'s zone rect math (`PercentInZone`, `ZonePercent`,
|
||||
// `ContinentPercent`, `RowForAreaID`) so the world↔map convention lives in one
|
||||
// place.
|
||||
//
|
||||
// ## vector2 returns
|
||||
//
|
||||
// Positions are returned as `Vector2DMixin` objects (built through the
|
||||
// `CreateVector2D` global) so consumers can call `pos:GetXY()` / read `pos.x`.
|
||||
// If that global is unavailable, a plain `{x=, y=}` table carries the same
|
||||
// coordinates.
|
||||
|
||||
#include "Game.h"
|
||||
#include "Offsets.h"
|
||||
#include "dbc/Lookup.h"
|
||||
#include "map/Area.h"
|
||||
#include "map/Vector2.h"
|
||||
#include "unit/Position.h"
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
namespace Map::Coordinates {
|
||||
|
||||
namespace {
|
||||
|
||||
// True when a projected 0..1 map position actually lands on the map.
|
||||
bool OnMap(double px, double py) {
|
||||
return px >= 0.0 && px <= 1.0 && py >= 0.0 && py <= 1.0;
|
||||
}
|
||||
|
||||
using Map::PushVector2D;
|
||||
|
||||
// Reads `t.x` / `t.y` from the table at absolute stack index `idx`.
|
||||
bool ReadXY(void *L, int idx, double *x, double *y) {
|
||||
return Map::ReadVector2D(L, idx, x, y);
|
||||
}
|
||||
|
||||
// `C_Map.GetPlayerMapPosition(uiMapID, unitToken)` — the unit's normalized
|
||||
// (0..1) position within zone `uiMapID`, or nil when the unit's live world
|
||||
// position isn't inside that zone's rect (wrong zone, an instance with no map
|
||||
// row, or no position yet). Invalid unit tokens error like other unit
|
||||
// functions (`UnitHealth("garbage")`).
|
||||
int __fastcall Script_GetPlayerMapPosition(void *L) {
|
||||
const bool ok = Game::Lua::IsNumber(L, 1) && Game::Lua::IsString(L, 2);
|
||||
const int areaID = ok ? static_cast<int>(Game::Lua::ToNumber(L, 1)) : 0;
|
||||
const char *token = ok ? Game::Lua::ToString(L, 2) : nullptr;
|
||||
|
||||
const int row = ok ? Map::Area::RowForUiMapID(areaID) : -1;
|
||||
float pos[3];
|
||||
double px = 0.0, py = 0.0;
|
||||
const bool have = ok && token != nullptr && row > 0 &&
|
||||
Unit::Position::ReadToken(token, pos) &&
|
||||
Map::Area::PercentInRow(row, pos[0], pos[1], &px, &py) &&
|
||||
OnMap(px, py);
|
||||
|
||||
Game::Lua::SetTop(L, 0);
|
||||
if (!have) {
|
||||
Game::Lua::PushNil(L);
|
||||
return 1;
|
||||
}
|
||||
PushVector2D(L, px, py);
|
||||
return 1;
|
||||
}
|
||||
|
||||
// `C_Map.GetWorldPosFromMapPos(uiMapID, mapPosition)` — the continent-space
|
||||
// world position of a normalized (0..1) point on zone `uiMapID`. Returns
|
||||
// `continentID, worldPosition`; nil when the zone has no map row. Inverse of
|
||||
// `GetMapPosFromWorldPos`.
|
||||
int __fastcall Script_GetWorldPosFromMapPos(void *L) {
|
||||
const bool ok =
|
||||
Game::Lua::IsNumber(L, 1) && Game::Lua::Type(L, 2) == Game::Lua::TYPE_TABLE;
|
||||
const int areaID = ok ? static_cast<int>(Game::Lua::ToNumber(L, 1)) : 0;
|
||||
|
||||
const int row = ok ? Map::Area::RowForUiMapID(areaID) : -1;
|
||||
double mx = 0.0, my = 0.0;
|
||||
double worldX = 0.0, worldY = 0.0;
|
||||
int continentID = 0;
|
||||
const bool have = ok && ReadXY(L, 2, &mx, &my) && row > 0 &&
|
||||
Map::Area::RowRect(row, nullptr, nullptr, nullptr, nullptr,
|
||||
&continentID) &&
|
||||
Map::Area::WorldFromRow(row, mx, my, &worldX, &worldY);
|
||||
|
||||
Game::Lua::SetTop(L, 0);
|
||||
if (!have) {
|
||||
Game::Lua::PushNil(L);
|
||||
return 1;
|
||||
}
|
||||
|
||||
Game::Lua::PushNumber(L, static_cast<double>(continentID));
|
||||
PushVector2D(L, worldX, worldY); // vector.x = world X (north), .y = world Y (west)
|
||||
return 2;
|
||||
}
|
||||
|
||||
// `C_Map.GetMapPosFromWorldPos(continentID, worldPosition [, overrideUiMapID])`
|
||||
// — the map position of a continent-space world point. With `overrideUiMapID`,
|
||||
// projects into that specific zone (nil if the point is outside its rect);
|
||||
// without it, resolves the zone the point falls in. Returns `uiMapID,
|
||||
// mapPosition`. (Vanilla has no continent-level uiMapID, so the no-override
|
||||
// form resolves to the containing zone rather than continent-normalizing.)
|
||||
int __fastcall Script_GetMapPosFromWorldPos(void *L) {
|
||||
const bool ok =
|
||||
Game::Lua::IsNumber(L, 1) && Game::Lua::Type(L, 2) == Game::Lua::TYPE_TABLE;
|
||||
const int continentID = ok ? static_cast<int>(Game::Lua::ToNumber(L, 1)) : 0;
|
||||
const bool hasOverride = Game::Lua::IsNumber(L, 3);
|
||||
const int overrideArea = hasOverride ? static_cast<int>(Game::Lua::ToNumber(L, 3)) : 0;
|
||||
|
||||
double wx = 0.0, wy = 0.0;
|
||||
const bool readOk = ok && ReadXY(L, 2, &wx, &wy);
|
||||
|
||||
int areaID = 0;
|
||||
double mapX = 0.0, mapY = 0.0;
|
||||
bool have = false;
|
||||
if (readOk) {
|
||||
if (hasOverride) {
|
||||
const int row = Map::Area::RowForUiMapID(overrideArea);
|
||||
double px = 0.0, py = 0.0;
|
||||
have = row > 0 &&
|
||||
Map::Area::PercentInRow(row, static_cast<float>(wx),
|
||||
static_cast<float>(wy), &px, &py) &&
|
||||
OnMap(px, py);
|
||||
if (have) {
|
||||
areaID = overrideArea;
|
||||
mapX = px * 100.0; // normalized below with the ZonePercent path
|
||||
mapY = py * 100.0;
|
||||
}
|
||||
} else {
|
||||
have = Map::Area::ZonePercent(continentID, static_cast<float>(wx),
|
||||
static_cast<float>(wy), &areaID, &mapX, &mapY);
|
||||
}
|
||||
}
|
||||
|
||||
Game::Lua::SetTop(L, 0);
|
||||
if (!have) {
|
||||
Game::Lua::PushNil(L);
|
||||
return 1;
|
||||
}
|
||||
Game::Lua::PushNumber(L, static_cast<double>(areaID));
|
||||
PushVector2D(L, mapX / 100.0, mapY / 100.0);
|
||||
return 2;
|
||||
}
|
||||
|
||||
// `C_Map.GetMapRectOnMap(uiMapID, topUiMapID)` — the normalized (0..1)
|
||||
// rectangle that zone `uiMapID` occupies on its continent map, as
|
||||
// `minX, maxX, minY, maxY`. `topUiMapID` names the containing continent; since
|
||||
// vanilla has no continent uiMapID, the rect is computed on the zone's own
|
||||
// continent. Returns nothing (→ nil ×4) when the zone has no map row.
|
||||
int __fastcall Script_GetMapRectOnMap(void *L) {
|
||||
const int areaID = Game::Lua::IsNumber(L, 1) ? static_cast<int>(Game::Lua::ToNumber(L, 1)) : 0;
|
||||
const int topID = Game::Lua::IsNumber(L, 2) ? static_cast<int>(Game::Lua::ToNumber(L, 2)) : 0;
|
||||
|
||||
const int row = Map::Area::RowForUiMapID(areaID);
|
||||
double left = 0.0, right = 0.0, top = 0.0, bottom = 0.0;
|
||||
int continentID = 0;
|
||||
double aPx = 0.0, aPy = 0.0, bPx = 0.0, bPy = 0.0;
|
||||
bool have = row > 0 && Map::Area::RowRect(row, &left, &right, &top, &bottom,
|
||||
&continentID);
|
||||
if (have) {
|
||||
// `topUiMapID` names the map to measure against; fall back to the
|
||||
// zone's own continent when it isn't a usable map.
|
||||
int topRow = (topID != 0) ? Map::Area::RowForUiMapID(topID) : -1;
|
||||
if (topRow <= 0)
|
||||
topRow = Map::Area::ContinentRowForMapID(continentID);
|
||||
// Project the rect's two extreme world corners onto that map.
|
||||
// Corner A = (world X=top, Y=left); B = (bottom, right).
|
||||
have = topRow > 0 &&
|
||||
Map::Area::PercentInRow(topRow, static_cast<float>(top),
|
||||
static_cast<float>(left), &aPx, &aPy) &&
|
||||
Map::Area::PercentInRow(topRow, static_cast<float>(bottom),
|
||||
static_cast<float>(right), &bPx, &bPy);
|
||||
}
|
||||
|
||||
Game::Lua::SetTop(L, 0);
|
||||
if (!have)
|
||||
return 0; // nil, nil, nil, nil
|
||||
|
||||
const double minX = aPx < bPx ? aPx : bPx;
|
||||
const double maxX = aPx > bPx ? aPx : bPx;
|
||||
const double minY = aPy < bPy ? aPy : bPy;
|
||||
const double maxY = aPy > bPy ? aPy : bPy;
|
||||
Game::Lua::PushNumber(L, minX);
|
||||
Game::Lua::PushNumber(L, maxX);
|
||||
Game::Lua::PushNumber(L, minY);
|
||||
Game::Lua::PushNumber(L, maxY);
|
||||
return 4;
|
||||
}
|
||||
|
||||
void RegisterLuaFunctions() {
|
||||
Game::Lua::RegisterTableFunction("C_Map", "GetPlayerMapPosition",
|
||||
&Script_GetPlayerMapPosition);
|
||||
Game::Lua::RegisterTableFunction("C_Map", "GetWorldPosFromMapPos",
|
||||
&Script_GetWorldPosFromMapPos);
|
||||
Game::Lua::RegisterTableFunction("C_Map", "GetMapPosFromWorldPos",
|
||||
&Script_GetMapPosFromWorldPos);
|
||||
Game::Lua::RegisterTableFunction("C_Map", "GetMapRectOnMap",
|
||||
&Script_GetMapRectOnMap);
|
||||
}
|
||||
|
||||
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
|
||||
|
||||
} // namespace
|
||||
|
||||
} // namespace Map::Coordinates
|
||||
@@ -0,0 +1,446 @@
|
||||
// This file is part of ClassicAPI.
|
||||
//
|
||||
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
|
||||
// of the GNU General Public License as published by the Free Software Foundation, either
|
||||
// version 3 of the License, or (at your option) any later version.
|
||||
//
|
||||
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
|
||||
// PURPOSE. See the GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License along with
|
||||
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
// `C_Map` map hierarchy — the tree the coordinate layer hangs off:
|
||||
//
|
||||
// GetMapInfo(uiMapID) -> UiMapDetails table
|
||||
// GetMapChildrenInfo(uiMapID) -> array of UiMapDetails
|
||||
// GetMapInfoAtPosition(uiMapID,x,y) -> UiMapDetails at a point
|
||||
// GetFallbackWorldMapID() -> the world map's uiMapID
|
||||
//
|
||||
// ## The tree
|
||||
//
|
||||
// Three levels, all read from `WorldMapArea.dbc` + `Map.dbc`:
|
||||
//
|
||||
// World (the "World" row)
|
||||
// +- Continent (Kalimdor, Eastern Kingdoms)
|
||||
// +- Zone (Durotar, Elwynn Forest, ...)
|
||||
//
|
||||
// A zone is named by its positive `AreaTable.dbc` area id. The World and
|
||||
// continent levels carry no AreaTable id (their `WorldMapArea` rows have
|
||||
// areaID 0), so they use the negative `-(WorldMapArea row)` half of the
|
||||
// uiMapID space — see the uiMapID section in `Map::Area`. Instance maps
|
||||
// (dungeons, raids, battlegrounds) are the same shape as continents: an
|
||||
// areaID-0 row, addressed negatively.
|
||||
//
|
||||
// A zone's parent is the continent-spanning row of the `Map.dbc` map it sits
|
||||
// on. Each continent's parent is the World row. The World row's own rect is
|
||||
// all zeroes, so it names the map but carries no geometry — the coordinate
|
||||
// functions correctly report nothing for it.
|
||||
//
|
||||
// ## mapType
|
||||
//
|
||||
// `Enum.UIMapType`: Cosmic 0, World 1, Continent 2, Zone 3, Dungeon 4. A
|
||||
// positive id is always a Zone. A negative id takes its type from the
|
||||
// `Map.dbc` instance type of the map it names (0 = world map → Continent,
|
||||
// anything else → Dungeon), except the World row itself.
|
||||
|
||||
#include "Game.h"
|
||||
#include "Offsets.h"
|
||||
#include "dbc/Lookup.h"
|
||||
#include "dbc/Names.h"
|
||||
#include "map/Area.h"
|
||||
|
||||
#include <cstdint>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
|
||||
namespace Map::MapInfo {
|
||||
|
||||
namespace {
|
||||
|
||||
// Enum.UIMapType values we can produce.
|
||||
constexpr int kMapTypeWorld = 1;
|
||||
constexpr int kMapTypeContinent = 2;
|
||||
constexpr int kMapTypeZone = 3;
|
||||
constexpr int kMapTypeDungeon = 4;
|
||||
|
||||
// `Map.dbc` is indexed by map id, and map id 0 (Eastern Kingdoms) is a real
|
||||
// row — so the shared `DBC::Record` (which treats id 0 as "unused slot") can't
|
||||
// be used here. Mirrors the reader in `Instance::Info`.
|
||||
const uint8_t *MapRecord(int mapID) {
|
||||
if (mapID < 0)
|
||||
return nullptr;
|
||||
const int count = Game::Read<int>(Offsets::VAR_MAP_COUNT);
|
||||
if (mapID > count)
|
||||
return nullptr;
|
||||
auto *const *records = *reinterpret_cast<const uint8_t *const *const *>(
|
||||
static_cast<uintptr_t>(Offsets::VAR_MAP_RECORDS));
|
||||
if (records == nullptr)
|
||||
return nullptr;
|
||||
return records[mapID];
|
||||
}
|
||||
|
||||
// Localized `Map.dbc` display name ("Eastern Kingdoms", "Deadmines"), or null.
|
||||
const char *MapName(int mapID) {
|
||||
const uint8_t *rec = MapRecord(mapID);
|
||||
if (rec == nullptr)
|
||||
return nullptr;
|
||||
const uint32_t locale = Game::Read<uint32_t>(Offsets::VAR_LOCALE_INDEX);
|
||||
const char *name = *reinterpret_cast<const char *const *>(
|
||||
rec + Offsets::OFF_MAP_NAME + locale * 4);
|
||||
if (name == nullptr || name[0] == '\0')
|
||||
return nullptr;
|
||||
return name;
|
||||
}
|
||||
|
||||
// `Map.dbc` instance type (0 = world map, 1 = dungeon, 2 = raid, 3 = BG).
|
||||
// -1 when the map has no row.
|
||||
int MapInstanceType(int mapID) {
|
||||
const uint8_t *rec = MapRecord(mapID);
|
||||
if (rec == nullptr)
|
||||
return -1;
|
||||
return Game::Read<int>(rec, Offsets::OFF_MAP_INSTANCE_TYPE);
|
||||
}
|
||||
|
||||
// The WorldMapArea directory name of a row ("Kalimdor", "World", ""), or null.
|
||||
const char *RowName(int row) {
|
||||
if (row <= 0)
|
||||
return nullptr;
|
||||
const uint8_t *rec = DBC::Record(Offsets::VAR_WORLDMAP_AREA_RECORDS,
|
||||
Offsets::VAR_WORLDMAP_AREA_COUNT,
|
||||
static_cast<uint32_t>(row));
|
||||
if (rec == nullptr)
|
||||
return nullptr;
|
||||
return Game::Read<const char *>(rec, Offsets::OFF_WMA_NAME);
|
||||
}
|
||||
|
||||
bool IsWorldRow(int row) {
|
||||
const char *name = RowName(row);
|
||||
return name != nullptr && std::strcmp(name, "World") == 0;
|
||||
}
|
||||
|
||||
// The WorldMapArea row the client uses for the whole-world map: the areaID-0
|
||||
// row named "World". -1 when the client has none.
|
||||
int WorldRow() {
|
||||
const int count = Game::Read<int>(Offsets::VAR_WORLDMAP_AREA_COUNT);
|
||||
for (int id = 1; id <= count; ++id) {
|
||||
const uint8_t *rec = DBC::Record(Offsets::VAR_WORLDMAP_AREA_RECORDS,
|
||||
Offsets::VAR_WORLDMAP_AREA_COUNT,
|
||||
static_cast<uint32_t>(id));
|
||||
if (rec == nullptr ||
|
||||
Game::Read<int>(rec, Offsets::OFF_WMA_AREA_ID) != 0)
|
||||
continue;
|
||||
if (IsWorldRow(id))
|
||||
return id;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
// Builds the UiMapDetails table for `uiMapID` and leaves it on the stack top.
|
||||
// Returns false (pushing nothing) when the id names no map.
|
||||
bool PushMapInfo(void *L, int uiMapID) {
|
||||
const int row = Map::Area::RowForUiMapID(uiMapID);
|
||||
if (row <= 0)
|
||||
return false;
|
||||
|
||||
const char *name = nullptr;
|
||||
int mapType = kMapTypeZone;
|
||||
int parentMapID = 0;
|
||||
|
||||
if (uiMapID > 0) {
|
||||
// A zone: AreaTable name, parented to its continent.
|
||||
name = DBC::AreaName(static_cast<uint32_t>(uiMapID),
|
||||
/*resolveToParent=*/false);
|
||||
mapType = kMapTypeZone;
|
||||
int continentID = 0;
|
||||
if (Map::Area::RowRect(row, nullptr, nullptr, nullptr, nullptr,
|
||||
&continentID)) {
|
||||
const int contRow = Map::Area::ContinentRowForMapID(continentID);
|
||||
if (contRow > 0)
|
||||
parentMapID = -contRow;
|
||||
}
|
||||
} else if (IsWorldRow(row)) {
|
||||
// The whole-world map: named by the row itself, no parent.
|
||||
name = RowName(row);
|
||||
mapType = kMapTypeWorld;
|
||||
parentMapID = 0;
|
||||
} else {
|
||||
// A continent or instance map: Map.dbc supplies the display name and
|
||||
// the type; continents hang off the world map.
|
||||
int mapID = 0;
|
||||
Map::Area::RowRect(row, nullptr, nullptr, nullptr, nullptr, &mapID);
|
||||
name = MapName(mapID);
|
||||
if (name == nullptr)
|
||||
name = RowName(row); // custom map with no Map.dbc row
|
||||
const int instanceType = MapInstanceType(mapID);
|
||||
if (instanceType == 0) {
|
||||
mapType = kMapTypeContinent;
|
||||
const int worldRow = WorldRow();
|
||||
if (worldRow > 0)
|
||||
parentMapID = -worldRow;
|
||||
} else {
|
||||
mapType = kMapTypeDungeon;
|
||||
}
|
||||
}
|
||||
|
||||
Game::Lua::NewTable(L);
|
||||
Game::Lua::SetFieldNumber(L, "mapID", static_cast<double>(uiMapID));
|
||||
Game::Lua::SetFieldString(L, "name", name);
|
||||
Game::Lua::SetFieldNumber(L, "mapType", static_cast<double>(mapType));
|
||||
Game::Lua::SetFieldNumber(L, "parentMapID", static_cast<double>(parentMapID));
|
||||
Game::Lua::SetFieldNumber(L, "flags", 0.0);
|
||||
return true;
|
||||
}
|
||||
|
||||
// `C_Map.GetMapInfo(uiMapID)` — the map's details table, or nil.
|
||||
int __fastcall Script_GetMapInfo(void *L) {
|
||||
const bool ok = Game::Lua::IsNumber(L, 1);
|
||||
const int uiMapID = ok ? static_cast<int>(Game::Lua::ToNumber(L, 1)) : 0;
|
||||
Game::Lua::SetTop(L, 0);
|
||||
if (!ok || !PushMapInfo(L, uiMapID))
|
||||
Game::Lua::PushNil(L);
|
||||
return 1;
|
||||
}
|
||||
|
||||
// `C_Map.GetMapChildrenInfo(uiMapID)` — the maps one level below `uiMapID`:
|
||||
// the continents of the world map, or the zones of a continent / instance map.
|
||||
// Always returns a table (empty for a zone, which has no children).
|
||||
int __fastcall Script_GetMapChildrenInfo(void *L) {
|
||||
const bool ok = Game::Lua::IsNumber(L, 1);
|
||||
const int uiMapID = ok ? static_cast<int>(Game::Lua::ToNumber(L, 1)) : 0;
|
||||
const int row = ok ? Map::Area::RowForUiMapID(uiMapID) : -1;
|
||||
|
||||
// A zone (positive id) has no children; anything else enumerates below.
|
||||
const bool wantContinents = (row > 0) && IsWorldRow(row);
|
||||
int parentMapDbcID = 0;
|
||||
const bool wantZones = (row > 0) && uiMapID < 0 && !wantContinents &&
|
||||
Map::Area::RowRect(row, nullptr, nullptr, nullptr,
|
||||
nullptr, &parentMapDbcID);
|
||||
|
||||
Game::Lua::SetTop(L, 0);
|
||||
Game::Lua::NewTable(L);
|
||||
if (!wantContinents && !wantZones)
|
||||
return 1;
|
||||
|
||||
const int count = Game::Read<int>(Offsets::VAR_WORLDMAP_AREA_COUNT);
|
||||
int outIdx = 0;
|
||||
for (int id = 1; id <= count; ++id) {
|
||||
const uint8_t *rec = DBC::Record(Offsets::VAR_WORLDMAP_AREA_RECORDS,
|
||||
Offsets::VAR_WORLDMAP_AREA_COUNT,
|
||||
static_cast<uint32_t>(id));
|
||||
if (rec == nullptr)
|
||||
continue;
|
||||
const int areaID = Game::Read<int>(rec, Offsets::OFF_WMA_AREA_ID);
|
||||
const int mapID = Game::Read<int>(rec, Offsets::OFF_WMA_MAP_ID);
|
||||
|
||||
int childID = 0;
|
||||
if (wantContinents) {
|
||||
// Continent-spanning rows of real world maps, minus the World row.
|
||||
if (areaID != 0 || id == row || MapInstanceType(mapID) != 0)
|
||||
continue;
|
||||
if (Map::Area::ContinentRowForMapID(mapID) != id)
|
||||
continue; // skip the stray zero-rect duplicate
|
||||
childID = -id;
|
||||
} else {
|
||||
// Zone rows of this map.
|
||||
if (areaID == 0 || mapID != parentMapDbcID)
|
||||
continue;
|
||||
childID = areaID;
|
||||
}
|
||||
|
||||
outIdx += 1;
|
||||
Game::Lua::PushNumber(L, static_cast<double>(outIdx));
|
||||
if (!PushMapInfo(L, childID)) {
|
||||
Game::Lua::SetTop(L, -2); // drop the index; nothing to store
|
||||
outIdx -= 1;
|
||||
continue;
|
||||
}
|
||||
Game::Lua::SetTable(L, -3);
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
// `C_Map.GetMapInfoAtPosition(uiMapID, x, y)` — the zone at a normalized
|
||||
// (0..1) point on `uiMapID`, or nil when the point is on no zone.
|
||||
int __fastcall Script_GetMapInfoAtPosition(void *L) {
|
||||
const bool ok = Game::Lua::IsNumber(L, 1) && Game::Lua::IsNumber(L, 2) &&
|
||||
Game::Lua::IsNumber(L, 3);
|
||||
const int uiMapID = ok ? static_cast<int>(Game::Lua::ToNumber(L, 1)) : 0;
|
||||
const double px = ok ? Game::Lua::ToNumber(L, 2) : 0.0;
|
||||
const double py = ok ? Game::Lua::ToNumber(L, 3) : 0.0;
|
||||
|
||||
const int row = ok ? Map::Area::RowForUiMapID(uiMapID) : -1;
|
||||
double worldX = 0.0, worldY = 0.0;
|
||||
int mapID = 0;
|
||||
int areaID = 0;
|
||||
double mapX = 0.0, mapY = 0.0;
|
||||
const bool have =
|
||||
row > 0 &&
|
||||
Map::Area::RowRect(row, nullptr, nullptr, nullptr, nullptr, &mapID) &&
|
||||
Map::Area::WorldFromRow(row, px, py, &worldX, &worldY) &&
|
||||
Map::Area::ZonePercent(mapID, static_cast<float>(worldX),
|
||||
static_cast<float>(worldY), &areaID, &mapX, &mapY);
|
||||
|
||||
Game::Lua::SetTop(L, 0);
|
||||
if (!have || !PushMapInfo(L, areaID))
|
||||
Game::Lua::PushNil(L);
|
||||
return 1;
|
||||
}
|
||||
|
||||
// A map's background is this many tiles (`NUM_WORLDMAP_DETAIL_TILES`).
|
||||
constexpr int kWorldMapDetailTiles = 12;
|
||||
|
||||
bool FileExists(const char *path) {
|
||||
using FileExists_t = int(__stdcall *)(const char *path, int mode);
|
||||
auto fn = reinterpret_cast<FileExists_t>(
|
||||
static_cast<uintptr_t>(Offsets::FUN_FILE_EXISTS));
|
||||
return fn(path, 1) != 0;
|
||||
}
|
||||
|
||||
// Builds tile `n`'s texture path (1-based) for map directory `dir`, without
|
||||
// the file extension — the form `SetTexture` takes.
|
||||
void BuildTilePath(char *out, size_t size, const char *dir, int n) {
|
||||
std::snprintf(out, size, "Interface\\WorldMap\\%s\\%s%d", dir, dir, n);
|
||||
}
|
||||
|
||||
// True when the client ships the map directory's first background tile —
|
||||
// the test for "this map has art".
|
||||
bool DirHasArt(const char *dir) {
|
||||
if (dir == nullptr || dir[0] == '\0')
|
||||
return false;
|
||||
char path[0x120];
|
||||
BuildTilePath(path, sizeof(path), dir, 1);
|
||||
char probe[0x140];
|
||||
std::snprintf(probe, sizeof(probe), "%s.blp", path);
|
||||
return FileExists(probe);
|
||||
}
|
||||
|
||||
// `C_Map.MapHasArt(uiMapID)` — whether the map has a drawable background.
|
||||
//
|
||||
// A map's art is the tile set `Interface\WorldMap\<dir>\<dir>1..12`, where
|
||||
// `<dir>` is the WorldMapArea row's own name — the same directory the world
|
||||
// map frame builds its tile paths from. So the question is answered by
|
||||
// probing for the first tile: a row with no name, or one whose art the
|
||||
// client doesn't ship, has none.
|
||||
//
|
||||
// Note this is independent of whether a map has a coordinate rect. The
|
||||
// whole-world map has art but no rect, and an instance row can have a rect
|
||||
// but ship no art.
|
||||
int __fastcall Script_MapHasArt(void *L) {
|
||||
const bool ok = Game::Lua::IsNumber(L, 1);
|
||||
const int uiMapID = ok ? static_cast<int>(Game::Lua::ToNumber(L, 1)) : 0;
|
||||
const int row = ok ? Map::Area::RowForUiMapID(uiMapID) : -1;
|
||||
|
||||
const bool hasArt = DirHasArt(RowName(row));
|
||||
|
||||
Game::Lua::SetTop(L, 0);
|
||||
Game::Lua::PushBool(L, hasArt);
|
||||
return 1;
|
||||
}
|
||||
|
||||
// `C_Map.GetMapArtLayers(uiMapID)` — the map's art layers, as an array of
|
||||
// layer descriptions. Always returns a table; a map with no art gives an
|
||||
// empty one.
|
||||
//
|
||||
// This client draws a map's background as one fixed layer, so there is at
|
||||
// most a single entry, and it is the same for every map that has art: a
|
||||
// 1002x668 canvas tiled in 256-pixel squares (a 4x3 grid — the twelve tiles
|
||||
// the world map frame draws). There is no zoom within a map, so the scale
|
||||
// range is 1 to 1 with no extra zoom steps.
|
||||
int __fastcall Script_GetMapArtLayers(void *L) {
|
||||
const bool ok = Game::Lua::IsNumber(L, 1);
|
||||
const int uiMapID = ok ? static_cast<int>(Game::Lua::ToNumber(L, 1)) : 0;
|
||||
const int row = ok ? Map::Area::RowForUiMapID(uiMapID) : -1;
|
||||
const bool hasArt = DirHasArt(RowName(row));
|
||||
|
||||
Game::Lua::SetTop(L, 0);
|
||||
Game::Lua::NewTable(L);
|
||||
if (!hasArt)
|
||||
return 1;
|
||||
|
||||
Game::Lua::PushNumber(L, 1.0);
|
||||
Game::Lua::NewTable(L);
|
||||
Game::Lua::SetFieldNumber(L, "layerWidth", Map::Area::kMapCanvasWidth);
|
||||
Game::Lua::SetFieldNumber(L, "layerHeight", Map::Area::kMapCanvasHeight);
|
||||
Game::Lua::SetFieldNumber(L, "tileWidth",
|
||||
static_cast<double>(Map::Area::kMapTileSize));
|
||||
Game::Lua::SetFieldNumber(L, "tileHeight",
|
||||
static_cast<double>(Map::Area::kMapTileSize));
|
||||
Game::Lua::SetFieldNumber(L, "minScale", 1.0);
|
||||
Game::Lua::SetFieldNumber(L, "maxScale", 1.0);
|
||||
Game::Lua::SetFieldNumber(L, "additionalZoomSteps", 0.0);
|
||||
Game::Lua::SetTable(L, -3);
|
||||
return 1;
|
||||
}
|
||||
|
||||
// `C_Map.GetMapArtLayerTextures(uiMapID, layerIndex)` — the background tile
|
||||
// textures for one of a map's art layers, in draw order (left to right, top
|
||||
// to bottom).
|
||||
//
|
||||
// A map here has exactly one art layer, the 12-tile background the world map
|
||||
// frame draws, so `layerIndex` 1 returns it and any other index returns an
|
||||
// empty table. Always returns a table.
|
||||
//
|
||||
// Entries are texture PATHS, ready to hand to `SetTexture`, rather than the
|
||||
// numeric file ids the modern call returns — this client identifies a texture
|
||||
// by its path, which is the same substitution `C_Map.GetMapOverlays` makes
|
||||
// for its `fileDataIDs`. Tiles the client doesn't ship are skipped, so a map
|
||||
// with no art gives an empty table.
|
||||
int __fastcall Script_GetMapArtLayerTextures(void *L) {
|
||||
const bool ok = Game::Lua::IsNumber(L, 1) && Game::Lua::IsNumber(L, 2);
|
||||
const int uiMapID = ok ? static_cast<int>(Game::Lua::ToNumber(L, 1)) : 0;
|
||||
const int layerIndex = ok ? static_cast<int>(Game::Lua::ToNumber(L, 2)) : 0;
|
||||
const int row = ok ? Map::Area::RowForUiMapID(uiMapID) : -1;
|
||||
const char *dir = (layerIndex == 1) ? RowName(row) : nullptr;
|
||||
|
||||
Game::Lua::SetTop(L, 0);
|
||||
Game::Lua::NewTable(L);
|
||||
if (dir == nullptr || dir[0] == '\0')
|
||||
return 1;
|
||||
|
||||
int outIdx = 0;
|
||||
for (int n = 1; n <= kWorldMapDetailTiles; ++n) {
|
||||
char path[0x120];
|
||||
BuildTilePath(path, sizeof(path), dir, n);
|
||||
char probe[0x140];
|
||||
std::snprintf(probe, sizeof(probe), "%s.blp", path);
|
||||
if (!FileExists(probe))
|
||||
continue;
|
||||
outIdx += 1;
|
||||
Game::Lua::PushNumber(L, static_cast<double>(outIdx));
|
||||
Game::Lua::PushString(L, path);
|
||||
Game::Lua::SetTable(L, -3);
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
// `C_Map.GetFallbackWorldMapID()` — the uiMapID of the whole-world map, the
|
||||
// safe default when no better map is known.
|
||||
int __fastcall Script_GetFallbackWorldMapID(void *L) {
|
||||
const int row = WorldRow();
|
||||
Game::Lua::SetTop(L, 0);
|
||||
Game::Lua::PushNumber(L, (row > 0) ? static_cast<double>(-row) : 0.0);
|
||||
return 1;
|
||||
}
|
||||
|
||||
void RegisterLuaFunctions() {
|
||||
Game::Lua::RegisterTableFunction("C_Map", "GetMapInfo", &Script_GetMapInfo);
|
||||
Game::Lua::RegisterTableFunction("C_Map", "GetMapChildrenInfo",
|
||||
&Script_GetMapChildrenInfo);
|
||||
Game::Lua::RegisterTableFunction("C_Map", "GetMapInfoAtPosition",
|
||||
&Script_GetMapInfoAtPosition);
|
||||
Game::Lua::RegisterTableFunction("C_Map", "GetFallbackWorldMapID",
|
||||
&Script_GetFallbackWorldMapID);
|
||||
Game::Lua::RegisterTableFunction("C_Map", "MapHasArt", &Script_MapHasArt);
|
||||
Game::Lua::RegisterTableFunction("C_Map", "GetMapArtLayerTextures",
|
||||
&Script_GetMapArtLayerTextures);
|
||||
Game::Lua::RegisterTableFunction("C_Map", "GetMapArtLayers",
|
||||
&Script_GetMapArtLayers);
|
||||
}
|
||||
|
||||
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
|
||||
|
||||
} // namespace
|
||||
|
||||
} // namespace Map::MapInfo
|
||||
@@ -377,15 +377,12 @@ void PushTiles(void *L, const ResolvedOverlay *r, const char *basePath,
|
||||
}
|
||||
}
|
||||
|
||||
using ResolveUnitToken_t = void *(__fastcall *)(const char *token);
|
||||
|
||||
// The local player's explored-areas bitfield (`[player+0xE68] + 0xE6C`), or
|
||||
// null before the player is resident. Bit `AreaBit` set == that AreaTable
|
||||
// area is explored. Same storage the engine's discovered-overlay rebuild
|
||||
// (FUN_004a67a0) reads.
|
||||
const uint8_t *ExplorationBits() {
|
||||
auto fn = reinterpret_cast<ResolveUnitToken_t>(Offsets::FUN_RESOLVE_UNIT_TOKEN);
|
||||
auto *player = static_cast<const uint8_t *>(fn("player"));
|
||||
auto *player = static_cast<const uint8_t *>(Game::ResolveUnitToken("player"));
|
||||
if (player == nullptr)
|
||||
return nullptr;
|
||||
auto *sub = Game::Read<const uint8_t *>(player, Offsets::OFF_CGPLAYER_INFO);
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
// This file is part of ClassicAPI.
|
||||
//
|
||||
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
|
||||
// of the GNU General Public License as published by the Free Software Foundation, either
|
||||
// version 3 of the License, or (at your option) any later version.
|
||||
//
|
||||
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
|
||||
// PURPOSE. See the GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License along with
|
||||
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "Game.h"
|
||||
|
||||
// Shared `vector2` marshalling for the `C_Map` surface. Map positions cross
|
||||
// the C/Lua boundary as `Vector2DMixin` objects (so consumers can call
|
||||
// `pos:GetXY()` as well as read `.x` / `.y`), and several modules both return
|
||||
// and accept them — this keeps the stack handling in one place.
|
||||
|
||||
namespace Map {
|
||||
|
||||
// Pushes a `Vector2DMixin` built from (x, y) via the `CreateVector2D` global.
|
||||
// Falls back to a plain `{x=, y=}` table when that global isn't reachable
|
||||
// (the addon half isn't loaded). Leaves exactly one value on the stack.
|
||||
inline void PushVector2D(void *L, double x, double y) {
|
||||
const int base = Game::Lua::GetTop(L);
|
||||
Game::Lua::PushString(L, "CreateVector2D");
|
||||
Game::Lua::GetTable(L, Game::Lua::GLOBALS_INDEX);
|
||||
Game::Lua::PushNumber(L, x);
|
||||
Game::Lua::PushNumber(L, y);
|
||||
if (Game::Lua::PCall(L, 2, 1, 0) == 0)
|
||||
return; // Vector2DMixin object on top
|
||||
// pcall failed (global missing / not callable): drop the error object and
|
||||
// build a plain coordinate table instead.
|
||||
Game::Lua::SetTop(L, base);
|
||||
Game::Lua::NewTable(L);
|
||||
Game::Lua::SetFieldNumber(L, "x", x);
|
||||
Game::Lua::SetFieldNumber(L, "y", y);
|
||||
}
|
||||
|
||||
// Reads `t.x` / `t.y` from the table at absolute stack index `idx` (a
|
||||
// `Vector2DMixin` or a plain `{x, y}`). `idx` must be positive so it survives
|
||||
// the key pushes. Returns false when the slot isn't a table. Stack-balanced.
|
||||
inline bool ReadVector2D(void *L, int idx, double *x, double *y) {
|
||||
if (Game::Lua::Type(L, idx) != Game::Lua::TYPE_TABLE)
|
||||
return false;
|
||||
Game::Lua::PushString(L, "x");
|
||||
Game::Lua::GetTable(L, idx);
|
||||
*x = Game::Lua::ToNumber(L, -1);
|
||||
Game::Lua::SetTop(L, -2);
|
||||
Game::Lua::PushString(L, "y");
|
||||
Game::Lua::GetTable(L, idx);
|
||||
*y = Game::Lua::ToNumber(L, -1);
|
||||
Game::Lua::SetTop(L, -2);
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace Map
|
||||
@@ -0,0 +1,309 @@
|
||||
// This file is part of ClassicAPI.
|
||||
//
|
||||
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
|
||||
// of the GNU General Public License as published by the Free Software Foundation, either
|
||||
// version 3 of the License, or (at your option) any later version.
|
||||
//
|
||||
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
|
||||
// PURPOSE. See the GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License along with
|
||||
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
// `C_Map` user waypoint — the single player-placed map pin:
|
||||
//
|
||||
// CanSetUserWaypointOnMap(uiMapID) -> bool
|
||||
// SetUserWaypoint(uiMapPoint) -> wasSet
|
||||
// GetUserWaypoint() -> UiMapPoint
|
||||
// HasUserWaypoint() -> bool
|
||||
// ClearUserWaypoint()
|
||||
// GetUserWaypointPositionForMap(id) -> vector2
|
||||
// GetUserWaypointHyperlink() -> worldmap link string
|
||||
// GetUserWaypointFromHyperlink(link) -> UiMapPoint
|
||||
//
|
||||
// Fires `USER_WAYPOINT_UPDATED` whenever the waypoint changes.
|
||||
//
|
||||
// The waypoint is one piece of client-side state: a `uiMapID` (zone or
|
||||
// continent — see `Map::MapInfo`), a 0..1 position on that map, and an
|
||||
// optional `z`. It lives for the session and survives a UI reload.
|
||||
//
|
||||
// `GetUserWaypointPositionForMap` re-projects the pin onto a DIFFERENT map by
|
||||
// going through world coordinates (`Map::Area`'s row transforms), so a pin
|
||||
// dropped on a zone still resolves on the continent map and the reverse.
|
||||
//
|
||||
// The hyperlink is the standard `worldmap:uiMapID:x:y` payload with both
|
||||
// coordinates scaled by 10000, wrapped in a clickable link.
|
||||
|
||||
#include "Game.h"
|
||||
#include "event/Custom.h"
|
||||
#include "map/Area.h"
|
||||
#include "map/Vector2.h"
|
||||
|
||||
#include <cstdint>
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
|
||||
namespace Map::Waypoint {
|
||||
|
||||
namespace {
|
||||
|
||||
const Event::Custom::AutoReserve _evtUpdated{"USER_WAYPOINT_UPDATED"};
|
||||
|
||||
struct UserWaypoint {
|
||||
bool has = false;
|
||||
int uiMapID = 0;
|
||||
double x = 0.0;
|
||||
double y = 0.0;
|
||||
bool hasZ = false;
|
||||
double z = 0.0;
|
||||
};
|
||||
|
||||
UserWaypoint g_wp;
|
||||
|
||||
void FireUpdated() { Event::Custom::Fire(_evtUpdated.Slot(), ""); }
|
||||
|
||||
// A map can hold a pin when it has a usable coordinate rect. The world map's
|
||||
// row carries an all-zero rect, so it correctly reports false.
|
||||
bool MapSupportsWaypoint(int uiMapID) {
|
||||
const int row = Map::Area::RowForUiMapID(uiMapID);
|
||||
double left, right, top, bottom;
|
||||
if (row <= 0 || !Map::Area::RowRect(row, &left, &right, &top, &bottom, nullptr))
|
||||
return false;
|
||||
return (left - right) > 0.0 && (top - bottom) > 0.0;
|
||||
}
|
||||
|
||||
// Pushes a UiMapPoint table: { uiMapID, position (vector2), z? }.
|
||||
void PushUiMapPoint(void *L, int uiMapID, double x, double y, bool hasZ, double z) {
|
||||
Game::Lua::NewTable(L);
|
||||
Game::Lua::SetFieldNumber(L, "uiMapID", static_cast<double>(uiMapID));
|
||||
Game::Lua::PushString(L, "position");
|
||||
Map::PushVector2D(L, x, y);
|
||||
Game::Lua::SetTable(L, -3);
|
||||
if (hasZ)
|
||||
Game::Lua::SetFieldNumber(L, "z", z);
|
||||
}
|
||||
|
||||
// Reads a UiMapPoint from the table at absolute stack index `idx`.
|
||||
bool ReadUiMapPoint(void *L, int idx, int *uiMapID, double *x, double *y,
|
||||
bool *hasZ, double *z) {
|
||||
if (Game::Lua::Type(L, idx) != Game::Lua::TYPE_TABLE)
|
||||
return false;
|
||||
|
||||
Game::Lua::PushString(L, "uiMapID");
|
||||
Game::Lua::GetTable(L, idx);
|
||||
const bool idOk = Game::Lua::IsNumber(L, -1) != 0;
|
||||
if (idOk)
|
||||
*uiMapID = static_cast<int>(Game::Lua::ToNumber(L, -1));
|
||||
Game::Lua::SetTop(L, -2);
|
||||
if (!idOk)
|
||||
return false;
|
||||
|
||||
Game::Lua::PushString(L, "position");
|
||||
Game::Lua::GetTable(L, idx);
|
||||
const int posIdx = Game::Lua::GetTop(L);
|
||||
const bool posOk = Map::ReadVector2D(L, posIdx, x, y);
|
||||
Game::Lua::SetTop(L, -2);
|
||||
if (!posOk)
|
||||
return false;
|
||||
|
||||
Game::Lua::PushString(L, "z");
|
||||
Game::Lua::GetTable(L, idx);
|
||||
*hasZ = Game::Lua::IsNumber(L, -1) != 0;
|
||||
if (*hasZ)
|
||||
*z = Game::Lua::ToNumber(L, -1);
|
||||
Game::Lua::SetTop(L, -2);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Parses `worldmap:<uiMapID>:<x>:<y>` out of `s` — either the bare payload or
|
||||
// a full `|Hworldmap:…|h[…]|h` link. Coordinates arrive scaled by 10000.
|
||||
bool ParseWorldmapLink(const char *s, int *uiMapID, double *x, double *y) {
|
||||
if (s == nullptr)
|
||||
return false;
|
||||
const char *p = std::strstr(s, "worldmap:");
|
||||
if (p == nullptr)
|
||||
return false;
|
||||
p += 9; // past "worldmap:"
|
||||
|
||||
char *end = nullptr;
|
||||
const long id = std::strtol(p, &end, 10);
|
||||
if (end == p || *end != ':')
|
||||
return false;
|
||||
p = end + 1;
|
||||
const long xi = std::strtol(p, &end, 10);
|
||||
if (end == p || *end != ':')
|
||||
return false;
|
||||
p = end + 1;
|
||||
const long yi = std::strtol(p, &end, 10);
|
||||
if (end == p)
|
||||
return false;
|
||||
|
||||
*uiMapID = static_cast<int>(id);
|
||||
*x = static_cast<double>(xi) / 10000.0;
|
||||
*y = static_cast<double>(yi) / 10000.0;
|
||||
return true;
|
||||
}
|
||||
|
||||
// `C_Map.CanSetUserWaypointOnMap(uiMapID)` — whether a pin can be placed on
|
||||
// the given map.
|
||||
int __fastcall Script_CanSetUserWaypointOnMap(void *L) {
|
||||
const bool ok = Game::Lua::IsNumber(L, 1) != 0;
|
||||
const int uiMapID = ok ? static_cast<int>(Game::Lua::ToNumber(L, 1)) : 0;
|
||||
const bool can = ok && MapSupportsWaypoint(uiMapID);
|
||||
Game::Lua::SetTop(L, 0);
|
||||
Game::Lua::PushBool(L, can);
|
||||
return 1;
|
||||
}
|
||||
|
||||
// `C_Map.SetUserWaypoint(uiMapPoint)` — places the pin. Returns whether it
|
||||
// was set (false for a malformed point or a map that can't hold one).
|
||||
int __fastcall Script_SetUserWaypoint(void *L) {
|
||||
int uiMapID = 0;
|
||||
double x = 0.0, y = 0.0, z = 0.0;
|
||||
bool hasZ = false;
|
||||
const bool ok = ReadUiMapPoint(L, 1, &uiMapID, &x, &y, &hasZ, &z) &&
|
||||
MapSupportsWaypoint(uiMapID);
|
||||
|
||||
Game::Lua::SetTop(L, 0);
|
||||
if (!ok) {
|
||||
Game::Lua::PushBool(L, false);
|
||||
return 1;
|
||||
}
|
||||
g_wp.has = true;
|
||||
g_wp.uiMapID = uiMapID;
|
||||
g_wp.x = x;
|
||||
g_wp.y = y;
|
||||
g_wp.hasZ = hasZ;
|
||||
g_wp.z = z;
|
||||
FireUpdated();
|
||||
Game::Lua::PushBool(L, true);
|
||||
return 1;
|
||||
}
|
||||
|
||||
// `C_Map.GetUserWaypoint()` — the pin as a UiMapPoint, or nil.
|
||||
int __fastcall Script_GetUserWaypoint(void *L) {
|
||||
Game::Lua::SetTop(L, 0);
|
||||
if (!g_wp.has) {
|
||||
Game::Lua::PushNil(L);
|
||||
return 1;
|
||||
}
|
||||
PushUiMapPoint(L, g_wp.uiMapID, g_wp.x, g_wp.y, g_wp.hasZ, g_wp.z);
|
||||
return 1;
|
||||
}
|
||||
|
||||
// `C_Map.HasUserWaypoint()` — whether a pin is placed.
|
||||
int __fastcall Script_HasUserWaypoint(void *L) {
|
||||
Game::Lua::SetTop(L, 0);
|
||||
Game::Lua::PushBool(L, g_wp.has);
|
||||
return 1;
|
||||
}
|
||||
|
||||
// `C_Map.ClearUserWaypoint()` — removes the pin.
|
||||
int __fastcall Script_ClearUserWaypoint(void *L) {
|
||||
const bool had = g_wp.has;
|
||||
g_wp = UserWaypoint{};
|
||||
Game::Lua::SetTop(L, 0);
|
||||
if (had)
|
||||
FireUpdated();
|
||||
return 0;
|
||||
}
|
||||
|
||||
// `C_Map.GetUserWaypointPositionForMap(uiMapID)` — the pin's position on the
|
||||
// given map as a vector2, or nil when it doesn't land on that map. Re-projects
|
||||
// through world coordinates, so a zone pin resolves on the continent too.
|
||||
int __fastcall Script_GetUserWaypointPositionForMap(void *L) {
|
||||
const bool ok = Game::Lua::IsNumber(L, 1) != 0;
|
||||
const int wantID = ok ? static_cast<int>(Game::Lua::ToNumber(L, 1)) : 0;
|
||||
|
||||
bool have = false;
|
||||
double px = 0.0, py = 0.0;
|
||||
if (ok && g_wp.has) {
|
||||
if (wantID == g_wp.uiMapID) {
|
||||
px = g_wp.x;
|
||||
py = g_wp.y;
|
||||
have = true;
|
||||
} else {
|
||||
const int fromRow = Map::Area::RowForUiMapID(g_wp.uiMapID);
|
||||
const int toRow = Map::Area::RowForUiMapID(wantID);
|
||||
double worldX = 0.0, worldY = 0.0;
|
||||
have = fromRow > 0 && toRow > 0 &&
|
||||
Map::Area::WorldFromRow(fromRow, g_wp.x, g_wp.y, &worldX, &worldY) &&
|
||||
Map::Area::PercentInRow(toRow, static_cast<float>(worldX),
|
||||
static_cast<float>(worldY), &px, &py) &&
|
||||
px >= 0.0 && px <= 1.0 && py >= 0.0 && py <= 1.0;
|
||||
}
|
||||
}
|
||||
|
||||
Game::Lua::SetTop(L, 0);
|
||||
if (!have) {
|
||||
Game::Lua::PushNil(L);
|
||||
return 1;
|
||||
}
|
||||
Map::PushVector2D(L, px, py);
|
||||
return 1;
|
||||
}
|
||||
|
||||
// `C_Map.GetUserWaypointHyperlink()` — a clickable worldmap link for the pin,
|
||||
// or nil when none is placed.
|
||||
int __fastcall Script_GetUserWaypointHyperlink(void *L) {
|
||||
const bool has = g_wp.has;
|
||||
const int uiMapID = g_wp.uiMapID;
|
||||
const long xi = static_cast<long>(g_wp.x * 10000.0 + 0.5);
|
||||
const long yi = static_cast<long>(g_wp.y * 10000.0 + 0.5);
|
||||
|
||||
Game::Lua::SetTop(L, 0);
|
||||
if (!has) {
|
||||
Game::Lua::PushNil(L);
|
||||
return 1;
|
||||
}
|
||||
char link[160];
|
||||
std::snprintf(link, sizeof(link),
|
||||
"|cffffff00|Hworldmap:%d:%ld:%ld|h[Map Pin Location]|h|r",
|
||||
uiMapID, xi, yi);
|
||||
Game::Lua::PushString(L, link);
|
||||
return 1;
|
||||
}
|
||||
|
||||
// `C_Map.GetUserWaypointFromHyperlink(hyperlink)` — the UiMapPoint a worldmap
|
||||
// link describes, or nil when the string isn't one.
|
||||
int __fastcall Script_GetUserWaypointFromHyperlink(void *L) {
|
||||
const char *s = Game::Lua::IsString(L, 1) ? Game::Lua::ToString(L, 1) : nullptr;
|
||||
int uiMapID = 0;
|
||||
double x = 0.0, y = 0.0;
|
||||
const bool ok = ParseWorldmapLink(s, &uiMapID, &x, &y);
|
||||
|
||||
Game::Lua::SetTop(L, 0);
|
||||
if (!ok) {
|
||||
Game::Lua::PushNil(L);
|
||||
return 1;
|
||||
}
|
||||
PushUiMapPoint(L, uiMapID, x, y, /*hasZ=*/false, 0.0);
|
||||
return 1;
|
||||
}
|
||||
|
||||
void RegisterLuaFunctions() {
|
||||
Game::Lua::RegisterTableFunction("C_Map", "CanSetUserWaypointOnMap",
|
||||
&Script_CanSetUserWaypointOnMap);
|
||||
Game::Lua::RegisterTableFunction("C_Map", "SetUserWaypoint",
|
||||
&Script_SetUserWaypoint);
|
||||
Game::Lua::RegisterTableFunction("C_Map", "GetUserWaypoint",
|
||||
&Script_GetUserWaypoint);
|
||||
Game::Lua::RegisterTableFunction("C_Map", "HasUserWaypoint",
|
||||
&Script_HasUserWaypoint);
|
||||
Game::Lua::RegisterTableFunction("C_Map", "ClearUserWaypoint",
|
||||
&Script_ClearUserWaypoint);
|
||||
Game::Lua::RegisterTableFunction("C_Map", "GetUserWaypointPositionForMap",
|
||||
&Script_GetUserWaypointPositionForMap);
|
||||
Game::Lua::RegisterTableFunction("C_Map", "GetUserWaypointHyperlink",
|
||||
&Script_GetUserWaypointHyperlink);
|
||||
Game::Lua::RegisterTableFunction("C_Map", "GetUserWaypointFromHyperlink",
|
||||
&Script_GetUserWaypointFromHyperlink);
|
||||
}
|
||||
|
||||
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
|
||||
|
||||
} // namespace
|
||||
|
||||
} // namespace Map::Waypoint
|
||||
@@ -433,4 +433,6 @@ void PrepareForReload() {
|
||||
CancelJob(job);
|
||||
}
|
||||
|
||||
static const Game::ReloadAutoRegister _reloadReg{&PrepareForReload};
|
||||
|
||||
} // namespace Model::DisplayInfo
|
||||
|
||||
+13
-15
@@ -59,13 +59,9 @@ namespace NamePlate::Events {
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr const char *kEventCreated = "NAME_PLATE_CREATED";
|
||||
constexpr const char *kEventUnitAdded = "NAME_PLATE_UNIT_ADDED";
|
||||
constexpr const char *kEventUnitRemoved = "NAME_PLATE_UNIT_REMOVED";
|
||||
|
||||
const Event::Custom::AutoReserve _r1{kEventCreated};
|
||||
const Event::Custom::AutoReserve _r2{kEventUnitAdded};
|
||||
const Event::Custom::AutoReserve _r3{kEventUnitRemoved};
|
||||
const Event::Custom::AutoReserve _evtCreated{"NAME_PLATE_CREATED"};
|
||||
const Event::Custom::AutoReserve _evtUnitAdded{"NAME_PLATE_UNIT_ADDED"};
|
||||
const Event::Custom::AutoReserve _evtUnitRemoved{"NAME_PLATE_UNIT_REMOVED"};
|
||||
|
||||
// Previous tick's snapshot — GUID → nameplate-frame pointer for each
|
||||
// nameplated unit. Compared against the next tick's walk to compute
|
||||
@@ -112,14 +108,14 @@ int AssignSlot(uint64_t guid) {
|
||||
return static_cast<int>(g_slots.size() - 1);
|
||||
}
|
||||
|
||||
// Fire `eventName` with a pre-formatted string as `arg1`. The engine
|
||||
// Fire `event` with a pre-formatted string as `arg1`. The engine
|
||||
// dispatcher's `%s` format code pushes the C string into `_G.arg1`
|
||||
// as a Lua string — no escaping concerns for our own input
|
||||
// (`"nameplateN"`).
|
||||
void FireWithString(const char *eventName, const char *value) {
|
||||
void FireWithString(const Event::Custom::AutoReserve &event, const char *value) {
|
||||
if (value == nullptr)
|
||||
return;
|
||||
const int slot = Event::Custom::Lookup(eventName);
|
||||
const int slot = event.Slot();
|
||||
if (slot < 0)
|
||||
return;
|
||||
Event::Custom::Fire(slot, "%s", value);
|
||||
@@ -172,10 +168,10 @@ using LuaRefRef_t = int(__fastcall *)(void *L, int t);
|
||||
using LuaRefUnref_t = void(__fastcall *)(void *L, int t, int ref);
|
||||
using LuaRawGetI_t = void(__fastcall *)(void *L, int t, int n);
|
||||
|
||||
void FireWithFrame(const char *eventName, void *frame) {
|
||||
void FireWithFrame(const Event::Custom::AutoReserve &event, void *frame) {
|
||||
if (frame == nullptr)
|
||||
return;
|
||||
const int slot = Event::Custom::Lookup(eventName);
|
||||
const int slot = event.Slot();
|
||||
if (slot < 0)
|
||||
return;
|
||||
|
||||
@@ -229,7 +225,7 @@ void OnWorldTick() {
|
||||
// event handler.
|
||||
for (const auto &kv : g_currentTickPlates) {
|
||||
if (g_seenPlates.insert(kv.second).second)
|
||||
FireWithFrame(kEventCreated, const_cast<void *>(kv.second));
|
||||
FireWithFrame(_evtCreated, const_cast<void *>(kv.second));
|
||||
if (g_lastTickPlates.find(kv.first) == g_lastTickPlates.end()) {
|
||||
const int slot = AssignSlot(kv.first);
|
||||
// Watch this unit's fields so UNIT_HEALTH/UNIT_AURA/… fire with
|
||||
@@ -237,7 +233,7 @@ void OnWorldTick() {
|
||||
// target/party/raid tokens, not nameplates).
|
||||
Unit::TokenObserver::Register(kv.first, &NamePlateFieldCb);
|
||||
char tokenBuf[24];
|
||||
FireWithString(kEventUnitAdded,
|
||||
FireWithString(_evtUnitAdded,
|
||||
FormatNamePlateToken(tokenBuf, sizeof tokenBuf, slot + 1));
|
||||
}
|
||||
}
|
||||
@@ -254,7 +250,7 @@ void OnWorldTick() {
|
||||
continue;
|
||||
const int oneBased = static_cast<int>(it - g_slots.begin()) + 1;
|
||||
char tokenBuf[24];
|
||||
FireWithString(kEventUnitRemoved,
|
||||
FireWithString(_evtUnitRemoved,
|
||||
FormatNamePlateToken(tokenBuf, sizeof tokenBuf, oneBased));
|
||||
Unit::TokenObserver::Unregister(kv.first, &NamePlateFieldCb);
|
||||
*it = 0; // free the slot (no shift of survivors)
|
||||
@@ -294,6 +290,8 @@ void PrepareForReload() {
|
||||
g_slots.clear();
|
||||
}
|
||||
|
||||
static const Game::ReloadAutoRegister _reloadReg{&PrepareForReload};
|
||||
|
||||
// Exposed via `nameplate/Walk.h` so the `nameplateN` token resolver
|
||||
// in `unit/TokenExtensions.cpp` can map an index to a GUID without seeing
|
||||
// the internal array. Returns 0 for an out-of-range OR currently-free slot.
|
||||
|
||||
@@ -109,8 +109,7 @@ int __fastcall EquipSlotChanged_cb(uint32_t fieldOffset, uint32_t /*size*/,
|
||||
const int slot0 =
|
||||
static_cast<int>(fieldOffset - Offsets::OFF_DESC_PLAYER_EQUIP_FIRST) >> 3;
|
||||
if (slot0 >= 0 && slot0 < Offsets::DESC_PLAYER_EQUIP_SLOTS) {
|
||||
Event::Custom::FireIdSuccess(Event::Custom::Lookup(kEvtEquipmentChanged),
|
||||
slot0 + 1,
|
||||
Event::Custom::FireIdSuccess(_r.Slot(), slot0 + 1,
|
||||
SlotHasItem(guidLo, guidHi, slot0));
|
||||
// Gear swap changes item +healing and Spirit/Armor — invalidate the
|
||||
// GetSpellBonusHealing cache.
|
||||
@@ -225,7 +224,7 @@ void __fastcall AlertsRecompute_h() {
|
||||
return;
|
||||
}
|
||||
if (changed)
|
||||
Event::Custom::Fire(Event::Custom::Lookup(kEvtDurability), "");
|
||||
Event::Custom::Fire(_r2.Slot(), "");
|
||||
}
|
||||
|
||||
static const Game::HookAutoRegister _alertsHook{
|
||||
|
||||
@@ -68,8 +68,7 @@ void __fastcall BindPointUpdateHandler_h(void *packetBuffer) {
|
||||
static_cast<uintptr_t>(Offsets::VAR_IN_WORLD)) == 0)
|
||||
return;
|
||||
|
||||
const int slot = Event::Custom::Lookup(kEventName);
|
||||
Event::Custom::Fire(slot, "");
|
||||
Event::Custom::Fire(_reserve.Slot(), "");
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
+13
-22
@@ -43,12 +43,12 @@ namespace Player::InputEvents {
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr const char *kStartedMovingEvent = "PLAYER_STARTED_MOVING";
|
||||
constexpr const char *kStoppedMovingEvent = "PLAYER_STOPPED_MOVING";
|
||||
constexpr const char *kStartedLookingEvent = "PLAYER_STARTED_LOOKING";
|
||||
constexpr const char *kStoppedLookingEvent = "PLAYER_STOPPED_LOOKING";
|
||||
constexpr const char *kStartedTurningEvent = "PLAYER_STARTED_TURNING";
|
||||
constexpr const char *kStoppedTurningEvent = "PLAYER_STOPPED_TURNING";
|
||||
const Event::Custom::AutoReserve _reserveStartedMoving{"PLAYER_STARTED_MOVING"};
|
||||
const Event::Custom::AutoReserve _reserveStoppedMoving{"PLAYER_STOPPED_MOVING"};
|
||||
const Event::Custom::AutoReserve _reserveStartedLooking{"PLAYER_STARTED_LOOKING"};
|
||||
const Event::Custom::AutoReserve _reserveStoppedLooking{"PLAYER_STOPPED_LOOKING"};
|
||||
const Event::Custom::AutoReserve _reserveStartedTurning{"PLAYER_STARTED_TURNING"};
|
||||
const Event::Custom::AutoReserve _reserveStoppedTurning{"PLAYER_STOPPED_TURNING"};
|
||||
|
||||
// Below this per-frame delta, treat the yaw as stable. Picks up
|
||||
// real rotation (smallest user-perceptible RMB-drag is ~0.5°/frame
|
||||
@@ -65,15 +65,12 @@ bool g_havePrevBodyYaw = false;
|
||||
float g_prevCameraYaw = 0.0f;
|
||||
bool g_havePrevCameraYaw = false;
|
||||
|
||||
using ResolveUnitToken_t = void *(__fastcall *)(const char *token);
|
||||
|
||||
const uint8_t *Controller() {
|
||||
return Game::Read<const uint8_t *>(Offsets::VAR_UI_INPUT_CONTROLLER);
|
||||
}
|
||||
|
||||
const uint8_t *Player() {
|
||||
auto fn = reinterpret_cast<ResolveUnitToken_t>(Offsets::FUN_RESOLVE_UNIT_TOKEN);
|
||||
return static_cast<const uint8_t *>(fn("player"));
|
||||
return static_cast<const uint8_t *>(Game::ResolveUnitToken("player"));
|
||||
}
|
||||
|
||||
const uint8_t *Camera() {
|
||||
@@ -85,12 +82,12 @@ const uint8_t *Camera() {
|
||||
gameState, Offsets::OFF_GAME_STATE_CAMERA_PTR);
|
||||
}
|
||||
|
||||
void FireTransition(bool now, bool &prev, const char *startedName,
|
||||
const char *stoppedName) {
|
||||
void FireTransition(bool now, bool &prev, const Event::Custom::AutoReserve &started,
|
||||
const Event::Custom::AutoReserve &stopped) {
|
||||
if (now == prev)
|
||||
return;
|
||||
prev = now;
|
||||
const int slot = Event::Custom::Lookup(now ? startedName : stoppedName);
|
||||
const int slot = (now ? started : stopped).Slot();
|
||||
if (slot >= 0)
|
||||
Event::Custom::Fire(slot, "");
|
||||
}
|
||||
@@ -172,19 +169,13 @@ void OnWorldTick() {
|
||||
g_havePrevCameraYaw = true;
|
||||
}
|
||||
|
||||
FireTransition(moving, g_wasMoving, kStartedMovingEvent, kStoppedMovingEvent);
|
||||
FireTransition(turning, g_wasTurning, kStartedTurningEvent, kStoppedTurningEvent);
|
||||
FireTransition(looking, g_wasLooking, kStartedLookingEvent, kStoppedLookingEvent);
|
||||
FireTransition(moving, g_wasMoving, _reserveStartedMoving, _reserveStoppedMoving);
|
||||
FireTransition(turning, g_wasTurning, _reserveStartedTurning, _reserveStoppedTurning);
|
||||
FireTransition(looking, g_wasLooking, _reserveStartedLooking, _reserveStoppedLooking);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
static const Event::Custom::AutoReserve _reserveStartedMoving{kStartedMovingEvent};
|
||||
static const Event::Custom::AutoReserve _reserveStoppedMoving{kStoppedMovingEvent};
|
||||
static const Event::Custom::AutoReserve _reserveStartedLooking{kStartedLookingEvent};
|
||||
static const Event::Custom::AutoReserve _reserveStoppedLooking{kStoppedLookingEvent};
|
||||
static const Event::Custom::AutoReserve _reserveStartedTurning{kStartedTurningEvent};
|
||||
static const Event::Custom::AutoReserve _reserveStoppedTurning{kStoppedTurningEvent};
|
||||
static const Tick::WorldTick::AutoSubscribe _tickSub{&OnWorldTick};
|
||||
|
||||
} // namespace Player::InputEvents
|
||||
|
||||
+3
-3
@@ -33,8 +33,8 @@ static const Event::Custom::AutoReserve _reserveQuestDataLoadResult{kQuestDataLo
|
||||
static void __stdcall QuestLoadCallback(void *userData, int success) {
|
||||
const auto questID =
|
||||
static_cast<int>(reinterpret_cast<uintptr_t>(userData));
|
||||
Event::Custom::FireIdSuccess(Event::Custom::Lookup(kQuestDataLoadResult),
|
||||
questID, success != 0);
|
||||
Event::Custom::FireIdSuccess(_reserveQuestDataLoadResult.Slot(), questID,
|
||||
success != 0);
|
||||
}
|
||||
|
||||
static void RequestAndMaybeNotify(uint32_t questID) {
|
||||
@@ -45,7 +45,7 @@ static void RequestAndMaybeNotify(uint32_t questID) {
|
||||
// invoking our callback. Fire the event ourselves so addons get the
|
||||
// notification regardless of cache state, matching modern semantics.
|
||||
if (wasCached) {
|
||||
Event::Custom::FireIdSuccess(Event::Custom::Lookup(kQuestDataLoadResult),
|
||||
Event::Custom::FireIdSuccess(_reserveQuestDataLoadResult.Slot(),
|
||||
static_cast<int>(questID), true);
|
||||
}
|
||||
}
|
||||
|
||||
+1
-5
@@ -21,8 +21,6 @@ namespace Quest::Log {
|
||||
|
||||
namespace {
|
||||
|
||||
using ResolveUnitToken_t = void *(__fastcall *)(const char *token);
|
||||
|
||||
// Walks the unit's `+0xE68` sub-struct quest list (20 slots, stride
|
||||
// 0xC) and returns true on first match. Mirrors the engine's loop in
|
||||
// `Script_IsUnitOnQuest` (`0x004DFE10`).
|
||||
@@ -225,9 +223,7 @@ static int __fastcall Script_IsUnitOnQuest(void *L) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
auto resolve = reinterpret_cast<ResolveUnitToken_t>(
|
||||
static_cast<uintptr_t>(Offsets::FUN_RESOLVE_UNIT_TOKEN));
|
||||
auto *unit = static_cast<const uint8_t *>(resolve(token));
|
||||
auto *unit = static_cast<const uint8_t *>(Game::ResolveUnitToken(token));
|
||||
Game::Lua::PushBool(L, UnitHasQuest(unit, target));
|
||||
return 1;
|
||||
}
|
||||
|
||||
@@ -157,7 +157,7 @@ void __fastcall QuestLogRebuild_h(int param_1) {
|
||||
// (login or post-reload). Modern WoW's QUEST_ACCEPTED doesn't
|
||||
// fire on initial sync either; we match that.
|
||||
if (newCount == 1) {
|
||||
const int slot = Event::Custom::Lookup(kAcceptedEventName);
|
||||
const int slot = _reserveAccepted.Slot();
|
||||
if (slot >= 0)
|
||||
Event::Custom::Fire(slot, "%d%d", firstNewIdx, firstNewID);
|
||||
}
|
||||
@@ -168,7 +168,7 @@ void __fastcall QuestLogRebuild_h(int param_1) {
|
||||
// all but one of the previous character's quests (1 removal, N
|
||||
// additions in the same rebuild).
|
||||
if (removedCount == 1 && newCount <= 1) {
|
||||
const int slot = Event::Custom::Lookup(kRemovedEventName);
|
||||
const int slot = _reserveRemoved.Slot();
|
||||
if (slot >= 0)
|
||||
Event::Custom::Fire(slot, "%d", firstRemovedID);
|
||||
}
|
||||
|
||||
@@ -77,8 +77,7 @@ int __fastcall Handler_h(void *thisPlayer, void *edx, void *packetStream) {
|
||||
const int result = Handler_o(thisPlayer, edx, packetStream);
|
||||
|
||||
if (questID > 0) {
|
||||
const int slot = Event::Custom::Lookup(kEventName);
|
||||
Event::Custom::Fire(slot, "%u%u%u",
|
||||
Event::Custom::Fire(_reserve.Slot(), "%u%u%u",
|
||||
questID, xpReward, moneyReward);
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
|
||||
#include "Account.h"
|
||||
|
||||
#include "Offsets.h"
|
||||
#include "Paths.h"
|
||||
|
||||
#include <cstdint>
|
||||
#include <cstdio>
|
||||
@@ -27,11 +27,6 @@ AutoRegister *g_head = nullptr;
|
||||
std::string g_loadedAccount;
|
||||
bool g_anyLoadAttempted = false;
|
||||
|
||||
const char *ReadAccountName() {
|
||||
return *reinterpret_cast<const char *const *>(
|
||||
static_cast<uintptr_t>(Offsets::VAR_ACCOUNT_NAME_PTR));
|
||||
}
|
||||
|
||||
void ResetAllSections() {
|
||||
for (auto *s = g_head; s != nullptr; s = s->next)
|
||||
s->reset();
|
||||
@@ -62,19 +57,10 @@ AutoRegister::AutoRegister(ResetFn r, SerializeFn s, ParseFn p)
|
||||
g_head = this;
|
||||
}
|
||||
|
||||
std::string Path() {
|
||||
const char *acct = ReadAccountName();
|
||||
if (acct == nullptr || acct[0] == '\0')
|
||||
return {};
|
||||
std::string out = "WTF\\Account\\";
|
||||
out += acct;
|
||||
out += "\\ClassicAPI.txt";
|
||||
return out;
|
||||
}
|
||||
std::string Path() { return Paths::AccountFile("ClassicAPI.txt"); }
|
||||
|
||||
bool EnsureLoaded() {
|
||||
const char *acctRaw = ReadAccountName();
|
||||
const std::string account = (acctRaw != nullptr) ? acctRaw : "";
|
||||
const std::string account = Paths::AccountName();
|
||||
if (account.empty())
|
||||
return false;
|
||||
if (g_anyLoadAttempted && g_loadedAccount == account)
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
// This file is part of ClassicAPI.
|
||||
//
|
||||
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
|
||||
// of the GNU General Public License as published by the Free Software Foundation, either
|
||||
// version 3 of the License, or (at your option) any later version.
|
||||
//
|
||||
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
|
||||
// PURPOSE. See the GNU General Public License for more details.
|
||||
|
||||
// See `Paths.h`. The account / realm / character names each come from a
|
||||
// different engine global, so the three reads live here together rather than
|
||||
// once per module that wants a file path.
|
||||
|
||||
#include "Paths.h"
|
||||
|
||||
#include "Game.h"
|
||||
#include "Offsets.h"
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
namespace Settings::Paths {
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr const char *kAccountRoot = "WTF\\Account\\";
|
||||
|
||||
const char *ReadAccountName() {
|
||||
return Game::Read<const char *>(Offsets::VAR_ACCOUNT_NAME_PTR);
|
||||
}
|
||||
|
||||
const char *ReadRealmName() {
|
||||
auto *info = Game::Read<uint8_t *>(Offsets::VAR_REALM_INFO_PTR);
|
||||
if (info == nullptr)
|
||||
return nullptr;
|
||||
return Game::Read<const char *>(info, Offsets::OFF_REALM_INFO_NAME);
|
||||
}
|
||||
|
||||
// The global is an inline buffer rather than a pointer, so "absent" reads as an
|
||||
// empty string instead of null.
|
||||
const char *ReadCharacterName() {
|
||||
auto *p = reinterpret_cast<const char *>(Offsets::VAR_CHARACTER_NAME);
|
||||
return (p[0] == '\0') ? nullptr : p;
|
||||
}
|
||||
|
||||
bool Usable(const char *s) { return s != nullptr && s[0] != '\0'; }
|
||||
|
||||
} // namespace
|
||||
|
||||
std::string AccountName() {
|
||||
const char *account = ReadAccountName();
|
||||
return Usable(account) ? std::string(account) : std::string();
|
||||
}
|
||||
|
||||
std::string AccountFile(const char *leaf) {
|
||||
const char *account = ReadAccountName();
|
||||
if (!Usable(account))
|
||||
return {};
|
||||
std::string out = kAccountRoot;
|
||||
out += account;
|
||||
out += '\\';
|
||||
out += leaf;
|
||||
return out;
|
||||
}
|
||||
|
||||
std::string CharacterFile(const char *leaf) {
|
||||
const char *account = ReadAccountName();
|
||||
if (!Usable(account))
|
||||
return {};
|
||||
const char *realm = ReadRealmName();
|
||||
if (!Usable(realm))
|
||||
return {};
|
||||
const char *character = ReadCharacterName();
|
||||
if (!Usable(character))
|
||||
return {};
|
||||
std::string out = kAccountRoot;
|
||||
out += account;
|
||||
out += '\\';
|
||||
out += realm;
|
||||
out += '\\';
|
||||
out += character;
|
||||
out += '\\';
|
||||
out += leaf;
|
||||
return out;
|
||||
}
|
||||
|
||||
} // namespace Settings::Paths
|
||||
@@ -0,0 +1,47 @@
|
||||
// This file is part of ClassicAPI.
|
||||
//
|
||||
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
|
||||
// of the GNU General Public License as published by the Free Software Foundation, either
|
||||
// version 3 of the License, or (at your option) any later version.
|
||||
//
|
||||
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
|
||||
// PURPOSE. See the GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License along with
|
||||
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
|
||||
// `Settings::Paths` — the one place ClassicAPI builds a path to its own data
|
||||
// files under `WTF\Account\`.
|
||||
//
|
||||
// Three modules used to resolve these independently, each re-reading the same
|
||||
// account / realm / character globals and re-joining the same separators. The
|
||||
// engine's own `WTF\Account\%s` format strings are deliberately NOT used: they
|
||||
// belong to the engine's saved-variable layout, and a module mirroring THAT
|
||||
// (see `AddOns::SavedVarsFirst`) has to match it byte for byte and so keeps its
|
||||
// own copies. These functions describe ClassicAPI's files, which is a separate
|
||||
// concern that happens to share a directory.
|
||||
//
|
||||
// Every function returns an empty string until the session globals it needs are
|
||||
// populated, so a caller can treat "empty" as "not available yet" and never has
|
||||
// to null-check the individual names.
|
||||
namespace Settings::Paths {
|
||||
|
||||
// The logged-in account name, upper-cased by the engine. Empty before login.
|
||||
std::string AccountName();
|
||||
|
||||
// `WTF\Account\<account>\<leaf>` — for data shared by every character on the
|
||||
// account. Empty until the account is known.
|
||||
std::string AccountFile(const char *leaf);
|
||||
|
||||
// `WTF\Account\<account>\<realm>\<character>\<leaf>` — for per-character data.
|
||||
// Empty until all three are known, which is only true in-world: a
|
||||
// logout to character select clears the character, so callers that re-resolve
|
||||
// on each access pick up a new character without a reload.
|
||||
std::string CharacterFile(const char *leaf);
|
||||
|
||||
} // namespace Settings::Paths
|
||||
@@ -58,10 +58,7 @@ bool InWorld() {
|
||||
// Player resolves only when in-world. Same gate other modules
|
||||
// (e.g. `Item::Location::ResolveBag`) use to avoid touching
|
||||
// engine state during loading screens or glue.
|
||||
using ResolveUnitToken_t = void *(__fastcall *)(const char *);
|
||||
auto fn = reinterpret_cast<ResolveUnitToken_t>(
|
||||
Offsets::FUN_RESOLVE_UNIT_TOKEN);
|
||||
return fn("player") != nullptr;
|
||||
return Game::ResolveUnitToken("player") != nullptr;
|
||||
}
|
||||
|
||||
void CancelPlacement() {
|
||||
|
||||
+22
-12
@@ -70,6 +70,7 @@
|
||||
|
||||
#include "Game.h"
|
||||
#include "Offsets.h"
|
||||
#include "aura/Source.h"
|
||||
#include "dbc/Lookup.h"
|
||||
#include "net/PacketDispatch.h"
|
||||
#include "net/PacketReader.h"
|
||||
@@ -86,7 +87,6 @@ namespace Spell::Cast {
|
||||
|
||||
namespace {
|
||||
|
||||
using ResolveUnitToken_t = void *(__fastcall *)(const char *token);
|
||||
using GetCastTime_t = uint32_t(__fastcall *)(int spellID, int unit, int flag);
|
||||
using GetDuration_t = int(__fastcall *)(const uint8_t *spellRecord, int unit, int skipMod);
|
||||
|
||||
@@ -219,7 +219,7 @@ void PushMs(void *L, int ms) {
|
||||
}
|
||||
|
||||
void *Resolve(const char *token) {
|
||||
return reinterpret_cast<ResolveUnitToken_t>(Offsets::FUN_RESOLVE_UNIT_TOKEN)(token);
|
||||
return Game::ResolveUnitToken(token);
|
||||
}
|
||||
|
||||
// Effective cast time (ms) for the local player, 0 if instant. Uses the
|
||||
@@ -514,14 +514,18 @@ void HandleSpellStart(uint64_t caster, int spellID, uint32_t castTime,
|
||||
now - g_cast.startMs < kCastStartDedupMs) {
|
||||
// Confirming packet. Keep startMs (no visual restart), but SNAP the
|
||||
// end time to the server's authoritative castTime. The client stamp
|
||||
// came from FUN_006e3340, which folds in the caster's cast-speed /
|
||||
// ranged-haste multiplier (descriptor +0x22c) — right for spells the
|
||||
// server also hastes (Aimed/Multi-Shot), WRONG for ones it doesn't.
|
||||
// Volley is the case that surfaced this: not channeled, not sped by
|
||||
// Quick Shots server-side, so the hasted prediction ended the bar
|
||||
// early and the player was "still casting after the bar finished."
|
||||
// The server's castTime is reality; reconcile to it (preserving any
|
||||
// pushback already accumulated). A no-op when client and server agree.
|
||||
// came from FUN_006e3340, whose only caster factor is the cast-speed
|
||||
// multiplier at descriptor +0x22c (UNIT_MOD_CAST_SPEED — spell
|
||||
// haste); the server additionally scales ranged ABILITIES by ranged
|
||||
// attack speed (SpellEntry::GetCastTime's IsRangedSpell branch,
|
||||
// m_modAttackSpeedPct[RANGED_ATTACK] — quiver/Quick Shots), which
|
||||
// the prediction can't see. Two real divergences so far: Volley
|
||||
// (client +0x22c hastes it, server doesn't → bar ended early) and
|
||||
// Steady/Aimed Shot (server ranged-hastes it, client doesn't → bar
|
||||
// ran long, pfUI#43). The server's castTime is reality; reconcile
|
||||
// to it (preserving accumulated pushback). A no-op when they agree;
|
||||
// when they differ, PollPlayer sees the end move and fires
|
||||
// UNIT_SPELLCAST_DELAYED so cast bars re-read the corrected times.
|
||||
g_cast.endMs =
|
||||
g_cast.startMs + static_cast<int>(castTime) + g_cast.delayMs;
|
||||
g_castTargetGuid = targetGuid; // confirming packet carries the target
|
||||
@@ -612,7 +616,11 @@ void ParseChannelStart(Net::CDataStore *packet) {
|
||||
// stores the new end nowhere, so re-anchor g_channel.endMs to the server's
|
||||
// remaining time so UnitChannelInfo (and the OnWorldTick endMs self-expiry)
|
||||
// track pushback; on remaining == 0 clear the channel so CHANNEL_STOP fires
|
||||
// promptly (ahead of the ~1 s-lagged +0x228 field).
|
||||
// promptly (ahead of the ~1 s-lagged +0x228 field). The pushback also
|
||||
// shortened the channel's aura on each hit target server-side
|
||||
// (Spell::DelayedChannel → DelaySpellAuraHolder), so re-anchor the cached
|
||||
// target-side expirations too — else C_UnitAuras reads a pushed-back channel
|
||||
// debuff (Dark Harvest, the drains) late by the accumulated pushback.
|
||||
void ParseChannelUpdate(Net::CDataStore *packet) {
|
||||
if (g_channel.spellID == 0)
|
||||
return;
|
||||
@@ -622,6 +630,8 @@ void ParseChannelUpdate(Net::CDataStore *packet) {
|
||||
g_channel.spellID = 0; // authoritative channel end
|
||||
} else {
|
||||
g_channel.endMs = NowMs() + static_cast<int>(remaining);
|
||||
Aura::Source::RestampPlayerChannel(static_cast<uint32_t>(spellID),
|
||||
remaining);
|
||||
Spell::CastEvents::OnPlayerChannelUpdate(spellID);
|
||||
}
|
||||
}
|
||||
@@ -837,7 +847,7 @@ void OnWorldTick() {
|
||||
// based so it needs no changes to the many stamp/clear sites; ~1 frame
|
||||
// latency is imperceptible for a cast bar, and every fire is
|
||||
// listener-gated (near-free when no addon uses these).
|
||||
Spell::CastEvents::PollPlayer(g_cast.spellID, g_cast.startMs, g_cast.delayMs,
|
||||
Spell::CastEvents::PollPlayer(g_cast.spellID, g_cast.startMs, g_cast.endMs,
|
||||
g_channel.spellID, g_channel.startMs);
|
||||
Spell::CastEvents::PollRemote();
|
||||
Spell::CastEvents::PollReticle();
|
||||
|
||||
+59
-49
@@ -31,33 +31,20 @@ namespace Spell::CastEvents {
|
||||
namespace {
|
||||
|
||||
|
||||
constexpr const char *kStart = "UNIT_SPELLCAST_START";
|
||||
constexpr const char *kStop = "UNIT_SPELLCAST_STOP";
|
||||
constexpr const char *kDelayed = "UNIT_SPELLCAST_DELAYED";
|
||||
constexpr const char *kChannelStart = "UNIT_SPELLCAST_CHANNEL_START";
|
||||
constexpr const char *kChannelStop = "UNIT_SPELLCAST_CHANNEL_STOP";
|
||||
constexpr const char *kChannelUpdate = "UNIT_SPELLCAST_CHANNEL_UPDATE";
|
||||
constexpr const char *kSucceeded = "UNIT_SPELLCAST_SUCCEEDED";
|
||||
constexpr const char *kInterrupted = "UNIT_SPELLCAST_INTERRUPTED";
|
||||
constexpr const char *kFailed = "UNIT_SPELLCAST_FAILED";
|
||||
constexpr const char *kFailedQuiet = "UNIT_SPELLCAST_FAILED_QUIET";
|
||||
constexpr const char *kSent = "UNIT_SPELLCAST_SENT";
|
||||
constexpr const char *kReticleTarget = "UNIT_SPELLCAST_RETICLE_TARGET";
|
||||
constexpr const char *kReticleClear = "UNIT_SPELLCAST_RETICLE_CLEAR";
|
||||
|
||||
const Event::Custom::AutoReserve _rStart{kStart};
|
||||
const Event::Custom::AutoReserve _rStop{kStop};
|
||||
const Event::Custom::AutoReserve _rDelayed{kDelayed};
|
||||
const Event::Custom::AutoReserve _rChannelStart{kChannelStart};
|
||||
const Event::Custom::AutoReserve _rChannelStop{kChannelStop};
|
||||
const Event::Custom::AutoReserve _rChannelUpdate{kChannelUpdate};
|
||||
const Event::Custom::AutoReserve _rSucceeded{kSucceeded};
|
||||
const Event::Custom::AutoReserve _rInterrupted{kInterrupted};
|
||||
const Event::Custom::AutoReserve _rFailed{kFailed};
|
||||
const Event::Custom::AutoReserve _rFailedQuiet{kFailedQuiet};
|
||||
const Event::Custom::AutoReserve _rSent{kSent};
|
||||
const Event::Custom::AutoReserve _rReticleTarget{kReticleTarget};
|
||||
const Event::Custom::AutoReserve _rReticleClear{kReticleClear};
|
||||
// The reservations double as the fire handles (`k*.Slot()` — O(1)).
|
||||
const Event::Custom::AutoReserve kStart{"UNIT_SPELLCAST_START"};
|
||||
const Event::Custom::AutoReserve kStop{"UNIT_SPELLCAST_STOP"};
|
||||
const Event::Custom::AutoReserve kDelayed{"UNIT_SPELLCAST_DELAYED"};
|
||||
const Event::Custom::AutoReserve kChannelStart{"UNIT_SPELLCAST_CHANNEL_START"};
|
||||
const Event::Custom::AutoReserve kChannelStop{"UNIT_SPELLCAST_CHANNEL_STOP"};
|
||||
const Event::Custom::AutoReserve kChannelUpdate{"UNIT_SPELLCAST_CHANNEL_UPDATE"};
|
||||
const Event::Custom::AutoReserve kSucceeded{"UNIT_SPELLCAST_SUCCEEDED"};
|
||||
const Event::Custom::AutoReserve kInterrupted{"UNIT_SPELLCAST_INTERRUPTED"};
|
||||
const Event::Custom::AutoReserve kFailed{"UNIT_SPELLCAST_FAILED"};
|
||||
const Event::Custom::AutoReserve kFailedQuiet{"UNIT_SPELLCAST_FAILED_QUIET"};
|
||||
const Event::Custom::AutoReserve kSent{"UNIT_SPELLCAST_SENT"};
|
||||
const Event::Custom::AutoReserve kReticleTarget{"UNIT_SPELLCAST_RETICLE_TARGET"};
|
||||
const Event::Custom::AutoReserve kReticleClear{"UNIT_SPELLCAST_RETICLE_CLEAR"};
|
||||
|
||||
// Autoshot spams client-side failures while ramping — nampower filters it
|
||||
// out of its failure event, so do we.
|
||||
@@ -108,8 +95,9 @@ void BuildCastGuid(char *out, size_t n, int type, int spellID, int castUID) {
|
||||
// rank)`. Gated on a listener so an unwatched event does no DBC lookups or
|
||||
// string formatting. A null name/rank pushes `nil` through the dispatcher's
|
||||
// `lua_pushstring(NULL) → pushnil` tail-jump, which is fine.
|
||||
void Fire(const char *eventName, int spellID, int guidNum, int type = 3) {
|
||||
const int slot = Event::Custom::Lookup(eventName);
|
||||
void Fire(const Event::Custom::AutoReserve &event, int spellID, int guidNum,
|
||||
int type = 3) {
|
||||
const int slot = event.Slot();
|
||||
if (!Event::Custom::HasListeners(slot))
|
||||
return;
|
||||
const uint8_t *rec = Spell::Lookup::RecordForID(spellID);
|
||||
@@ -127,7 +115,7 @@ void Fire(const char *eventName, int spellID, int guidNum, int type = 3) {
|
||||
// inserted at arg2. `target` is a unit token (e.g. "target") resolved from
|
||||
// the cast packet, or "" for self / no-target casts.
|
||||
void FireSent(int spellID, int guidNum, const char *target) {
|
||||
const int slot = Event::Custom::Lookup(kSent);
|
||||
const int slot = kSent.Slot();
|
||||
if (!Event::Custom::HasListeners(slot))
|
||||
return;
|
||||
const uint8_t *rec = Spell::Lookup::RecordForID(spellID);
|
||||
@@ -148,8 +136,8 @@ void FireSent(int spellID, int guidNum, const char *target) {
|
||||
// the load-bearing fields — are exact; only arg2 differs (`""` vs `nil`),
|
||||
// which is inconsequential for a reticle (no cast to identify). spellName /
|
||||
// rank are our usual extension tail.
|
||||
void FireReticle(const char *eventName, int spellID) {
|
||||
const int slot = Event::Custom::Lookup(eventName);
|
||||
void FireReticle(const Event::Custom::AutoReserve &event, int spellID) {
|
||||
const int slot = event.Slot();
|
||||
if (!Event::Custom::HasListeners(slot))
|
||||
return;
|
||||
const uint8_t *rec = Spell::Lookup::RecordForID(spellID);
|
||||
@@ -176,7 +164,7 @@ int g_guidCounter = 0;
|
||||
int g_castSpell = 0; // 0 = not casting
|
||||
int g_castStart = 0; // startMs of the tracked cast (detects same-spell recast)
|
||||
int g_castGuid = 0; // castGUID counter value for the tracked cast
|
||||
int g_castDelay = 0; // last-seen accumulated pushback (ms)
|
||||
int g_castEnd = 0; // last-seen endMs (any movement fires DELAYED)
|
||||
bool g_castSucceeded = false; // SPELL_GO seen for the tracked cast this cast
|
||||
|
||||
// UNIT_SPELLCAST_INTERRUPTED de-dup. SpellFailed_h and PollPlayer can both
|
||||
@@ -192,6 +180,7 @@ constexpr int kInterruptDedupMs = 1000;
|
||||
|
||||
int g_chanSpell = 0;
|
||||
int g_chanGuid = 0;
|
||||
int g_chanStart = 0; // startMs we last fired CHANNEL_START for (detects a recast)
|
||||
|
||||
// castGUID minted at SENT, pending until the matching downstream event
|
||||
// (START / SUCCEEDED / FAILED for the same spell) consumes it — so all of
|
||||
@@ -382,9 +371,9 @@ const Net::SendObserver::AutoSubscribe _sendSub{&OnSend};
|
||||
// the payload is fanned out per token (`target` / `focus` / `nameplateN` /
|
||||
// `party` / `raid` / `mouseover`). Same `(unit, castGUID, spellID, spellName,
|
||||
// rank)` shape as the player events, just with a non-"player" unit.
|
||||
void FireRemote(const char *eventName, uint64_t casterGuid, int spellID,
|
||||
int guidNum) {
|
||||
const int slot = Event::Custom::Lookup(eventName);
|
||||
void FireRemote(const Event::Custom::AutoReserve &event, uint64_t casterGuid,
|
||||
int spellID, int guidNum) {
|
||||
const int slot = event.Slot();
|
||||
if (!Event::Custom::HasListeners(slot))
|
||||
return;
|
||||
const uint8_t *rec = Spell::Lookup::RecordForID(spellID);
|
||||
@@ -441,7 +430,7 @@ RemoteEvt *AllocRemoteEvt(uint64_t guid) {
|
||||
|
||||
bool PlayerCastSucceeded() { return g_castSucceeded; }
|
||||
|
||||
void PollPlayer(int castSpellID, int castStartMs, int castDelayMs,
|
||||
void PollPlayer(int castSpellID, int castStartMs, int castEndMs,
|
||||
int channelSpellID, int channelStartMs) {
|
||||
// ---- Regular cast -------------------------------------------------
|
||||
// A "new cast" is a different spell OR the same spell re-stamped at a
|
||||
@@ -474,24 +463,44 @@ void PollPlayer(int castSpellID, int castStartMs, int castDelayMs,
|
||||
g_castGuid = NextCastGuid(castSpellID);
|
||||
g_castSpell = castSpellID;
|
||||
g_castStart = castStartMs;
|
||||
g_castDelay = castDelayMs;
|
||||
g_castEnd = castEndMs;
|
||||
g_castSucceeded = false;
|
||||
Fire(kStart, castSpellID, g_castGuid);
|
||||
} else if (g_castSpell != 0 && castDelayMs > g_castDelay) {
|
||||
// Pushback grew (SMSG_SPELL_DELAYED extended the cast).
|
||||
g_castDelay = castDelayMs;
|
||||
} else if (g_castSpell != 0 && castEndMs != g_castEnd) {
|
||||
// The cast's end moved: pushback (SMSG_SPELL_DELAYED extends it) or
|
||||
// the confirming SMSG_SPELL_START snapping the client-predicted cast
|
||||
// time to the server's authoritative one — the server scales ranged
|
||||
// abilities by ranged attack speed (Steady/Aimed Shot), which the
|
||||
// client's prediction helper doesn't model, so the confirming packet
|
||||
// shortens the bar ~RTT after START (pfUI#43). Either direction, the
|
||||
// addon must re-read the times; DELAYED is the re-read trigger.
|
||||
g_castEnd = castEndMs;
|
||||
Fire(kDelayed, g_castSpell, g_castGuid);
|
||||
}
|
||||
|
||||
// ---- Channel ------------------------------------------------------
|
||||
// Detect a new channel by spellID ONLY (not startMs): a channel is
|
||||
// stamped twice at start — SMSG_SPELL_START, then MSG_CHANNEL_START
|
||||
// re-stamps with the server duration — which changes startMs but is the
|
||||
// SAME channel. A startMs check would spuriously fire STOP+START on the
|
||||
// re-stamp. A genuine re-channel of the same spell passes through
|
||||
// g_chanSpell==0 (CHANNEL_STOP) first, so spellID alone still catches it.
|
||||
(void)channelStartMs;
|
||||
const bool newChan = channelSpellID != 0 && channelSpellID != g_chanSpell;
|
||||
// A DIFFERENT channel spell is trivially a new channel. A SAME-spell
|
||||
// re-channel (recasting Fishing while already fishing) is ALSO a new
|
||||
// channel and must fire CHANNEL_STOP + CHANNEL_START, but it keeps
|
||||
// channelSpellID == g_chanSpell (the channel never returns to 0 between the
|
||||
// two — +0x228 stays set for a continuous channel), so spellID alone can't
|
||||
// see it. Two independent tells together identify it without a timing
|
||||
// heuristic — BOTH required:
|
||||
// - the channel was RE-STAMPED: channelStartMs advanced past the value we
|
||||
// fired CHANNEL_START for, AND
|
||||
// - a fresh CMSG_CAST_SPELL is pending for this spell (g_pendingGuid — the
|
||||
// recast's SENT, not yet consumed).
|
||||
// Each start stamps the channel TWICE (SMSG_SPELL_START then
|
||||
// MSG_CHANNEL_START), which advances startMs on the SAME start — but the
|
||||
// first CHANNEL_START already consumed g_pendingGuid, so the second stamp
|
||||
// fails the pending test → no spurious restart. A REJECTED recast
|
||||
// (SPELL_IN_PROGRESS) sends a CMSG (guid pending) but the server never
|
||||
// re-stamps the channel, so startMs is unchanged → also no restart.
|
||||
const bool reChannel = channelSpellID != 0 && channelSpellID == g_chanSpell &&
|
||||
channelStartMs != g_chanStart && g_pendingGuid != 0 &&
|
||||
g_pendingSpell == channelSpellID;
|
||||
const bool newChan = channelSpellID != 0 &&
|
||||
(channelSpellID != g_chanSpell || reChannel);
|
||||
if (g_chanSpell != 0 && (channelSpellID == 0 || newChan)) {
|
||||
// Channels only ever fire CHANNEL_STOP — never INTERRUPTED — whether
|
||||
// they end naturally or are cut short (retail behavior, verified).
|
||||
@@ -505,6 +514,7 @@ void PollPlayer(int castSpellID, int castStartMs, int castDelayMs,
|
||||
// SUCCEEDED / CHANNEL_STOP on one castGUID.
|
||||
g_chanGuid = NextCastGuid(channelSpellID);
|
||||
g_chanSpell = channelSpellID;
|
||||
g_chanStart = channelStartMs;
|
||||
Fire(kChannelStart, channelSpellID, g_chanGuid);
|
||||
// Modern fires SUCCEEDED right after CHANNEL_START. SPELL_GO deferred
|
||||
// it to here so it lands after START, sharing the channel's guid.
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user