Compare commits

...
7 Commits
Author SHA1 Message Date
Brues a2d25cef8b fix(object): gate ByGuid on the registry root to survive out-of-world calls
The engine resolver FUN_OBJECT_RESOLVE_BY_GUID funnels unconditionally
into FUN_00464890, whose first action loads the registry root from
[VAR_LOCAL_PLAYER_PTR] and derefs its +0x24 hash mask with no null
check. That root is NULL out of world (login, loading screens, logout
teardown), so any resolve reaching it mid-transition dies with ERROR
#132 (MOV EAX,[EDX+0x24], EDX=0) - hit in the wild by Aura::Source's
SMSG_SPELL_GO path, whose Store/Claim eviction consults
DescriptorListsAura while packets still dispatch during transitions.

Promote the readiness predicate from Unit::TokenObserver's private
RegistryReady into Object::RegistryReady (object/Resolve.h) and gate
ByGuid on it, returning nullptr out of world. That is both the
contract ByGuid's comment already promised ("pre-world ... safe from
any context") and provably behavior-preserving: a zero result is the
engine's own handled "not found" case (verified in Ghidra - 0x468460
null-checks the node; 0x464870 itself returns 0 for guid==0), and out
of world the call had no non-crashing outcome. Every ByGuid call site
already null-checks. TokenObserver now delegates to the shared helper.

Fixes #34
2026-08-24 08:43:03 -05:00
Brues c3c79356be fix(tooltip): clear per-tooltip OnTooltipSet* cells on reload
Tooltip::SetEvents kept its handler cells in a fixed 32-entry table keyed
by tooltip pointer, assuming tooltips are permanent. They are not: every
/reload (and logout) destroys and recreates the tooltip objects, so the
table gained ~2 dead cells per reload with no eviction. After ~18 reloads
it filled, the resolver co-hook returned 0 for new tooltips, and
SetScript/HookScript failed with "doesn't have a 'OnTooltipSetItem'
script". Worse, a stale cell whose address the allocator recycled into a
new tooltip fired its old handler ref into the reset Lua registry - a
dead-epoch ref, garbage call, crash.

Add Tooltip::SetEvents::PrepareForReload() - a full cell-table clear -
and call it from FrameScript_Initialize_h alongside the other per-reload
resets. The clear is semantically right, not just leak plumbing: handler
refs never survive the Lua reset, so post-reload every cell held garbage
regardless; addons re-register in OnLoad and get fresh cells.

Fixes #33
2026-08-24 08:17:12 -05:00
PeapandBrues 6ae3299041 Add Secure Action Button Templates and Comma-Split Support (#32)
* feat: port SecureActionButtonTemplate + CreateFrame comma-split

- Hook Script_CreateFrame (0x007060B0) to split comma-separated template
  strings. Vanilla only accepts a single template name; modern addons pass
  lists like 'UIPanelButtonTemplate, SecureActionButtonTemplate'.

- Add SecureTemplates.xml registering SecureFrameTemplate,
  SecureActionButtonTemplate, SecureUnitButtonTemplate, and
  InsecureActionButtonTemplate as real virtual XML templates.

- Add SecureTemplates.lua with modifier/button attribute resolution helpers
  (SecureButton_GetModifiedAttribute, GetModifierPrefix, GetButtonSuffix,
  GetModifiedUnit) and OnClick dispatchers.

- Remove duplicate SecureButton_GetAttribute/GetUnit from SecureStateDriver.lua
  (now provided by SecureTemplates.lua which loads earlier).

- Include SecureTemplateTest debug addon for verification.

* Include AuraUtil.lua in ClassicAPI.toc

mistake from merge

* fix(frame): apply every template in a CreateFrame comma list

Vanilla 1.12 CreateFrame resolves the 4th arg as a single template name.
The initial comma-split hook only applied one template, and its tokenizer
dropped everything after the first entry for the common "A, B" format
(writing the NUL terminator clobbered the delimiter the advance step
checked). Rework it into real multi-inheritance:

- SplitTemplates: record whether a comma was hit before writing the
  terminator, so "A, B" / "A,,B" / leading-comma inputs all split.
- CreateFrame_h: build the frame with the first resolved template through
  the engine, then graft each remaining template onto the created object
  with the engine's own inherit primitive - content (frameObj->vtable[+8],
  FUN_00769820) and child <Frames> ((frameObj+0x24)->vtable[+8],
  FUN_0076a060), the two __thiscall calls the builder FUN_006ee280 emits
  per inherited node. Regions, backdrop, attributes, script handlers, and
  child frames all apply, in left-to-right override order; OnLoad runs once.
- Offsets: add FUN_FRAMESCRIPT_STATUS_RESET (the XML-build error
  accumulator reset, __thiscall) and document the status object layout.

Also fix the relocated SecureButton_GetUnit/GetModifiedUnit pet-token
pattern: "[^%%d]"/"[%%d]" matched literal % and d instead of the digit
class %d, so raid1pet never normalized to raidpet1. Restore single %d.

Verified in-game: a comma list applies both templates' regions/text in
either order; pet-suffix normalization works.

* refactor(secure): tidy SecureTemplates review follow-ups

- Guard RunMacroText (absent on this client) so a type=macro button with
  only a macrotext attribute no-ops instead of erroring.
- Honor a SecureUnitButton_OnLoad menufunc: the menu/togglemenu action now
  calls self.menu when set, else falls back to ClassicAPI_ToggleUnitMenu
  (self.menu was previously stored but never read).
- InsecureActionButtonTemplate now inherits SecureActionButtonTemplate
  instead of duplicating its OnClick block.
- Drop the committed SecureTemplateTest debug addon; test harnesses live in
  the WoW install, not the repo.

* refactor(secure): make Frame::Attributes the single click dispatcher

The Lua SecureActionButton_OnClick from the secure-templates work
duplicated the native C++ dispatcher and only backfilled the verbs C++
lacked. Fold those into DispatchVerb so C++ is the sole dispatcher for
every secure button (template or plain SetAttribute), and drop the Lua
redundancy.

- DispatchVerb gains `action` (UseAction), `pet` (CastPetAction), `click`
  (forward to the frame in the `clickbutton` attribute via
  delegate:Click), and a custom fallback (a function stored as a raw
  field on the frame named by the verb). `menu`/`togglemenu` now cancels
  spell-targeting when a spell is on the cursor (the old
  SecureUnitButton_OnClick nuance).
- Numeric spell IDs work as the `spell` attribute: SetAttribute("spell",
  id) (a Lua number) or a numeric string cast the EXACT rank by ID via
  the spellbook-slot resolver (Spell::AtUnit::CastByID at a unit,
  Spell::AtCursor::DispatchSpellCast with none) - not name resolution,
  which fired the highest known rank.
- CopyAttr coerces a number-valued attribute to its digits, so numeric
  IDs also read for `item` and any other string attribute.
- Remove SecureActionButton_OnClick / SecureUnitButton_OnClick from
  SecureTemplates.lua, simplify SecureUnitButton_OnLoad, and strip the
  <OnClick> from the templates (plain Button templates now; the C++
  closure installs on SetAttribute("type*")). The attribute-reader
  helpers stay - public API and SecureStateDriver depend on them.

* fix(frame): restore the Lua stack after the multi-template graft

The comma-template graft can run a grafted template's child-frame OnLoad,
and in this hook-saturated environment a nested Lua op can shift the stack
slot the caller reads its return value from. Snapshot lua_gettop after the
engine build and restore it once the graft finishes, so the caller always
reads back the frame the engine left. Mirrors the Tooltip::SetEvents
save/restore discipline.

---------

Co-authored-by: Brues <5278969+brues-code@users.noreply.github.com>
2026-08-23 18:15:27 -05:00
Brues f97379795d Localize UnitAura call for performance 2026-08-23 13:45:02 -05:00
Brues 3c6b449df5 feat(aura): zero-alloc positional accessors + AuraUtil
C_UnitAuras.GetAuraDataByIndex builds a fresh Lua table per call, churning
Lua 5.0's mark-sweep GC when addons scan many units' auras in an OnUpdate
loop. Add Blizzard's own answer: positional (multi-return) accessors plus the
AuraUtil helper library, so hot loops allocate nothing.

DLL: C_UnitAuras.UnitAura / UnitBuff / UnitDebuff return the 15-value
Classic-Era UnitAura tuple with no table allocation. Built on a shared
Emit { Table, Positional } seam through PushEnriched -- selection, cache
fallback, out-of-range group, and timing resolution are the same code as the
table path; only the terminal leaf differs (BuildTable vs the new
PushPositional, both fed by an extracted ResolveDisplay). Namespaced under
C_UnitAuras so the native UnitBuff/UnitDebuff globals keep their vanilla
short return.

Addon: Util/AuraUtil.lua backports ForEachAura / FindAura / FindAuraByName /
UnpackAuraData over those accessors. ForEachAura (non-packed) and FindAura
both source from the positional accessor, so they allocate nothing per aura --
FindAura included, where retail itself allocates a table per aura scanned.

Verified in-game: positional values match GetAuraDataByIndex field-by-field
(#12 isBossDebuff / #13 castByPlayer differ by design, matching retail's
UnitAura-vs-AuraData divergence); ForEachAura counts match GetUnitAuras; and
the positional path allocates ~0 KB/scan where the table path allocates
~1.3 KB per aura.

Fixes #30
2026-08-23 12:16:53 -05:00
Brues f000fd6ed7 build(release): emit matched PDB and ship crash symbols as a zip
Add /Zi (compiler) + /DEBUG (linker) so the Release build produces a
line-level ClassicAPI.pdb alongside the existing linker map. /OPT:REF and
/OPT:ICF stay explicit so /DEBUG does not re-enable dead code and the
shipped DLL does not grow; the PDB is a separate file matched to the DLL
by signature.

CI release runs now bundle the pdb + map into one crash-symbols zip
attached to the release, replacing the loose renamed map. The zip name is
kept unappealing to normal players and carries the tag so a dev can match
symbols to the exact build.
2026-08-23 10:40:03 -05:00
Brues 7960266969 feat(addon): add [known] macro conditional to SecureCmdOptionParse
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 via GetSpellName, since
1.12 has no name-to-id resolver -- so a name matches castable spellbook
entries only.

Verified in-game: [known:133] false on a non-Mage, [known:45651] and
[known:Aspect of the Viper] both true by id and by name.
2026-08-23 02:42:48 -05:00
22 changed files with 1135 additions and 108 deletions
+17 -6
View File
@@ -104,12 +104,23 @@ jobs:
name: ClassicAPI-${{ github.sha }}
path: build/Release/ClassicAPI.dll
# The linker map resolves the logical addresses in a user's crash log
# (the ERROR #132 stack trace) to function names. Ship it under a name
# that tells users what it's for, so crash reports come with it.
- name: Rename linker map for release
# 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: Copy-Item build/Release/ClassicAPI.map build/Release/USE_FOR_DEBUGGING_CAPI_CRASHES.map
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'
@@ -118,5 +129,5 @@ jobs:
tag_name: ${{ steps.ver.outputs.tag }}
files: |
build/Release/ClassicAPI.dll
build/Release/USE_FOR_DEBUGGING_CAPI_CRASHES.map
build/Release/Crash-Symbols-Use-For-Debugging-${{ steps.ver.outputs.tag }}.zip
generate_release_notes: true
+2
View File
@@ -7,6 +7,7 @@
locales\locales.xml
SecureTemplates.xml
Compat.lua
Util\Print.lua
Util\Mixin.lua
@@ -44,6 +45,7 @@ Util\Vector3D.lua
Util\Vector4D.lua
Util\UIParent.lua
Util\AuraDurationModifiers.lua
Util\AuraUtil.lua
Util\SecureUnitMenu.lua
Util\TextureUtil.lua
Util\MacroOptions.lua
+125
View File
@@ -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
+21
View File
@@ -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>
+128
View File
@@ -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
+39 -1
View File
@@ -20,7 +20,8 @@
-- * 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.
-- 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
@@ -97,6 +98,26 @@ local function IsEquippedTypeMatch(want)
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> };
@@ -289,6 +310,23 @@ 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;
@@ -35,34 +35,8 @@
-- SecureButton_* (minimal -- no taint, no modifier/button suffix machinery)
-- ---------------------------------------------------------------------------
-- frame:GetAttribute(name), then walk up parents while the chain opts in via
-- "useparent-<name>" or "useparent*".
function SecureButton_GetAttribute(frame, name)
local value = frame:GetAttribute(name);
while value == nil
and (frame:GetAttribute("useparent-" .. name) or frame:GetAttribute("useparent*")) do
frame = frame:GetParent();
if not frame then
break;
end
value = frame:GetAttribute(name);
end
return value;
end
-- The frame's `unit` attribute plus an optional `unitsuffix` (3.3.5 verbatim,
-- including the raid1pet -> raidpet1 normalization).
function SecureButton_GetUnit(frame)
local unit = SecureButton_GetAttribute(frame, "unit");
if unit then
local unitsuffix = SecureButton_GetAttribute(frame, "unitsuffix");
if unitsuffix then
unit = unit .. unitsuffix;
unit = string.gsub(unit, "^([^%d]+)([%d]+)[pP][eE][tT]", "%1pet%2");
end
return unit;
end
end
-- SecureButton_GetAttribute and SecureButton_GetUnit are defined in
-- SecureTemplates.lua (loaded earlier via SecureTemplates.xml).
-- ---------------------------------------------------------------------------
-- Private state
+21 -7
View File
@@ -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"
)
+2 -1
View File
@@ -49,6 +49,7 @@ reference in **[docs/API.md](docs/API.md)**.
| [Action](docs/API.md#action) | `GetActionInfo` |
| [AddOns](docs/API.md#addons) | `C_AddOns.DoesAddOnExist`, `C_AddOns.GetAddOnLocalTable`, `C_AddOns.GetAddOnName`, `C_AddOns.GetAddOnNotes`, `C_AddOns.GetAddOnOptionalDependencies`, `C_AddOns.GetAddOnSecurity`, `C_AddOns.GetAddOnTitle`, `C_AddOns.IsAddOnLoadable`, `C_AddOns.IsAddOnLoaded` |
| [AuctionHouse](docs/API.md#auctionhouse) | `C_AuctionHouse.PostItem` |
| [AuraUtil](docs/API.md#aurautil) | `AuraUtil.FindAura`, `AuraUtil.FindAuraByName`, `AuraUtil.ForEachAura`, `AuraUtil.UnpackAuraData` |
| [Bindings](docs/API.md#bindings) | `SetBindingSpell`, `SetBindingItem`, `SetBindingMacro`, `SetBindingClick`, `SetOverrideBinding`, `SetOverrideBindingSpell`, `SetOverrideBindingItem`, `SetOverrideBindingMacro`, `SetOverrideBindingClick`, `ClearOverrideBindings` |
| [Chat](docs/API.md#chat) | `GetCurrentChatGUID` |
| [ChatBubbles](docs/API.md#chatbubbles) | `C_ChatBubbles.GetAllChatBubbles` |
@@ -102,7 +103,7 @@ reference in **[docs/API.md](docs/API.md)**.
| [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` |
+103
View File
@@ -28,6 +28,12 @@ build instructions.
- [AuctionHouse](#auctionhouse)
- [`C_AuctionHouse.PostItem(itemLocation, duration, quantity, numStacks, bid, buyout)`](#c_auctionhousepostitemitemlocation-duration-quantity-numstacks-bid-buyout)
- [AuraUtil](#aurautil)
- [`AuraUtil.ForEachAura`](#aurautilforeachaura)
- [`AuraUtil.FindAura`](#aurautilfindaura)
- [`AuraUtil.FindAuraByName`](#aurautilfindaurabyname)
- [`AuraUtil.UnpackAuraData`](#aurautilunpackauradata)
- [CharacterList](#characterlist)
- [`GetSavedCharacterOrder(realm)` / `SetSavedCharacterOrder(realm, order)` — GlueXML only](#getsavedcharacterorderrealm--setsavedcharacterorderrealm-order--gluexml-only)
@@ -621,6 +627,8 @@ build instructions.
- [UnitAuras](#unitauras)
- [`C_UnitAuras.GetAuraDataByIndex(unit, index [, filter])`](#c_unitaurasgetauradatabyindexunit-index--filter)
- [`C_UnitAuras.GetBuffDataByIndex(unit, index)` / `GetDebuffDataByIndex(unit, index)`](#c_unitaurasgetbuffdatabyindexunit-index--getdebuffdatabyindexunit-index)
- [`C_UnitAuras.UnitAura(unit, index [, filter])`](#c_unitaurasunitauraunit-index--filter)
- [`C_UnitAuras.UnitBuff(unit, index [, filter])` / `UnitDebuff(unit, index [, filter])`](#c_unitaurasunitbuffunit-index--filter--unitdebuffunit-index--filter)
- [`C_UnitAuras.GetUnitAuraBySpellID(unit, spellID [, filter])`](#c_unitaurasgetunitaurabyspellidunit-spellid--filter)
- [`C_UnitAuras.GetPlayerAuraBySpellID(spellID)`](#c_unitaurasgetplayeraurabyspellidspellid)
- [`C_UnitAuras.GetAuraDataBySpellName(unit, spellName [, filter])`](#c_unitaurasgetauradatabyspellnameunit-spellname--filter)
@@ -1144,6 +1152,60 @@ stack); temp splits use general-purpose bags only, so family-restricted items
(arrows/shards that live in specialty bags) aren't supported; one job runs at
a time.
## AuraUtil
Helper library over [`C_UnitAuras`](#unitauras), backported from FrameXML. Its
purpose on 1.12 is the allocation-free aura scan: the non-packed path builds no
table per aura, which matters under Lua's garbage collector when you scan many
units each frame.
### AuraUtil.ForEachAura
`AuraUtil.ForEachAura(unit, filter, batchSize, func [, usePackedAura])` calls
`func` for each aura on `unit` matching `filter`, in order, until `func` returns a
truthy value or the auras run out.
- Non-packed (default): `func` receives the 15 positional values of
[`C_UnitAuras.UnitAura`](#c_unitaurasunitauraunit-index--filter) — no table is
built per aura.
- `usePackedAura` true: `func` receives the `AuraData` table from
`GetAuraDataByIndex`.
`batchSize` of `0` or less does nothing; any other value (or `nil`) visits every
matching aura — `batchSize` is retail's slot-batch hint, not a cap on the count.
```lua
AuraUtil.ForEachAura("target", "HARMFUL", nil, function(name, icon, count)
print(name, count)
end)
```
### AuraUtil.FindAura
`AuraUtil.FindAura(predicate, unit, filter [, arg1, arg2, arg3])` returns the
positional values of the first aura for which `predicate` returns truthy, or
`nil`. `predicate` receives the (up to three) caller arguments, then the aura's
positional values:
```lua
local function castByMe(_, _, _, name, icon, count, dispelType, duration, exp, source)
return source == "player"
end
local name = AuraUtil.FindAura(castByMe, "target", "HARMFUL")
```
### AuraUtil.FindAuraByName
`AuraUtil.FindAuraByName(auraName, unit, filter)` returns the positional values of
the first aura whose name matches `auraName`, or `nil`. Names are localized and
not unique, so this returns the first match.
### AuraUtil.UnpackAuraData
`AuraUtil.UnpackAuraData(auraData)` returns an `AuraData` table's fields as 15
positional values in the classic `UnitAura` order, or `nil` when `auraData` is
`nil`. (Position 13 is the table's `isFromPlayerOrPlayerPet`.)
## CharacterList
### `GetSavedCharacterOrder(realm)` / `SetSavedCharacterOrder(realm, order)` — GlueXML only
@@ -4804,6 +4866,7 @@ SecureCmdOptionParse("[nocombat] rest") -- "rest" out of combat, else nil
| `equipped:type` | an item of that type, subtype, or slot is equipped (`worn` is the same) |
| `cursor` | the cursor holds an item, a spell, or money |
| `spec` / `spec:1` | always true (vanilla has one spec) |
| `known:spellID` / `known:name` | you know that spell |
**Always false.** `flying`, `flyable`, `vehicleui`, and `unithasvehicleui`
describe state that 1.12 does not have, so they never match.
@@ -4816,6 +4879,12 @@ The `[stance:N]` number is the shapeshift bar slot, not the form ID that
[`GetShapeshiftFormID()`](#getshapeshiftformid) returns. On a druid, bar slot 1
is Bear and slot 3 is Cat.
`known` is a ClassicAPI extension, not a Blizzard conditional. A numeric
argument uses [`IsPlayerSpell`](#isplayerspellspellid), so it matches any known
spell — a spellbook spell, a talent, a profession recipe, or a racial. A name
argument matches only a spellbook spell, because there is no name-to-ID
resolver. Use the spell ID for a talent or a recipe.
### `RegisterStateDriver` / `UnregisterStateDriver`
`RegisterStateDriver(frame, state, values)` drives a frame's state from a macro
@@ -15254,6 +15323,40 @@ Convenience wrappers locking the filter to `HELPFUL` or `HARMFUL`
respectively. Equivalent to `GetAuraDataByIndex(unit, index,
"HELPFUL")` / `"HARMFUL"`.
### `C_UnitAuras.UnitAura(unit, index [, filter])`
Returns the same aura as `GetAuraDataByIndex`, but as 15 positional values
instead of a table — so it allocates nothing. Use it in an `OnUpdate` loop that
scans many auras, where one table per aura strains Lua's garbage collector.
Prefer [`AuraUtil.ForEachAura`](#aurautilforeachaura) for iteration.
The values, in order:
```
name, icon, count, dispelType, duration, expirationTime, source, isStealable,
nameplateShowPersonal, spellId, canApplyAura, isBossDebuff, castByPlayer,
nameplateShowAll, timeMod
```
Returns a single `nil` when the unit has fewer than `index` matching auras.
`dispelType` is the dispel-type string (`"Magic"`, `"Curse"`, `"Disease"`,
`"Poison"`, or `nil`). `source` is the caster's unit token, or `nil` when the cast
was not seen this session. `castByPlayer` is true when the local player cast the
aura. `filter` takes the same tokens as `GetAuraDataByIndex`.
The values are the `GetAuraDataByIndex` fields in the classic `UnitAura` order,
with two field-name differences from the table: position 12 is `isBossDebuff`
(the table field is `isBossAura`) and position 13 is `castByPlayer` (the table
field is `isFromPlayerOrPlayerPet`). On 1.12 the boss field is always false;
`castByPlayer` is reported truthfully.
### `C_UnitAuras.UnitBuff(unit, index [, filter])` / `UnitDebuff(unit, index [, filter])`
The `UnitAura` positional form with the range locked to `HELPFUL` or `HARMFUL`.
The `filter` still honors the `PLAYER`, `DISPELLABLE`, and `CROWD_CONTROL`
predicates. Namespaced under `C_UnitAuras` so they never clash with the native
global `UnitBuff` / `UnitDebuff` (which keep their vanilla texture-first return).
### `C_UnitAuras.GetUnitAuraBySpellID(unit, spellID [, filter])`
Linear-searches `unit`'s aura array for the first populated slot
+7
View File
@@ -24,6 +24,7 @@
#include "text/InlineTexturePool.h"
#include "texture/Mask.h"
#include "texture/Transform.h"
#include "tooltip/SetEvents.h"
static Game::FrameScript_Initialize_t FrameScript_Initialize_o = nullptr;
static Game::LoadScriptFunctions_t LoadScriptFunctions_o = nullptr;
@@ -70,6 +71,12 @@ static bool __fastcall FrameScript_Initialize_h() {
// reload destroys every Model frame the jobs point at.
Model::DisplayInfo::PrepareForReload();
// Drop the per-tooltip OnTooltipSet* handler cells — the reload destroys
// and recreates every tooltip object, and the handler refs die with the
// Lua reset (issue #33: the cell table filled after ~18 reloads, then
// recycled tooltip addresses fired dead refs).
Tooltip::SetEvents::PrepareForReload();
// Forget the captured `string` table before the world Lua state resets
// (this hook also fires on /logout, where the state's tables die) —
// string-method resolution fails closed (stock index error) until
+25
View File
@@ -6900,6 +6900,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.
+75
View File
@@ -183,6 +183,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 +240,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 +495,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",
+94 -25
View File
@@ -449,23 +449,44 @@ 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; the table uses `isFromPlayerOrPlayerPet`
// instead, mirroring retail's own divergence between the two aura shapes.
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 =
casterGuid != 0 && casterGuid == Unit::Identity::PlayerGuid();
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);
@@ -518,6 +539,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 +606,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 +639,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 +653,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 +669,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 +679,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 +765,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 +788,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 +895,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 +907,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 +961,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 +977,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
View File
@@ -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
+130 -12
View File
@@ -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.
@@ -211,7 +218,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 +526,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 +630,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 +733,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 +799,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);
+259
View File
@@ -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
+10
View File
@@ -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
View File
@@ -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).
+19 -4
View File
@@ -62,10 +62,19 @@ constexpr const char *kNamesLower[SK_COUNT] = {
};
// Per-tooltip storage: one 8-byte {handler, context} slot per script kind.
// Tooltips are few and permanent (GameTooltip, ItemRefTooltip,
// ShoppingTooltip1/2, AtlasLootTooltip, WorldMapTooltip, plus the odd addon
// tooltip), so a small fixed table with a linear scan is plenty and gives each
// cell a stable address to hand out.
// Tooltips are few (GameTooltip, ItemRefTooltip, ShoppingTooltip1/2,
// AtlasLootTooltip, WorldMapTooltip, plus the odd addon tooltip), so a small
// fixed table with a linear scan is plenty and gives each cell a stable
// address to hand out.
//
// Few — but NOT permanent: every /reload (and logout) destroys and recreates
// the tooltip objects, usually at new addresses. `PrepareForReload` clears the
// table at each UI teardown; without it the table gained ~2 dead cells per
// reload until full (≈18 reloads — SetScript then failed with "doesn't have a
// 'OnTooltipSetItem' script") and a stale cell whose address the allocator
// recycled into a new tooltip fired its dead handler ref → crash (issue #33).
// The clear is also semantically right: handler refs never survive the Lua
// reset, so post-reload every cell held garbage regardless.
struct Cell {
void *tooltip;
uint32_t slot[SK_COUNT][2]; // [kind] = {handler, exec context}
@@ -238,4 +247,10 @@ Suppressor::~Suppressor() {
--g_suppress;
}
void PrepareForReload() {
// Forget every cell — count gates the scan, so resetting it is a full
// clear; the create path re-initializes a cell's slots on reuse.
g_cellCount = 0;
}
} // namespace Tooltip::SetEvents
+10
View File
@@ -29,4 +29,14 @@ struct Suppressor {
Suppressor &operator=(const Suppressor &) = delete;
};
// Drop every per-tooltip handler cell. Must run at each UI teardown
// (FrameScript_Initialize_h — /reload and /logout): the engine destroys and
// recreates the tooltip objects, so the cells' tooltip pointers go stale and
// their handler refs die with the reset Lua state. Without this the fixed
// cell table leaks one entry per recreated tooltip per reload until it fills
// (SetScript then errors "doesn't have a 'OnTooltipSetX' script") and a
// recycled tooltip address can match a stale cell and fire a dead handler
// ref — issue #33.
void PrepareForReload();
} // namespace Tooltip::SetEvents
+10 -14
View File
@@ -35,6 +35,7 @@
#include "unit/TokenObserver.h"
#include "Offsets.h"
#include "object/Resolve.h"
#include <cstdint>
@@ -55,20 +56,15 @@ const char *const *EventNames() {
static_cast<uintptr_t>(Offsets::VAR_EVENT_NAME_TABLE_STATIC));
}
// Both register and unregister funnel through `FUN_00464890`, which
// dereferences the client/player-rooted observer registry at
// `[VAR_LOCAL_PLAYER_PTR]` (reads its `+0x24` mask). That root is NULL out of
// world — login screen, loading screens, logout teardown — where the engine
// helper null-derefs (crash: `MOV EAX,[EDX+0x24]`, EDX=0). Bail when it isn't
// up: there are no live objects or observer nodes to touch anyway (nodes die
// with their objects during teardown), and in-world callers re-register/clean
// up on the next tick. Unlike `Player::Equipment` (which only ever registers
// from the enter-world hook), our consumers register from Lua / WorldTick,
// which can run mid-transition — hence the guard here.
bool RegistryReady() {
return *reinterpret_cast<void *const volatile *>(
static_cast<uintptr_t>(Offsets::VAR_LOCAL_PLAYER_PTR)) != nullptr;
}
// Both register and unregister funnel through `FUN_00464890`, whose registry
// root at `[VAR_LOCAL_PLAYER_PTR]` is NULL out of world — the shared
// `Object::RegistryReady` (object/Resolve.h) documents the crash mechanics.
// Bail when it isn't up: there are no live objects or observer nodes to touch
// anyway (nodes die with their objects during teardown), and in-world callers
// re-register/clean up on the next tick. Unlike `Player::Equipment` (which
// only ever registers from the enter-world hook), our consumers register from
// Lua / WorldTick, which can run mid-transition — hence the guard here.
using Object::RegistryReady;
// Bytes the engine watches for the observer on event index `i` — verbatim
// from `FUN_0051bbb0`'s size switch. The 8-byte entries are the 64-bit GUID