mirror of
https://github.com/brues-code/ClassicAPI.git
synced 2026-11-13 22:50:44 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
a2d25cef8b | ||
|
|
c3c79356be | ||
|
|
6ae3299041 | ||
|
|
f97379795d | ||
|
|
3c6b449df5 | ||
|
|
f000fd6ed7 | ||
|
|
7960266969 | ||
|
|
85ed4eb378 | ||
|
|
7f1e632b49 | ||
|
|
71d193073a | ||
|
|
eda44e6d2d | ||
|
|
81f04c82ad | ||
|
|
54516ad22b | ||
|
|
7e784005b9 | ||
|
|
d12ff75c6c | ||
|
|
76043fcdef | ||
|
|
b9d23fbc8c | ||
|
|
a2c51a1ae6 | ||
|
|
d5c8db9c2e | ||
|
|
9c7c8dd8e5 | ||
|
|
813a10f696 |
@@ -104,6 +104,24 @@ jobs:
|
||||
name: ClassicAPI-${{ github.sha }}
|
||||
path: build/Release/ClassicAPI.dll
|
||||
|
||||
# Bundle both debug artifacts into one zip so a crash from a shipped DLL
|
||||
# can be triaged exactly. The PDB gives WinDbg/x64dbg/VS a line-level
|
||||
# symbolicated call stack from a crash dump; the map resolves the
|
||||
# `0001:offset` addresses in WoW's ERROR #132 crash log by hand when no
|
||||
# dump is available. Both match THIS exact DLL build.
|
||||
# The filename is deliberately off-putting: it is NOT the addon and has no
|
||||
# value to a normal player. The loud all-caps name keeps them from
|
||||
# grabbing it by mistake; the tag suffix lets a dev match symbols to the
|
||||
# exact build. (pdb/map keep their real names inside so the pdb signature
|
||||
# still lines up with the DLL.)
|
||||
- name: Package debug symbols for release
|
||||
if: steps.ver.outputs.is_release == 'true'
|
||||
run: >
|
||||
Compress-Archive
|
||||
-Path build/Release/ClassicAPI.pdb, build/Release/ClassicAPI.map
|
||||
-DestinationPath build/Release/Crash-Symbols-Use-For-Debugging-${{ steps.ver.outputs.tag }}.zip
|
||||
-Force
|
||||
|
||||
- name: Release
|
||||
if: steps.ver.outputs.is_release == 'true'
|
||||
uses: softprops/action-gh-release@v2
|
||||
@@ -111,5 +129,5 @@ jobs:
|
||||
tag_name: ${{ steps.ver.outputs.tag }}
|
||||
files: |
|
||||
build/Release/ClassicAPI.dll
|
||||
build/Release/ClassicAPI.map
|
||||
build/Release/Crash-Symbols-Use-For-Debugging-${{ steps.ver.outputs.tag }}.zip
|
||||
generate_release_notes: true
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
|
||||
locales\locales.xml
|
||||
|
||||
SecureTemplates.xml
|
||||
Compat.lua
|
||||
Util\Print.lua
|
||||
Util\Mixin.lua
|
||||
@@ -44,5 +45,8 @@ Util\Vector3D.lua
|
||||
Util\Vector4D.lua
|
||||
Util\UIParent.lua
|
||||
Util\AuraDurationModifiers.lua
|
||||
Util\AuraUtil.lua
|
||||
Util\SecureUnitMenu.lua
|
||||
Util\TextureUtil.lua
|
||||
Util\MacroOptions.lua
|
||||
Util\SecureStateDriver.lua
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
-- SecureTemplates.lua — Lua-side helpers for SecureActionButtonTemplate et al.
|
||||
--
|
||||
-- Backport of the modifier/button attribute-resolution functions from
|
||||
-- Blizzard's 2.0 SecureTemplates.lua that addons call directly
|
||||
-- (SecureButton_GetModifiedAttribute, SecureButton_GetModifierPrefix,
|
||||
-- SecureButton_GetUnit, …), plus the SecureUnitButton_OnLoad convenience.
|
||||
--
|
||||
-- Click dispatch is NOT here: the C++ Frame::Attributes module is the single
|
||||
-- dispatcher. Setting a "type*" attribute on a frame installs a native OnClick
|
||||
-- that resolves the verb (target/spell/item/macro/focus/assist/stop/menu/
|
||||
-- action/pet/click/custom), so the templates carry no <OnClick> and there is no
|
||||
-- parallel Lua dispatcher to drift out of sync.
|
||||
|
||||
-- The "modified attribute" takes the form of: modifier-name-button
|
||||
-- The modifier is one of "shift-", "ctrl-", "alt-", and the button is a
|
||||
-- number from 1 through 5.
|
||||
--
|
||||
-- Setting the attribute by itself is equivalent to *attribute*
|
||||
|
||||
ATTRIBUTE_NOOP = "";
|
||||
|
||||
function SecureButton_GetModifierPrefix()
|
||||
local prefix = "";
|
||||
if IsShiftKeyDown() then
|
||||
prefix = "shift-" .. prefix;
|
||||
end
|
||||
if IsControlKeyDown() then
|
||||
prefix = "ctrl-" .. prefix;
|
||||
end
|
||||
if IsAltKeyDown() then
|
||||
prefix = "alt-" .. prefix;
|
||||
end
|
||||
return prefix;
|
||||
end
|
||||
|
||||
function SecureButton_GetButtonSuffix(button)
|
||||
if button == "LeftButton" then
|
||||
return "1";
|
||||
elseif button == "RightButton" then
|
||||
return "2";
|
||||
elseif button == "MiddleButton" then
|
||||
return "3";
|
||||
elseif button == "Button4" then
|
||||
return "4";
|
||||
elseif button == "Button5" then
|
||||
return "5";
|
||||
elseif button and button ~= "" then
|
||||
return "-" .. tostring(button);
|
||||
end
|
||||
return "";
|
||||
end
|
||||
|
||||
function SecureButton_GetModifiedAttribute(frame, name, button, prefix, suffix)
|
||||
if not prefix then
|
||||
prefix = SecureButton_GetModifierPrefix();
|
||||
end
|
||||
if not suffix then
|
||||
suffix = SecureButton_GetButtonSuffix(button);
|
||||
end
|
||||
local value = frame:GetAttribute(prefix .. name .. suffix) or
|
||||
frame:GetAttribute("*" .. name .. suffix) or
|
||||
frame:GetAttribute(prefix .. name .. "*") or
|
||||
frame:GetAttribute("*" .. name .. "*") or
|
||||
frame:GetAttribute(name);
|
||||
if not value and (frame:GetAttribute("useparent-" .. name) or
|
||||
frame:GetAttribute("useparent*")) then
|
||||
local parent = frame:GetParent();
|
||||
if parent then
|
||||
value = SecureButton_GetModifiedAttribute(parent, name, button, prefix, suffix);
|
||||
end
|
||||
end
|
||||
if value == ATTRIBUTE_NOOP then
|
||||
value = nil;
|
||||
end
|
||||
return value;
|
||||
end
|
||||
|
||||
-- Unmodified attribute lookup (no modifier prefix, no button suffix).
|
||||
-- Overrides the simpler version in SecureStateDriver.lua with the full
|
||||
-- parent-walk + ATTRIBUTE_NOOP semantics.
|
||||
function SecureButton_GetAttribute(frame, name)
|
||||
return SecureButton_GetModifiedAttribute(frame, name, nil, "", "");
|
||||
end
|
||||
|
||||
function SecureButton_GetModifiedUnit(self, button)
|
||||
local unit = SecureButton_GetModifiedAttribute(self, "unit", button);
|
||||
if unit then
|
||||
local unitsuffix = SecureButton_GetModifiedAttribute(self, "unitsuffix", button);
|
||||
if unitsuffix then
|
||||
unit = unit .. unitsuffix;
|
||||
unit = string.gsub(unit, "^([^%d]+)([%d]+)[pP][eE][tT]", "%1pet%2");
|
||||
end
|
||||
return unit;
|
||||
end
|
||||
if SecureButton_GetModifiedAttribute(self, "checkselfcast", button) then
|
||||
if IsAltKeyDown() then
|
||||
return "player";
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
function SecureButton_GetUnit(self)
|
||||
local unit = SecureButton_GetAttribute(self, "unit");
|
||||
if unit then
|
||||
local unitsuffix = SecureButton_GetAttribute(self, "unitsuffix");
|
||||
if unitsuffix then
|
||||
unit = unit .. unitsuffix;
|
||||
unit = string.gsub(unit, "^([^%d]+)([%d]+)[pP][eE][tT]", "%1pet%2");
|
||||
end
|
||||
return unit;
|
||||
end
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- SecureUnitButton_OnLoad — convenience for the common unit-button setup:
|
||||
-- left-click targets, any other button opens the right-click unit menu. The
|
||||
-- click behavior itself is native: setting these "type*"/"unit" attributes is
|
||||
-- what makes the C++ Frame::Attributes dispatcher act on the clicks.
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
function SecureUnitButton_OnLoad(self, unit)
|
||||
self:SetAttribute("type1", "target");
|
||||
self:SetAttribute("type*", "menu");
|
||||
self:SetAttribute("unit", unit);
|
||||
end
|
||||
@@ -0,0 +1,21 @@
|
||||
<Ui xmlns="http://www.blizzard.com/wow/ui/" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.blizzard.com/wow/ui/
|
||||
..\FrameXML\UI.xsd">
|
||||
<Script file="SecureTemplates.lua"/>
|
||||
|
||||
<!-- A base template for secure frames (no-op in 1.12, no taint system) -->
|
||||
<Frame name="SecureFrameTemplate" virtual="true"/>
|
||||
|
||||
<!-- Secure action button. Click behavior is driven natively by the C++
|
||||
Frame::Attributes module: setting a "type*" attribute installs the
|
||||
OnClick dispatcher, so the template itself needs no <OnClick>. -->
|
||||
<Button name="SecureActionButtonTemplate" inherits="SecureFrameTemplate" virtual="true"/>
|
||||
|
||||
<!-- Unit-button variant (identical to the action button on 1.12; its
|
||||
"type"/"unit" attributes drive it). SecureUnitButton_OnLoad is a Lua
|
||||
convenience for the common target + right-click-menu setup. -->
|
||||
<Button name="SecureUnitButtonTemplate" inherits="SecureFrameTemplate" virtual="true"/>
|
||||
|
||||
<!-- Insecure variant: identical behavior in 1.12 (no combat lockdown), so
|
||||
just inherit the secure template rather than duplicate its OnClick. -->
|
||||
<Button name="InsecureActionButtonTemplate" inherits="SecureActionButtonTemplate" virtual="true"/>
|
||||
</Ui>
|
||||
@@ -84,3 +84,32 @@ if C_AddOns.DoesAddOnExist("Puppeteer") then
|
||||
CAPI_MouseoverClearedCompat(PTEnemyUpdater)
|
||||
end)
|
||||
end
|
||||
|
||||
-- Cartographer_Notes gates a WorldMap release-spirit hook behind an
|
||||
-- `if lua51` probe, treating a 5.1-capable Lua as a modern client that has
|
||||
-- the WorldMapDeathRelease frame. Our 5.1 syntax backport makes the probe
|
||||
-- pass, so Cartographer takes the modern branch and hooks a frame 1.12's
|
||||
-- WorldMap never had (WorldMapDeathRelease:SetScript in OnEnable) -> nil
|
||||
-- index. Provide an inert, hidden stub so the hook succeeds and does nothing
|
||||
-- -- the same outcome vanilla had, where the branch never ran. The embedded
|
||||
-- !!!ClassicAPI addon loads first, so the frame exists before Cartographer's
|
||||
-- OnEnable.
|
||||
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
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
-- AuraUtil: iterate / find auras without allocating a table per aura.
|
||||
--
|
||||
-- Backport of FrameXML's AuraUtil, the helper library that sits on top of
|
||||
-- C_UnitAuras. Its whole value on 1.12 is the zero-allocation path: modern
|
||||
-- addons that scan many units' auras every frame call AuraUtil.ForEachAura (with
|
||||
-- usePackedAura false) instead of C_UnitAuras.GetAuraDataByIndex, so no AuraData
|
||||
-- table is built per aura -- which matters far more under Lua 5.0's mark-sweep
|
||||
-- GC than it does on retail's generational GC. See ClassicAPI issue #30.
|
||||
--
|
||||
-- Ported from the anniversary source (Blizzard_FrameXMLUtil/AuraUtil.lua),
|
||||
-- keeping the exact public shapes:
|
||||
-- * ForEachAura(unit, filter, batchSize, func, usePackedAura)
|
||||
-- * FindAura(predicate, unit, filter, arg1, arg2, arg3)
|
||||
-- * FindAuraByName(auraName, unit, filter)
|
||||
-- * UnpackAuraData(auraData)
|
||||
--
|
||||
-- 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.
|
||||
--
|
||||
-- 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
|
||||
|
||||
AuraUtil = AuraUtil or {};
|
||||
|
||||
-- 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
|
||||
-- #13 is `isFromPlayerOrPlayerPet` (the table's field) -- note the positional
|
||||
-- C_UnitAuras.UnitAura reports the higher-fidelity `castByPlayer` at #13 instead.
|
||||
function AuraUtil.UnpackAuraData(auraData)
|
||||
if not auraData then
|
||||
return nil;
|
||||
end
|
||||
return auraData.name,
|
||||
auraData.icon,
|
||||
auraData.applications,
|
||||
auraData.dispelName,
|
||||
auraData.duration,
|
||||
auraData.expirationTime,
|
||||
auraData.sourceUnit,
|
||||
auraData.isStealable,
|
||||
auraData.nameplateShowPersonal,
|
||||
auraData.spellId,
|
||||
auraData.canApplyAura,
|
||||
auraData.isBossAura,
|
||||
auraData.isFromPlayerOrPlayerPet,
|
||||
auraData.nameplateShowAll,
|
||||
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.
|
||||
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
|
||||
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));
|
||||
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.
|
||||
function AuraUtil.FindAura(predicate, unit, filter, arg1, arg2, arg3)
|
||||
return FindAuraRecurse(predicate, unit, filter, 1, arg1, arg2, arg3,
|
||||
UnitAura(unit, 1, filter));
|
||||
end
|
||||
|
||||
-- Returns the positional values of the first aura whose name matches `auraName`,
|
||||
-- or nil. Names are localized and not unique -- this finds the first match.
|
||||
function AuraUtil.FindAuraByName(auraName, unit, filter)
|
||||
return AuraUtil.UnpackAuraData(
|
||||
C_UnitAuras.GetAuraDataBySpellName(unit, auraName, filter));
|
||||
end
|
||||
@@ -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();
|
||||
|
||||
@@ -0,0 +1,488 @@
|
||||
-- SecureCmdOptionParse(options) -> value[, target]
|
||||
--
|
||||
-- Backports the macro-conditional parser (added in 2.0) so a modern option
|
||||
-- string like "[combat,@focus] Foo; [nostealth] Bar; Baz" resolves to the
|
||||
-- value of the first matching clause. Used by Util/SecureStateDriver.lua and
|
||||
-- callable by any macro / click-verb system that wants Blizzard's grammar.
|
||||
--
|
||||
-- Grammar reverse-engineered from the 3.3.5 Wow.exe (functions 0x005f0df0
|
||||
-- clause splitter, 0x005f0bb0 group parser, 0x005f0040 condition parser,
|
||||
-- 0x005ef5c0 evaluator), so it matches Blizzard's own precedence exactly:
|
||||
-- * clauses are split on ';' at bracket depth 0;
|
||||
-- * a clause is zero or more [group] blocks followed by a value (the text
|
||||
-- after the last ']', edge-trimmed -- may be empty);
|
||||
-- * a clause with NO groups always matches; groups are OR'd (first pass
|
||||
-- wins); conditions inside a group are comma-split and AND'd;
|
||||
-- * an '@unit' or 'target=unit' piece (case-insensitive) sets the group's
|
||||
-- target; every other piece is a condition, optionally negated by a 'no'
|
||||
-- prefix (stripped only when the piece is >= 3 chars) and optionally
|
||||
-- carrying ':a/b/c' args (each trimmed, multiple args OR'd);
|
||||
-- * the match returns the clause value plus the passing group's target
|
||||
-- token (nil when the passing group set none).
|
||||
--
|
||||
-- The 33 condition keywords are the 3.3.5 set, matched case-sensitively, plus
|
||||
-- one ClassicAPI extension: [known:spellID] / [known:name].
|
||||
-- Where a keyword's underlying state does not exist on 1.12 (flying, flyable,
|
||||
-- vehicleui, unithasvehicleui) the condition is a constant false; where 1.12
|
||||
-- only has a partial answer (spec is always primary, button/actionbar default
|
||||
-- to the resting state) the closest honest mapping is used. An UNKNOWN keyword
|
||||
-- is a hard false with a one-time warning -- never a silent pass -- so a typo
|
||||
-- can't wrongly show a frame. See docs/API.md for the full contract.
|
||||
--
|
||||
-- Each unique options string is parsed ONCE into a clause structure and cached
|
||||
-- (a plain table, matching the 3.3.5 C hash -- entries are never evicted, and
|
||||
-- the set is bounded by the strings addons actually register). Evaluation then
|
||||
-- just walks the structure calling predicate functions, so the 0.2s driver
|
||||
-- poll re-parses nothing.
|
||||
|
||||
local type = type;
|
||||
local tonumber = tonumber;
|
||||
local strlen = string.len;
|
||||
local strsub = string.sub;
|
||||
local strlower = string.lower;
|
||||
local strmatch = string.match;
|
||||
local gmatch = string.gmatch;
|
||||
|
||||
local function Trim(s)
|
||||
return strmatch(s, "^%s*(.-)%s*$") or "";
|
||||
end
|
||||
|
||||
local function AlwaysTrue()
|
||||
return true;
|
||||
end
|
||||
|
||||
local function AlwaysFalse()
|
||||
return false;
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Query helpers used by more than one predicate
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
-- The shapeshift-BAR index of the active form (1-based), or 0 when formless.
|
||||
-- [stance:N] / [form:N] number by this bar order (druid 1 = Bear, 3 = Cat),
|
||||
-- which is NOT the DBC form id GetShapeshiftFormID() returns -- so the ':N'
|
||||
-- form deliberately does not use that faster reader.
|
||||
local function GetCurrentFormIndex()
|
||||
for i = 1, GetNumShapeshiftForms() do
|
||||
local _, _, active = GetShapeshiftFormInfo(i);
|
||||
if active then
|
||||
return i;
|
||||
end
|
||||
end
|
||||
return 0;
|
||||
end
|
||||
|
||||
-- True when any equipped item (slots 1..19) matches `want` by localized item
|
||||
-- type, subtype, or equip-location slot name. C_Item.GetItemInfoInstant is
|
||||
-- synchronous and link-accepting; worn gear is always cached, and a nil field
|
||||
-- simply fails the match (fail-safe).
|
||||
local function IsEquippedTypeMatch(want)
|
||||
want = strlower(want);
|
||||
for slot = 1, 19 do
|
||||
local link = GetInventoryItemLink("player", slot);
|
||||
if link then
|
||||
local _, itemType, itemSubType, equipLoc = C_Item.GetItemInfoInstant(link);
|
||||
if (itemType and strlower(itemType) == want)
|
||||
or (itemSubType and strlower(itemSubType) == want) then
|
||||
return true;
|
||||
end
|
||||
if equipLoc and equipLoc ~= "" then
|
||||
local slotName = _G[equipLoc];
|
||||
if slotName and strlower(slotName) == want then
|
||||
return true;
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
return false;
|
||||
end
|
||||
|
||||
-- True when a spell named `want` (case-insensitive) is in the player's
|
||||
-- spellbook. This is the name form of [known:...]. There is no name -> id
|
||||
-- resolver, so a name can only resolve against castable spellbook entries;
|
||||
-- talents / passives / profession recipes that IsPlayerSpell reports for a
|
||||
-- spell id are not found by name.
|
||||
local function IsSpellNameKnown(want)
|
||||
want = strlower(want);
|
||||
local i = 1;
|
||||
while true do
|
||||
local name = GetSpellName(i, "spell");
|
||||
if not name then
|
||||
return false;
|
||||
end
|
||||
if strlower(name) == want then
|
||||
return true;
|
||||
end
|
||||
i = i + 1;
|
||||
end
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Condition predicates -- signature f(target, args) -> truthy.
|
||||
-- `args` is nil for a bare condition, else { n = <count>, [i] = <trimmed> };
|
||||
-- multiple args are OR'd.
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
local CONDITIONS = {};
|
||||
|
||||
CONDITIONS[""] = AlwaysTrue; -- empty keyword ([] / [ ]) always matches
|
||||
|
||||
CONDITIONS.combat = function()
|
||||
return UnitAffectingCombat("player");
|
||||
end
|
||||
|
||||
CONDITIONS.exists = function(target)
|
||||
return UnitExists(target);
|
||||
end
|
||||
|
||||
CONDITIONS.dead = function(target)
|
||||
return UnitIsDeadOrGhost(target);
|
||||
end
|
||||
|
||||
CONDITIONS.help = function(target)
|
||||
return UnitCanAssist("player", target);
|
||||
end
|
||||
|
||||
CONDITIONS.harm = function(target)
|
||||
return UnitCanAttack("player", target);
|
||||
end
|
||||
|
||||
CONDITIONS.party = function(target)
|
||||
return UnitPlayerOrPetInParty(target);
|
||||
end
|
||||
|
||||
CONDITIONS.raid = function(target)
|
||||
return UnitInRaid(target);
|
||||
end
|
||||
|
||||
-- [group] = in any group; [group:party] true in a party OR raid (raid members
|
||||
-- occupy party subgroups, matching retail); [group:raid] true only in a raid.
|
||||
CONDITIONS.group = function(target, args)
|
||||
local inParty = GetNumPartyMembers() > 0;
|
||||
local inRaid = GetNumRaidMembers() > 0;
|
||||
if args then
|
||||
for i = 1, args.n do
|
||||
local a = strlower(args[i]);
|
||||
if a == "raid" then
|
||||
if inRaid then return true; end
|
||||
elseif a == "party" then
|
||||
if inParty or inRaid then return true; end
|
||||
end
|
||||
end
|
||||
return false;
|
||||
end
|
||||
return inParty or inRaid;
|
||||
end
|
||||
|
||||
CONDITIONS.stance = function(target, args)
|
||||
if args then
|
||||
local cur = GetCurrentFormIndex();
|
||||
for i = 1, args.n do
|
||||
if tonumber(args[i]) == cur then return true; end
|
||||
end
|
||||
return false;
|
||||
end
|
||||
return GetShapeshiftFormID() ~= 0;
|
||||
end
|
||||
|
||||
CONDITIONS.stealth = function()
|
||||
return IsStealthed();
|
||||
end
|
||||
|
||||
CONDITIONS.mounted = function()
|
||||
return IsMounted();
|
||||
end
|
||||
|
||||
CONDITIONS.swimming = function()
|
||||
return IsSwimming();
|
||||
end
|
||||
|
||||
CONDITIONS.indoors = function()
|
||||
return IsIndoors();
|
||||
end
|
||||
|
||||
CONDITIONS.outdoors = function()
|
||||
return IsOutdoors();
|
||||
end
|
||||
|
||||
-- Vanilla has one permanent spec: [spec] and [spec:1] pass, higher indices fail.
|
||||
CONDITIONS.spec = function(target, args)
|
||||
if args then
|
||||
for i = 1, args.n do
|
||||
if args[i] == "1" then return true; end
|
||||
end
|
||||
return false;
|
||||
end
|
||||
return true;
|
||||
end
|
||||
|
||||
CONDITIONS.modifier = function(target, args)
|
||||
if args then
|
||||
for i = 1, args.n do
|
||||
local a = strlower(args[i]);
|
||||
if a == "shift" then
|
||||
if IsShiftKeyDown() then return true; end
|
||||
elseif a == "ctrl" then
|
||||
if IsControlKeyDown() then return true; end
|
||||
elseif a == "alt" then
|
||||
if IsAltKeyDown() then return true; end
|
||||
end
|
||||
end
|
||||
return false;
|
||||
end
|
||||
return IsShiftKeyDown() or IsControlKeyDown() or IsAltKeyDown();
|
||||
end
|
||||
|
||||
-- Outside a click context the "current" button is the left button.
|
||||
CONDITIONS.button = function(target, args)
|
||||
if args then
|
||||
for i = 1, args.n do
|
||||
local a = args[i];
|
||||
if a == "1" or strlower(a) == "leftbutton" then return true; end
|
||||
end
|
||||
return false;
|
||||
end
|
||||
return true;
|
||||
end
|
||||
|
||||
CONDITIONS.actionbar = function(target, args)
|
||||
if args then
|
||||
local page = CURRENT_ACTIONBAR_PAGE or 1;
|
||||
for i = 1, args.n do
|
||||
if tonumber(args[i]) == page then return true; end
|
||||
end
|
||||
return false;
|
||||
end
|
||||
return true;
|
||||
end
|
||||
|
||||
CONDITIONS.bonusbar = function(target, args)
|
||||
local offset = GetBonusBarOffset();
|
||||
if args then
|
||||
for i = 1, args.n do
|
||||
if tonumber(args[i]) == offset then return true; end
|
||||
end
|
||||
return false;
|
||||
end
|
||||
return offset > 0;
|
||||
end
|
||||
|
||||
CONDITIONS.pet = function(target, args)
|
||||
if args then
|
||||
local petName = UnitName("pet");
|
||||
local petFamily = UnitCreatureFamily("pet");
|
||||
petName = petName and strlower(petName);
|
||||
petFamily = petFamily and strlower(petFamily);
|
||||
for i = 1, args.n do
|
||||
local a = strlower(args[i]);
|
||||
if a == petName or a == petFamily then return true; end
|
||||
end
|
||||
return false;
|
||||
end
|
||||
return UnitExists("pet");
|
||||
end
|
||||
|
||||
-- ClassicAPI cedes the global name UnitChannelInfo/ChannelInfo to addons, so the
|
||||
-- player's channel state is read through the namespaced C_Spell.ChannelInfo().
|
||||
CONDITIONS.channeling = function(target, args)
|
||||
local name = C_Spell.ChannelInfo();
|
||||
if args then
|
||||
if not name then return false; end
|
||||
name = strlower(name);
|
||||
for i = 1, args.n do
|
||||
if strlower(args[i]) == name then return true; end
|
||||
end
|
||||
return false;
|
||||
end
|
||||
return name ~= nil;
|
||||
end
|
||||
|
||||
CONDITIONS.equipped = function(target, args)
|
||||
if not args then return false; end -- [equipped] with no type never matches
|
||||
for i = 1, args.n do
|
||||
if IsEquippedTypeMatch(args[i]) then return true; end
|
||||
end
|
||||
return false;
|
||||
end
|
||||
|
||||
CONDITIONS.cursor = function()
|
||||
return GetCursorInfo() ~= nil;
|
||||
end
|
||||
|
||||
-- ClassicAPI extension (not a 3.3.5 conditional). [known:spellID] uses
|
||||
-- IsPlayerSpell (broad knowledge -- spellbook, talents, recipes, racials);
|
||||
-- [known:name] falls back to a spellbook name scan (see IsSpellNameKnown).
|
||||
CONDITIONS.known = function(target, args)
|
||||
if not args then return false; end
|
||||
for i = 1, args.n do
|
||||
local a = args[i];
|
||||
local id = tonumber(a);
|
||||
if id then
|
||||
if IsPlayerSpell(id) then return true; end
|
||||
elseif IsSpellNameKnown(a) then
|
||||
return true;
|
||||
end
|
||||
end
|
||||
return false;
|
||||
end
|
||||
|
||||
-- No such state on 1.12.
|
||||
CONDITIONS.flying = AlwaysFalse;
|
||||
CONDITIONS.flyable = AlwaysFalse;
|
||||
CONDITIONS.vehicleui = AlwaysFalse;
|
||||
CONDITIONS.unithasvehicleui = AlwaysFalse;
|
||||
|
||||
-- Aliases (share the function reference).
|
||||
CONDITIONS.form = CONDITIONS.stance;
|
||||
CONDITIONS.mod = CONDITIONS.modifier;
|
||||
CONDITIONS.btn = CONDITIONS.button;
|
||||
CONDITIONS.bar = CONDITIONS.actionbar;
|
||||
CONDITIONS.worn = CONDITIONS.equipped;
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Parser
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
local warned = {};
|
||||
|
||||
-- piece -> { pred = fn, neg = bool, args = { n, [i] } or nil }
|
||||
local function ParseCondition(piece)
|
||||
local neg = false;
|
||||
if strlen(piece) >= 3 and strsub(piece, 1, 2) == "no" then
|
||||
neg = true;
|
||||
piece = strsub(piece, 3);
|
||||
end
|
||||
|
||||
local keyword, argstr = strmatch(piece, "^([^:]*):?(.*)$");
|
||||
keyword = Trim(keyword);
|
||||
|
||||
local args;
|
||||
if argstr ~= "" then
|
||||
args = { n = 0 };
|
||||
for a in gmatch(argstr .. "/", "([^/]*)/") do
|
||||
args.n = args.n + 1;
|
||||
args[args.n] = Trim(a);
|
||||
end
|
||||
end
|
||||
|
||||
local pred = CONDITIONS[keyword];
|
||||
if not pred then
|
||||
if not warned[keyword] then
|
||||
warned[keyword] = true;
|
||||
print("SecureCmdOptionParse: unknown condition '" .. keyword .. "'");
|
||||
end
|
||||
pred = AlwaysFalse;
|
||||
neg = false; -- an unknown keyword is a hard false
|
||||
end
|
||||
|
||||
return { pred = pred, neg = neg, args = args };
|
||||
end
|
||||
|
||||
-- group body -> { target = str or nil, n, [i] = cond }
|
||||
local function ParseGroup(body)
|
||||
local group = { n = 0 };
|
||||
-- Sentinel comma so a run ending at end-of-body (and an empty body) yields
|
||||
-- a piece; each piece is (non-comma)* up to a comma.
|
||||
for piece in gmatch(body .. ",", "([^,]*),") do
|
||||
piece = Trim(piece);
|
||||
if strsub(piece, 1, 1) == "@" then
|
||||
group.target = Trim(strsub(piece, 2));
|
||||
elseif strlower(strsub(piece, 1, 7)) == "target=" then
|
||||
group.target = Trim(strsub(piece, 8));
|
||||
else
|
||||
group.n = group.n + 1;
|
||||
group[group.n] = ParseCondition(piece);
|
||||
end
|
||||
end
|
||||
return group;
|
||||
end
|
||||
|
||||
-- clause text -> { value = str, groups = { n, [i] = group } }
|
||||
local function ParseClause(text)
|
||||
local groups = { n = 0 };
|
||||
-- Greedy up to the LAST ']' splits the [group] blocks from the value.
|
||||
local body, value = strmatch(text, "^%s*(.*%])(.*)$");
|
||||
if not body then
|
||||
return { value = Trim(text), groups = groups };
|
||||
end
|
||||
for groupBody in gmatch(body, "%[([^%]]*)%]") do
|
||||
groups.n = groups.n + 1;
|
||||
groups[groups.n] = ParseGroup(groupBody);
|
||||
end
|
||||
return { value = Trim(value), groups = groups };
|
||||
end
|
||||
|
||||
-- options -> { n, [i] = clause }. Splits on ';' at bracket depth 0.
|
||||
local function ParseOptions(options)
|
||||
local clauses = { n = 0 };
|
||||
local len = strlen(options);
|
||||
local pos = 1;
|
||||
while pos <= len + 1 do
|
||||
local i = pos;
|
||||
local depth = 0;
|
||||
while i <= len do
|
||||
local c = strsub(options, i, i);
|
||||
if c == "[" then
|
||||
depth = 1;
|
||||
elseif c == "]" then
|
||||
depth = 0;
|
||||
elseif c == ";" and depth == 0 then
|
||||
break;
|
||||
end
|
||||
i = i + 1;
|
||||
end
|
||||
clauses.n = clauses.n + 1;
|
||||
clauses[clauses.n] = ParseClause(strsub(options, pos, i - 1));
|
||||
pos = i + 1;
|
||||
end
|
||||
return clauses;
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Evaluation
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
local cache = {};
|
||||
|
||||
local function GroupPasses(group)
|
||||
local target = group.target or "target";
|
||||
for i = 1, group.n do
|
||||
local cond = group[i];
|
||||
local ok = cond.pred(target, cond.args);
|
||||
if cond.neg then
|
||||
ok = not ok;
|
||||
end
|
||||
if not ok then
|
||||
return false;
|
||||
end
|
||||
end
|
||||
return true;
|
||||
end
|
||||
|
||||
function SecureCmdOptionParse(options)
|
||||
if type(options) ~= "string" then
|
||||
error("Usage: SecureCmdOptionParse(\"options\")");
|
||||
return nil; -- WoW's error() may not unwind
|
||||
end
|
||||
|
||||
local clauses = cache[options];
|
||||
if not clauses then
|
||||
clauses = ParseOptions(options);
|
||||
cache[options] = clauses;
|
||||
end
|
||||
|
||||
for i = 1, clauses.n do
|
||||
local clause = clauses[i];
|
||||
local groups = clause.groups;
|
||||
if groups.n == 0 then
|
||||
return clause.value;
|
||||
end
|
||||
for j = 1, groups.n do
|
||||
if GroupPasses(groups[j]) then
|
||||
return clause.value, groups[j].target;
|
||||
end
|
||||
end
|
||||
end
|
||||
return nil;
|
||||
end
|
||||
@@ -0,0 +1,222 @@
|
||||
-- SecureStateDriver: automatic frame state / visibility from macro conditionals.
|
||||
--
|
||||
-- Backports FrameXML's SecureStateDriverManager. A registered driver re-runs
|
||||
-- SecureCmdOptionParse (Util/MacroOptions.lua) on a 0.2s poll (plus an instant
|
||||
-- rescan on the events below) and applies the result:
|
||||
-- * RegisterStateDriver(frame, "visibility", "[combat] hide; show") -> the
|
||||
-- value "show"/"hide" drives frame:Show()/Hide();
|
||||
-- * RegisterStateDriver / RegisterAttributeDriver for any other state ->
|
||||
-- frame:SetAttribute("state-"..state, value), so an OnAttributeChanged
|
||||
-- handler reacts to it;
|
||||
-- * RegisterUnitWatch(frame [, asState]) -> shows/hides (or sets
|
||||
-- "state-unitexists") as the frame's `unit` attribute comes and goes.
|
||||
--
|
||||
-- Base: 3.3.5 FrameXML/SecureStateDriver.lua, with the anniversary refinements
|
||||
-- grafted in (the resolveDriver() consolidation, tonumber() coercion of driver
|
||||
-- values, the literal string 'nil' -> nil, and the "statehidden" attribute).
|
||||
--
|
||||
-- Three deliberate deviations from upstream, all because 1.12 has no taint and
|
||||
-- our attribute system fires OnAttributeChanged SYNCHRONOUSLY:
|
||||
-- 1. No attribute-bootstrap manager. Upstream drives itself through
|
||||
-- SecureStateDriverManager:SetAttribute("setframe"/"setstate"/...) -- a
|
||||
-- taint-boundary trick. Our SetAttribute has a recursion guard (a nested
|
||||
-- SetAttribute stores the value but does NOT fire the handler, see
|
||||
-- src/frame/Attributes.cpp), so doing the registration-time resolve from
|
||||
-- inside that handler would silently swallow the target frame's own
|
||||
-- OnAttributeChanged. Instead the public Register* functions write plain
|
||||
-- local tables and resolve immediately at top level. Public signatures are
|
||||
-- unchanged from retail.
|
||||
-- 2. GetTime()-deadline throttle instead of the OnUpdate `elapsed` arg (1.12
|
||||
-- handlers take no parameters).
|
||||
-- 3. UpdateUnitWatch guards its "statehidden"/"state-unitexists" writes on a
|
||||
-- real change, so a watched frame's handler is not fired at 5 Hz.
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- SecureButton_* (minimal -- no taint, no modifier/button suffix machinery)
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
-- SecureButton_GetAttribute and SecureButton_GetUnit are defined in
|
||||
-- SecureTemplates.lua (loaded earlier via SecureTemplates.xml).
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Private state
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
local secureAttributeDrivers = {}; -- [frame][attribute] = optionsString
|
||||
local unitExistsWatchers = {}; -- [frame] = doState (boolean)
|
||||
local unitExistsCache = setmetatable({}, {
|
||||
__index = function(t, k)
|
||||
local v = (UnitExists(k) and true) or false;
|
||||
t[k] = v;
|
||||
return v;
|
||||
end,
|
||||
});
|
||||
|
||||
local STATE_DRIVER_UPDATE_THROTTLE = 0.2;
|
||||
local nextUpdate = 0;
|
||||
|
||||
local pairs = pairs;
|
||||
|
||||
function UnitWatchRegistered(frame)
|
||||
return unitExistsWatchers[frame] ~= nil;
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Resolvers
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
local function UpdateUnitWatch(frame, doState)
|
||||
local unit = SecureButton_GetUnit(frame);
|
||||
local exists = (unit and unitExistsCache[unit]) or false;
|
||||
if doState then
|
||||
if frame:GetAttribute("state-unitexists") ~= exists then
|
||||
frame:SetAttribute("state-unitexists", exists);
|
||||
end
|
||||
else
|
||||
if exists then
|
||||
frame:Show();
|
||||
else
|
||||
frame:Hide();
|
||||
end
|
||||
local hidden = (not exists) or nil;
|
||||
if frame:GetAttribute("statehidden") ~= hidden then
|
||||
frame:SetAttribute("statehidden", hidden);
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local function resolveDriver(frame, attribute, values)
|
||||
local newValue = SecureCmdOptionParse(values);
|
||||
|
||||
if attribute == "state-visibility" then
|
||||
if newValue == "show" then
|
||||
frame:Show();
|
||||
if frame:GetAttribute("statehidden") ~= nil then
|
||||
frame:SetAttribute("statehidden", nil);
|
||||
end
|
||||
elseif newValue == "hide" then
|
||||
frame:Hide();
|
||||
if frame:GetAttribute("statehidden") ~= true then
|
||||
frame:SetAttribute("statehidden", true);
|
||||
end
|
||||
end
|
||||
elseif newValue then
|
||||
if newValue == "nil" then
|
||||
newValue = nil;
|
||||
else
|
||||
newValue = tonumber(newValue) or newValue;
|
||||
end
|
||||
if frame:GetAttribute(attribute) ~= newValue then
|
||||
frame:SetAttribute(attribute, newValue);
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Manager frame
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
SecureStateDriverManager = CreateFrame("Frame", "SecureStateDriverManager");
|
||||
SecureStateDriverManager:Hide(); -- hidden -> no OnUpdate until a driver exists
|
||||
|
||||
SecureStateDriverManager:SetScript("OnUpdate", function()
|
||||
local now = GetTime();
|
||||
if now < nextUpdate then
|
||||
return;
|
||||
end
|
||||
nextUpdate = now + STATE_DRIVER_UPDATE_THROTTLE;
|
||||
|
||||
for frame, drivers in pairs(secureAttributeDrivers) do
|
||||
for attribute, values in pairs(drivers) do
|
||||
resolveDriver(frame, attribute, values);
|
||||
end
|
||||
end
|
||||
|
||||
for k in pairs(unitExistsCache) do
|
||||
unitExistsCache[k] = nil;
|
||||
end
|
||||
for frame, doState in pairs(unitExistsWatchers) do
|
||||
UpdateUnitWatch(frame, doState);
|
||||
end
|
||||
end);
|
||||
|
||||
SecureStateDriverManager:SetScript("OnEvent", function()
|
||||
nextUpdate = 0; -- force a rescan on the next frame
|
||||
end);
|
||||
|
||||
SecureStateDriverManager:SetScript("OnAttributeChanged", function()
|
||||
if arg1 == "updatetime" and tonumber(arg2) then
|
||||
STATE_DRIVER_UPDATE_THROTTLE = tonumber(arg2);
|
||||
end
|
||||
end);
|
||||
|
||||
-- Events that warrant an early rescan. Each is guarded so a name this client
|
||||
-- doesn't define (e.g. UPDATE_STEALTH) is simply skipped.
|
||||
local rescanEvents = {
|
||||
"MODIFIER_STATE_CHANGED", -- ClassicAPI-fired
|
||||
"PLAYER_FOCUS_CHANGED", -- ClassicAPI-fired
|
||||
"UPDATE_SHAPESHIFT_FORM", -- ClassicAPI-fired (singular)
|
||||
"UPDATE_SHAPESHIFT_FORMS", -- native 1.12 (plural)
|
||||
"ACTIONBAR_PAGE_CHANGED",
|
||||
"UPDATE_BONUS_ACTIONBAR",
|
||||
"PLAYER_ENTERING_WORLD",
|
||||
"UPDATE_STEALTH",
|
||||
"PLAYER_TARGET_CHANGED",
|
||||
"PLAYER_REGEN_DISABLED",
|
||||
"PLAYER_REGEN_ENABLED",
|
||||
"UNIT_PET",
|
||||
"RAID_ROSTER_UPDATE",
|
||||
"PARTY_MEMBERS_CHANGED",
|
||||
};
|
||||
for _, event in ipairs(rescanEvents) do
|
||||
if C_EventUtils.IsEventValid(event) then
|
||||
SecureStateDriverManager:RegisterEvent(event);
|
||||
end
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Public API
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
function RegisterAttributeDriver(frame, attribute, values)
|
||||
if attribute and values and string.sub(attribute, 1, 1) ~= "_" then
|
||||
local drivers = secureAttributeDrivers[frame];
|
||||
if not drivers then
|
||||
drivers = {};
|
||||
secureAttributeDrivers[frame] = drivers;
|
||||
end
|
||||
drivers[attribute] = values;
|
||||
SecureStateDriverManager:Show();
|
||||
resolveDriver(frame, attribute, values);
|
||||
end
|
||||
end
|
||||
|
||||
function UnregisterAttributeDriver(frame, attribute)
|
||||
if attribute then
|
||||
local drivers = secureAttributeDrivers[frame];
|
||||
if drivers then
|
||||
drivers[attribute] = nil;
|
||||
end
|
||||
else
|
||||
secureAttributeDrivers[frame] = nil;
|
||||
end
|
||||
end
|
||||
|
||||
function RegisterStateDriver(frame, state, values)
|
||||
RegisterAttributeDriver(frame, "state-" .. state, values);
|
||||
end
|
||||
|
||||
function UnregisterStateDriver(frame, state)
|
||||
UnregisterAttributeDriver(frame, state and ("state-" .. state));
|
||||
end
|
||||
|
||||
function RegisterUnitWatch(frame, asState)
|
||||
local doState = (asState and true) or false;
|
||||
unitExistsWatchers[frame] = doState;
|
||||
SecureStateDriverManager:Show();
|
||||
UpdateUnitWatch(frame, doState);
|
||||
end
|
||||
|
||||
function UnregisterUnitWatch(frame)
|
||||
unitExistsWatchers[frame] = nil;
|
||||
end
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
-7
@@ -142,15 +142,29 @@ target_include_directories(
|
||||
set_target_properties(${PROJECT_NAME} PROPERTIES OUTPUT_NAME "ClassicAPI")
|
||||
|
||||
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} /MT")
|
||||
set(CMAKE_CXX_FLAGS_RELEASE "/MT /O2 /Ob2 /DNDEBUG")
|
||||
# /Zi retains full debug info (source file + line numbers, locals, types) in a
|
||||
# separate PDB WITHOUT disabling optimization — the DLL stays a fully optimized
|
||||
# /O2 release build; the symbols just live in ClassicAPI.pdb on the side. Feeds
|
||||
# the linker's /DEBUG below.
|
||||
set(CMAKE_CXX_FLAGS_RELEASE "/MT /O2 /Ob2 /DNDEBUG /Zi")
|
||||
set(CMAKE_SHARED_LINKER_FLAGS
|
||||
# /MAP emits build/Release/ClassicAPI.map — a linker-only output (no effect
|
||||
# on code layout) that maps `0001:offset` addresses from WoW's ERROR #132
|
||||
# crash dumps back to functions. Published as a build/release artifact so
|
||||
# any crash from a shipped DLL can be triaged exactly. (/OPT:ICF folds
|
||||
# identical functions, so a few map symbols share an address — harmless.)
|
||||
# Two debug artifacts for triaging crashes from a SHIPPED DLL. Both are
|
||||
# published as release artifacts, match this exact build, and have no effect
|
||||
# on code layout:
|
||||
# /MAP -> build/Release/ClassicAPI.map: a text symbol->address table
|
||||
# that resolves the `0001:offset` addresses in WoW's ERROR #132
|
||||
# crash log back to functions by hand (no debugger/dump needed).
|
||||
# /DEBUG -> build/Release/ClassicAPI.pdb: full symbols (paired with the
|
||||
# compiler's /Zi) so WinDbg/x64dbg/VS produce a line-level
|
||||
# symbolicated call stack from a crash dump. A SEPARATE file —
|
||||
# it is NOT embedded in the DLL, only a small signature record
|
||||
# linking the two is.
|
||||
# /DEBUG implicitly disables /OPT:REF and /OPT:ICF (dead-code strip +
|
||||
# identical-COMDAT fold); they are listed explicitly below so they stay ON
|
||||
# and the shipped DLL does not grow. (/OPT:ICF folds identical functions, so
|
||||
# a few map symbols share an address — harmless.)
|
||||
# /DEF exports `Load` (VanillaFixes' post-injection entry point) undecorated
|
||||
# so its GetProcAddress(module, "Load") resolves. See src/ClassicAPI.def and
|
||||
# the loader-lock rationale in src/DllMain.cpp.
|
||||
"/OPT:REF /OPT:ICF /MERGE:.rdata=.text /MERGE:.pdata=.text /SECTION:.text,EWR /NODEFAULTLIB:MSVCRT /MAP /DEF:${CMAKE_CURRENT_SOURCE_DIR}/src/ClassicAPI.def"
|
||||
"/OPT:REF /OPT:ICF /MERGE:.rdata=.text /MERGE:.pdata=.text /SECTION:.text,EWR /NODEFAULTLIB:MSVCRT /MAP /DEBUG /DEF:${CMAKE_CURRENT_SOURCE_DIR}/src/ClassicAPI.def"
|
||||
)
|
||||
|
||||
@@ -1,19 +1,49 @@
|
||||
# ClassicAPI
|
||||
|
||||
A small DLL for World of Warcraft 1.12.1 (Vanilla / Turtle WoW) that adds a
|
||||
collection of Lua API calls Blizzard never exposed in 1.12 but which make
|
||||
addon authoring noticeably less painful — primarily for backporting addons
|
||||
written against later API versions (3.3.5+) where these calls do exist.
|
||||
ClassicAPI is a DLL for World of Warcraft 1.12.1 (Vanilla / Turtle WoW). It
|
||||
backports a large part of the modern WoW API — 550+ functions and 50+ events,
|
||||
plus much of Lua 5.1 — into the 1.12 client, so addons written for later
|
||||
versions (3.3.5+ or Classic Era) run with little or no change.
|
||||
|
||||
The DLL hooks the FrameScript engine after WoW boots and registers its
|
||||
extensions through the same in-engine mechanisms WoW uses for its own Lua
|
||||
functions. No companion addon is required.
|
||||
It hooks the engine after WoW boots and registers everything the same way WoW
|
||||
registers its own Lua functions. Nothing else to install — the DLL is
|
||||
self-contained.
|
||||
|
||||
## What's added
|
||||
## Highlights
|
||||
|
||||
Full per-function reference: **[docs/API.md](docs/API.md)**.
|
||||
The headline features. Each one takes effect as soon as the DLL loads, so your
|
||||
existing addons benefit without any code changes. The complete per-function
|
||||
reference is in **[docs/API.md](docs/API.md)**.
|
||||
|
||||
### Calls
|
||||
### Lua 5.1 compatibility
|
||||
|
||||
The flagship feature: run modern Lua 5.1 addon code on 1.12's Lua 5.0 VM.
|
||||
|
||||
| Feature | Effect |
|
||||
|---------|--------|
|
||||
| [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. 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](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. |
|
||||
|
||||
## Full API reference
|
||||
|
||||
Everything ClassicAPI adds, in full. Each section is collapsed — click a
|
||||
heading to expand it. For signatures and return values, see the per-function
|
||||
reference in **[docs/API.md](docs/API.md)**.
|
||||
|
||||
<details>
|
||||
<summary><b>In-game Lua calls</b> — 550+ functions across ~60 namespaces</summary>
|
||||
|
||||
| Namespace | Calls |
|
||||
|-----------|-------|
|
||||
@@ -25,7 +55,7 @@ Full per-function reference: **[docs/API.md](docs/API.md)**.
|
||||
| [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` |
|
||||
@@ -37,7 +67,7 @@ Full per-function reference: **[docs/API.md](docs/API.md)**.
|
||||
| [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` |
|
||||
| [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`, `fontstring:SetRotation`, `fontstring:GetRotation`, `frame:SetResizeBounds`, `frame:HookScript`, `frame:IsEventRegistered`, `frame:GetEffectiveAlpha`, `frame:SetAttribute`, `frame:SetAttributeNoHandler`, `frame:ClearAttribute`, `frame:GetAttribute`, `OnAttributeChanged` (script), `SetModernScriptArgs`, `GetModernScriptArgs` |
|
||||
| [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`, `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` |
|
||||
@@ -63,28 +93,30 @@ Full per-function reference: **[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` |
|
||||
| [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` |
|
||||
| [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` |
|
||||
| [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` |
|
||||
|
||||
### GlueXML calls
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>GlueXML calls</b> — login / realm-select / character-select screens</summary>
|
||||
|
||||
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.
|
||||
|
||||
@@ -97,10 +129,13 @@ the glue state because GlueXML had no way to reach them otherwise.
|
||||
| Script | `RunScript` (compile and run a Lua chunk in the glue state's globals — useful for slash-command-style helpers in GlueXML) |
|
||||
| State | `IsLoggedIn` |
|
||||
|
||||
### Macros
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<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 |
|
||||
@@ -109,7 +144,10 @@ functions, just behavior the stock 1.12 engine didn't have. See the
|
||||
| `CastSpellByName("<spellID>")` | Same — numeric strings resolve through the engine's name resolver |
|
||||
| `CastSpellNoToggle("<name>")` in a macro | Engine's macro parser now recognizes it as a primary-spell line, so the macro slot in an addon like pfUI highlights when its spell is auto-repeating or its self-aura is active |
|
||||
|
||||
### Console commands
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Console commands</b> — developer console (<code>-console</code>)</summary>
|
||||
|
||||
Registered on the engine's developer console (the `~` console available
|
||||
when launching with `-console`), not as Lua functions. See the
|
||||
@@ -121,7 +159,10 @@ when launching with `-console`), not as Lua functions. See the
|
||||
| `ExportInterfaceFiles art` | Extracts Blizzard's UI art (`.blp`/`.tga`) from the MPQs to `BlizzardInterfaceArt\` |
|
||||
| `ExportDBCFiles` | Extracts the client's `.dbc` tables from the MPQs to `DBFilesClient\` |
|
||||
|
||||
### Events
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Events</b> — new events fired to addons</summary>
|
||||
|
||||
| Event | Payload |
|
||||
|-------|---------|
|
||||
@@ -177,11 +218,14 @@ when launching with `-console`), not as Lua functions. See the
|
||||
| `VOICE_CHAT_TTS_PLAYBACK_FAILED` | `status, utteranceID, destination` |
|
||||
| `VOICE_CHAT_TTS_VOICES_UPDATE` | *(none)* |
|
||||
|
||||
### Globals
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Globals & enums</b></summary>
|
||||
|
||||
| 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` |
|
||||
@@ -193,7 +237,10 @@ when launching with `-console`), not as Lua functions. See the
|
||||
| Spellbook bank | `Enum.SpellBookSpellBank.{Player,Pet}` (0–1) |
|
||||
| Spellbook item type | `Enum.SpellBookItemType.{None,Spell,FutureSpell,PetAction,Flyout}` (0–4; 1.12 only yields `Spell`/`PetAction`) |
|
||||
|
||||
### Unit tokens
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Unit tokens</b> — <code>nameplateN</code>, <code>focus</code>, <code>markN</code></summary>
|
||||
|
||||
| Token | Resolves to |
|
||||
|-------|-------------|
|
||||
@@ -201,9 +248,12 @@ when launching with `-console`), not as Lua functions. See the
|
||||
| `focus` / `focustarget` | Sticky target set via [`FocusUnit`](docs/API.md#focusunitunit), cleared via [`ClearFocus`](docs/API.md#clearfocus). Same `UnitX` coverage as `nameplateN`. Fires [`PLAYER_FOCUS_CHANGED`](docs/API.md#player_focus_changed-event) on transition. See [Focus](docs/API.md#focus). |
|
||||
| `mark1`..`mark8` | Unit currently wearing the Nth raid-target marker (`mark1` = star … `mark8` = skull). Same `UnitX` coverage as `nameplateN`, and fires unit events (`UNIT_HEALTH`, `UNIT_AURA`, …) with `arg1 == "markN"`. `UnitExists("markN")` is `false` when the marker is unset, out of range, or on a non-unit. Suffix chains (`mark1target`) compose. See [Unit tokens (`markN`)](docs/API.md#unit-tokens-markn). |
|
||||
|
||||
### Bindings
|
||||
</details>
|
||||
|
||||
ClassicAPI backports the later-client direct-action and temporary override
|
||||
<details>
|
||||
<summary><b>Bindings</b> — direct-action and override binding families</summary>
|
||||
|
||||
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.
|
||||
@@ -212,11 +262,10 @@ See the [Bindings API reference](docs/API.md#bindings) for signatures,
|
||||
precedence, execution behavior, macro-text usage, and 1.12 compatibility
|
||||
notes.
|
||||
|
||||
#### Predefined focus bindings
|
||||
|
||||
Injected into the engine's **Targeting Functions** group at FrameXML
|
||||
Bindings.xml load time, so they appear in the keybind UI alongside
|
||||
native targeting bindings instead of orphaned at the bottom.
|
||||
**Predefined focus bindings.** Injected into the engine's **Targeting
|
||||
Functions** group at FrameXML Bindings.xml load time, so they appear in the
|
||||
keybind UI alongside native targeting bindings instead of orphaned at the
|
||||
bottom.
|
||||
|
||||
| Binding | Action |
|
||||
|---------|--------|
|
||||
@@ -226,20 +275,7 @@ native targeting bindings instead of orphaned at the bottom.
|
||||
See [Predefined focus bindings](docs/API.md#predefined-focus-bindings-focustarget--targetfocus)
|
||||
for the implementation note.
|
||||
|
||||
### Behaviors
|
||||
|
||||
Transparent engine tweaks — no API to call, they just fix a vanilla limitation:
|
||||
|
||||
| Tweak | Effect |
|
||||
|-------|--------|
|
||||
| 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. |
|
||||
| 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. |
|
||||
| Lua 5.1 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). |
|
||||
| Lua 5.1 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). |
|
||||
| Lua 5.1 environment protection | `getfenv` / `setfenv` honor a `__environment` metatable field — the Lua 5.1 sandbox form — in addition to vanilla's raw `__fenv`. See [getfenv / setfenv environment protection](docs/API.md#getfenv--setfenv-environment-protection). |
|
||||
| Multi-flavor & conditional 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. See [Conditional and multi-flavor TOC loading](docs/API.md#conditional-and-multi-flavor-toc-loading). |
|
||||
| SavedVariables loaded first | Honors the modern `## LoadSavedVariablesFirst` TOC directive: a flagged addon's SavedVariables load before its Lua runs, so file-scope code sees restored config (instead of vanilla's `nil`). See [SavedVariables loaded first](docs/API.md#savedvariables-loaded-first). |
|
||||
| Modern 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). |
|
||||
</details>
|
||||
|
||||
## Installation
|
||||
|
||||
@@ -298,7 +334,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
|
||||
@@ -313,12 +349,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). |
|
||||
|
||||
|
||||
+1883
-1409
File diff suppressed because it is too large
Load Diff
@@ -30,6 +30,21 @@ The DLL hooks the engine's own text pipeline — no companion addon:
|
||||
- **Rendering** — the paint-pass co-hook `FUN_005c8fe0` walks the icon records
|
||||
and queues each to `Text::InlineTexturePool`, which places the region on the
|
||||
next frame tick (never mid-render).
|
||||
- **Chat has no one-frame lag** — a ScrollingMessageFrame re-`SetText`s every
|
||||
visible line on each new message, and the engine builds each line lazily at
|
||||
paint, so a line's icons would land one frame after its glyphs (glyphs draw
|
||||
synchronously; the icon regions are separate objects whose placement is only
|
||||
known once the line builds mid-paint, after the frame's texture pass already
|
||||
drew). A co-hook on the SMF display refresh (`FUN_00788750`, which runs
|
||||
pre-render from the frame's own methods) forces each icon-bearing line to
|
||||
resolve+build and applies its icon placement synchronously — before the frame
|
||||
draws — so icons move in lockstep with their text. It reuses the engine's own
|
||||
`Realize(0)` → `RebuildString` → ensure-built path and the same placement math
|
||||
the paint flush uses (`ComputePlacementsForNode`), so the later flush dedups to
|
||||
a no-op. Additive and best-effort: any line whose rect isn't resolvable in time
|
||||
falls back to the paint + tick pipeline (correct, one-frame lagged). A pure
|
||||
scroll or drag never lagged — the icons are anchored to the line and the engine
|
||||
moves them for free; only content changes on a reused line needed this.
|
||||
- **Placement coordinates** — text pen space IS render-target pixels. The
|
||||
engine stores node positions NORMALIZED (fs-local anchor ÷
|
||||
`[0x832A44]`/`[0x832A48]` per axis, `FUN_0041ade0`), and the origin finalize
|
||||
@@ -85,9 +100,11 @@ Every fs-level measure path now counts icons: width (`FUN_00772890`), height
|
||||
refute that: the GameTooltip auto-size measures each WRAPPED SEGMENT of a
|
||||
wrap-enabled line through it, so icon-bearing wrapped tooltip lines
|
||||
undersized their tooltip until it was hooked too). The Lua control surface is
|
||||
one kill switch, `_classicapi_InlineTexEnable` (the SEH latch trips it on a
|
||||
flush fault); the bring-up tune / stat / probe functions were removed
|
||||
(`5e5677b`, `6800a9a`).
|
||||
two toggles: `_classicapi_InlineTexEnable` (the whole-feature kill switch; the
|
||||
SEH latch trips it on a flush fault) and `_classicapi_InlineTexEarly` (the
|
||||
SMF-refresh early apply above — on by default, toggle off to A/B the chat lag
|
||||
against the baseline paint+tick pipeline, which keeps running either way). The
|
||||
bring-up tune / stat / probe functions were removed (`5e5677b`, `6800a9a`).
|
||||
|
||||
## REMOVED (bring-up record) — the raw-GxU-quad rendering primitive
|
||||
|
||||
|
||||
+12
-40
@@ -16,12 +16,10 @@
|
||||
#include "MinHook.h"
|
||||
#include "Offsets.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"
|
||||
|
||||
static Game::FrameScript_Initialize_t FrameScript_Initialize_o = nullptr;
|
||||
@@ -38,44 +36,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 pending character-dress jobs and their engine compositors — the
|
||||
// reload destroys every Model frame the jobs point at.
|
||||
Model::DisplayInfo::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();
|
||||
@@ -103,6 +74,7 @@ static void __stdcall LoadGlueScriptFunctions_h() {
|
||||
Text::InlineTexture::PrepareForReload();
|
||||
Text::InlineTexturePool::PrepareForReload();
|
||||
Texture::Transform::PrepareForReload();
|
||||
Texture::Mask::PrepareForReload();
|
||||
}
|
||||
|
||||
// Every Lua-side `frame:RegisterEvent(...)` is a chance to claim a slot
|
||||
|
||||
@@ -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
|
||||
|
||||
+536
-2
@@ -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
|
||||
@@ -419,6 +453,14 @@ enum Offsets {
|
||||
VAR_SIMPLETEXTURE_POOL = 0x00CF4CE0, // &DAT_00cf4ce0 CSimpleTexture free-list pool (`this`)
|
||||
VAR_SIMPLETEXTURE_CLASS_TAG = 0x00846588, // ".?AVCSimpleTexture@@" (alloc debug tag)
|
||||
FUN_SIMPLETEXTURE_CTOR = 0x0076FC40, // __thiscall(mem, parent, layer, sublayer) -> tex
|
||||
// --- MaskTexture object API (frame:CreateMaskTexture / Texture:AddMaskTexture) ---
|
||||
// CreateMaskTexture mints its mask region by calling the engine's own
|
||||
// Script_CreateTexture (reads (frame,name,layer,template) off the Lua stack and
|
||||
// pushes the new CSimpleTexture), then hides it — the mask is a source, never drawn.
|
||||
// The draw hook reads the mask's HTEXTURE (OFF_SIMPLETEXTURE_HTEXTURE, +0xCC, set
|
||||
// by SetTexture and valid while hidden) + its rect (FUN_REGION_GET_RECT); the
|
||||
// +0xC4 shown flag is OFF_REGION_DESIRED_SHOWN (defined below).
|
||||
FUN_SCRIPT_CREATE_TEXTURE = 0x00773A20, // Script frame:CreateTexture, __fastcall(L)->int
|
||||
// CSimpleTexture::SetTexture(path). Loads via FUN_00449D90, stores the owned
|
||||
// HTEXTURE at +0xCC (releases the old via DecRef FUN_0041AED0), marks dirty.
|
||||
// `__thiscall(tex, path, 0, *VAR_TEXTURE_BLEND_DEFAULT, 0) -> u32`. The
|
||||
@@ -482,6 +524,10 @@ enum Offsets {
|
||||
// rect (see FUN_00770410's SetTexCoord path); texture/Rotation.cpp reads it to
|
||||
// rebuild axis-aligned corners before rotating.
|
||||
FUN_REGION_GET_RECT = 0x00768320,
|
||||
// __thiscall(region+OFF_REGION_ANCHOR) -> int (nonzero = layout dirty, needs a
|
||||
// resolve). GetLeft gates its resolve on this. Paired with the flag-1 resolve
|
||||
// below to read a hidden region's rect synchronously.
|
||||
FUN_REGION_LAYOUT_DIRTY = 0x00768310,
|
||||
|
||||
// __thiscall(region+OFF_REGION_ANCHOR, int flag /*stack; pass 0*/) — force a
|
||||
// synchronous layout resolve: recomputes the region's rect (+0x64) from its
|
||||
@@ -557,6 +603,34 @@ enum Offsets {
|
||||
// stale-owner bugs (ghost icons, orphaned records) structurally impossible
|
||||
// instead of heuristically guarded.
|
||||
FUN_TEXT_NODE_FREE = 0x005CD950,
|
||||
// Ensure a text node is laid out: `__fastcall(node)` -> FUN_005cd3f0 + the
|
||||
// draw builder FUN_005CDC20 (runs the glyph emitter and finalizes the node
|
||||
// origin +0x70/+0x74), then clears node+0xc0. Same "ensure built" the paint
|
||||
// pass (FUN_005c8fe0) calls per node; safe to call directly (the builder
|
||||
// re-lays the node — a clean node just re-emits the same glyphs). Verified
|
||||
// callers: the paint pass and FUN_005cd4d0 (the width/resize helper).
|
||||
// Text::InlineTexture drives it PRE-RENDER from the SMF-refresh early-apply
|
||||
// so a chat line's icons are recorded before the frame draws.
|
||||
FUN_TEXT_ENSURE_BUILT = 0x005CD6A0,
|
||||
|
||||
// CScrollingMessageFrame display refresh — `__thiscall(smf, int newestIdx)`.
|
||||
// Rebuilds the visible line set bottom-up: per slot it SetTexts the line
|
||||
// fontstring (FUN_00788af0 -> SetText FUN_00771d80 + enqueue-resolve
|
||||
// FUN_007680e0 flag 1), (re)anchors newly-allocated lines, shows/hides by
|
||||
// message. Every new message shifts the newest index, so EVERY visible slot
|
||||
// is re-SetText'd -> its inline icons must be recomputed; the engine builds
|
||||
// the line lazily at paint, so icons land one frame after the glyphs (the
|
||||
// one-frame lag). Text::InlineTexture co-hooks this and, post-refresh,
|
||||
// forces each icon-bearing line to resolve+build and applies its icon
|
||||
// placement synchronously — pre-render (the refresh runs from the SMF's
|
||||
// Lua/event-driven methods, never mid-render), so icons draw with their
|
||||
// glyphs the same frame. Line-entry layout verified from the decompile:
|
||||
// count@+0x3A0, stride-0x10 entry array@+0x3A4, entry+8 = the line fs.
|
||||
FUN_SMF_DISPLAY_REFRESH = 0x00788750,
|
||||
OFF_SMF_LINE_COUNT = 0x3A0, // int: allocated visible-line count
|
||||
OFF_SMF_LINE_ARRAY = 0x3A4, // base of the stride-0x10 line-entry array
|
||||
SMF_LINE_STRIDE = 0x10, // per-line-entry stride
|
||||
OFF_SMF_LINE_FONTSTRING = 0x8, // entry+8 = the line's CSimpleFontString
|
||||
// THE pen↔anchor unit bridge. RebuildString (0x7724A0) multiplies every
|
||||
// text-unit quantity by region+0x7C when crossing into node creation:
|
||||
// nodePos = inset×s + rect corner, nodeFontH = fontPx×s, spacing = +0xF4×s,
|
||||
@@ -1020,6 +1094,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
|
||||
@@ -1715,6 +1802,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
|
||||
@@ -1745,6 +1841,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
|
||||
@@ -1803,6 +1906,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
|
||||
@@ -4062,6 +4170,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,
|
||||
@@ -4332,6 +4449,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
|
||||
@@ -4750,6 +4876,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.
|
||||
@@ -4829,6 +4960,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
|
||||
@@ -5333,8 +5474,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
|
||||
@@ -5606,6 +5751,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
|
||||
@@ -6307,6 +6624,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
|
||||
@@ -6459,6 +6793,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.
|
||||
@@ -6860,6 +7200,31 @@ enum Offsets {
|
||||
// (case-insensitive, hashed). See FUN_006ee6f0 in Templates.cpp notes.
|
||||
FUN_XML_TEMPLATE_LOOKUP = 0x006EE6F0,
|
||||
|
||||
// `CreateFrame(type [, name, parent, template])` — the global FrameScript
|
||||
// function at table 0x00872E74 slot 3. Standard `int __fastcall(void *L)`
|
||||
// shape. Vanilla accepts only a SINGLE template name; the hook in
|
||||
// `Frame::CreateFrame` splits comma-separated inherits strings so modern
|
||||
// multi-template calls work (e.g. "UIPanelButtonTemplate,
|
||||
// SecureActionButtonTemplate"). It builds the frame with the first
|
||||
// resolved template through the engine, then applies each remaining
|
||||
// template onto the created object with the engine's OWN inherit
|
||||
// primitive: `frameObj->vtable[+8](templateDefNode, status)` for content
|
||||
// (FUN_00769820) and `(frameObj+0x24)->vtable[+8](...)` for the template's
|
||||
// `<Frames>` children (FUN_0076a060) — exactly the two `__thiscall`
|
||||
// recursion calls the builder FUN_006ee280 emits when resolving an
|
||||
// `inherits=`. Finalize/OnLoad (`vtable[+0x24]`) runs once during the
|
||||
// engine build and is NOT re-run.
|
||||
FUN_SCRIPT_CREATEFRAME = 0x007060B0,
|
||||
|
||||
// XML-build "status" object — a printf-style error/warning accumulator the
|
||||
// appliers log through (`status->vtable[+0xc](status, level, fmt, ...)`,
|
||||
// __cdecl varargs). Script_CreateFrame builds one on its stack as the
|
||||
// 5-dword `{PTR_TEXLOAD_DESC_VTBL, 8, &self+8, (&self+8)|1, 0}` (same shape
|
||||
// the texture loader reuses; see PTR_TEXLOAD_DESC_VTBL). Reset/emptied by
|
||||
// `FUN_FRAMESCRIPT_STATUS_RESET` (`__thiscall(this)`), which frees any
|
||||
// accumulated message nodes and re-stamps the vtable.
|
||||
FUN_FRAMESCRIPT_STATUS_RESET = 0x00419E30,
|
||||
|
||||
// --- Inline texture escape (`|Tpath:h:w:...|t`) backport -------------------
|
||||
// See src/text/InlineTexture.cpp and docs/InlineTextureEscapes.md. The
|
||||
// 1.12 text pipeline is a shared `|`-tokenizer feeding per-purpose loops.
|
||||
@@ -6881,6 +7246,126 @@ enum Offsets {
|
||||
// Script_Texture_SetTexture's own call site does.
|
||||
VAR_TEXTURE_BLEND_DEFAULT = 0x00878CF0,
|
||||
|
||||
// --- Texture masking (`Texture:SetMask` backport) ---------------------------
|
||||
// Mirrors the engine's own canonical masked-quad draw: the minimap BLIP mask
|
||||
// FUN_004eae10 (a sibling of the disc FUN_004ec440; both fed by the mask that
|
||||
// Minimap:SetMaskTexture at 0x004EE4A0 loads into DAT_00bc7968). FUN_004eae10
|
||||
// is the exact template — bind base on unit 0, mask on unit 1, force each
|
||||
// unit's combiner to MODULATE, submit with SEPARATE uv0/uv1, then unbind
|
||||
// unit 1:
|
||||
// GxRs(0x17, base); GxRs(0x1F, 1); // unit 0: bind + MODULATE preset
|
||||
// GxRs(0x18, mask); GxRs(0x20, 1); // unit 1: bind + MODULATE preset
|
||||
// FUN_0058a2a0(4, corners, …, uv0, uv1); // explicit second UV stream
|
||||
// GxRs(0x18, 0); // unbind unit 1
|
||||
// Unit 1's MODULATE combiner multiplies the mask's alpha into the base's
|
||||
// alpha, so a white-with-alpha mask clips the base to the mask's shape.
|
||||
//
|
||||
// The recipe was also pinned by an in-game probe (git history's
|
||||
// MaskProbe.cpp) on the live backend — findings, load-bearing:
|
||||
// * The COMBINER PRESET selectors are 0x1F+unit (GXRS_COMBINER0), value 1 =
|
||||
// MODULATE/MODULATE (the .rdata preset tables at 0x0080a25c/0x0080a274,
|
||||
// consumed by the D3D9 combiner apply FUN_005a2dd0: COLOROP table
|
||||
// {4,4,13,7,5,12}, ALPHAOP {3,4,3,7,5,3} — value 1 is the only preset
|
||||
// that MODULATEs the alpha channel too, which is what masking rides).
|
||||
// Binding a texture on unit 1 alone happens to give MODULATE by default,
|
||||
// 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. 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
|
||||
// GxRs setters — `__fastcall(selector, value)`. The int form (0x00589E60)
|
||||
// writes combiner presets and the like; the pointer form (0x00589E80) writes
|
||||
// texture binds.
|
||||
FUN_GX_RS_SET = 0x00589E60,
|
||||
FUN_GX_RS_SET_PTR = 0x00589E80,
|
||||
|
||||
// --- Texgen + per-unit texture matrices (multi-mask / rotated masks) -------
|
||||
// Decoded from the D3D9 backend's draw-time state flush FUN_005a2f00 (the
|
||||
// selector → SetTextureStageState/SetTransform Rosetta stone) and the
|
||||
// minimap DISC draw FUN_004ec440, which is the engine's own texgen-mask
|
||||
// template (it submits positions+colors ONLY and feeds BOTH units via
|
||||
// texgen + a per-unit texture matrix). Selector namespace is five per-unit
|
||||
// families, each 8 units wide (device supports 8 fixed-function stages;
|
||||
// live cap at [VAR_GX_DEVICE]+OFF_GXDEV_STAGE_CAP):
|
||||
// 0x17+u bind 0x1F+u combiner 0x27+u mip-bias 0x2F+u texgen
|
||||
// 0x37+u coord-transform mode
|
||||
// Texgen values (FUN_005a2b40): 0 = passthrough (stage reads vertex UV set
|
||||
// #u — the default; why stage 1 sees the explicit uv1 stream), 1 = OBJECT-
|
||||
// space position (D3D TCI_CAMERASPACEPOSITION + an auto inv(view)·inv(model)
|
||||
// basis matrix — UV becomes a pure function of the RAW vertex position,
|
||||
// immune to whatever model/view the pass set), 2 = world-space position,
|
||||
// 3 = camera-space position (identity basis; what the disc draw uses),
|
||||
// 5/4/6 = normal/reflection.
|
||||
// Coord-transform values (FUN_005a14e0): 0 = off, 1 = apply the unit's
|
||||
// texture matrix (basis × user matrix, D3DTTFF_COUNT3), 2 = projected.
|
||||
// Per-unit texture-matrix STACKS (4 levels, base ctx+0xFD0+unit*0x118;
|
||||
// slot 8 = model, 10 = view) — the disc draw's push/translate/scale/pop:
|
||||
GXRS_TEXGEN0 = 0x2F, // texgen mode, +unit (int form)
|
||||
GXRS_TEXGEN_OBJECT_POS = 1,
|
||||
GXRS_TEXXFORM0 = 0x37, // coord-transform mode, +unit (int form)
|
||||
GXRS_TEXXFORM_MATRIX = 1,
|
||||
// Texture-matrix stack ops — `__fastcall(int unit, ...)`. PUSH duplicates
|
||||
// the top (max depth 4); PUSH_LOAD pushes then loads an arbitrary 4×4
|
||||
// (row-vector D3D layout: out = pos·M, translation in row 3). POP restores.
|
||||
// (PUSH/POP were previously mislabeled FUN_GX_TEXUNIT_ENABLE/_DISABLE —
|
||||
// they never enabled anything; binding a texture is what enables a stage,
|
||||
// binding null disables it, per FUN_005a29d0.)
|
||||
FUN_GX_TEXMTX_PUSH = 0x0058B200, // (unit)
|
||||
FUN_GX_TEXMTX_PUSH_LOAD = 0x0058B210, // (unit, const float m[16])
|
||||
FUN_GX_TEXMTX_POP = 0x0058B220, // (unit)
|
||||
// Gx render-state journal push/pop — brackets a state scope; pop replays
|
||||
// every selector written since the push back to its prior value. The engine
|
||||
// wraps each region-layer draw (FUN_0076fb00) and the disc draw in a pair.
|
||||
FUN_GX_STATE_PUSH = 0x00589F40,
|
||||
FUN_GX_STATE_POP = 0x00589F50,
|
||||
// The indexed submit that actually queues the draw for the verts staged by
|
||||
// FUN_GX_PRIM_STREAMS — `__fastcall(int primType, int indexCount, const
|
||||
// void *indices)`. Every FUN_GX_PRIM_STREAMS caller invokes it immediately
|
||||
// after (region layer: FUN_0076fb00; disc: FUN_004ec440). State/matrix
|
||||
// RESTORES must run AFTER this call, not after the stream call — the disc
|
||||
// draw pops its matrices/state only past this point, and restoring earlier
|
||||
// reverts the state the queued draw will be flushed with.
|
||||
FUN_GX_PRIM_INDEXED = 0x0058A2E0,
|
||||
// The device/context singleton the Gx wrappers route through (set at
|
||||
// backend creation, FUN_00589ac0). +0x23C holds the live texture-stage cap
|
||||
// the per-stage applies clamp against (FUN_005a29d0/FUN_005a2b40).
|
||||
VAR_GX_DEVICE = 0x00C0ED38,
|
||||
OFF_GXDEV_STAGE_CAP = 0x23C,
|
||||
// The vertex-stream primitive every textured region draws through
|
||||
// (FUN_0076fb00 calls it per region). Decompiler signature (13 params):
|
||||
// (count, positions, posStride, s3, s3Stride, colors, colorStride, drop8,
|
||||
// drop9, uv0, uv0Stride, uv1, uv1Stride); RET 0x2C. The inner FUN_0058a3d0
|
||||
// builds an interleaved vertex from whichever of {s3, colors, uv0, uv1} are
|
||||
// non-null — so uv1 is a real, buildable second UV slot no engine caller
|
||||
// populates. `positions` for a region draw is the region's corner array
|
||||
// (region + OFF_SIMPLETEXTURE_CORNERS), which is how the mask co-hook
|
||||
// recovers the owning region: region = positions - OFF_SIMPLETEXTURE_CORNERS
|
||||
// (verified: FUN_00772fd0 appends the batch entry with positions =
|
||||
// region+0xD4, and FUN_0076fb00 forwards that pointer here).
|
||||
FUN_GX_PRIM_STREAMS = 0x0058A2A0,
|
||||
// Path → HTEXTURE loader trio (re-adds of the quad-era offsets removed in
|
||||
// 56c2670, with the same derivations — see docs/InlineTextureEscapes.md):
|
||||
// FUN_TEXTURE_LOAD_BY_PATH(path, &desc, flags, 0, 1) __fastcall, desc =
|
||||
// the 5-dword {PTR_TEXLOAD_DESC_VTBL, 8, &self8, (&self8)|1, 0} on-stack
|
||||
// node FUN_00770200 builds; flags via FUN_GX_TEXFLAGS_INIT(&flags, blend,
|
||||
// 0,0, 0,0,0, 1, 0) __thiscall. Never returns null (engine substitutes a
|
||||
// fallback texture). FUN_TEXTURE_GET_RENDERABLE(hTex, 1, 0) __fastcall
|
||||
// returns the bindable CGxTex at [hTex+0x140] and drives the streaming
|
||||
// load — calling it each frame IS the residency reference (what the
|
||||
// minimap's masked draw does for its mask at 0x004ec65c).
|
||||
FUN_TEXTURE_LOAD_BY_PATH = 0x00449D90,
|
||||
PTR_TEXLOAD_DESC_VTBL = 0x007FFA10,
|
||||
FUN_GX_TEXFLAGS_INIT = 0x0058A980,
|
||||
FUN_TEXTURE_GET_RENDERABLE = 0x0044ACF0,
|
||||
|
||||
// --- 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
|
||||
@@ -7099,6 +7584,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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
+76
-5
@@ -46,14 +46,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.
|
||||
@@ -183,6 +179,31 @@ int PushAuraByIndex(void *L, const char *unitToken, int index,
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Positional (multi-return) sibling of `PushAuraByIndex` for the
|
||||
// `C_UnitAuras.UnitAura` family: pushes the Classic-Era `UnitAura` 15-tuple with
|
||||
// NO table allocation (the zero-GC path). Returns 15 on a hit, or 1 (a single
|
||||
// nil) on a miss. The bool-returning push helpers leave exactly the 15-tuple on a
|
||||
// hit and nothing on a miss, so the miss `PushNil` + `return 1` needs no cleanup.
|
||||
int PushUnitAuraPositional(void *L, const char *unitToken, int index,
|
||||
Data::Filter filter, Data::Match match = {}) {
|
||||
const uint8_t *unit = ResolveUnit(unitToken);
|
||||
if (unit != nullptr) {
|
||||
const int slot = Data::FindNthSlot(unit, index, filter, match);
|
||||
if (slot >= 0) {
|
||||
Data::Push(L, unit, slot, Data::Emit::Positional);
|
||||
return 15;
|
||||
}
|
||||
if (Data::PushNthCacheFallback(L, unit, index, filter, match,
|
||||
Data::Emit::Positional))
|
||||
return 15;
|
||||
} else if (Data::PushNthGroupAura(L, GuidForOutOfRange(unitToken), index,
|
||||
filter, match, Data::Emit::Positional)) {
|
||||
return 15;
|
||||
}
|
||||
Game::Lua::PushNil(L);
|
||||
return 1;
|
||||
}
|
||||
|
||||
int __fastcall Script_GetAuraDataByIndex(void *L) {
|
||||
const char *unit = ArgUnit(L, 1);
|
||||
const int index = ArgInt(L, 2);
|
||||
@@ -215,6 +236,50 @@ int __fastcall Script_GetDebuffDataByIndex(void *L) {
|
||||
return PushAuraByIndex(L, unit, index, Data::Filter::Harmful);
|
||||
}
|
||||
|
||||
// Zero-allocation positional accessors — Classic-Era `UnitAura`/`UnitBuff`/
|
||||
// `UnitDebuff` shape, namespaced under `C_UnitAuras` to avoid clashing with the
|
||||
// native `UnitBuff`/`UnitDebuff` globals (which keep their vanilla short return).
|
||||
// `UnitAura` reads the range from the filter (helpful by default); `UnitBuff` /
|
||||
// `UnitDebuff` lock the range and still honor the filter's PLAYER/DISPELLABLE/
|
||||
// CROWD_CONTROL predicates.
|
||||
int __fastcall Script_UnitAura(void *L) {
|
||||
const char *unit = ArgUnit(L, 1);
|
||||
const int index = ArgInt(L, 2);
|
||||
const char *filterStr = ArgOptString(L, 3);
|
||||
if (unit == nullptr || index < 1) {
|
||||
Game::Lua::PushNil(L);
|
||||
return 1;
|
||||
}
|
||||
const ParsedFilter pf = ParseFilters(filterStr);
|
||||
return PushUnitAuraPositional(L, unit, index, RangeFilter(pf), pf.ToMatch());
|
||||
}
|
||||
|
||||
int __fastcall Script_UnitBuff(void *L) {
|
||||
const char *unit = ArgUnit(L, 1);
|
||||
const int index = ArgInt(L, 2);
|
||||
const char *filterStr = ArgOptString(L, 3);
|
||||
if (unit == nullptr || index < 1) {
|
||||
Game::Lua::PushNil(L);
|
||||
return 1;
|
||||
}
|
||||
const ParsedFilter pf = ParseFilters(filterStr);
|
||||
return PushUnitAuraPositional(L, unit, index, Data::Filter::Helpful,
|
||||
pf.ToMatch());
|
||||
}
|
||||
|
||||
int __fastcall Script_UnitDebuff(void *L) {
|
||||
const char *unit = ArgUnit(L, 1);
|
||||
const int index = ArgInt(L, 2);
|
||||
const char *filterStr = ArgOptString(L, 3);
|
||||
if (unit == nullptr || index < 1) {
|
||||
Game::Lua::PushNil(L);
|
||||
return 1;
|
||||
}
|
||||
const ParsedFilter pf = ParseFilters(filterStr);
|
||||
return PushUnitAuraPositional(L, unit, index, Data::Filter::Harmful,
|
||||
pf.ToMatch());
|
||||
}
|
||||
|
||||
int __fastcall Script_GetUnitAuraBySpellID(void *L) {
|
||||
const char *unitToken = ArgUnit(L, 1);
|
||||
const int spellID = ArgInt(L, 2);
|
||||
@@ -426,6 +491,12 @@ static void RegisterLuaFunctions() {
|
||||
&Script_GetBuffDataByIndex);
|
||||
Game::Lua::RegisterTableFunction("C_UnitAuras", "GetDebuffDataByIndex",
|
||||
&Script_GetDebuffDataByIndex);
|
||||
Game::Lua::RegisterTableFunction("C_UnitAuras", "UnitAura",
|
||||
&Script_UnitAura);
|
||||
Game::Lua::RegisterTableFunction("C_UnitAuras", "UnitBuff",
|
||||
&Script_UnitBuff);
|
||||
Game::Lua::RegisterTableFunction("C_UnitAuras", "UnitDebuff",
|
||||
&Script_UnitDebuff);
|
||||
Game::Lua::RegisterTableFunction("C_UnitAuras", "GetUnitAuraBySpellID",
|
||||
&Script_GetUnitAuraBySpellID);
|
||||
Game::Lua::RegisterTableFunction("C_UnitAuras", "GetPlayerAuraBySpellID",
|
||||
|
||||
+102
-31
@@ -77,13 +77,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
|
||||
@@ -449,23 +445,49 @@ const char *DispelName(uint32_t dispelTypeID) {
|
||||
return (name == nullptr || *name == '\0') ? nullptr : name;
|
||||
}
|
||||
|
||||
// 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 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;
|
||||
const char *dispelName;
|
||||
bool castByPlayer;
|
||||
};
|
||||
|
||||
static Display ResolveDisplay(uint32_t spellID, uint64_t casterGuid) {
|
||||
const uint8_t *spellRecord = SpellRecord(spellID);
|
||||
uint32_t dispelTypeID = 0;
|
||||
if (spellRecord != nullptr) {
|
||||
dispelTypeID = *reinterpret_cast<const uint32_t *>(
|
||||
spellRecord + Offsets::OFF_SPELL_DISPEL_TYPE);
|
||||
}
|
||||
Display d;
|
||||
d.name = LocalizedSpellName(spellRecord);
|
||||
d.icon = SpellIconPath(spellRecord);
|
||||
d.dispelName = DispelName(dispelTypeID);
|
||||
d.castByPlayer = Guid::IsPlayer(casterGuid) || Guid::IsPet(casterGuid);
|
||||
return d;
|
||||
}
|
||||
|
||||
// Writes the full modern AuraData table on top of the Lua stack from
|
||||
// already-resolved values. Shared by the descriptor path (`Push`) and the
|
||||
// cache-fallback path (`PushFromCache`). Net stack effect: +1 (the table).
|
||||
static void BuildTable(void *L, uint32_t spellID, int applications,
|
||||
bool isHelpful, double duration, double expirationTime,
|
||||
uint64_t casterGuid) {
|
||||
const uint8_t *spellRecord = SpellRecord(spellID);
|
||||
|
||||
uint32_t dispelTypeID = 0;
|
||||
if (spellRecord != nullptr) {
|
||||
dispelTypeID = *reinterpret_cast<const uint32_t *>(
|
||||
spellRecord + Offsets::OFF_SPELL_DISPEL_TYPE);
|
||||
}
|
||||
|
||||
const char *name = LocalizedSpellName(spellRecord);
|
||||
const char *icon = SpellIconPath(spellRecord);
|
||||
const char *dispel = DispelName(dispelTypeID);
|
||||
const Display disp = ResolveDisplay(spellID, casterGuid);
|
||||
const char *name = disp.name;
|
||||
const char *icon = disp.icon;
|
||||
const char *dispel = disp.dispelName;
|
||||
|
||||
Game::Lua::NewTable(L);
|
||||
|
||||
@@ -502,10 +524,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);
|
||||
@@ -518,6 +541,45 @@ static void BuildTable(void *L, uint32_t spellID, int applications,
|
||||
// yields nil, so the table doesn't need an explicit entry.
|
||||
}
|
||||
|
||||
// Emits the Classic-Era `UnitAura` 15-value tuple onto the Lua stack from the
|
||||
// same already-resolved values `BuildTable` takes. Net stack effect: +15 — NO
|
||||
// table allocation, the zero-GC path behind `C_UnitAuras.UnitAura`. Field order
|
||||
// matches Classic-Era `UnitAura`: name, icon, count, dispelType(string),
|
||||
// duration, expirationTime, source, isStealable, nameplateShowPersonal, spellId,
|
||||
// canApplyAura, isBossDebuff, castByPlayer, nameplateShowAll, timeMod. Values
|
||||
// mirror the AuraData table exactly so packed / non-packed callers agree: name /
|
||||
// icon / dispelType push `""` when absent (as `SetFieldString` does), while
|
||||
// `source` pushes nil when there's no caster (as the table omits `sourceUnit`).
|
||||
// `isHelpful` is unused (the tuple has no isHelpful/isHarmful) — kept so the leaf
|
||||
// dispatch calls either emitter with a uniform arg list.
|
||||
static void PushPositional(void *L, uint32_t spellID, int applications,
|
||||
bool /*isHelpful*/, double duration,
|
||||
double expirationTime, uint64_t casterGuid) {
|
||||
const Display disp = ResolveDisplay(spellID, casterGuid);
|
||||
|
||||
const char *source = nullptr;
|
||||
char tokenBuf[32];
|
||||
if (casterGuid != 0)
|
||||
source = Unit::Identity::TokenFromGUID(casterGuid, tokenBuf,
|
||||
sizeof tokenBuf);
|
||||
|
||||
Game::Lua::PushString(L, disp.name ? disp.name : ""); // 1 name
|
||||
Game::Lua::PushString(L, disp.icon ? disp.icon : ""); // 2 icon
|
||||
Game::Lua::PushNumber(L, static_cast<double>(applications)); // 3 count
|
||||
Game::Lua::PushString(L, disp.dispelName ? disp.dispelName : ""); // 4 dispelType
|
||||
Game::Lua::PushNumber(L, duration); // 5 duration
|
||||
Game::Lua::PushNumber(L, expirationTime); // 6 expirationTime
|
||||
Game::Lua::PushString(L, source); // 7 source (nil if none)
|
||||
Game::Lua::PushBool(L, false); // 8 isStealable
|
||||
Game::Lua::PushBool(L, false); // 9 nameplateShowPersonal
|
||||
Game::Lua::PushNumber(L, static_cast<double>(spellID)); // 10 spellId
|
||||
Game::Lua::PushBool(L, false); // 11 canApplyAura
|
||||
Game::Lua::PushBool(L, false); // 12 isBossDebuff
|
||||
Game::Lua::PushBool(L, disp.castByPlayer); // 13 castByPlayer
|
||||
Game::Lua::PushBool(L, false); // 14 nameplateShowAll
|
||||
Game::Lua::PushNumber(L, 1); // 15 timeMod
|
||||
}
|
||||
|
||||
// Enriches a bare `(guid, spellID)` into a full AuraData table — the shared
|
||||
// core behind every push path (descriptor, cache fallback, group-array). No
|
||||
// dependence on a live descriptor: the descriptor path passes what it read
|
||||
@@ -546,7 +608,8 @@ static void BuildTable(void *L, uint32_t spellID, int applications,
|
||||
static void PushEnriched(void *L, uint64_t guid, uint32_t spellID,
|
||||
bool isHelpful, int applications, int unitLevel,
|
||||
bool isPlayer, int slot,
|
||||
const Attribution *known = nullptr) {
|
||||
const Attribution *known = nullptr,
|
||||
Emit emit = Emit::Table) {
|
||||
double duration = 0.0;
|
||||
double expirationTime = 0.0;
|
||||
uint64_t casterGuid = 0;
|
||||
@@ -578,8 +641,12 @@ static void PushEnriched(void *L, uint64_t guid, uint32_t spellID,
|
||||
duration = static_cast<double>(a.durationMs) * 0.001;
|
||||
casterGuid = a.caster;
|
||||
}
|
||||
BuildTable(L, spellID, applications, isHelpful, duration, expirationTime,
|
||||
casterGuid);
|
||||
if (emit == Emit::Positional)
|
||||
PushPositional(L, spellID, applications, isHelpful, duration,
|
||||
expirationTime, casterGuid);
|
||||
else
|
||||
BuildTable(L, spellID, applications, isHelpful, duration, expirationTime,
|
||||
casterGuid);
|
||||
}
|
||||
|
||||
// Attribution held directly by an `Aura::Source` cache entry (from `Enumerate`),
|
||||
@@ -588,12 +655,13 @@ static Attribution CachedAttribution(const Aura::Source::CachedAura &c) {
|
||||
return {c.casterGuid, c.expirationMs, c.durationMs};
|
||||
}
|
||||
|
||||
void Push(void *L, const uint8_t *unit, int slot) {
|
||||
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 int unitLevel = (unit != nullptr) ? PlayerLevel(unit) : 0;
|
||||
PushEnriched(L, UnitGuid(unit), spellID, isHelpful, ReadStacks(unit, slot),
|
||||
unitLevel, unit != nullptr && unit == LocalPlayer(), slot);
|
||||
unitLevel, unit != nullptr && unit == LocalPlayer(), slot,
|
||||
/*known*/ nullptr, emit);
|
||||
}
|
||||
|
||||
namespace {
|
||||
@@ -603,7 +671,8 @@ namespace {
|
||||
// in SMSG_SPELL_GO, so `applications` defaults to 1. `duration` uses the
|
||||
// applied (caster-modified) ms when known, else the Spell.dbc base.
|
||||
void PushFromCache(void *L, const uint8_t *unit,
|
||||
const Aura::Source::CachedAura &c, bool isHelpful) {
|
||||
const Aura::Source::CachedAura &c, bool isHelpful,
|
||||
Emit emit = Emit::Table) {
|
||||
// `c` already carries this exact instance's caster + timing (Enumerate
|
||||
// copied it out), so hand it to `PushEnriched` as `known` rather than
|
||||
// re-resolving by (guid, spell, slot) — an unbound entry sharing its spell
|
||||
@@ -612,7 +681,7 @@ void PushFromCache(void *L, const uint8_t *unit,
|
||||
const int unitLevel = (unit != nullptr) ? PlayerLevel(unit) : 0;
|
||||
const Attribution a = CachedAttribution(c);
|
||||
PushEnriched(L, UnitGuid(unit), c.spellId, isHelpful, 1, unitLevel, false,
|
||||
c.slot, &a);
|
||||
c.slot, &a, emit);
|
||||
}
|
||||
|
||||
// Reconciles the `Aura::Source` cache against `unit`'s descriptor: when the
|
||||
@@ -698,7 +767,7 @@ bool FallbackEligible(const uint8_t *unit, const Aura::Source::CachedAura &c,
|
||||
} // namespace
|
||||
|
||||
bool PushNthCacheFallback(void *L, const uint8_t *unit, int oneBasedIndex,
|
||||
Filter filter, Match match) {
|
||||
Filter filter, Match match, Emit emit) {
|
||||
if (unit == nullptr)
|
||||
return false;
|
||||
ReconcileCache(unit); // drop entries the (synced) descriptor contradicts
|
||||
@@ -721,7 +790,7 @@ bool PushNthCacheFallback(void *L, const uint8_t *unit, int oneBasedIndex,
|
||||
if (!FallbackEligible(unit, buf[i], filter, match))
|
||||
continue;
|
||||
if (++seen == target) {
|
||||
PushFromCache(L, unit, buf[i], filter == Filter::Helpful);
|
||||
PushFromCache(L, unit, buf[i], filter == Filter::Helpful, emit);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -828,7 +897,8 @@ bool GroupFallbackEligible(const uint16_t *arr, const Aura::Source::CachedAura &
|
||||
// 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,
|
||||
int oneBasedIndex, Filter filter, Match match) {
|
||||
int oneBasedIndex, Filter filter, Match match,
|
||||
Emit emit = Emit::Table) {
|
||||
Aura::Source::CachedAura buf[kFallbackMax];
|
||||
const int n =
|
||||
Aura::Source::Enumerate(guid, filter == Filter::Harmful, buf, kFallbackMax);
|
||||
@@ -839,7 +909,7 @@ bool PushNthGroupCacheFallback(void *L, uint64_t guid, const uint16_t *arr,
|
||||
if (++seen == oneBasedIndex) {
|
||||
const Attribution a = CachedAttribution(buf[i]);
|
||||
PushEnriched(L, guid, buf[i].spellId, filter == Filter::Helpful, 1,
|
||||
GroupMemberLevel(guid), false, buf[i].slot, &a);
|
||||
GroupMemberLevel(guid), false, buf[i].slot, &a, emit);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -893,7 +963,7 @@ bool PushGroupCacheFallbackMatch(void *L, uint64_t guid, const uint16_t *arr,
|
||||
} // namespace
|
||||
|
||||
bool PushNthGroupAura(void *L, uint64_t guid, int oneBasedIndex, Filter filter,
|
||||
Match match) {
|
||||
Match match, Emit emit) {
|
||||
if (guid == 0 || oneBasedIndex < 1)
|
||||
return false;
|
||||
const uint16_t *arr = Group::MemberStats::AuraArray(guid);
|
||||
@@ -909,14 +979,15 @@ bool PushNthGroupAura(void *L, uint64_t guid, int oneBasedIndex, Filter filter,
|
||||
if (++matches == oneBasedIndex) {
|
||||
PushEnriched(L, guid, GroupSpellID(arr, slot),
|
||||
filter == Filter::Helpful, 1, GroupMemberLevel(guid),
|
||||
false, Aura::Source::SLOT_UNBOUND);
|
||||
false, Aura::Source::SLOT_UNBOUND, /*known*/ nullptr,
|
||||
emit);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
// Empty array (server delta-resend gap): supplement from the cache.
|
||||
if (!GroupArrayHasVisibleAura(arr))
|
||||
return PushNthGroupCacheFallback(L, guid, arr, oneBasedIndex, filter,
|
||||
match);
|
||||
match, emit);
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
+19
-7
@@ -33,6 +33,14 @@ namespace Aura::Data {
|
||||
// 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
|
||||
// paths. `Table` builds the modern `AuraData` table (net stack +1); `Positional`
|
||||
// pushes the Classic-Era `UnitAura` 15-value tuple (net stack +15) with NO table
|
||||
// allocation, for the `C_UnitAuras.UnitAura/UnitBuff/UnitDebuff` accessors that
|
||||
// avoid the per-call GC churn. Selection + timing resolution are identical for
|
||||
// both — only the terminal leaf differs.
|
||||
enum class Emit { Table, Positional };
|
||||
|
||||
// Caster restriction from the `PLAYER` / `!PLAYER` aura filter tokens.
|
||||
// `Any` = no restriction; `PlayerOnly` = only auras the local player cast
|
||||
// (`PLAYER`); `NotPlayer` = only auras NOT cast by the local player
|
||||
@@ -154,8 +162,9 @@ const char *DispelName(uint32_t dispelTypeID);
|
||||
// nameplateShowPersonal=false, canApplyAura=false,
|
||||
// shouldConsolidate=false, isRaid=false
|
||||
//
|
||||
// Net stack effect: +1 (the table).
|
||||
void Push(void *L, const uint8_t *unit, int slot);
|
||||
// Net stack effect: +1 for `Emit::Table` (the table), +15 for `Emit::Positional`
|
||||
// (the Classic-Era `UnitAura` tuple — see `Emit`).
|
||||
void Push(void *L, const uint8_t *unit, int slot, Emit emit = Emit::Table);
|
||||
|
||||
// Fallback for the index path when the descriptor has dropped an aura's slot
|
||||
// (rogue stealth, party range fluctuation) but it's still active server-side.
|
||||
@@ -165,9 +174,11 @@ void Push(void *L, const uint8_t *unit, int slot);
|
||||
// order). On a hit it pushes the AuraData table and returns true; on a miss it
|
||||
// pushes nothing and returns false (caller pushes nil). Only entries whose
|
||||
// helpful/harmful classification matches `filter` and that aren't already in a
|
||||
// populated descriptor slot are considered.
|
||||
// populated descriptor slot are considered. `emit` selects the table vs the
|
||||
// positional tuple leaf (see `Emit`); the pushed count follows `Push`.
|
||||
bool PushNthCacheFallback(void *L, const uint8_t *unit, int oneBasedIndex,
|
||||
Filter filter, Match match = {});
|
||||
Filter filter, Match match = {},
|
||||
Emit emit = Emit::Table);
|
||||
|
||||
// Appends every eligible `Aura::Source` cache fallback for `unit` matching
|
||||
// `filter` into the array table at `outerIdx`, continuing from `nextKey`
|
||||
@@ -196,10 +207,11 @@ void AppendCacheFallbacks(void *L, const uint8_t *unit, Filter filter,
|
||||
// to dedup against.
|
||||
|
||||
// Pushes AuraData for the `oneBasedIndex`-th group-array aura on `guid`
|
||||
// matching `filter`. On a hit pushes the table and returns true; otherwise
|
||||
// pushes nothing and returns false (caller pushes nil).
|
||||
// matching `filter`. On a hit pushes the aura and returns true; otherwise
|
||||
// pushes nothing and returns false (caller pushes nil). `emit` selects the
|
||||
// table vs the positional tuple leaf (see `Emit`).
|
||||
bool PushNthGroupAura(void *L, uint64_t guid, int oneBasedIndex, Filter filter,
|
||||
Match match = {});
|
||||
Match match = {}, Emit emit = Emit::Table);
|
||||
|
||||
// Pushes AuraData for the group-array aura with `spellID` on `guid`, optionally
|
||||
// restricted to one filter range (nullptr = both, helpful first). Returns true
|
||||
|
||||
@@ -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
|
||||
+627
-13
@@ -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,10 @@ 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);
|
||||
|
||||
// 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
|
||||
@@ -373,6 +393,12 @@ void StoreFromCast(uint64_t targetGuid, uint32_t spellId, uint64_t casterGuid,
|
||||
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);
|
||||
}
|
||||
|
||||
// The OnAuraAdded / OnAuraStacksChanged application hooks: a descriptor slot,
|
||||
@@ -396,7 +422,7 @@ 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) {
|
||||
@@ -742,6 +768,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 +939,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 +973,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 +1004,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 +1314,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;
|
||||
|
||||
@@ -933,9 +1420,38 @@ 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));
|
||||
@@ -1043,6 +1559,85 @@ 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) {
|
||||
@@ -1077,6 +1672,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;
|
||||
|
||||
@@ -69,6 +69,36 @@ 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,
|
||||
@@ -88,6 +118,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);
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
+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)
|
||||
|
||||
@@ -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 *>(
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
+176
-16
@@ -69,7 +69,14 @@
|
||||
// `macrotext`/`macro` attribute — prefers an addon RunMacro, else runs natively
|
||||
// via the stock ChatEdit_ParseText), `stopcasting`, `menu`/`togglemenu` (pops
|
||||
// the standard unit dropdown at the cursor via the addon's
|
||||
// ClassicAPI_ToggleUnitMenu). Actions call ordinary Lua globals where the entry
|
||||
// ClassicAPI_ToggleUnitMenu, or cancels spell-targeting when a spell is on the
|
||||
// cursor), `action` (UseAction of the `action` slot), `pet` (CastPetAction of
|
||||
// the `action` index), `click` (forwards to the frame in the `clickbutton`
|
||||
// attribute via delegate:Click), and any other verb as a custom action — a
|
||||
// function stored as a raw field on the frame named by the verb, called as
|
||||
// func(self, unit, button). This is the complete secure-button verb set, so the
|
||||
// !!!ClassicAPI addon ships no parallel Lua dispatcher. Actions call ordinary
|
||||
// Lua globals where the entry
|
||||
// is the engine's (TargetUnit, IsAltKeyDown, …); where we own a C++ module the
|
||||
// dispatch goes straight to it, no Lua round-trip (Spell::AtUnit for `spell`,
|
||||
// Unit::Focus for `focus`). All run under the engine's protected OnClick.
|
||||
@@ -96,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"
|
||||
@@ -211,7 +220,12 @@ bool CopyAttr(void *L, int frameIdx, const char *lname, char *buf, size_t n) {
|
||||
const int si = Game::Lua::GetTop(L);
|
||||
Game::Lua::PushString(L, lname);
|
||||
Game::Lua::RawGet(L, si); // [.., sub, val]
|
||||
if (Game::Lua::Type(L, -1) == Game::Lua::TYPE_STRING) {
|
||||
const int t = Game::Lua::Type(L, -1);
|
||||
if (t == Game::Lua::TYPE_STRING || t == Game::Lua::TYPE_NUMBER) {
|
||||
// lua_tostring coerces a number to its digits in place — on our
|
||||
// temporary stack copy, so the stored attribute is untouched. This
|
||||
// lets a numeric attribute value (e.g. SetAttribute("spell", 12345)
|
||||
// or a numeric itemID) read as a string.
|
||||
const char *s = Game::Lua::ToString(L, -1);
|
||||
if (s) {
|
||||
BoundedCopy(buf, s, n);
|
||||
@@ -514,10 +528,79 @@ bool CallGlobalNum2(void *L, const char *name, double a, double b) {
|
||||
return called;
|
||||
}
|
||||
|
||||
// _G[name](a) — one number arg, no results. Returns true iff called.
|
||||
bool CallGlobalNum1(void *L, const char *name, double a) {
|
||||
const int top = Game::Lua::GetTop(L);
|
||||
const bool called = Game::Lua::PushGlobalFunction(L, name);
|
||||
if (called) {
|
||||
Game::Lua::PushNumber(L, a);
|
||||
Game::Lua::Call(L, 1, 0);
|
||||
}
|
||||
Game::Lua::SetTop(L, top);
|
||||
return called;
|
||||
}
|
||||
|
||||
// `click` verb: the `clickbutton` attribute holds a FRAME (not a string).
|
||||
// Resolve it with the same modified-key precedence as ReadModAttr, then forward
|
||||
// the click via delegate:Click(button). Returns true iff a delegate was called.
|
||||
bool ClickDelegate(void *L, int fi, const char *prefix, const char *suffix,
|
||||
const char *button) {
|
||||
char key[128];
|
||||
Compose3Lower(key, sizeof key, prefix, "clickbutton", suffix);
|
||||
bool got = TryPushValue(L, fi, key);
|
||||
if (!got) {
|
||||
Compose3Lower(key, sizeof key, "", "clickbutton", suffix);
|
||||
got = TryPushValue(L, fi, key);
|
||||
}
|
||||
if (!got) {
|
||||
Compose3Lower(key, sizeof key, "", "clickbutton", "");
|
||||
got = TryPushValue(L, fi, key);
|
||||
}
|
||||
if (!got) return false; // no delegate configured
|
||||
|
||||
const int di = Game::Lua::GetTop(L); // the delegate value
|
||||
bool called = false;
|
||||
if (Game::Lua::Type(L, di) == Game::Lua::TYPE_TABLE) {
|
||||
Game::Lua::PushString(L, "Click");
|
||||
Game::Lua::GetTable(L, di); // delegate.Click
|
||||
if (Game::Lua::Type(L, -1) == Game::Lua::TYPE_FUNCTION) {
|
||||
Game::Lua::PushValue(L, di); // self = delegate
|
||||
Game::Lua::PushString(L, button); // button name
|
||||
Game::Lua::Call(L, 2, 0);
|
||||
called = true;
|
||||
}
|
||||
}
|
||||
Game::Lua::SetTop(L, di - 1); // drop the delegate (+leftovers)
|
||||
return called;
|
||||
}
|
||||
|
||||
// Custom verb: a function stored as a raw field on the frame's own table named
|
||||
// by the verb (retail's escape hatch), invoked as func(self, unit, button).
|
||||
// Returns true iff such a function existed and was called.
|
||||
bool CallCustomAction(void *L, int fi, const char *verb, const char *unit,
|
||||
const char *button) {
|
||||
if (Game::Lua::Type(L, fi) != Game::Lua::TYPE_TABLE) return false;
|
||||
Game::Lua::PushString(L, verb);
|
||||
Game::Lua::RawGet(L, fi); // frame[verb]
|
||||
bool called = false;
|
||||
if (Game::Lua::Type(L, -1) == Game::Lua::TYPE_FUNCTION) {
|
||||
Game::Lua::PushValue(L, fi); // self
|
||||
Game::Lua::PushString(L, unit); // unit (nil if null)
|
||||
Game::Lua::PushString(L, button); // button name
|
||||
Game::Lua::Call(L, 3, 0);
|
||||
called = true;
|
||||
} else {
|
||||
Game::Lua::SetTop(L, Game::Lua::GetTop(L) - 1);
|
||||
}
|
||||
return called;
|
||||
}
|
||||
|
||||
// Performs the resolved `verb` on `unit` (a token attribute value, may be null).
|
||||
// Returns true if it owned the click (so the chained handler is skipped).
|
||||
// `button` is the raw click-button name ("LeftButton", …), needed by the
|
||||
// `click` and custom verbs. Returns true if it owned the click (so the chained
|
||||
// handler is skipped).
|
||||
bool DispatchVerb(void *L, int fi, const char *prefix, const char *suffix,
|
||||
const char *verb, const char *unit) {
|
||||
const char *verb, const char *unit, const char *button) {
|
||||
if (Ascii::EqualCI(verb, "target")) {
|
||||
if (!unit) return false;
|
||||
// `unit="none"` clears the target (retail's SecureActionButton behavior).
|
||||
@@ -549,14 +632,28 @@ bool DispatchVerb(void *L, int fi, const char *prefix, const char *suffix,
|
||||
return true;
|
||||
}
|
||||
if (Ascii::EqualCI(verb, "spell")) {
|
||||
// A numeric spell ID (modern addons do SetAttribute("spell", 12345),
|
||||
// stored as a Lua number; a numeric string is accepted too). Cast the
|
||||
// EXACT rank by ID through the spellbook-slot resolver — at the unit, or
|
||||
// with no unit on the current target (targetGuid 0). NOT name
|
||||
// resolution, which casts the highest known rank regardless of the rank
|
||||
// the caller asked for.
|
||||
int spellID;
|
||||
if (ReadModAttrInt(L, fi, prefix, "spell", suffix, &spellID)) {
|
||||
if (unit)
|
||||
Spell::AtUnit::CastByID(spellID, unit);
|
||||
else
|
||||
Spell::AtCursor::DispatchSpellCast(spellID);
|
||||
return true;
|
||||
}
|
||||
// Otherwise a spell name. With a `unit`, cast straight at it (GUID →
|
||||
// dispatcher, no target juggling; a ground-target spell lands at its
|
||||
// feet). With no `unit`, fall back to the plain global cast on the
|
||||
// current target — a `type="spell"` button that sets no `unit`
|
||||
// attribute (issue #18) works like `/cast <spell>`.
|
||||
char spell[128];
|
||||
if (!ReadModAttr(L, fi, prefix, "spell", suffix, spell, sizeof spell))
|
||||
return false;
|
||||
// With a `unit`, cast straight at it (GUID → dispatcher, no target
|
||||
// juggling; a ground-target spell lands at its feet). With no `unit`,
|
||||
// fall back to the plain global cast on the current target — a
|
||||
// `type="spell"` button that sets no `unit` attribute (issue #18) now
|
||||
// works like `/cast <spell>` instead of doing nothing.
|
||||
if (unit)
|
||||
Spell::AtUnit::CastByName(spell, unit);
|
||||
else
|
||||
@@ -638,15 +735,38 @@ bool DispatchVerb(void *L, int fi, const char *prefix, const char *suffix,
|
||||
Game::Lua::CallGlobal(L, "SpellStopCasting");
|
||||
return true;
|
||||
}
|
||||
if (Ascii::EqualCI(verb, "action")) {
|
||||
int slot;
|
||||
if (!ReadModAttrInt(L, fi, prefix, "action", suffix, &slot)) return false;
|
||||
CallGlobalNum1(L, "UseAction", slot);
|
||||
return true;
|
||||
}
|
||||
if (Ascii::EqualCI(verb, "pet")) {
|
||||
int index;
|
||||
if (!ReadModAttrInt(L, fi, prefix, "action", suffix, &index)) return false;
|
||||
CallGlobalNum1(L, "CastPetAction", index);
|
||||
return true;
|
||||
}
|
||||
if (Ascii::EqualCI(verb, "click")) {
|
||||
return ClickDelegate(L, fi, prefix, suffix, button);
|
||||
}
|
||||
if (Ascii::EqualCI(verb, "menu") || Ascii::EqualCI(verb, "togglemenu")) {
|
||||
// While a spell is on the cursor, cancel targeting instead of opening
|
||||
// the menu (retail's SecureUnitButton behavior).
|
||||
if (CallBoolGlobal(L, "SpellIsTargeting")) {
|
||||
Game::Lua::CallGlobal(L, "SpellStopTargeting");
|
||||
return true;
|
||||
}
|
||||
if (!unit) return false;
|
||||
// The unit dropdown is pure FrameXML work (UnitPopup + ToggleDropDown),
|
||||
// so it lives in the !!!ClassicAPI addon; we just pop it at the cursor.
|
||||
Game::Lua::CallGlobalString(L, "ClassicAPI_ToggleUnitMenu", unit);
|
||||
return true;
|
||||
}
|
||||
// Unknown verb → not handled here; the chained handler runs.
|
||||
return false;
|
||||
// Unknown verb → a custom action: a function stored as a raw field on the
|
||||
// frame named by the verb (retail's escape hatch). If none exists it isn't
|
||||
// handled here and the frame's own chained OnClick runs.
|
||||
return CallCustomAction(L, fi, verb, unit, button);
|
||||
}
|
||||
|
||||
// The chained OnClick handler. Upvalues: 1 = previous handler (or nil), 2 = the
|
||||
@@ -681,7 +801,7 @@ int __fastcall OnClick_c(void *L) {
|
||||
const bool haveUnit =
|
||||
ReadModAttr(L, fi, prefix, "unit", suffix, unit, sizeof unit);
|
||||
handled = DispatchVerb(L, fi, prefix, suffix, verb,
|
||||
haveUnit ? unit : nullptr);
|
||||
haveUnit ? unit : nullptr, btn);
|
||||
}
|
||||
}
|
||||
Game::Lua::SetTop(L, top);
|
||||
@@ -827,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)
|
||||
@@ -970,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
|
||||
@@ -0,0 +1,259 @@
|
||||
// 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/>.
|
||||
|
||||
// Multi-template `CreateFrame` support.
|
||||
//
|
||||
// Modern WoW (2.0+) allows a comma-separated template list in CreateFrame's
|
||||
// 4th argument:
|
||||
//
|
||||
// CreateFrame("Button", nil, parent,
|
||||
// "UIPanelButtonTemplate, SecureActionButtonTemplate")
|
||||
//
|
||||
// Vanilla 1.12's `Script_CreateFrame` (0x007060B0) resolves the 4th arg as a
|
||||
// SINGLE template name — the whole comma string is one lookup, which fails.
|
||||
// 1.12 has no native multi-inheritance: the frame builder `FUN_006ee280`
|
||||
// applies exactly one `inherits` name.
|
||||
//
|
||||
// This hook adds real multi-inheritance by mirroring the engine's OWN inherit
|
||||
// mechanism. It:
|
||||
// 1. Splits the 4th arg on commas and trims each entry.
|
||||
// 2. Resolves each name in the XML template registry, keeping the resolvable
|
||||
// ones in list order (typos / not-yet-loaded optional deps are skipped).
|
||||
// 3. Builds the frame through the original `Script_CreateFrame` using the
|
||||
// FIRST resolved template — the engine does the object creation, that
|
||||
// template's full inherit chain, ref/name setup, and OnLoad.
|
||||
// 4. Grafts each REMAINING resolved template onto the created frame with the
|
||||
// two `__thiscall` calls the builder `FUN_006ee280` emits per inherited
|
||||
// node (verified at 0x006ee4ca):
|
||||
// content : frameObj->vtable[+8](templateDefNode, status) (FUN_00769820)
|
||||
// child <Frames> : (frameObj+0x24)->vtable[+8](templateDefNode, status) (FUN_0076a060)
|
||||
// Both recurse the grafted template's own `inherits`, add its regions /
|
||||
// backdrop / attributes / script handlers, and create its child frames —
|
||||
// exactly as if the frame had inherited it. Later templates override
|
||||
// earlier ones, matching modern left-to-right inherit order.
|
||||
//
|
||||
// OnLoad runs once, during the engine build of the first template (step 3).
|
||||
// Secondary templates' `<OnLoad>` handlers are registered (they fire on the
|
||||
// next relevant event) but not re-run at creation, and the frame's OnLoad is
|
||||
// not re-fired. `status` is the engine's XML-build error accumulator, built
|
||||
// and reset exactly as `Script_CreateFrame` does.
|
||||
|
||||
#include "Game.h"
|
||||
#include "Offsets.h"
|
||||
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
|
||||
namespace Frame::CreateFrame {
|
||||
|
||||
namespace {
|
||||
|
||||
using ScriptFn_t = int(__fastcall *)(void *L);
|
||||
using TemplateLookup_t = const uint8_t *(__fastcall *)(const char *name);
|
||||
|
||||
// XML-node applier vmethod: applies `node` (and its `inherits` chain) to
|
||||
// `self`. __thiscall(self /*ecx*/, node, status). Reached at vtable byte
|
||||
// offset +8 on both the frame's main object (content applier, FUN_00769820)
|
||||
// and its child-<Frames> sub-object at frame+0x24 (FUN_0076a060).
|
||||
using ApplyNodeFn = void(__thiscall *)(void *self, const void *node, void *status);
|
||||
|
||||
// Empties / re-initializes the XML-build status object. __thiscall(this).
|
||||
using StatusReset_t = void(__thiscall *)(void *self);
|
||||
|
||||
// The original Script_CreateFrame function, populated by MinHook.
|
||||
ScriptFn_t g_origCreateFrame = nullptr;
|
||||
|
||||
// ---- helpers ---------------------------------------------------------------
|
||||
|
||||
// Returns the definition node if `name` resolves in the engine's XML template
|
||||
// registry, else nullptr.
|
||||
const uint8_t *LookupTemplate(const char *name) {
|
||||
return reinterpret_cast<TemplateLookup_t>(
|
||||
static_cast<uintptr_t>(Offsets::FUN_XML_TEMPLATE_LOOKUP))(name);
|
||||
}
|
||||
|
||||
// Byte offset of the child-<Frames> applier sub-object within a frame object,
|
||||
// and the vtable slot of the "apply XML node" vmethod. Both read straight from
|
||||
// the builder FUN_006ee280's apply sequence at 0x006ee4ca:
|
||||
// MOV EDX,[EDI] CALL [EDX+0x20] ; top-level content
|
||||
// MOV EAX,[EDI+0x24] LEA ECX,[EDI+0x24] CALL [EAX+0x8] ; child <Frames>
|
||||
// MOV EDX,[EDI] CALL [EDX+0x24] ; finalize / OnLoad
|
||||
// The inherit-recursion inside FUN_00769820 / FUN_0076a060 uses vtable[+8] —
|
||||
// that is the applier we invoke to graft an extra template.
|
||||
constexpr int kChildApplierSubObj = 0x24;
|
||||
constexpr int kApplyNodeVtableSlot = 2; // byte offset +8 / sizeof(void*)
|
||||
|
||||
// The XML-build status object: the 5-dword error accumulator Script_CreateFrame
|
||||
// stack-builds ({vtable, 8, &self+8, (&self+8)|1, 0}). The appliers only ever
|
||||
// call its vtable[+0xc] (a __cdecl printf-style logger); building the engine's
|
||||
// real one absorbs any sub-node warning exactly as the engine does and is safe
|
||||
// against every slot.
|
||||
struct BuildStatus {
|
||||
const void *vtable;
|
||||
int kind;
|
||||
void *head;
|
||||
uintptr_t tail;
|
||||
int count;
|
||||
};
|
||||
|
||||
void InitStatus(BuildStatus *s) {
|
||||
s->vtable = reinterpret_cast<const void *>(
|
||||
static_cast<uintptr_t>(Offsets::PTR_TEXLOAD_DESC_VTBL));
|
||||
s->kind = 8;
|
||||
s->head = &s->head;
|
||||
s->tail = reinterpret_cast<uintptr_t>(&s->head) | 1u;
|
||||
s->count = 0;
|
||||
}
|
||||
|
||||
void ResetStatus(BuildStatus *s) {
|
||||
reinterpret_cast<StatusReset_t>(
|
||||
static_cast<uintptr_t>(Offsets::FUN_FRAMESCRIPT_STATUS_RESET))(s);
|
||||
}
|
||||
|
||||
// Grafts template definition `node` onto the already-created frame `frameObj`,
|
||||
// applying its content and its child <Frames> the same way the engine applies
|
||||
// an inherited node.
|
||||
void ApplyTemplateNode(void *frameObj, const void *node, void *status) {
|
||||
void **vtbl = *reinterpret_cast<void ***>(frameObj);
|
||||
reinterpret_cast<ApplyNodeFn>(vtbl[kApplyNodeVtableSlot])(frameObj, node, status);
|
||||
|
||||
void *childSub = reinterpret_cast<char *>(frameObj) + kChildApplierSubObj;
|
||||
void **childVtbl = *reinterpret_cast<void ***>(childSub);
|
||||
reinterpret_cast<ApplyNodeFn>(childVtbl[kApplyNodeVtableSlot])(childSub, node, status);
|
||||
}
|
||||
|
||||
// Maximum templates in a single comma-separated list.
|
||||
constexpr int kMaxTemplates = 8;
|
||||
|
||||
// Splits `input` on commas into `out[]`, writing NUL terminators into the
|
||||
// mutable buffer `buf`. Each entry is whitespace-trimmed. Returns the count.
|
||||
int SplitTemplates(char *buf, size_t bufLen, const char *input,
|
||||
const char *out[kMaxTemplates]) {
|
||||
size_t len = std::strlen(input);
|
||||
if (len >= bufLen) len = bufLen - 1;
|
||||
std::memcpy(buf, input, len);
|
||||
buf[len] = '\0';
|
||||
|
||||
int count = 0;
|
||||
char *p = buf;
|
||||
while (*p && count < kMaxTemplates) {
|
||||
while (*p == ' ' || *p == '\t') ++p;
|
||||
if (*p == '\0') break;
|
||||
|
||||
char *start = p;
|
||||
while (*p && *p != ',') ++p;
|
||||
|
||||
// Note whether we stopped on a comma BEFORE writing the terminator:
|
||||
// when there is no trailing whitespace, `end` lands on the comma
|
||||
// itself, so `*end = '\0'` would clobber it and the advance below
|
||||
// would then miss it — dropping every entry after the first.
|
||||
const bool atComma = (*p == ',');
|
||||
|
||||
char *end = p;
|
||||
while (end > start && (*(end - 1) == ' ' || *(end - 1) == '\t'))
|
||||
--end;
|
||||
*end = '\0';
|
||||
|
||||
if (*start != '\0')
|
||||
out[count++] = start;
|
||||
|
||||
if (atComma) ++p;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
// ---- the hook --------------------------------------------------------------
|
||||
|
||||
int __fastcall CreateFrame_h(void *L) {
|
||||
// No 4th arg or not a string → pass through.
|
||||
if (Game::Lua::GetTop(L) < 4 || !Game::Lua::IsString(L, 4))
|
||||
return g_origCreateFrame(L);
|
||||
|
||||
const char *inherits = Game::Lua::ToString(L, 4);
|
||||
if (inherits == nullptr || *inherits == '\0')
|
||||
return g_origCreateFrame(L);
|
||||
|
||||
// Fast path: no comma → single template, engine handles it natively.
|
||||
if (std::strchr(inherits, ',') == nullptr)
|
||||
return g_origCreateFrame(L);
|
||||
|
||||
// ---- comma-delimited: split, resolve, combine --------------------------
|
||||
|
||||
char buf[1024];
|
||||
const char *names[kMaxTemplates];
|
||||
const int count = SplitTemplates(buf, sizeof buf, inherits, names);
|
||||
if (count == 0)
|
||||
return g_origCreateFrame(L);
|
||||
|
||||
// Resolve each token; keep the resolvable definition nodes in list order.
|
||||
const char *resolvedNames[kMaxTemplates];
|
||||
const void *resolvedNodes[kMaxTemplates];
|
||||
int resolved = 0;
|
||||
for (int i = 0; i < count; ++i) {
|
||||
const uint8_t *node = LookupTemplate(names[i]);
|
||||
if (node != nullptr) {
|
||||
resolvedNames[resolved] = names[i];
|
||||
resolvedNodes[resolved] = node;
|
||||
++resolved;
|
||||
}
|
||||
}
|
||||
|
||||
// 0 or 1 resolvable: nothing to combine. Hand the engine a single name —
|
||||
// the first resolved one, or (if none resolved) the first token so the
|
||||
// engine emits its normal "couldn't find inherited node" error rather than
|
||||
// silently building a template-less frame.
|
||||
if (resolved <= 1) {
|
||||
Game::Lua::SetTop(L, 3);
|
||||
Game::Lua::PushString(L, resolved == 1 ? resolvedNames[0] : names[0]);
|
||||
return g_origCreateFrame(L);
|
||||
}
|
||||
|
||||
// Build with the first resolved template through the engine (object
|
||||
// creation + its full inherit chain + ref/name setup + OnLoad).
|
||||
Game::Lua::SetTop(L, 3);
|
||||
Game::Lua::PushString(L, resolvedNames[0]);
|
||||
const int rc = g_origCreateFrame(L);
|
||||
if (rc != 1)
|
||||
return rc; // creation failed (e.g. unknown frame type) — leave as-is.
|
||||
|
||||
// Recover the created frame object (top of stack) and graft the remaining
|
||||
// templates onto it with the engine's own inherit primitive. Snapshot the
|
||||
// stack the engine left (frame at `savedTop`) and restore it afterward, so
|
||||
// anything a grafted template's appliers touch on the Lua stack (a child
|
||||
// frame's OnLoad, `$parent` name resolution) can't shift the return value
|
||||
// the caller reads back — the Tooltip::SetEvents save/restore discipline,
|
||||
// which matters in this hook-saturated environment.
|
||||
const int savedTop = Game::Lua::GetTop(L);
|
||||
void *frameObj = Game::Lua::ResolveObject(L, savedTop);
|
||||
if (frameObj != nullptr) {
|
||||
BuildStatus status;
|
||||
InitStatus(&status);
|
||||
for (int i = 1; i < resolved; ++i)
|
||||
ApplyTemplateNode(frameObj, resolvedNodes[i], &status);
|
||||
ResetStatus(&status);
|
||||
Game::Lua::SetTop(L, savedTop);
|
||||
}
|
||||
|
||||
return rc;
|
||||
}
|
||||
|
||||
// ---- registration ----------------------------------------------------------
|
||||
|
||||
const Game::HookAutoRegister _createFrameHook{
|
||||
Offsets::FUN_SCRIPT_CREATEFRAME,
|
||||
reinterpret_cast<void *>(&CreateFrame_h),
|
||||
reinterpret_cast<void **>(&g_origCreateFrame)};
|
||||
|
||||
} // namespace
|
||||
|
||||
} // namespace Frame::CreateFrame
|
||||
@@ -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
|
||||
|
||||
+70
-22
@@ -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
|
||||
@@ -252,6 +253,16 @@ size_t SkipNumber(const char *src, size_t len, size_t pos) {
|
||||
}
|
||||
|
||||
void Tokenize(const char *src, size_t len, std::vector<Token> &out) {
|
||||
// Clear first — RunPasses re-tokenizes into the SAME vector after a pass
|
||||
// rewrites the buffer. Without this, the re-lex APPENDED the new tokens
|
||||
// after the stale ones, and the next pass walked a mixed stream: stale
|
||||
// positions from the old buffer, then new tokens starting back at 0,
|
||||
// whose `t.start` sits BEHIND the splice cursor — the `t.start - p`
|
||||
// size_t underflow then threw std::length_error("string too long")
|
||||
// through the loadbuffer hook into the engine = fatal ERROR #132.
|
||||
// Field-reproduced with WeakAuras' Chomp Internal.lua (hex pass fires,
|
||||
// then the vararg pass walks the doubled stream).
|
||||
out.clear();
|
||||
size_t i = 0;
|
||||
while (i < len) {
|
||||
unsigned char c = static_cast<unsigned char>(src[i]);
|
||||
@@ -1250,9 +1261,22 @@ int __fastcall LoadBuffer_h(void *L, const char *buff, unsigned size, const char
|
||||
|
||||
// Syntax transpile: hex literals first, then vararg, then # / % — one shared
|
||||
// tokenization (see RunPasses). `didVararg` / `didOps` drive the preamble.
|
||||
//
|
||||
// GUARDED: a C++ exception from the rewrite machinery (std::length_error
|
||||
// from an underflowed splice length, bad_alloc, anything) must NEVER cross
|
||||
// into the engine — the engine has no handler, so it surfaces as a fatal
|
||||
// ERROR #132 at RaiseException (seen in the field: a WeakAuras Chomp
|
||||
// chunk killed the client from inside this hook). On any exception the
|
||||
// chunk falls through to the ORIGINAL buffer untouched: worst case it
|
||||
// fails to compile exactly as stock 1.12 would — never a crash.
|
||||
std::string transpiled;
|
||||
bool didVararg = false, didOps = false;
|
||||
const bool changed = RunPasses(buff, size, transpiled, &didVararg, &didOps);
|
||||
bool changed = false;
|
||||
try {
|
||||
changed = RunPasses(buff, size, transpiled, &didVararg, &didOps);
|
||||
} catch (...) {
|
||||
return g_origLoadBuffer(L, buff, size, name);
|
||||
}
|
||||
const char *body = changed ? transpiled.data() : buff;
|
||||
size_t bodyLen = changed ? transpiled.size() : size;
|
||||
|
||||
@@ -1277,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)) {
|
||||
@@ -1288,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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1318,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
|
||||
@@ -1334,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;
|
||||
}
|
||||
|
||||
@@ -1349,7 +1391,13 @@ int __fastcall Script_Transpile(void *L) {
|
||||
}
|
||||
unsigned n = Game::Lua::StrLen(L, 1);
|
||||
std::string out;
|
||||
if (RewriteChunk(s, n, out))
|
||||
bool rewrote = false;
|
||||
try {
|
||||
rewrote = RewriteChunk(s, n, out);
|
||||
} catch (...) {
|
||||
rewrote = false; // same guard as LoadBuffer_h — never throw into the VM
|
||||
}
|
||||
if (rewrote)
|
||||
Game::Lua::PushLString(L, out.data(), static_cast<unsigned>(out.size()));
|
||||
else
|
||||
Game::Lua::PushLString(L, s, n);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -17,7 +17,17 @@
|
||||
|
||||
namespace Object {
|
||||
|
||||
bool RegistryReady() {
|
||||
return *reinterpret_cast<void *const volatile *>(
|
||||
static_cast<uintptr_t>(Offsets::VAR_LOCAL_PLAYER_PTR)) != nullptr;
|
||||
}
|
||||
|
||||
void *ByGuid(int typeMask, uint64_t guid, const char *debugName, int priority) {
|
||||
// Out of world the engine resolver null-derefs its registry root (see
|
||||
// RegistryReady in Resolve.h); with no world there are no live objects, so
|
||||
// "not found" is also the correct answer.
|
||||
if (!RegistryReady())
|
||||
return nullptr;
|
||||
using Fn = void *(__fastcall *)(int typeMask, const char *debugName,
|
||||
uint32_t guidLo, uint32_t guidHi,
|
||||
int priority);
|
||||
|
||||
+17
-3
@@ -22,14 +22,28 @@
|
||||
|
||||
namespace Object {
|
||||
|
||||
// True while the engine's client/player-rooted object registry is up. The
|
||||
// resolver (`FUN_OBJECT_RESOLVE_BY_GUID`) and the descriptor-observer
|
||||
// register/unregister all funnel through `FUN_00464890`, whose FIRST action
|
||||
// dereferences the registry root at `[VAR_LOCAL_PLAYER_PTR]` (reads its
|
||||
// `+0x24` mask) with no null check. That root is NULL out of world — login
|
||||
// screen, loading screens, logout teardown — where the deref is a fatal
|
||||
// ERROR #132 (`MOV EAX,[EDX+0x24]`, EDX=0). Anything that can run
|
||||
// mid-transition (packet hooks, Lua, WorldTick) must gate on this before
|
||||
// touching the registry. See issue #34 for a live crash (SMSG_SPELL_GO
|
||||
// dispatched during a world transition).
|
||||
bool RegistryReady();
|
||||
|
||||
// Resolve `guid` to its live client object. `typeMask` is a bitmask of
|
||||
// `Offsets::TYPEMASK_*` (e.g. `TYPEMASK_UNIT | TYPEMASK_PLAYER`, or
|
||||
// `TYPEMASK_UNIT` / `TYPEMASK_OBJECT`). Returns nullptr when the GUID isn't
|
||||
// currently in the object table (out of range, despawned, pre-world).
|
||||
// Non-throwing — safe from any context (Lua callback, world tick, packet
|
||||
// hook). `priority` mirrors the engine's call-site priority argument: 0x172
|
||||
// for a normal resolve, 0 on the loot/enumeration paths. `debugName` is an
|
||||
// engine diagnostic label (may be nullptr).
|
||||
// hook): internally gated on `RegistryReady`, so an out-of-world call
|
||||
// resolves to nullptr instead of null-derefing in the engine. `priority`
|
||||
// mirrors the engine's call-site priority argument: 0x172 for a normal
|
||||
// resolve, 0 on the loot/enumeration paths. `debugName` is an engine
|
||||
// diagnostic label (may be nullptr).
|
||||
//
|
||||
// Returns `void *` — callers static_cast to the concrete object type they
|
||||
// expect (the engine hands back the same pointer regardless of type).
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
+35
-41
@@ -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
|
||||
@@ -382,9 +370,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 +429,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,12 +462,18 @@ 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);
|
||||
}
|
||||
|
||||
|
||||
@@ -45,11 +45,15 @@ namespace Spell::CastEvents {
|
||||
// after its own upkeep, passing the live `g_cast` / `g_channel` fields.
|
||||
// Detects, against the previous snapshot:
|
||||
// - regular cast start / stop / same-spell recast → UNIT_SPELLCAST_START / _STOP
|
||||
// - accumulated pushback growth → UNIT_SPELLCAST_DELAYED
|
||||
// - end-time movement (either direction) → UNIT_SPELLCAST_DELAYED
|
||||
// - channel start / stop → UNIT_SPELLCAST_CHANNEL_START / _STOP
|
||||
// End-time movement covers both pushback (SMSG_SPELL_DELAYED) and the
|
||||
// confirming SMSG_SPELL_START reconciling the client-predicted cast time to
|
||||
// the server's (ranged-haste-scaled Steady/Aimed Shot, pfUI#43) — DELAYED is
|
||||
// the "re-read the times" trigger, whichever way the end moved.
|
||||
// Channels never fire INTERRUPTED — retail emits only CHANNEL_STOP for a
|
||||
// channel whether it ends naturally or is cut short.
|
||||
void PollPlayer(int castSpellID, int castStartMs, int castDelayMs,
|
||||
void PollPlayer(int castSpellID, int castStartMs, int castEndMs,
|
||||
int channelSpellID, int channelStartMs);
|
||||
|
||||
// Derive the ground-target reticle events from the engine's targeting flags.
|
||||
|
||||
+37
-7
@@ -19,6 +19,7 @@
|
||||
|
||||
#include <cstdint>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
|
||||
namespace Spell::Info {
|
||||
|
||||
@@ -179,15 +180,44 @@ static bool BookTypeIsPet(const char *s) {
|
||||
return lc(s[0]) == 'p' && lc(s[1]) == 'e' && lc(s[2]) == 't' && s[3] == '\0';
|
||||
}
|
||||
|
||||
// Resolves the Lua args to a spellID, supporting:
|
||||
// GetSpellInfo(spellID) -- arg2 absent or non-string
|
||||
// GetSpellInfo(slot, "spell"|"pet") -- arg2 string → spellbook lookup
|
||||
// Returns 0 for invalid/empty inputs (Lua side surfaces 0 as nil since
|
||||
// spellID 0 is never valid). Calls `lua_error` if arg1 isn't a number,
|
||||
// matching the engine's own `lua_error`-on-misuse style.
|
||||
// Parses a spellID out of a spell hyperlink ("…|Hspell:<id>[:…]…").
|
||||
// Returns 0 when `s` isn't a spell link, so the caller can fall through
|
||||
// to a plain-name lookup.
|
||||
static int SpellIDFromLink(const char *s) {
|
||||
if (s == nullptr)
|
||||
return 0;
|
||||
const char *p = std::strstr(s, "Hspell:");
|
||||
if (p == nullptr)
|
||||
return 0;
|
||||
p += 7; // past "Hspell:"
|
||||
if (*p < '0' || *p > '9')
|
||||
return 0;
|
||||
int id = 0;
|
||||
while (*p >= '0' && *p <= '9')
|
||||
id = id * 10 + (*p++ - '0');
|
||||
return id;
|
||||
}
|
||||
|
||||
// Resolves the Lua args to a spellID, supporting the full retail
|
||||
// GetSpellInfo argument set:
|
||||
// GetSpellInfo(spellID) -- arg1 number, arg2 non-string
|
||||
// GetSpellInfo(slot, "spell"|"pet") -- arg1 number, arg2 string → book slot
|
||||
// GetSpellInfo("name") -- arg1 string → spellbook name lookup
|
||||
// GetSpellInfo("…|Hspell:ID|h…") -- arg1 string → link's embedded spellID
|
||||
// A name resolves against the player's/pet's spellbook (retail's scope),
|
||||
// returning the highest known rank; an unknown name yields 0. Returns 0
|
||||
// for invalid/empty inputs (Lua side surfaces 0 as nil since spellID 0 is
|
||||
// never valid). Only a non-string, non-number arg1 raises `lua_error` —
|
||||
// a name retail doesn't recognize returns nil, it does not error.
|
||||
static int ResolveLuaArgsToSpellID(void *L) {
|
||||
if (Game::Lua::Type(L, 1) == Game::Lua::TYPE_STRING) {
|
||||
const char *s = Game::Lua::ToString(L, 1);
|
||||
const int fromLink = SpellIDFromLink(s);
|
||||
return fromLink > 0 ? fromLink : Spell::Lookup::SpellNameToID(s);
|
||||
}
|
||||
if (!Game::Lua::IsNumber(L, 1)) {
|
||||
Game::Lua::Error(L, "Usage: GetSpellInfo(spellID) or GetSpellInfo(slot, bookType)");
|
||||
Game::Lua::Error(L, "Usage: GetSpellInfo(spellID | \"name\" | link) "
|
||||
"or GetSpellInfo(slot, bookType)");
|
||||
return 0;
|
||||
}
|
||||
const int arg1 = static_cast<int>(Game::Lua::ToNumber(L, 1));
|
||||
|
||||
@@ -34,15 +34,12 @@ namespace Spell::IsCurrent {
|
||||
|
||||
namespace {
|
||||
|
||||
using ResolveUnitToken_t = void *(__fastcall *)(const char *token);
|
||||
|
||||
int ReadGlobalSpellID(uintptr_t addr) {
|
||||
return *reinterpret_cast<const int *>(addr);
|
||||
}
|
||||
|
||||
int ReadPlayerChannelSpell() {
|
||||
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 0;
|
||||
auto *desc = *reinterpret_cast<const uint8_t *const *>(
|
||||
|
||||
+1
-4
@@ -22,15 +22,12 @@ namespace Spell::Level {
|
||||
|
||||
namespace {
|
||||
|
||||
using ResolveUnitToken_t = void *(__fastcall *)(const char *token);
|
||||
|
||||
// Reads the local player's descriptor — same pattern as
|
||||
// Unit::Combat::Script_InCombatLockdown. Returns nullptr at login
|
||||
// screen / character select / loading transition where the
|
||||
// CGPlayer doesn't exist yet.
|
||||
const uint8_t *PlayerDescriptor() {
|
||||
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;
|
||||
return Game::Read<const uint8_t *>(
|
||||
|
||||
@@ -16,6 +16,8 @@
|
||||
#include "Offsets.h"
|
||||
#include "dbc/Lookup.h"
|
||||
|
||||
#include <cstring>
|
||||
|
||||
namespace Spell::Lookup {
|
||||
|
||||
const uint8_t *RecordForID(int spellID) {
|
||||
@@ -109,4 +111,77 @@ int FindSpellbookSlot(int spellID, int *outBookType) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
int SpellNameToID(const char *name) {
|
||||
if (name == nullptr || *name == '\0')
|
||||
return 0;
|
||||
const int locale = *reinterpret_cast<const int *>(
|
||||
static_cast<uintptr_t>(Offsets::VAR_LOCALE_INDEX));
|
||||
|
||||
// A trailing "(subtext)" pins a specific rank — the vanilla
|
||||
// "SpellName(Rank N)" addressing that CastSpellByName and friends use
|
||||
// (a space before the paren is tolerated: "Mind Blast (Rank 8)"). The
|
||||
// subtext is matched against the record's localized Rank field. With no
|
||||
// subtext, we return the highest known rank of the base name.
|
||||
char base[256];
|
||||
char wantRank[64];
|
||||
bool haveRank = false;
|
||||
{
|
||||
const char *open = std::strrchr(name, '(');
|
||||
const char *close = (open != nullptr) ? std::strrchr(name, ')') : nullptr;
|
||||
size_t baseLen;
|
||||
if (open != nullptr && close != nullptr && close > open + 1) {
|
||||
baseLen = static_cast<size_t>(open - name);
|
||||
size_t rankLen = static_cast<size_t>(close - open - 1);
|
||||
if (rankLen >= sizeof(wantRank))
|
||||
rankLen = sizeof(wantRank) - 1;
|
||||
std::memcpy(wantRank, open + 1, rankLen);
|
||||
wantRank[rankLen] = '\0';
|
||||
haveRank = true;
|
||||
} else {
|
||||
baseLen = std::strlen(name);
|
||||
}
|
||||
while (baseLen > 0 &&
|
||||
(name[baseLen - 1] == ' ' || name[baseLen - 1] == '\t'))
|
||||
--baseLen; // trim any space between the name and the paren
|
||||
if (baseLen == 0 || baseLen >= sizeof(base))
|
||||
return 0;
|
||||
std::memcpy(base, name, baseLen);
|
||||
base[baseLen] = '\0';
|
||||
}
|
||||
|
||||
// Player book first, then pet — retail's precedence. For a plain name we
|
||||
// keep the LAST matching slot: a spell's ranks are stored in ascending
|
||||
// order, so the final match is the highest rank the player knows. For a
|
||||
// rank subtext we return the exact name+rank match.
|
||||
const uintptr_t books[2] = {Offsets::VAR_PLAYER_SPELLBOOK,
|
||||
Offsets::VAR_PET_SPELLBOOK};
|
||||
for (uintptr_t bookBase : books) {
|
||||
auto *array = reinterpret_cast<const int *>(bookBase);
|
||||
int best = 0;
|
||||
for (int i = 0; i < Offsets::SPELLBOOK_MAX_SLOTS; ++i) {
|
||||
const int spellID = array[i];
|
||||
if (spellID <= 0)
|
||||
continue; // BSS-zeroed empty slot
|
||||
const uint8_t *record = RecordForID(spellID);
|
||||
if (record == nullptr)
|
||||
continue;
|
||||
const char *sname = *reinterpret_cast<const char *const *>(
|
||||
record + Offsets::OFF_SPELL_NAMES + locale * 4);
|
||||
if (sname == nullptr || std::strcmp(sname, base) != 0)
|
||||
continue;
|
||||
if (!haveRank) {
|
||||
best = spellID; // highest rank (last match)
|
||||
continue;
|
||||
}
|
||||
const char *srank = *reinterpret_cast<const char *const *>(
|
||||
record + Offsets::OFF_SPELL_RECORD_RANK + locale * 4);
|
||||
if (srank != nullptr && std::strcmp(srank, wantRank) == 0)
|
||||
return spellID; // exact name + rank
|
||||
}
|
||||
if (!haveRank && best != 0)
|
||||
return best;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
} // namespace Spell::Lookup
|
||||
|
||||
@@ -51,6 +51,15 @@ int SpellbookSlotToID(int slot1Based, int bookType);
|
||||
// in either book.
|
||||
int FindSpellbookSlot(int spellID, int *outBookType);
|
||||
|
||||
// Resolves a spell NAME to a spellID against the player's (then pet's)
|
||||
// spellbook — the same scope retail's `GetSpellInfo(name)` uses. The
|
||||
// match is exact and case-sensitive against the current locale's name
|
||||
// field (matching retail and ShaguTweaks' `libspell`). When a spell has
|
||||
// several ranks in the book, the highest rank is returned (the spellbook
|
||||
// arrays list a spell's ranks in ascending order, so the last match
|
||||
// wins). Returns 0 for a null/empty name or one the player doesn't know.
|
||||
int SpellNameToID(const char *name);
|
||||
|
||||
// Resolves a 0-based UI slot into the recipe's spellID. Used by both
|
||||
// tradeskill and craft scrapers — they share the same storage shape:
|
||||
// `[entriesVar]` is a pointer to a heap-allocated array of recipe-
|
||||
|
||||
+1
-4
@@ -21,15 +21,12 @@ namespace Spell::Mod {
|
||||
|
||||
namespace {
|
||||
|
||||
using ResolveUnitToken_t = void *(__fastcall *)(const char *token);
|
||||
|
||||
// The local player's class SpellFamilyName, or 0 if it can't be resolved
|
||||
// (e.g. at the login screen). Gates the SpellMod family match — only
|
||||
// spells of the player's own class family receive the player's mods, so
|
||||
// a same-flag-bit spell of another family isn't falsely modified.
|
||||
uint32_t PlayerSpellFamily() {
|
||||
auto resolve = reinterpret_cast<ResolveUnitToken_t>(Offsets::FUN_RESOLVE_UNIT_TOKEN);
|
||||
auto *unit = static_cast<const uint8_t *>(resolve("player"));
|
||||
auto *unit = static_cast<const uint8_t *>(Game::ResolveUnitToken("player"));
|
||||
if (unit == nullptr)
|
||||
return 0;
|
||||
auto *desc = Game::Read<const uint8_t *>(unit, Offsets::OFF_UNIT_DESCRIPTOR);
|
||||
|
||||
@@ -21,7 +21,6 @@ namespace Spell::SkillLine {
|
||||
|
||||
namespace {
|
||||
|
||||
using ResolveUnitToken_t = void *(__fastcall *)(const char *token);
|
||||
using SkillLineToSlot_t = int(__thiscall *)(void *player, uint32_t skillLineID);
|
||||
|
||||
// Reads UNIT_FIELD_BYTES_0 from the local player's descriptor. Same
|
||||
@@ -31,8 +30,7 @@ using SkillLineToSlot_t = int(__thiscall *)(void *player, uint32_t skillLineID);
|
||||
void ReadPlayerClassRace(uint8_t *outClassByte, uint8_t *outRaceByte) {
|
||||
*outClassByte = 0;
|
||||
*outRaceByte = 0;
|
||||
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;
|
||||
auto *desc = Game::Read<const uint8_t *>(
|
||||
@@ -232,8 +230,7 @@ int __fastcall Script_GetSkillLineRank(void *L) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
auto resolve = reinterpret_cast<ResolveUnitToken_t>(Offsets::FUN_RESOLVE_UNIT_TOKEN);
|
||||
auto *player = static_cast<uint8_t *>(resolve("player"));
|
||||
auto *player = static_cast<uint8_t *>(Game::ResolveUnitToken("player"));
|
||||
if (player == nullptr) {
|
||||
Game::Lua::PushNil(L);
|
||||
return 1;
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
// 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/>.
|
||||
|
||||
#include "Game.h"
|
||||
#include "Offsets.h"
|
||||
#include "dbc/Lookup.h"
|
||||
#include "spell/Lookup.h"
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
// `GetNumTrackingTypes()` / `GetTrackingInfo(index)` / `SetTracking(index)` —
|
||||
// backport of the minimap-tracking enumeration the modern client exposes.
|
||||
//
|
||||
// Vanilla 1.12 never enumerates the tracking abilities the player KNOWS — it
|
||||
// only stores the single currently-active tracker as a plain spellID in
|
||||
// `VAR_ACTIVE_TRACKING_SPELL`, surfaced through `GetTrackingTexture()` /
|
||||
// `CancelTrackingBuff()` / `GameTooltip:SetTrackingSpell()`. 3.3.5's
|
||||
// `GetTrackingInfo` iterates a spellbook-derived list of tracking spells and
|
||||
// reports name/texture/active/category per entry; we rebuild that list here.
|
||||
//
|
||||
// A tracking spell is identified exactly the way the engine's own active-
|
||||
// tracker updater `FUN_004E4170` identifies one: a Spell.dbc record with an
|
||||
// `EffectApplyAuraName` of Track Creatures (44), Track Resources (45), or
|
||||
// Track Stealthed (151). That effect-based test reproduces pfUI's hardcoded
|
||||
// spellID/icon table (Find Herbs/Minerals/Treasure/Trees, every Hunter
|
||||
// Track*, Sense Undead/Demons, Track Humanoids) with no table to maintain,
|
||||
// and auto-covers server-custom trackers such as Turtle's Find Trees (52917).
|
||||
namespace Spell::Tracking {
|
||||
|
||||
// Tracking aura effect types — SPELL_AURA_TRACK_* ApplyAura names, the same
|
||||
// values `FUN_004E4170` scans a spell's three effects for.
|
||||
static constexpr int32_t AURA_TRACK_CREATURES = 44;
|
||||
static constexpr int32_t AURA_TRACK_RESOURCES = 45;
|
||||
static constexpr int32_t AURA_TRACK_STEALTHED = 151;
|
||||
|
||||
// True iff `spellID`'s record has any of the three Track* effects.
|
||||
static bool IsTrackingSpell(int spellID) {
|
||||
const uint8_t *record = Spell::Lookup::RecordForID(spellID);
|
||||
if (record == nullptr)
|
||||
return false;
|
||||
auto *aura = Game::Ptr<const int32_t>(
|
||||
record, Offsets::OFF_SPELL_RECORD_EFFECT_APPLY_AURA_NAME);
|
||||
for (int i = 0; i < Offsets::SPELL_RECORD_EFFECT_COUNT; ++i) {
|
||||
if (aura[i] == AURA_TRACK_CREATURES || aura[i] == AURA_TRACK_RESOURCES ||
|
||||
aura[i] == AURA_TRACK_STEALTHED)
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Walks the player spellbook in slot order counting tracking spells. If
|
||||
// `wantNth >= 1` and an Nth tracking spell exists, writes its spellID and
|
||||
// 1-based spellbook slot to `*outSpellID` / `*outSlot`. Returns the total
|
||||
// number of tracking spells found (so callers detect out-of-range by
|
||||
// comparing the return against `wantNth`).
|
||||
//
|
||||
// The spellbook array is zero-padded past the populated count (see
|
||||
// `Spell::Lookup::FindSpellbookSlot`), so a 0 entry is just an empty slot —
|
||||
// skip it and keep scanning the full range rather than break, matching the
|
||||
// existing spellbook walkers.
|
||||
static int EnumerateTracking(int wantNth, int *outSpellID, int *outSlot) {
|
||||
auto *book = reinterpret_cast<const int *>(
|
||||
static_cast<uintptr_t>(Offsets::VAR_PLAYER_SPELLBOOK));
|
||||
int found = 0;
|
||||
for (int i = 0; i < Offsets::SPELLBOOK_MAX_SLOTS; ++i) {
|
||||
const int spellID = book[i];
|
||||
if (spellID <= 0 || !IsTrackingSpell(spellID))
|
||||
continue;
|
||||
++found;
|
||||
if (found == wantNth) {
|
||||
if (outSpellID != nullptr)
|
||||
*outSpellID = spellID;
|
||||
if (outSlot != nullptr)
|
||||
*outSlot = i + 1;
|
||||
}
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
// `GetNumTrackingTypes()` -> number of tracking spells in the player's book.
|
||||
static int __fastcall Script_GetNumTrackingTypes(void *L) {
|
||||
Game::Lua::PushNumber(L,
|
||||
static_cast<double>(EnumerateTracking(0, nullptr, nullptr)));
|
||||
return 1;
|
||||
}
|
||||
|
||||
// `GetTrackingInfo(index)` -> name, texture, active, category, spellID.
|
||||
// - index is 1-based into the tracking list (spellbook order).
|
||||
// - texture is the SpellIcon.dbc path (nil if the icon is missing).
|
||||
// - active is a boolean: the tracker currently in effect.
|
||||
// - category is always "spell" — vanilla has no non-spell ("other")
|
||||
// trackers, unlike 3.3.5's class-masked table.
|
||||
// - spellID is a ClassicAPI extension (the modern 5th return is `nested`,
|
||||
// which has no meaning here); handy since selecting/among trackers is
|
||||
// spellID-driven.
|
||||
// Returns nil for an out-of-range index.
|
||||
static int __fastcall Script_GetTrackingInfo(void *L) {
|
||||
if (!Game::Lua::IsNumber(L, 1)) {
|
||||
Game::Lua::Error(L, "Usage: GetTrackingInfo(index)");
|
||||
return 0;
|
||||
}
|
||||
const int index = static_cast<int>(Game::Lua::ToNumber(L, 1));
|
||||
if (index < 1)
|
||||
return 0;
|
||||
|
||||
int spellID = 0;
|
||||
int slot = 0;
|
||||
if (EnumerateTracking(index, &spellID, &slot) < index)
|
||||
return 0; // index past the number of tracking types -> nil
|
||||
|
||||
const uint8_t *record = Spell::Lookup::RecordForID(spellID);
|
||||
const int locale = Game::Read<int>(static_cast<uintptr_t>(Offsets::VAR_LOCALE_INDEX));
|
||||
const char *name =
|
||||
Game::Read<const char *>(record, Offsets::OFF_SPELL_NAMES + locale * 4);
|
||||
|
||||
const char *texture = nullptr;
|
||||
const int iconID = Game::Read<int>(record, Offsets::OFF_SPELL_RECORD_ICON_ID);
|
||||
if (auto *iconRec = DBC::Record(Offsets::VAR_SPELL_ICON_RECORDS,
|
||||
Offsets::VAR_SPELL_ICON_COUNT,
|
||||
static_cast<uint32_t>(iconID))) {
|
||||
texture = Game::Read<const char *>(iconRec, Offsets::OFF_SPELLICON_PATH);
|
||||
}
|
||||
|
||||
const bool active =
|
||||
Game::Read<int>(static_cast<uintptr_t>(Offsets::VAR_ACTIVE_TRACKING_SPELL)) ==
|
||||
spellID;
|
||||
|
||||
Game::Lua::PushString(L, name); // 1. name (PushString handles NULL -> nil)
|
||||
Game::Lua::PushString(L, texture); // 2. texture (icon path) or nil
|
||||
Game::Lua::PushBool(L, active); // 3. active
|
||||
Game::Lua::PushString(L, "spell"); // 4. category
|
||||
Game::Lua::PushNumber(L, static_cast<double>(spellID)); // 5. spellID (extension)
|
||||
return 5;
|
||||
}
|
||||
|
||||
// `SetTracking(index)` — selects (activates) the tracking spell at `index`.
|
||||
// Vanilla has no "set tracking" primitive: choosing a tracker IS casting its
|
||||
// spell (the tracking dropdown does exactly this). So we resolve the entry's
|
||||
// spellbook slot and tail-call the engine's own `Script_CastSpell(slot,
|
||||
// "spell")` — reusing its slot bounds-check, target resolution, and error
|
||||
// handling rather than reconstructing the cast path. Returns nil for an
|
||||
// out-of-range index.
|
||||
static int __fastcall Script_SetTracking(void *L) {
|
||||
if (!Game::Lua::IsNumber(L, 1)) {
|
||||
Game::Lua::Error(L, "Usage: SetTracking(index)");
|
||||
return 0;
|
||||
}
|
||||
const int index = static_cast<int>(Game::Lua::ToNumber(L, 1));
|
||||
if (index < 1)
|
||||
return 0;
|
||||
|
||||
int spellID = 0;
|
||||
int slot = 0;
|
||||
if (EnumerateTracking(index, &spellID, &slot) < index)
|
||||
return 0;
|
||||
|
||||
// Rebuild the stack as CastSpell(slot, "spell") and hand off to the
|
||||
// engine handler. `slot` is 1-based, matching what a Lua `CastSpell`
|
||||
// caller passes.
|
||||
Game::Lua::SetTop(L, 0);
|
||||
Game::Lua::PushNumber(L, static_cast<double>(slot));
|
||||
Game::Lua::PushString(L, "spell");
|
||||
using CastSpell_t = int(__fastcall *)(void *);
|
||||
auto castSpell = reinterpret_cast<CastSpell_t>(Offsets::FUN_SCRIPT_CAST_SPELL);
|
||||
return castSpell(L);
|
||||
}
|
||||
|
||||
static void RegisterLuaFunctions() {
|
||||
Game::Lua::RegisterGlobalFunction("GetNumTrackingTypes",
|
||||
&Script_GetNumTrackingTypes);
|
||||
Game::Lua::RegisterGlobalFunction("GetTrackingInfo", &Script_GetTrackingInfo);
|
||||
Game::Lua::RegisterGlobalFunction("SetTracking", &Script_SetTracking);
|
||||
}
|
||||
|
||||
static const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
|
||||
|
||||
} // namespace Spell::Tracking
|
||||
@@ -25,7 +25,6 @@ namespace Spell::Usable {
|
||||
|
||||
namespace {
|
||||
|
||||
using ResolveUnitToken_t = void *(__fastcall *)(const char *token);
|
||||
using GetSpellCost_t = uint32_t(__fastcall *)(int spellID, int unit);
|
||||
|
||||
// Spell.dbc record field offsets we read for usability. Fully documented
|
||||
@@ -52,8 +51,7 @@ bool PlayerKnowsSpell(int spellID) {
|
||||
}
|
||||
|
||||
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 Game::Read<const uint8_t *>(
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
// 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/>.
|
||||
|
||||
// Stale table-length healing — Lua 5.1 border semantics for 5.0's
|
||||
// out-of-band lengths.
|
||||
//
|
||||
// Lua 5.0 stores a table's `table.insert`/`getn` length OUT of band (a
|
||||
// `t.n` field, else the registry's weak LUA_SIZES table). Clearing a
|
||||
// table's keys (`for k in pairs(t) do t[k] = nil end`) does NOT reset it —
|
||||
// 5.0 code must call `table.setn(t, 0)`. Lua 5.1 removed all of that:
|
||||
// lengths are computed borders, and a cleared table is length 0
|
||||
// automatically.
|
||||
//
|
||||
// That difference breaks the standard Ace2-era dual-compatibility idiom
|
||||
// the moment our 5.1 syntax backport makes their probe pass:
|
||||
//
|
||||
// local lua51 = loadstring("return function(...) return ... end")
|
||||
// and true or false
|
||||
// local table_setn = lua51 and function() end or table.setn
|
||||
//
|
||||
// With `lua51` true (the `...`-expression now compiles via
|
||||
// LuaSyntax::Transpile), `table_setn` is a NO-OP — correct on real 5.1,
|
||||
// wrong on this VM, whose table functions still read the out-of-band
|
||||
// length. Cleared-and-reused tables keep stale lengths: `table.insert`
|
||||
// appends past the nil'd slots and `table.getn` counts entries that no
|
||||
// longer exist. Verified live: Dewdrop-2.0's menus (the SuperAPI /
|
||||
// NampowerSettings minimap buttons) break exactly this way, and work
|
||||
// again when the probe is forced back to 5.0.
|
||||
//
|
||||
// Since we made the client PASS 5.1 probes, 5.1 length semantics must
|
||||
// hold. The engine funnels every length read through ONE function —
|
||||
// `luaL_getn` (LUAL_GETN, consulted by table.insert / getn / remove /
|
||||
// concat / sort / foreachi, plus our `unpack`) — so a single co-hook
|
||||
// closes the gap for every consumer at once:
|
||||
//
|
||||
// 1. Reported slot populated (`t[n] ~= nil`) → return n unchanged. The
|
||||
// healthy path costs one rawgeti.
|
||||
// 2. `t[n]` nil but `t.n` is a number → return n unchanged. An explicit
|
||||
// `n` field is the 5.0 vararg-table contract (`arg` = {n=3, holes}),
|
||||
// where trailing nils are intentional — healing it would corrupt
|
||||
// vararg counts.
|
||||
// 3. `t[n]` nil, no `t.n`: scan down for the true border `b` and heal to
|
||||
// it (the 5.1 answer) — UNLESS the nil at slot `n` is a deliberate
|
||||
// `table.insert(t, nil)` append slot, detected by the writer-side mark
|
||||
// below, in which case keep the stored length. Read-only — no
|
||||
// write-back, so a length read never mutates state; the engine's own
|
||||
// `table.insert` calls `luaL_setn(n+1)` on the next append, which
|
||||
// re-syncs the stored length by itself.
|
||||
//
|
||||
// Why a writer-side mark (the tinsert co-hook) and not a state heuristic: a
|
||||
// deliberate 5.0 nil append (`table.insert(t, nil)` — writes `t[n]=nil` +
|
||||
// `setn(n)`; issue #36: Waterfall's `{key,val,…}` builders append a nil for
|
||||
// every option without a passValue, and the next insert must land AFTER the
|
||||
// reserved slot) and a cleared-and-reused table that happens to be stale by
|
||||
// exactly one (a one-element table pairs-cleared, or a recycled table whose
|
||||
// previous life was one slot longer — Tablet-2.0's `del()`/`copy()` pool
|
||||
// recycles per tooltip refresh, so this is everyday traffic) leave the table
|
||||
// in BYTE-IDENTICAL states: stored `n`, dense below, `t[n]` nil, no `t.n`.
|
||||
// No inspection of the table can split them, and caller-gating can't either
|
||||
// (both reach here through `table.insert`). v1.12.8 tried a staleness-gap
|
||||
// heuristic (keep when `n - b == 1`) and it corrupted the second group —
|
||||
// issue #39, FuBar/Tablet/Dewdrop menus and tooltips shifting by one with a
|
||||
// nil hole at slot 1. Only the WRITER knows which dialect the table speaks,
|
||||
// and the write is observable: a co-hook on `table.insert`
|
||||
// (FUN_LUA_TABLE_INSERT) records `t → storedN` in a weak-keyed registry
|
||||
// table whenever the two-arg form appends a literal nil. Rule 3 keeps the
|
||||
// stored length only for a marked table whose mark still equals `n`; every
|
||||
// unmarked off-by-one table heals. A stale mark dies on its own: any later
|
||||
// append/remove moves the stored length away from the recorded value, and
|
||||
// the weak key lets a dead table's entry be collected.
|
||||
//
|
||||
// Note real Lua 5.1 gives the HEAL answer even for the deliberate append
|
||||
// (`#`-based tinsert drops a trailing nil and the next insert overwrites
|
||||
// it), so keeping the slot is deliberately 5.0-native behavior for
|
||||
// 5.0-dialect writers — Waterfall's builder runs on the 5.0 `arg` vararg
|
||||
// table and predates 5.1. 5.1-dialect code never relies on insert(t, nil)
|
||||
// reserving a slot, because on real 5.1 it doesn't.
|
||||
//
|
||||
// Positional nil insertion (`table.insert(t, pos, nil)`) is not marked and
|
||||
// heals; no consumer is known, and 5.0 itself makes it a shifted no-op.
|
||||
//
|
||||
// `table.setn` itself still works for code that calls it — the heal only
|
||||
// changes the answer when the stored length points past the border and no
|
||||
// mark vouches for it, which is the same answer real 5.1 would give.
|
||||
|
||||
#include "Game.h"
|
||||
#include "Offsets.h"
|
||||
|
||||
namespace Table::Length {
|
||||
|
||||
namespace {
|
||||
|
||||
using LuaLGetN_t = int(__fastcall *)(void *L, int idx);
|
||||
using RawGetI_t = void(__fastcall *)(void *L, int idx, int n);
|
||||
|
||||
LuaLGetN_t g_getnOriginal = nullptr;
|
||||
Game::Lua::CFunction g_tinsertOriginal = nullptr;
|
||||
|
||||
// Registry key of the weak-keyed mark table: `marks[t] = storedN` recorded
|
||||
// at the moment `table.insert(t, nil)` reserved slot `storedN`.
|
||||
constexpr char kMarkKey[] = "ClassicAPI_TrailingNilMark";
|
||||
|
||||
// True if t[k] (rawgeti) is nil. Balances the stack.
|
||||
bool SlotIsNil(void *L, int absIdx, int k) {
|
||||
reinterpret_cast<RawGetI_t>(Offsets::LUA_RAWGETI)(L, absIdx, k);
|
||||
const bool isNil = Game::Lua::Type(L, -1) == Game::Lua::TYPE_NIL;
|
||||
Game::Lua::SetTop(L, -2);
|
||||
return isNil;
|
||||
}
|
||||
|
||||
// Pushes the mark table, creating `registry[kMarkKey] = setmetatable({},
|
||||
// {__mode = "k"})` on first use (per Lua state — the registry survives
|
||||
// `/reload` because the state is reused, and the marked tables survive with
|
||||
// it). Leaves exactly one value on the stack.
|
||||
void PushMarkTable(void *L) {
|
||||
Game::Lua::PushString(L, kMarkKey);
|
||||
Game::Lua::RawGet(L, Game::Lua::REGISTRY_INDEX);
|
||||
if (Game::Lua::Type(L, -1) == Game::Lua::TYPE_TABLE)
|
||||
return;
|
||||
Game::Lua::SetTop(L, -2); // pop the nil. []
|
||||
Game::Lua::NewTable(L); // [marks]
|
||||
Game::Lua::NewTable(L); // [marks, meta]
|
||||
Game::Lua::PushString(L, "__mode");
|
||||
Game::Lua::PushString(L, "k");
|
||||
Game::Lua::RawSet(L, -3); // meta.__mode = "k". [marks, meta]
|
||||
Game::Lua::SetMetatable(L, -2);// pops meta. [marks]
|
||||
Game::Lua::PushString(L, kMarkKey);
|
||||
Game::Lua::PushValue(L, -2); // [marks, key, marks]
|
||||
Game::Lua::RawSet(L, Game::Lua::REGISTRY_INDEX); // [marks]
|
||||
}
|
||||
|
||||
// True when `marks[t] == n` — the nil at slot `n` was written by a
|
||||
// deliberate `table.insert(t, nil)` and nothing has moved the stored
|
||||
// length since. Balances the stack.
|
||||
bool HasTrailingNilMark(void *L, int absIdx, int n) {
|
||||
PushMarkTable(L); // [marks]
|
||||
Game::Lua::PushValue(L, absIdx); // [marks, t]
|
||||
Game::Lua::RawGet(L, -2); // [marks, marks[t]]
|
||||
const bool match =
|
||||
Game::Lua::Type(L, -1) == Game::Lua::TYPE_NUMBER &&
|
||||
static_cast<int>(Game::Lua::ToNumber(L, -1)) == n;
|
||||
Game::Lua::SetTop(L, -3); // pop both
|
||||
return match;
|
||||
}
|
||||
|
||||
// Co-hook on the engine's `table.insert` (both `table.insert` and the
|
||||
// `tinsert` alias are this one C function). The two-arg form appending a
|
||||
// literal nil is the 5.0 idiom that must keep its reserved slot; record it
|
||||
// so rule 3 can tell it apart from an identically-shaped stale table. The
|
||||
// detection runs before the original (the arg stack is caller-owned, so
|
||||
// index 1 still holds the table afterwards); the mark reads the raw stored
|
||||
// length the original's `luaL_setn` just wrote.
|
||||
int __fastcall TableInsert_h(void *L) {
|
||||
const bool nilAppend = Game::Lua::GetTop(L) == 2 &&
|
||||
Game::Lua::Type(L, 1) == Game::Lua::TYPE_TABLE &&
|
||||
Game::Lua::Type(L, 2) == Game::Lua::TYPE_NIL;
|
||||
const int ret = g_tinsertOriginal(L);
|
||||
if (nilAppend) {
|
||||
const int storedN = g_getnOriginal(L, 1);
|
||||
PushMarkTable(L); // [marks]
|
||||
Game::Lua::PushValue(L, 1); // [marks, t]
|
||||
Game::Lua::PushNumber(L, storedN);
|
||||
Game::Lua::RawSet(L, -3); // marks[t] = storedN. [marks]
|
||||
Game::Lua::SetTop(L, -2); // pop marks
|
||||
}
|
||||
return ret;
|
||||
}
|
||||
|
||||
int __fastcall LuaLGetN_h(void *L, int idx) {
|
||||
const int n = g_getnOriginal(L, idx);
|
||||
if (n <= 0)
|
||||
return n;
|
||||
|
||||
// Normalize a relative index once — the checks below push/pop around
|
||||
// the access, and the `t.n` probe has a key on the stack at its
|
||||
// access point, which would shift a negative index. Pseudo-indices
|
||||
// (registry and below, <= -10000) pass through unchanged.
|
||||
int absIdx = idx;
|
||||
if (idx < 0 && idx > -10000)
|
||||
absIdx = Game::Lua::GetTop(L) + idx + 1;
|
||||
|
||||
if (!SlotIsNil(L, absIdx, n))
|
||||
return n; // healthy — the reported last slot is populated
|
||||
|
||||
// Explicit `t.n` count (vararg-table contract): trailing nils are
|
||||
// intentional, keep the stored length.
|
||||
Game::Lua::PushString(L, "n");
|
||||
Game::Lua::RawGet(L, absIdx);
|
||||
const bool hasExplicitN = Game::Lua::Type(L, -1) == Game::Lua::TYPE_NUMBER;
|
||||
Game::Lua::SetTop(L, -2);
|
||||
if (hasExplicitN)
|
||||
return n;
|
||||
|
||||
// Scan down for the true border.
|
||||
int b = n - 1;
|
||||
while (b > 0 && SlotIsNil(L, absIdx, b))
|
||||
--b;
|
||||
|
||||
// Stored length exactly one past the border: either a deliberate
|
||||
// `table.insert(t, nil)` reserved slot (keep — issue #36) or a cleared /
|
||||
// recycled table stale by one (heal — issue #39). The states are
|
||||
// identical; only the writer-side mark recorded by `TableInsert_h` can
|
||||
// tell them apart.
|
||||
if (n - b == 1 && HasTrailingNilMark(L, absIdx, n))
|
||||
return n;
|
||||
|
||||
// Stale — heal to the border, the 5.1 answer.
|
||||
return b;
|
||||
}
|
||||
|
||||
const Game::HookAutoRegister _hook{Offsets::LUAL_GETN,
|
||||
reinterpret_cast<void *>(&LuaLGetN_h),
|
||||
reinterpret_cast<void **>(&g_getnOriginal)};
|
||||
const Game::HookAutoRegister _hookInsert{
|
||||
Offsets::FUN_LUA_TABLE_INSERT, reinterpret_cast<void *>(&TableInsert_h),
|
||||
reinterpret_cast<void **>(&g_tinsertOriginal)};
|
||||
|
||||
} // namespace
|
||||
|
||||
} // namespace Table::Length
|
||||
+213
-92
@@ -1629,6 +1629,78 @@ static const Game::HookAutoRegister _emitterHook{Offsets::FUN_TEXT_EMITTER,
|
||||
using Paint_t = void(__fastcall *)(void *layout);
|
||||
Paint_t g_paintOriginal = nullptr;
|
||||
|
||||
// Builds the region placements for `node`'s recorded icons, expressed RELATIVE
|
||||
// to the owning fontstring `fs`'s rect min corner (the pool anchors them there,
|
||||
// so the engine moves them with the line). Fills `out` and returns true ONLY
|
||||
// when everything the placement needs is ready this instant — K live (raster
|
||||
// globals written) and the fs rect resolved. Returns false otherwise, and the
|
||||
// caller must NOT queue an empty set (that would hide the icons and the dedup
|
||||
// would freeze them hidden — the scroll-landing bug's second head). Shared by
|
||||
// the paint flush and the SMF-refresh early apply so both produce
|
||||
// bit-identical placements (the pool's dedup depends on that). The per-icon
|
||||
// geometry mirrors exactly what the emitter reserves — see IconAdvancePen.
|
||||
bool ComputePlacementsForNode(void *node, void *fs,
|
||||
std::vector<Text::InlineTexturePool::Placement> &out) {
|
||||
if (!LooksReadable(node) || !LooksReadable(fs))
|
||||
return false;
|
||||
auto itIcons = g_nodeIcons.find(node);
|
||||
if (itIcons == g_nodeIcons.end() || itIcons->second.empty())
|
||||
return false;
|
||||
const PenScale K = PenPerAnchor();
|
||||
if (!(K.x > 1.0f) || !(K.y > 1.0f))
|
||||
return false;
|
||||
auto *n = reinterpret_cast<uint8_t *>(node);
|
||||
const float ox = Game::Read<float>(n, Offsets::OFF_TEXT_NODE_ORIGIN_X);
|
||||
const float oy = Game::Read<float>(n, Offsets::OFF_TEXT_NODE_ORIGIN_Y);
|
||||
// Same pixel-snap mode the emitter honours (bit-7 clear) — gates the
|
||||
// drawn-rect snap in the placement loop below.
|
||||
const bool snapNode = (Game::Read<uint32_t>(n, Offsets::OFF_TEXT_NODE_FLAGS) & 0x80u) == 0;
|
||||
const float *rc = Game::Ptr<const float>(fs, Offsets::OFF_REGION_RECT);
|
||||
const float fsBottom = (rc[0] < rc[2]) ? rc[0] : rc[2];
|
||||
const float fsLeft = (rc[1] < rc[3]) ? rc[1] : rc[3];
|
||||
if (rc[1] == rc[3])
|
||||
return false; // unresolved rect reads 0-width
|
||||
for (const IconRecord &r : itIcons->second) {
|
||||
// Screen left = pen + the FULL lead pad (the emitter reserves
|
||||
// w + 1.5×pad in the advance: lead 1×, trail 0.5×). Pad is
|
||||
// fontH-relative — must match IconAdvancePen exactly.
|
||||
const float cx = r.x + ox + r.offsetX + r.fontH * g_iconPadFrac + r.outlineInk * 0.5f;
|
||||
// Centre on the line: penY sits near the text top. offsetY shifts up
|
||||
// (WoW convention), so subtract it. Tall icons centre in the grown line
|
||||
// (the string-height co-hook added the overflow).
|
||||
const float cy = r.y + r.fontH * g_centerFrac + oy + g_vBias - r.offsetY;
|
||||
float rx = cx + g_regionCalX;
|
||||
float y0 = cy + g_regionCalY - r.h * 0.5f;
|
||||
float w = r.w, h = r.h;
|
||||
if (snapNode) {
|
||||
// Land the drawn rect on WHOLE render-target pixels, like the
|
||||
// engine's own glyphs (crisp texel sampling). Size snaps
|
||||
// independently of position so a :16 icon is EXACTLY 16px.
|
||||
rx = std::floor(rx + 0.5f);
|
||||
y0 = std::floor(y0 + 0.5f);
|
||||
w = std::floor(w + 0.5f);
|
||||
h = std::floor(h + 0.5f);
|
||||
if (w < 1.0f)
|
||||
w = 1.0f;
|
||||
if (h < 1.0f)
|
||||
h = 1.0f;
|
||||
}
|
||||
Text::InlineTexturePool::Placement p;
|
||||
p.path = r.path;
|
||||
p.x0 = rx / K.x - fsLeft;
|
||||
p.y0 = y0 / K.y - fsBottom;
|
||||
p.x1 = (rx + w) / K.x - fsLeft;
|
||||
p.y1 = (y0 + h) / K.y - fsBottom;
|
||||
p.color = r.color;
|
||||
p.u0 = r.u0;
|
||||
p.v0 = r.v0;
|
||||
p.u1 = r.u1;
|
||||
p.v1 = r.v1;
|
||||
out.push_back(std::move(p));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Walks the layout's live render nodes and, for each recorded icon, computes
|
||||
// its screen geometry (node-local pen coords translated by the node's screen
|
||||
// origin +0x70/+0x74 — the SAME transform the paint pass applies to glyph
|
||||
@@ -1768,99 +1840,17 @@ void FlushLayout(void *layout) {
|
||||
}
|
||||
}
|
||||
}
|
||||
const float ox = Game::Read<float>(n, Offsets::OFF_TEXT_NODE_ORIGIN_X);
|
||||
const float oy = Game::Read<float>(n, Offsets::OFF_TEXT_NODE_ORIGIN_Y);
|
||||
// Same pixel-snap mode the emitter honours (bit-7 clear) — gates
|
||||
// the drawn-rect snap in the placement loop below.
|
||||
const bool snapNode =
|
||||
(Game::Read<uint32_t>(n, Offsets::OFF_TEXT_NODE_FLAGS) & 0x80u) == 0;
|
||||
// The fs rect, read HERE in the same flush as the icon coords — a
|
||||
// coherent snapshot. Placements are stored FS-RELATIVE: an
|
||||
// apply-time rect read raced the chat relayout (SetText invalidates
|
||||
// the rect briefly), and a placement applied against a mid-relayout
|
||||
// rect parked the icon off the line — then the dedup (unchanged
|
||||
// absolute want) froze it there. Relative offsets are also
|
||||
// position-invariant, so scrolling no longer re-places anything.
|
||||
const PenScale K = PenPerAnchor();
|
||||
float fsLeft = 0.0f, fsBottom = 0.0f;
|
||||
bool fsRectValid = false;
|
||||
if (fs != nullptr) {
|
||||
const float *rc = Game::Ptr<const float>(fs, Offsets::OFF_REGION_RECT);
|
||||
fsBottom = (rc[0] < rc[2]) ? rc[0] : rc[2];
|
||||
fsLeft = (rc[1] < rc[3]) ? rc[1] : rc[3];
|
||||
fsRectValid = rc[1] != rc[3]; // unresolved rect reads 0-width
|
||||
}
|
||||
// Geometry + readiness gate factored into ComputePlacementsForNode
|
||||
// (shared with the SMF-refresh early apply so both produce
|
||||
// bit-identical placements — the pool dedup depends on it). It
|
||||
// returns false when K or the fs rect isn't ready; SKIP the queue
|
||||
// then and retry next paint. An empty queue here would HIDE the
|
||||
// line's icons and the dedup would freeze it hidden forever
|
||||
// (identical empty re-queues never dirty) — the scroll-landing
|
||||
// bug's second head, where lines painted before K was available
|
||||
// queued {} and stayed iconless until their text changed.
|
||||
std::vector<Text::InlineTexturePool::Placement> places;
|
||||
for (const IconRecord &r : it->second) {
|
||||
// Screen left = pen + the FULL lead pad. The emitter reserves
|
||||
// w + 1.5×pad in the advance (lead 1×, trail 0.5×) — drawing at
|
||||
// the raw pen put all of that gap AFTER the icon, which is why
|
||||
// a string-final icon (money-string copper) looked jammed
|
||||
// against its digits and grew per-coin offset hacks. The pad is
|
||||
// fontH-relative (NOT r.w) — must match IconAdvancePen exactly.
|
||||
const float cx =
|
||||
r.x + ox + r.offsetX + r.fontH * g_iconPadFrac + r.outlineInk * 0.5f;
|
||||
// Centre on the line: penY sits near the text top, so add a
|
||||
// fraction of the font height (plus the fine-tune bias). offsetY
|
||||
// shifts up (WoW convention), so subtract it.
|
||||
//
|
||||
// TALL icons (h > fontH) centre too: the string-height co-hook
|
||||
// grew the line by the overflow, and the text centres itself
|
||||
// inside the grown rect (verified in-game — a bottom-aligned
|
||||
// icon clipped the neighbouring line while retail shows tall
|
||||
// emotes centred with no clip), so the extra space appears half
|
||||
// above and half below the text. A centred icon fills exactly
|
||||
// that — the retail look.
|
||||
const float cy = r.y + r.fontH * g_centerFrac + oy + g_vBias - r.offsetY;
|
||||
if (K.x > 1.0f && K.y > 1.0f && fs != nullptr && fsRectValid) {
|
||||
float rx = cx + g_regionCalX;
|
||||
float y0 = cy + g_regionCalY - r.h * 0.5f;
|
||||
float w = r.w, h = r.h;
|
||||
if (snapNode) {
|
||||
// Land the drawn rect on WHOLE render-target pixels,
|
||||
// like the engine's own glyphs (rounded origin +
|
||||
// truncated advances). Pen px = render px, the fsLeft/
|
||||
// fsBottom terms cancel exactly through the anchor
|
||||
// round-trip (the engine adds the same rect corner
|
||||
// back at resolve), and the pool's width convergence
|
||||
// preserves the linear factor — so an integral pen
|
||||
// rect lands integral on screen: 1:1-sized icons
|
||||
// sample texel centres (crisp) instead of blending
|
||||
// four neighbours (soft). Size snaps independently of
|
||||
// position so a :16 icon is EXACTLY 16px, never 15/17
|
||||
// from the two edges rounding apart.
|
||||
rx = std::floor(rx + 0.5f);
|
||||
y0 = std::floor(y0 + 0.5f);
|
||||
w = std::floor(w + 0.5f);
|
||||
h = std::floor(h + 0.5f);
|
||||
if (w < 1.0f)
|
||||
w = 1.0f;
|
||||
if (h < 1.0f)
|
||||
h = 1.0f;
|
||||
}
|
||||
Text::InlineTexturePool::Placement p;
|
||||
p.path = r.path;
|
||||
p.x0 = rx / K.x - fsLeft;
|
||||
p.y0 = y0 / K.y - fsBottom;
|
||||
p.x1 = (rx + w) / K.x - fsLeft;
|
||||
p.y1 = (y0 + h) / K.y - fsBottom;
|
||||
p.color = r.color;
|
||||
p.u0 = r.u0;
|
||||
p.v0 = r.v0;
|
||||
p.u1 = r.u1;
|
||||
p.v1 = r.v1;
|
||||
places.push_back(std::move(p));
|
||||
}
|
||||
}
|
||||
// The queue gate must match the per-icon build gate EXACTLY —
|
||||
// including K. If any input wasn't ready this paint (unresolved fs
|
||||
// rect, raster globals not yet written), SKIP the queue and retry
|
||||
// next paint: an empty queue here would HIDE the line's icons and
|
||||
// the dedup would freeze it hidden forever (identical empty
|
||||
// re-queues never dirty). That was the scroll-landing bug's second
|
||||
// head: lines painted before K was available queued {} and stayed
|
||||
// iconless until their text changed.
|
||||
if (fs != nullptr && fsRectValid && K.x > 1.0f && K.y > 1.0f)
|
||||
if (ComputePlacementsForNode(node, fs, places))
|
||||
Text::InlineTexturePool::QueuePlacements(fs, std::move(places));
|
||||
} else if (fs != nullptr) {
|
||||
Text::InlineTexturePool::QueuePlacements(fs, {});
|
||||
@@ -1890,6 +1880,121 @@ static const Game::HookAutoRegister _paintHook{Offsets::FUN_TEXT_PAINT,
|
||||
reinterpret_cast<void *>(&Paint_h),
|
||||
reinterpret_cast<void **>(&g_paintOriginal)};
|
||||
|
||||
// --- SMF-refresh early apply (kills the one-frame chat-icon lag) -------------
|
||||
//
|
||||
// The lag: a ScrollingMessageFrame re-SetTexts every visible line slot on each
|
||||
// new message (FUN_00788750), but the engine builds each line lazily at PAINT.
|
||||
// So a line's icons are recorded — and its regions placed — one frame AFTER its
|
||||
// glyphs draw, because a frame's textures (our icon regions) draw before its
|
||||
// fontstring glyphs and the placement isn't computed until the glyph build runs
|
||||
// mid-paint. This co-hook closes it: after the refresh sets up the lines (still
|
||||
// PRE-RENDER — the refresh runs from the SMF's Lua/event-driven methods, never
|
||||
// mid-render), force each icon-bearing line to resolve+build and apply its icon
|
||||
// placement synchronously, so the icons draw with their glyphs the same frame.
|
||||
//
|
||||
// Additive and best-effort: any line we can't resolve/build in time (rect still
|
||||
// pending) falls back to the existing paint-flush + FrameTick pipeline (correct,
|
||||
// one-frame lagged). Gated by its own toggle (default on) so it can be A/B'd and
|
||||
// disabled independently of the whole feature; the SEH latch trips it on a fault.
|
||||
bool g_earlyApply = true;
|
||||
|
||||
using SmfRefresh_t = void(__fastcall *)(void *smf, void *edx, int newestIdx);
|
||||
SmfRefresh_t g_smfRefreshOriginal = nullptr;
|
||||
|
||||
using EnsureBuilt_t = int(__fastcall *)(void *node);
|
||||
using RealizeAnchor_t = void(__thiscall *)(void *anchor, int flag);
|
||||
using RebuildStringFs_t = void(__fastcall *)(void *fs);
|
||||
|
||||
// Force `fs` to resolve its rect and lay out its text node NOW (pre-render),
|
||||
// mirroring what the engine's pending-layout pass + paint would do later.
|
||||
// Returns the live text node, or nullptr if it couldn't be built yet (rect
|
||||
// still unresolved — caller falls back to the lagged pipeline). Uses only the
|
||||
// engine's own supported force paths (the same Realize/RebuildString/ensure-
|
||||
// built the Show and paint paths call).
|
||||
void *ForceBuildFontString(void *fs) {
|
||||
// 1. Synchronous rect resolve (Realize flag 0 — the call Show itself uses;
|
||||
// it unlinks the anchor from the pending-layout list and resolves now).
|
||||
// The caller walks lines bottom-up, so this line's relativeTo (the line
|
||||
// below it / the frame) is already resolved when we get here.
|
||||
reinterpret_cast<RealizeAnchor_t>(Offsets::FUN_REGION_LAYOUT_REALIZE)(
|
||||
reinterpret_cast<uint8_t *>(fs) + Offsets::OFF_REGION_ANCHOR, 0);
|
||||
// 2. Rebuild the text block — creates the fresh node (but only if the rect
|
||||
// is now resolved). Internally gated on the fs dirty bit, so a clean
|
||||
// line is a no-op and we avoid depending on reading that bit ourselves.
|
||||
reinterpret_cast<RebuildStringFs_t>(Offsets::FUN_FONTSTRING_REBUILD_STRING)(fs);
|
||||
void *block = Game::Read<void *>(fs, Offsets::OFF_FONTSTRING_TEXT_BLOCK);
|
||||
if (!LooksReadable(block))
|
||||
return nullptr; // rect still unresolved — no node created
|
||||
void *node = Game::Read<void *>(block, Offsets::OFF_TEXTBLOCK_NODE);
|
||||
if (!LooksReadable(node))
|
||||
return nullptr;
|
||||
// 3. Lay the node out now: runs the glyph emitter (our Emitter_h records the
|
||||
// icons) and finalizes the node origin. Idempotent — paint's own ensure-
|
||||
// built re-runs later and the flush dedups the identical placement.
|
||||
reinterpret_cast<EnsureBuilt_t>(Offsets::FUN_TEXT_ENSURE_BUILT)(node);
|
||||
return node;
|
||||
}
|
||||
|
||||
// Walk the SMF's visible line array and early-apply each icon-bearing line.
|
||||
// The C++-object-holding body is kept out of the SEH frame (see
|
||||
// SafeSmfEarlyApply) — MSVC won't allow __try where objects need unwinding.
|
||||
void SmfEarlyApplyImpl(void *smf) {
|
||||
if (!LooksReadable(smf))
|
||||
return;
|
||||
const int count = Game::Read<int>(smf, Offsets::OFF_SMF_LINE_COUNT);
|
||||
if (count <= 0 || count > 1024)
|
||||
return;
|
||||
auto *arrayBase = Game::Read<uint8_t *>(smf, Offsets::OFF_SMF_LINE_ARRAY);
|
||||
if (!LooksReadable(arrayBase))
|
||||
return;
|
||||
for (int i = 0; i < count; ++i) {
|
||||
uint8_t *entry = arrayBase + i * Offsets::SMF_LINE_STRIDE;
|
||||
if (!LooksReadable(entry))
|
||||
break;
|
||||
void *fs = Game::Read<void *>(entry, Offsets::OFF_SMF_LINE_FONTSTRING);
|
||||
if (!LooksReadable(fs))
|
||||
continue;
|
||||
// Only shown lines (the desired-shown latch the SMF just set) that carry
|
||||
// inline markup — a plain chat line costs nothing but the text scan.
|
||||
if (Game::Read<uint32_t>(fs, Offsets::OFF_REGION_DESIRED_SHOWN) == 0)
|
||||
continue;
|
||||
const uint8_t *text = Game::Read<const uint8_t *>(fs, Offsets::OFF_FONTSTRING_TEXT);
|
||||
if (!LooksReadable(text))
|
||||
continue;
|
||||
int len = 0;
|
||||
while (len < 0x4000 && text[len] != '\0')
|
||||
++len;
|
||||
if (!HasInlineTexture(text, len))
|
||||
continue;
|
||||
void *node = ForceBuildFontString(fs);
|
||||
if (node == nullptr || NodeEditable(node))
|
||||
continue; // couldn't build in time, or editable (raw markup)
|
||||
std::vector<Text::InlineTexturePool::Placement> places;
|
||||
if (ComputePlacementsForNode(node, fs, places))
|
||||
Text::InlineTexturePool::ApplyNow(fs, std::move(places));
|
||||
}
|
||||
}
|
||||
|
||||
// SEH latch mirroring SafeFlush — a fault disables the early path for the
|
||||
// session (the baseline paint+tick pipeline still runs) instead of crashing.
|
||||
void SafeSmfEarlyApply(void *smf) {
|
||||
__try {
|
||||
SmfEarlyApplyImpl(smf);
|
||||
} __except (EXCEPTION_EXECUTE_HANDLER) {
|
||||
g_earlyApply = false;
|
||||
}
|
||||
}
|
||||
|
||||
void __fastcall SmfRefresh_h(void *smf, void *edx, int newestIdx) {
|
||||
g_smfRefreshOriginal(smf, edx, newestIdx);
|
||||
if (g_inlineEnabled && g_earlyApply)
|
||||
SafeSmfEarlyApply(smf);
|
||||
}
|
||||
|
||||
static const Game::HookAutoRegister _smfRefreshHook{
|
||||
Offsets::FUN_SMF_DISPLAY_REFRESH, reinterpret_cast<void *>(&SmfRefresh_h),
|
||||
reinterpret_cast<void **>(&g_smfRefreshOriginal)};
|
||||
|
||||
// --- Lua control surface ---------------------------------------------------
|
||||
|
||||
// _classicapi_InlineTexEnable([on]) -> enabled. The feature kill switch (also
|
||||
@@ -1903,8 +2008,22 @@ int __fastcall Script_InlineTexEnable(void *L) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
// _classicapi_InlineTexEarly([on]) -> enabled. Toggles the SMF-refresh
|
||||
// pre-render early apply (the chat-icon-lag fix) independently of the whole
|
||||
// feature — for A/B'ing the lag against the baseline paint+tick pipeline, which
|
||||
// keeps running either way. Also what the SMF SEH latch trips on a fault.
|
||||
int __fastcall Script_InlineTexEarly(void *L) {
|
||||
if (Game::Lua::GetTop(L) == 0)
|
||||
g_earlyApply = true;
|
||||
else
|
||||
g_earlyApply = Game::Lua::ToBoolean(L, 1) != 0;
|
||||
Game::Lua::PushBool(L, g_earlyApply);
|
||||
return 1;
|
||||
}
|
||||
|
||||
void RegisterLuaFunctions() {
|
||||
Game::Lua::RegisterGlobalFunction("_classicapi_InlineTexEnable", &Script_InlineTexEnable);
|
||||
Game::Lua::RegisterGlobalFunction("_classicapi_InlineTexEarly", &Script_InlineTexEarly);
|
||||
}
|
||||
|
||||
static const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
|
||||
@@ -1920,4 +2039,6 @@ void PrepareForReload() {
|
||||
g_nodeBakedColor.clear();
|
||||
}
|
||||
|
||||
static const Game::ReloadAutoRegister _reloadReg{&PrepareForReload};
|
||||
|
||||
} // namespace Text::InlineTexture
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user