Compare commits

...
3 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
13 changed files with 636 additions and 61 deletions
+1
View File
@@ -7,6 +7,7 @@
locales\locales.xml
SecureTemplates.xml
Compat.lua
Util\Print.lua
Util\Mixin.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>
@@ -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
+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.
+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