Compare commits

...
69 Commits
Author SHA1 Message Date
Brues 695077b6da refactor(callbacks): lighter EventRegistry dispatch
Modernize the frame-event forwarder: OnEvent now takes its (self, event, ...)
positionally (modern script args, on by default) and forwards straight to
TriggerEvent — dropping the DispatchGameEvent hop and the unpack(arg) repack,
and forwarding the exact arg count instead of a padded arg1..arg9 (which
silently dropped args for events that emit more than nine). A fallback reads
the event/arg globals when SetModernScriptArgs is off, so the registry stays
robust to that switch.

Keep the ordinary {[event]=count} ref-count table rather than storing counts
as frame attributes: SetAttribute is heavier per call (it lowercases the name,
runs the secure-attribute parsing, and fires OnAttributeChanged) for the same
steady-state memory, so a plain table is lighter on the register/unregister
path.
2026-08-31 16:51:17 -05:00
Brues 4d744b6638 feat(interface): INTERFACE_VERSION global constant
Publishes INTERFACE_VERSION (11200 on this client) — the client interface/TOC
version, read from the engine's own accessor (FUN_ADDON_CLIENT_INTERFACE_
VERSION, the same value the addon loader compares each `## Interface:`
against) rather than a literal. A global number re-published every /reload
via ModuleAutoRegister, mirroring CLASSIC_API_VERSION, since it is a fixed
client constant.

Verified in-game: /dump INTERFACE_VERSION -> 11200.
2026-08-31 15:06:01 -05:00
Brues c2a4cbdf27 docs: drop WoW-version and vanilla-gap phrasing
The docs are a modern-API backport onto 1.12, so "added in <patch>" and
"vanilla 1.12 lacks/does X differently" are implied by the doc existing —
they were noise. Swept both across docs/API.md and README.md: dropped the
version-introduction and vanilla-limitation clauses, and where a version note
was the only thing explaining a hardcoded/degenerate return value, kept the
reason without the version. Behavior notes and "ClassicAPI extension"
provenance are unchanged. The large line count is mostly paragraph reflow
after clause removal; no API names, signatures, or code examples changed.
2026-08-31 13:23:09 -05:00
Brues d30ae53fbf feat(editbox): ClearHistory
Empties the up/down input history. The engine's SetHistoryLines internal
(FUN_0077cd90) frees the whole history ring when called with 0, so clear =
free (0) then reallocate at the saved max — dropping every entry and resetting
the write position while keeping the line limit, so the box keeps recording
afterward (matching modern semantics, not "history disabled"). No-op when the
box has no history.

New offsets: OFF_EDITBOX_HISTORY_MAX (+0x388, the ring capacity GetHistoryLines
reads), FUN_EDITBOX_SET_HISTORY_MAX.

Verified in-game: AddHistoryLine + ClearHistory leaves GetHistoryLines
unchanged and the up-arrow history empty; new lines still record.
2026-08-31 11:14:34 -05:00
Brues 8f32955823 feat(editbox): SetHighlightColor / GetHighlightColor
The engine paints the text selection with three Texture regions (start-line
+0x350, middle-block +0x354, end-line +0x358; positioned by FUN_0077d950),
and their vertex color IS the highlight color — there's no single color field.
So SetHighlightColor packs {b,g,r,a} and writes it to all three via the
engine's own generic region color setter (FUN_0077f750, the same one behind
Texture:SetVertexColor / FontString:SetTextColor), and GetHighlightColor reads
the first region back through the matching getter FUN_0077f8c0 (the read half
of Texture:GetVertexColor). The blinking caret is a separate region (+0x3d4),
left untouched.

Colors are 0..1, alpha defaults to 1. New offsets: FUN_REGION_GET_COLOR,
OFF_EDITBOX_HIGHLIGHT_REGION; FUN_FONTSTRING_SET_COLOR relabeled as the
generic region color setter it always was.

Verified in-game: SetHighlightColor(1,0,0) reads back 1 0 0 1 and a selection
paints red.
2026-08-31 10:53:04 -05:00
Brues 2778643f4f feat(editbox): GetUTF8CursorPosition, ClearHighlightText, HasFocus, HasText
Round out the EditBox method backports alongside Set/GetCursorPosition, all
reusing state the engine already maintains:

- GetUTF8CursorPosition — caret as a character index (vs GetCursorPosition's
  byte offset), via the engine's own byte->char counter FUN_0077bc80.
- ClearHighlightText — drop the selection but keep the caret (collapse
  selStart/selEnd onto the cursor + highlight-dirty bit). Vanilla can only
  clear a selection with HighlightText(0,0), which yanks it to offset 0.
- HasFocus — pointer identity against VAR_FOCUSED_EDITBOX (the global the
  engine's SetFocus writes / ClearFocus clears).
- HasText — text byte length (+0x338) is non-zero.

Registered on the EditBox method registry, so only EditBox frames dispatch
them. New offsets: OFF_EDITBOX_TEXT_LENGTH, FUN_EDITBOX_COUNT_CHARS.

The module now covers cursor / selection / focus / text-state, so it moves
from editbox/Cursor.cpp to editbox/Methods.cpp (namespace EditBox::Methods).
Documented in docs/API.md and the README under the Frame widget section.

Verified in-game: SetText+SetFocus reports HasFocus/HasText true and both
cursor getters 5; an empty box reports both predicates false.
2026-08-31 10:38:35 -05:00
Brues 3b60ca4094 docs(readme): drop addon-Lua functions from the DLL call table
The "In-game Lua calls" table lists what the DLL registers in C++, but it
included functions provided by the bundled !!!ClassicAPI addon's Lua:
AuraUtil.* and the secure-template Frame functions (SecureCmdOptionParse,
Register/UnregisterStateDriver, Register/UnregisterAttributeDriver,
Register/UnregisterUnitWatch, UnitWatchRegistered, SecureButton_GetAttribute,
SecureButton_GetUnit). Remove those; keep the C++-backed entries
(SetAttribute/GetAttribute/ClearAttribute, PreClick/PostClick, GetClickFrame).
2026-08-31 10:25:45 -05:00
Brues c20be0cef1 feat(editbox): SetCursorPosition / GetCursorPosition
Vanilla's EditBox registry has 48 methods (HighlightText, Insert, SetText,
...) but no cursor accessor, so modern addons that set text then place the
caret error with "attempt to call method 'SetCursorPosition' (a nil value)"
(reported: TwitchEmotes autocomplete).

The caret already exists in the engine — a byte offset at editbox+0x36c —
with reusable primitives: FUN_0077e360 clamps and sets it (the engine's own
"restore caret" step in the insert path), FUN_0077ccf0 collapses the
selection onto it. Modern SetCursorPosition is byte-based too, so it maps
1:1. New EditBox::Cursor module registers both methods on the EditBox method
registry (0x00CF5378); registering under that context means only EditBox
frames dispatch them, so `this` is always a real CSimpleEditBox. Set path:
clamp+set the caret, collapse the selection, flag the highlight dirty.

Verified in-game: SetText + SetCursorPosition + GetCursorPosition round-trips
and the caret lands at the requested offset.
2026-08-31 10:16:37 -05:00
Brues 1ad6b57202 fix(table): writer-side mark for deliberate nil appends
The v1.12.8 gap heuristic (keep the stored length when it overshoots the
border by exactly one) protected `table.insert(t, nil)` slots (#36) but
broke every 5.1-dialect table that is stale by exactly one — a
pairs-cleared one-element table, or a recycled table whose previous life
was one slot longer. Tablet-2.0's del()/copy() pool produces the latter on
every tooltip refresh, so FuBar/Ace2/Tablet menus and tooltips shifted by
one with a nil hole at slot 1. The two states are byte-identical, and both
consumers reach the heal through table.insert — neither the table nor the
caller can split them.

Only the writer knows which dialect the table speaks, and the write is
observable: co-hook the engine's table.insert (FUN_LUA_TABLE_INSERT =
0x7FB6B0, verified against the table-lib luaL_reg pair in .data and a
decompile matching 5.0.2 tinsert line for line) and record
{table -> stored length} in a weak-keyed registry table whenever the
two-arg form appends a literal nil. Rule 3 now heals every stale table to
the border (the 5.1 answer) and keeps the stored length only when the gap
is one AND the mark still matches. Stale marks die on their own: any later
insert/remove moves the stored length off the recorded value, and the weak
key collects with the table.

Verified in-game with a 21-test suite (directed regressions for
#29/#36/#39, byte-identical twin discrimination, metamethod isolation,
coroutine-side marking, and a seeded differential fuzzer against an
executable model of the contract): 15/21 on the broken build, 21/21 with
the fix, #36's nil-append cases among the passes both times.

New plumbing: LUA_SET_METATABLE (0x6F4020, from LuaCAPI.md) and
Game::Lua::SetMetatable for building the weak mark table from C.

Fixes #39
2026-08-31 01:56:03 -05:00
Brues 993c82cca7 refactor: route unit-token resolution through Game::ResolveUnitToken
Sweep the ~35 modules that each hand-rolled a `ResolveUnitToken_t` typedef
plus `reinterpret_cast<…>(Offsets::FUN_RESOLVE_UNIT_TOKEN)` onto the shared
`Game::ResolveUnitToken` helper. Behavior-identical — it is the same engine
function — and net -87 lines. The typedef now lives in exactly one place
(Game.cpp).

unit/Position.h is a header, so it gains an `#include "Game.h"`; every other
touched file already pulled Game.h in. No offsets, logic, or unrelated
typedefs were changed.
2026-08-30 16:57:04 -05:00
Brues 94e77ef5af feat(unit): UnitCreatedBySpell + consolidate the unit-token resolver
Add `UnitCreatedBySpell(unit)` — the spell that summoned a unit (a totem's
totem-drop spell, a pet/guardian's summon spell), read from the unit's
UNIT_CREATED_BY_SPELL descriptor field (byte +0x230). It is the same field
the engine's unit-title builder consults to pick the Pet/Minion/Guardian/
Creation title, and it is broadcast, so it resolves for any unit in range.
This is the summoning spell, distinct from the (server-only) spell a totem
casts.

Also add a single `Game::ResolveUnitToken(token)` helper that wraps the
engine token->CGUnit* resolver, and route unit/Pet.cpp through it plus the
Game::Read<T> field accessors — removing the hand-rolled ResolveUnitToken_t
typedef and raw reinterpret_casts from this module. The name was already
referenced in Offsets.h and CLAUDE.md but had never been implemented.
2026-08-30 16:43:54 -05:00
Brues 1458d60b51 feat(spell): GetSpellInfo accepts a spell name, link, or "Name(Rank N)"
The shared spell-arg resolver errored on any non-number arg1, so
GetSpellInfo("Fireball") raised instead of resolving. Match retail's
full argument set: arg1 may now be a spellID, a spell name, or a spell
hyperlink.

A name resolves against the player's then pet's spellbook (retail's
scope) and returns the highest rank you know; an unknown name returns
nil rather than raising. A trailing "(Rank N)" subtext pins that exact
rank -- the vanilla SpellName(Rank N) form CastSpellByName uses, with a
space before the paren tolerated. A link's embedded spellID is parsed
directly, so it works for any spell, learned or not.

Because GetSpellLink / IsPassiveSpell / IsHarmfulSpell / IsHelpfulSpell
share this resolver, they gain the same name and link forms.

Verified in-game: "Mind Blast" -> rank 9 (max known); "Mind Blast
(Rank 8)" and "Mind Blast(Rank 8)" -> rank 8; "Mind Blast (Rank 99)"
-> nil.
2026-08-30 13:36:07 -05:00
Brues 8b106ae0d6 fix(addons): define global HookScript for ModernSpellBook
ModernSpellBook's MSB_Spellbook.lua re-hooks its "show all ranks"
checkbox by calling HookScript(frame, "OnClick", handler) as a GLOBAL.
That global never existed on any client -- HookScript has always been
the widget method frame:HookScript, which our DLL provides -- so the
call errors with "attempt to call global 'HookScript' (a nil value)".

Define the global in AddOnCompat, forwarding to the method, gated on the
addon being present so the global namespace stays clean otherwise.
2026-08-30 08:52:01 -05:00
Brues 3c4d4eda5f docs(readme): link the Feature column instead of trailing "See […]"
Move each feature table's docs/API.md link onto the Feature name in
column one and drop the "See [x]." tail from the Effect column. Rows
with no docs section (Inline textures, Tooltip line cap) are unchanged.
2026-08-29 19:20:59 -05:00
brues-code 6aea4a1752 Retail-like /reload: new addons, new files, ## metadata edits, deletions (#37)
* feat(addons): retail-like /reload — new addons and new files load

Two engine limitations kept anything created after boot from loading
until a client restart:

1. File visibility: every relative-path read resolves through a
   loose-file hash index built once at boot (FUN_00646EA0, latched);
   the archive layer's live disk probe is dead code (DAT_00865B44
   ships 1, boot clears it) and writes never register. New files -
   including a new addon's TOC and first-ever SavedVariables - were
   invisible until restart.
2. Registry membership: the addon registry is built once at login;
   new folders are never walked.

Addons::Rescan runs per /reload from the ModuleAutoRegister callback
(before the AddOns.txt re-read, progress count, and load pass) and:

- re-indexes Interface\AddOns + WTF\Account with the engine's own
  dedup-safe boot indexer (FUN_VFS_INDEX_SUBTREE) - new files in
  existing addons then load natively, since the per-addon loader
  re-reads the TOC from disk on every load pass; a newly installed
  addon's first SV save now also survives /reload;
- replays login scan walk #2 verbatim (FUN_ADDON_SCAN_DISK_DIRS with
  the engine's own dir-to-parser callback) so new folders register as
  complete normal entries via the dedup-guarded TOC parser;
- mirrors the two structures the login scan would have filled:
  reverse-LoadWith links (tail-loop grow+append, restricted to pairs
  involving a new entry - the engine's lists have no dedup) and the
  flat GetNumAddOns display array (FUN_0051DA70 phase 2: filter byte
  +0x29, engine grow + qsort comparator).

Append-only by design: no teardown, no eviction, no mutation of
existing entries - the prior hot-reload attempt died re-running the
login-only FUN_ADDON_INIT teardown mid-reload. Still login-only
(no safe mechanism): ## metadata edits of already-registered addons
and folder deletions.

Also centralizes Embedded.cpp's addon-entry field constants into
Offsets.h now that a second module reads them.

* refactor(addons): centralize the registry list walk; record dedup-guard probe

The intrusive linked-list walk (head at [VAR_ADDON_LIST_HEAD], next at
entry + [VAR_ADDON_LIST_CTRL] + 4, low-bit-1/NULL sentinel) was
implemented twice -- a template in Rescan.cpp and an inline loop in
Embedded.cpp's FinalizeEmbeddedEntry. Move it to addons/Registry.h as
Addons::ForEachEntry, with a void- or bool-returning visitor (false
stops early). The early stop is load-bearing for Embedded: re-linking
the found entry to the head invalidates the current next-pointer, so
the walk must not continue past it -- the shared helper makes that
contract explicit.

Also record the TOC-parser dedup-guard verification in Rescan.cpp's
scan-call comment: the guard is the parser's FIRST block (hash lookup +
name compare, early return before the TOC path is even built), and both
the Storm hash (uppercases a-z, folds '/'->'\') and the SStrCmpI compare
are case-insensitive -- so the per-/reload scan replay can never re-read
or mutate a registered entry's ## metadata, and a case-renamed folder
matches its existing entry instead of registering a duplicate.

* feat(addons): /reload re-parses edited ## metadata and drops deleted addons

The TOC parser's dedup guard makes a registered entry's ## metadata
read-once: an edited ## SavedVariables: / ## Dependencies: / any ##
line never re-parsed until re-login, and a deleted folder's entry
lingered in the addon list. Both were documented login-only limits of
the retail-like /reload feature.

Rescan's new step 1.5 closes them with evict + re-register, all engine
machinery:

- Change detection: FNV-1a over the TOC's ## lines only (BOM skipped
  like the parser, line terminators excluded), read through the hooked
  FUN_FILE_READ so flavor selection is hashed exactly as the parser
  reads it. File-reference edits never trigger the evict path -- the
  load pass re-reads those natively. Baselines seed at login and for
  newly registered folders in the same Run(), so an edit is always
  diffed against what was actually parsed.
- Evict: scrub the victim from every other entry's reverse-LoadWith
  list (the one reference the destructor cannot clean -- the loader's
  post-ADDON_LOADED loop dereferences those entries), then call the
  engine's own complete per-entry destructor FUN_ADDON_ENTRY_DESTROY
  (0x005200A0, __stdcall, RET 4; vtable slot 0 of the registry node
  factory at [0x00BE1B60], dispatched per entry by the login teardown).
  Verified by decompile: frees every owned allocation (name, dep/SV
  arrays + buffers, reverse-LoadWith buffer, the ## metadata table) and
  self-unlinks from both the intrusive list (FUN_00520A60, no-ops when
  already unlinked) and the hash bucket chain.
- Re-register: feed the name back to FUN_TOC_PARSER -- the dedup guard
  now misses and the entry rebuilds from the edited TOC exactly like a
  fresh registration. Refreshed entries join the existing
  FixupReverseLoadWith/RebuildDisplayArray fix-ups; the refresh runs
  before the before-snapshot so an address-reusing allocator cannot
  make the added-diff double-count or miss one. Any eviction forces the
  display-array rebuild (it holds the dead entry's freed name pointer
  until then). Victims are collected during the walk and processed
  after it -- the dtor zeroes the link node under the iterator.
- Deletion: an unreadable TOC evicts without re-registering; a folder
  the scan still finds simply re-registers the same reload.

Exempt: !!!ClassicAPI (Addons::Embedded owns its entry) and ## Secure:
/ SMSG-managed entries (the parser cannot restore packet-delivered
state; new OFF_ADDON_ENTRY_SECURE = 0x28, parser write site verified).

Verified in-game: ## X-Test metadata edit picked up across /reload
(GetAddOnMetadata returns the new value), SavedVariables list edit
takes effect, folder deletion drops the entry from GetNumAddOns, folder
restore re-registers it, and a no-change /reload is a pure hash-compare
no-op.

* fix(addons): address review — hash commit timing, evict flag, README row

- README: the /reload row said `##` metadata edits still need a
  re-login — stale from before the metadata-refresh step landed. Now
  matches docs/API.md: edits take effect, deleted folders drop from
  the addon list.
- Rescan: commit g_metaHash only after the re-registration actually
  happened, so a failed resolve/parse retries next /reload instead of
  silently absorbing the edit forever; a victim the parse cannot
  restore is erased (treated as deleted) so a reappearing folder
  re-seeds.
- Rescan: RefreshChangedMetadata returns whether anything was actually
  destroyed, not whether victims were collected — no display-array
  rebuild when every victim failed to resolve.
- Rescan: comment the load-order consequence of evict + re-register
  (the fresh entry appends at the list tail; declared deps still
  force-load first).
2026-08-29 19:16:34 -05:00
Brues 6304a253dd feat(aura): Dark Harvest tick compression; channel-aura pushback restamp
Turtle's Dark Harvest (52550-52552) makes the caster's Affliction dots on
the channel target tick 30% faster - and live preserves each dot's tick
COUNT, so the dot's real remaining time drains at 130% rate during the
overlap and it falls off early with no client-visible aura change (report:
gone with ~3 s still showing; Cursive's community calibration models the
same `remaining -= 0.30 x overlap`). tortoise-wow's recreation only
re-times tick timers and leaves holder duration alone, which does NOT
reproduce the early fall-off - so the compression math mirrors live, while
the affected-set predicate mirrors the server script
(IsDarkHarvestAfflictionPeriodicAura): same-caster warlock-family auras
matching the Corruption/Drain Life/CoA/Drain Soul/Siphon Life/CoD mask
with a periodic damage/leech effect (the aura-type gate is load-bearing:
Turtle's Atrocity shares Corruption's 0x2 bit but is a proc aura - DBC
sweep verified; the trigger mask 0x4000000000 selects exactly the three
DH ranks).

Aura::Source::AddTickCompression: while a caster's trigger-mask aura is
live on a target (its cache entry's lifetime: SpellGo -> OnAuraRemoved
eviction), that caster's matching entries drain an extra pct% of real
time. Anchored math (expiration = anchor - elapsed x pct/100) so frame
pacing can't drift it; an external re-stamp (dot recast, duration mod)
re-anchors from the fresh value. Caster-paired like the server, so an
observed party warlock's DH compresses their dots' timing too. ~1/s
UNIT_AURA nudges for event-driven consumers, final nudge at release.
Registered Turtle-gated in turtle/DarkHarvest.cpp with the percent read
from the rank record's EffectBasePoints[1]+1 (-31 -> 30, fallback 30).

Also: MSG_CHANNEL_UPDATE now re-anchors the cached target-side expiration
of the player's channel-applied auras (Aura::Source::RestampPlayerChannel).
The server shortens every hit target's holder on channel pushback
(Spell::DelayedChannel -> DelaySpellAuraHolder) and only tells the caster
via the new remaining time, so the SpellGo-stamped expiration read late by
the accumulated pushback; self-target holders were already corrected
natively via SMSG_UPDATE_AURA_DURATION. This also bounds the compression
window under pushback for free.
2026-08-28 21:56:02 -05:00
Brues fbe40743c0 fix(table): keep length on a deliberate single nil append
The stale-length heal (Length.cpp) could not tell a cleared-and-reused
table from a deliberate table.insert(t, nil): both leave the stored
length pointing at a nil slot with no explicit n field, but they want
opposite answers. Rule 3 healed both, so the next table.insert on a
single-nil-appended array wrote over the nil and shifted every later
element down by one -- silent corruption in Ace2-era addons (Waterfall's
{key1,val1,...} arg builder inserts a nil whenever an option has no
passValue, the common case), with no error anywhere.

The issue's preferred fix -- make table.insert always use the raw stored
length -- reintroduces #29: Dewdrop clears `values` with a pairs loop +
no-op table.setn, then rebuilds it via table.insert, and that append is
exactly what the heal fixed. Both consumers reach the heal through
table.insert, so the caller cannot distinguish them; only the staleness
gap can. A cleared table is stale by many slots; a deliberate nil append
is off by exactly one. So heal only when the stored length overshoots
the border by more than one slot, and keep it otherwise.

Verified in-game via CAPI_DemoTableLengthHeal (AddOnCompat.lua): nil
append preserved (getn=3, t[2]=nil, t[3]=b), Waterfall pairs intact, and
the cleared-table rebuild still heals to slot 1.

Fixes #36
2026-08-27 15:37:54 -05:00
Brues 772cd5ec15 fix(luasyntax): recover call args for loadstring'd vararg chunks
WoW's Lua 5.0 main chunks are not vararg (unlike stock 5.0), so a chunk
called with args had no `arg` to back the transpiled `...` and the old
`local arg={n=0}` fallback silently dropped them --
loadstring("return ...")(1,2,3) yielded nothing.

Compile non-addon vararg chunks (loadstring, /run, XML handler bodies)
as a real 5.0 vararg closure instead: wrap the body in
`return function(...) ... end` and call the main once after a successful
compile, so the caller receives the inner function whose genuine 5.0
`arg` carries the call args. Helper captures stay in the outer main
(runs once at load in the default env, preserving the setfenv-sandbox
rationale); the prefix is newline-free and the closing `end` rides its
own appended line, so error line numbers are unchanged. Addon file
chunks keep the (addonName, addonTable) preamble path.

Verified in-game: loadstring("print(select('#',...),...)")(1,2,3)
prints "3 1 2 3".
2026-08-26 20:24:15 -05:00
Brues a962c76f09 feat(aura): real isFromPlayerOrPlayerPet; fix castByPlayer to Era semantics
Populate AuraData's isFromPlayerOrPlayerPet from the cached caster GUID's
HIGHGUID prefix (player 0x0000 / pet 0xF140, confirmed in tortoise-wow
ObjectGuid.h) instead of hardcoding false. The UnitAura tuple's
castByPlayer (position 13) is the same value under retail's older name --
Era defines it as "applied by a player, their pet or vehicle", not the
local player -- so both now share one HIGHGUID test in ResolveDisplay.
The local-player question stays with the PLAYER filter, unchanged.

False on a cache miss (cast not observed this session); totem buffs read
false (vanilla totems carry the creature prefix, not pet).
2026-08-26 14:34:35 -05:00
Brues 292e206dee fix(aura): seat descriptor slots on cast captures FIFO, not newest-first
When two casters' same-spellID DoTs land in one descriptor-update batch,
the server assigns their slots ascending in cast order
(_AddSpellAuraHolder's first-free-slot scan, verified in tortoise-wow)
and the client diff dispatcher FUN_00604d00 fires OnAuraAdded in
ascending slot order (both loops are plain slot = 0..0x2F walks). The
first application therefore belongs to the earliest cast, and seating
the NEWEST unbound capture paired the two casters' timers and
attribution backwards.

Seat the oldest unbound capture instead, bounded by a 3s window that
covers instant applications and max-range projectile flight (Fireball's
DoT applies at impact) while skipping hit-but-never-applied captures.
The old "older = resisted" rationale for newest-first was moot: resisted
and immune targets sit in SMSG_SPELL_GO's miss list, which ParseSpellGo
never reads, so they never create captures at all.
2026-08-26 14:19:51 -05:00
Brues eef7140722 feat(combat): add StartAttack / StopAttack globals
Backport the non-toggling melee auto-attack verbs. 1.12 ships only the
toggling AttackTarget (FUN_006131A0), so /startattack can't guarantee it
won't cancel an in-progress swing. These call the engine's own start
(FUN_005ECB70, sends CMSG_ATTACKSWING) and stop (FUN_005ECAC0) primitives
directly, split into the two idempotent verbs.

StartAttack takes an optional unit-token target (resolved under pcall so a
name/bad token can't raise, falling back to the current target); with no
arg it validates and attacks the current target via FUN_00612DF0.
2026-08-26 13:49:44 -05:00
Brues 5279c463ed feat(slash): add /click handler and SLASH_CLICK locale strings
Register SlashCmdList["CLICK"], routing through SecureCmdOptionParse and
GetClickFrame to click a named button. Parse name + button only: 1.12's
Button:Click has no down/up path (verified in the base vmethod), so the
retail `down` arg has nothing to act on and a nil button defaults to
LeftButton natively.

Add SLASH_CLICK1/2 to all locale files from Classic Era GlobalStrings.
2026-08-26 12:28:30 -05:00
Brues 855b338736 feat(frame): add GetClickFrame global
Backport the engine-registered C global behind the /click command: resolve
a frame by its global name, returning it only when _G[name] holds a real
frame whose GetName() matches. Mirrors 3.3.5's Script_GetClickFrame; reads
the name from CFrameScriptObject vtable slot +4, verified against 1.12's
own Script_GetName.
2026-08-26 12:01:33 -05:00
Brues 6ca6af1497 fix(slash): route /focus, /clearfocus, /equipset through SecureCmdOptionParse 2026-08-26 11:30:32 -05:00
Brues 1170a4e63a fix(texture): mask via texgen for SetMask so SetTexCoord sprites clip right
SetMask rode the minimap BLIP recipe (explicit second UV stream, uv1 = uv0),
so the mask sampled at the base's texcoords - a SetTexCoord'd sprite made
unit 1 sample that same sub-rect of the MASK image, showing a corner chunk
of the shape instead of the shape.

Retire that path: the path mask is now one extra unit in the texgen draw
(MaskedRegionDrawImpl), its matrix built from the BASE region's own rect
(SetTexCoordModifiesRect crop gate included) plus the base's SetRotation so
a mask keeps following a rotated texture. The angle-0 matrix is exactly the
old recipe's formula re-expressed through positions (see BuildMaskMatrix),
so default 0..1-texcoord textures render identically; sprites become the
fixed case, matching retail (SetMask ignores SetTexCoord).

Side effect: SetMask and AddMaskTexture now compose (alpha intersection)
instead of object masks winning; the path mask occupies one of the 7 units.
2026-08-25 22:28:21 -05:00
Brues 0c8468cf2f refactor(reload): auto-register per-/reload cleanup; fix Time::Timer stale refs
Modules that keep reload-fragile file-static state (Lua registry refs,
frame/object-pointer-keyed maps, cached event-slot indices) each expose a
PrepareForReload() that DllMain called from the FrameScript_Initialize hook.
That list was hand-maintained, so a new module could silently forget to wire
itself in -- the failure mode behind the Frame::Attributes stale-ref bug and
Tooltip::SetEvents issue #33.

Add Game::ReloadAutoRegister + Game::RunReloadCleanups(), mirroring the
existing ModuleAutoRegister / HookAutoRegister / WorldTick::AutoSubscribe
patterns. Each module self-registers with one file-scope line after its
PrepareForReload:

    static const Game::ReloadAutoRegister _reloadReg{&PrepareForReload};

DllMain's 11-call manual list collapses to a single RunReloadCleanups(); a
new module can no longer be forgotten. Player::NameCache::Flush stays an
explicit call -- it's a save-before-teardown, not a stale-state clear, and
also runs on the glue-return path.

A sweep of all file-static state found one uncovered module: Time::Timer
pins C_Timer.After/NewTimer/NewTicker callbacks in the Lua registry but never
released them on /reload -- a surviving/stale ref could fire a dead closure.
Add Time::Timer::PrepareForReload (release each pending timer's registry
callback, clear the queue) and self-register it.

- Game.{h,cpp}: ReloadAutoRegister + RunReloadCleanups.
- DllMain.cpp: collapse the manual list to RunReloadCleanups(); drop 6
  now-dead includes.
- 11 existing reload modules + Time::Timer: add the self-register line.

Behavior-preserving (same cleanups, before the Lua teardown); auto-register
order is unspecified across TUs but every callback only clears its own state.
2026-08-25 18:37:44 -05:00
Brues 5196aab4b8 fix(frame): clear attribute maps on /reload to stop stale-ref SetAttribute error
Frame::Attributes kept two file-static maps keyed by the frame's C object
pointer -- g_attrHandlers (OnAttributeChanged handler refs) and
g_unitByFrame (unit tokens for the mouse-focus poll) -- and never cleared
them across a /reload. The Lua reset kills the handler refs, but the map
entries persist, and the allocator recycles frame addresses. So a new
frame reusing an old address, then getting a SetAttribute call, made
FireAttributeChanged fire a DEAD handler ref through the (protected)
invoker; the engine error handler still printed it as an error "in
function SetAttribute" with no Lua frame and an empty message.

SecureStateDriverManager sets OnAttributeChanged every session, so there
was always at least one entry to go stale -- this was the "SetAttribute
occasionally errors after /reload" report (pfUI UpdateVisibility ->
SetAttribute). Same class of bug as Tooltip::SetEvents issue #33.

Add Frame::Attributes::PrepareForReload() clearing both maps, wired from
FrameScript_Initialize_h alongside the other per-reload cleaners.

Also harden EnableFrameMouse (a best-effort side effect of setting the
"unit" attribute): it did SetTop(L, 1) to set up the engine EnableMouse
call, which destroyed the (name, value) at stack[2]/[3] that
FireAttributeChanged reads right after -- handing any OnAttributeChanged
handler a nil arg2. It now invokes the setter through lua_pcall at the top
of the stack and restores the caller's stack, so DoSet's (self, name,
value) survives, and a best-effort mouse-enable can never surface an error
in the addon's SetAttribute.
2026-08-25 18:22:49 -05:00
Brues 536c5e3c51 feat(frame): add PreClick/PostClick button scripts
Backport the modern PreClick/PostClick button scripts as real,
SetScript/GetScript/HookScript-able handlers. Vanilla 1.12 has only
OnClick and OnDoubleClick on buttons.

Two co-hooks (same technique as Tooltip::SetEvents):
- FUN_BUTTON_SCRIPT_RESOLVER: recognize the two names, returning an
  external per-button {handler, context} cell (a button has no free
  slot, and the resolver contract is just "return where the handler
  lives"). This makes them SetScript/GetScript/HookScript-able.
- FUN_FRAME_RUN_SCRIPT_WITH_CONTEXT (the arg'd script runner), gated on
  slotPtr == frame + OFF_BUTTON_ONCLICK_HANDLER (0x4CC): fire PreClick
  before / PostClick after the OnClick, re-invoking with the same
  (fmt, varargs) so both receive arg1 = the button name like OnClick.

Deliberately NOT the button click vmethod FUN_00779540 -- SuperWoW
inline-hooks it for click-casting and a second MinHook there faults
(ERROR #132). The runner is uncontested and fires at the same top-level
dispatch level, so its exec-context stamp is valid.

Cells are keyed by button pointer and cleared on /reload
(PrepareForReload, wired in FrameScript_Initialize_h), matching the
tooltip-cell lifetime.

Limitation (documented): PreClick/PostClick fire only when the button
also has an OnClick handler, since the runner only runs for a non-empty
slot. Bracketing an existing OnClick is the common case; the no-OnClick
path would need the SuperWoW-owned vmethod.

Verified in-game: programmatic and real mouse clicks fire pre,click,post
with matching button-name args, and GetScript round-trips.

- Add offsets FUN_BUTTON_SCRIPT_RESOLVER, OFF_BUTTON_ONCLICK_HANDLER,
  FUN_FRAME_RUN_SCRIPT_WITH_CONTEXT.
- Document under docs/API.md (Frame section) and the README index.
2026-08-25 17:43:41 -05:00
Brues c06bed9158 feat(unit): add GetSheathState
Vanilla 1.12 ships ToggleSheath but no getter for the player's
drawn-weapon type. Add GetSheathState(), which returns the modern
1-based value: 1 = None (sheathed), 2 = Melee, 3 = Ranged.

The engine stores the sheath state as a plain CGUnit int member at
+0xD40 (0-based: 0 sheathed, 1 melee, 2 ranged) -- the same field its
own toggler FUN_005EB480 reads to pick the next state and its setter
FUN_00611CF0 writes. GetSheathState reads the local player's value and
adds 1 for the Lua contract.

- Add OFF_UNIT_SHEATH_STATE (0xD40) with RE-provenance comment.
- Document under docs/API.md (State section) and the README index.
- Also list the Tracking API in the README function index (missed in
  the prior tracking commit).
2026-08-25 15:53:10 -05:00
Brues e793f80f6b feat(spell): add minimap tracking enumeration
Vanilla 1.12 only stores the single active tracking spell
(VAR_ACTIVE_TRACKING_SPELL, 0xBC6378) and never enumerates the tracking
abilities the player knows. Add GetNumTrackingTypes/GetTrackingInfo/
SetTracking, which rebuild that list by scanning the player spellbook for
spells whose EffectApplyAuraName is Track Creatures/Resources/Stealthed
(44/45/151) -- the same discriminator the engine's own active-tracker
updater (FUN_004E4170) uses. This reproduces pfUI's hardcoded tracking
table with no table to maintain and auto-covers server-custom trackers
(e.g. Turtle's Find Trees, 52917).

- GetTrackingInfo returns name, texture, active, category ("spell"),
  spellID; active is read from VAR_ACTIVE_TRACKING_SPELL.
- SetTracking selects a tracker by tail-calling the engine's
  Script_CastSpell(slot, "spell").
- Add offsets VAR_ACTIVE_TRACKING_SPELL and FUN_SCRIPT_CAST_SPELL
  (0x4B42F0), both with RE-provenance comments.
- Document under docs/API.md (Tracking section).

Verified in-game on a Hunter (8 trackers) and a Dwarf racial (1):
active flips on SetTracking, texture matches native GetTrackingTexture,
out-of-range returns nil.
2026-08-24 23:16:34 -05:00
Brues ded767bf19 feat(aura,turtle): support Turtle's Stinging Nettle serpent sting timers
The hunter talent (403; rank passives 51579/51580) makes Mongoose Bite
and triggered fire traps apply the highest known Serpent Sting rank at
20/40% of its duration. Server-side it's a direct AddAura (tortoise-wow
ApplyStingingNettle) — no SMSG_SPELL_GO for the sting — so the cache
seated it caster-less at the full 15s base.

Aura::Source gains a generalized triggered-application rule table (the
judgement-attribution shape, rule-driven): a registered trigger's
SPELL_GO — local player casts only — arms its hit targets for 2s, and a
caster-less application matching the rule's family/mask consumes the arm
in StampApplication (player attribution + the server's own
max(1, base × pct / 100) with player duration mods). Rules can be gated
on a talent passive, read live from the spell-knowledge bitmap at
trigger time so respecs are honored. Because the server's re-apply over
an existing shorter sting reuses the descriptor slot (no OnAuraAdded —
verified in-game with chained Mongoose Bites), the trigger also
refreshes the player's existing cached entry in place, honoring the
server's keep-the-longer-remaining guard. The arm consume runs BEFORE
the WasRecentPlayerCast attribution: the arm names the exact target,
while the recent-cast window is spell-scoped only — a real sting cast at
unit A must not stamp its full duration onto a nettle application on
unit B; a real cast's own application stays protected by the cast-owned
entry guard.

Turtle::StingingNettle registers the rules behind the Turtle::Detected
latch: fire traps by family mask (hunter 0x4 selects exactly the eight
trap-effect spells the server's script binding lists — rank-proof),
Mongoose Bite by exact IDs (its bit 0x2 is shared with Raptor Strike,
which does not trigger nettle). The percents come from the rank spells'
own EffectBasePoints[0] + 1 — the same DBC value the server's
CalculateSimpleValue reads — with the server's 20/40 fallback.
2026-08-24 22:47:52 -05:00
Brues 70a69b64fd fix(spell): fire UNIT_SPELLCAST_DELAYED when the server reconciles cast time
The server scales ranged abilities' cast times by ranged attack speed
(SpellEntry::GetCastTime's IsRangedSpell branch — quiver/Quick Shots),
which the client's prediction helper FUN_006e3340 can't see (its only
caster factor is the spell-haste mult at descriptor +0x22c). So Steady
and Aimed Shot bars started at the fixed 1.5s/2.5s prediction; the
confirming SMSG_SPELL_START already snapped g_cast.endMs to the server's
authoritative castTime, but nothing told addons — the bar kept the
duration it read at UNIT_SPELLCAST_START.

PollPlayer now snapshots the cast's endMs instead of its delayMs and
fires UNIT_SPELLCAST_DELAYED on any end movement — one signal covering
pushback (previously detected via delayMs growth) and the server
reconcile in either direction. Cast bars re-read UnitCastingInfo on
DELAYED and pick up the corrected end. If the packet ever beats the
first poll, START itself carries the corrected end and no DELAYED fires.

Fixes brues-code/pfUI#43
2026-08-24 21:57:11 -05:00
Brues 0f5b50ea14 feat(aura): refresh paladin judgement timers off swings and re-judges
The judgement debuff's duration is maintained server-side with no
client-visible aura change: the paladin's white swings refresh it
(tortoise-wow Unit::DealMeleeDamage - any nonzero melee damage
RefreshHolder's every SPELLFAMILY_PALADIN + SPELL_ATTR_EX3_ALWAYS_HIT
aura the attacker cast on the victim), and a re-judge is an in-place
holder refresh that dirties no descriptor field. Aura::Source's
countdown therefore ran to zero while the debuff stayed up
(brues-code/pfUI#45).

Two client-side triggers mirror the server exactly:

- Swings: Aura::JudgementRefresh subscribes to SMSG_ATTACKERSTATEUPDATE
  (wire format verified against the server's own SendAttackStateUpdate
  writer) and on totalDamage > 0 calls the caster-STRICT
  Aura::Source::RefreshJudgements(victim, attacker) - strict because a
  swing gives no proof of ownership, and adopting there would let any
  tank's white swings pin another paladin's judgement timer.

- The Judgement cast (20271, the one spell every seal judges through):
  the debuff itself is cast server-side with no SPELL_GO the client
  hears (verified live - entries were seated caster-less by
  OnAuraAdded), so the Judgement cast's own SPELL_GO is what names the
  owner. HandleSpellGo refresh-with-adoption's the victim's existing
  judgement entries (the re-judge case) and arms a 2s attribution
  window that StampApplication consumes to give the freshly applied
  debuff its caster - which is what makes the strict swing matching
  work at all.

Refreshes re-fire UNIT_AURA via the existing deferred signal;
StoreFromCast now signals on its in-place recast path too, since an
identity-preserving refresh changes no descriptor field and
event-driven aura bars would never re-read the new timing.

Ability damage (Crusader/Holy Strike) is deliberately NOT a trigger:
melee-class spell damage never routes through the server's refresh
block (DealMeleeDamage's only caller is the white-swing path).

Verified in-game: every landing swing snaps the cached expiration to
now + 10s and pfUI's timer follows; misses/dodges (totalDamage 0) do
not refresh; re-judging resets the timer.

Fixes brues-code/pfUI#45
2026-08-24 18:58:46 -05:00
Brues be6dc200fe fix(addon): restore MouseIsOver's stock 1/nil return contract
The embedded addon replaces FrameXML's MouseIsOver with a wrapper over
the native region:IsMouseOver(), which returns a boolean. Stock 1.12's
MouseIsOver (UIParent.lua:1718) returns 1 on hit and nil otherwise, and
vanilla-era addons compare against that literal - Quiver gates every
widget click on `MouseIsOver(f) == 1`, which `true` silently fails,
deadening its entire config dialog (buttons, checkboxes, icon clicks).

Return 1/nil from the wrapper. 1 is truthy, so modern-style
`if MouseIsOver(f) then` callers are unaffected.
2026-08-24 10:37:39 -05:00
Brues 7f52afeef4 perf(event): fire custom events through O(1) AutoReserve::Slot()
Every custom-event fire resolved its name to a slot via
Event::Custom::Lookup - a linear strcmp scan over the ~46-entry
reservation table, on every single fire. The slot itself was already
cached per table build; only the name->reservation hop was repeated.
On a client that is purely CPU-bound, that is wasted work on paths
that burst exactly when frames are scarce (nameplate churn, cast spam,
loot rolls, mouse/modifier input).

AutoReserve now remembers its reservation index (the ctor's dedup
resolves duplicate names to the same entry) and exposes Slot() - two
loads. Reload safety is unchanged and stays centralized: Slot() reads
through the shared table PrepareForReload/RetryClaims maintain, so the
value can never go stale. Lookup is deleted; LookupByName remains for
engine-defined names.

All fire sites converted to the instance handle:
- CastEvents: the k* name constants become the AutoReserve instances,
  so all 19 fire sites stay textually identical; the Fire/FireReticle/
  FireRemote helpers take the reservation by reference.
- equipmentset: reservations move into Set.h as C++17 inline consts so
  the Data and Api TUs share one handle.
- Cache::QueryLoad::Register takes the AutoReserve pointer instead of
  an event-name string (creature/gameobject callers updated).
- Tts keeps its lazy VanillaTTS-stand-down reservations via
  null-guarded pointers set at registration.
- GlobalMouse/InputEvents/lossofcontrol instances moved above their
  fire sites (static-init list order is irrelevant).
2026-08-24 10:11:10 -05:00
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
Brues 85ed4eb378 feat(addon): backport SecureStateDriver + SecureCmdOptionParse
RegisterStateDriver / RegisterUnitWatch / attribute drivers, driven by a
Lua SecureCmdOptionParse whose macro-conditional grammar is reverse-
engineered from the 3.3.5 client. Builds entirely on the shipped frame
attribute system -- no DLL change.

- MacroOptions.lua: parse-once cached SecureCmdOptionParse. 33 conditions
  mapped to 1.12 / ClassicAPI queries; states 1.12 lacks (flying, vehicle)
  are constant-false, an unknown keyword warns once and never matches.
- SecureStateDriver.lua: 0.2s-poll manager with instant event rescans,
  state-visibility Show/Hide, state-* attribute drivers, unit-existence
  watch, SecureButton_GetAttribute/GetUnit. Registers via plain tables +
  an immediate top-level resolve instead of the upstream attribute
  bootstrap, so the synchronous OnAttributeChanged recursion guard never
  swallows a target frame's handler.

Verified in-game: parser grammar, both drivers, and druid [stance:N] bar
numbering.
2026-08-23 02:05:35 -05:00
Brues 7f1e632b49 feat(texture): add MaskTexture object API with multi-mask and rotation
Backport frame:CreateMaskTexture plus texture:AddMaskTexture /
RemoveMaskTexture / GetNumMaskTextures / GetMaskTexture -- positioned,
rect-based masks, the object form later clients use (SetMask(path) stays
the simple single-shape form).

Positioned masks sample via texgen (object-space vertex position through
a per-unit texture matrix), the mechanism of the engine's own minimap
disc mask, so no vertex UV stream is consumed per mask. That lifts both
v1 limits at once:
- Up to 7 masks apply at once (device stage cap); a texture shows only
  the intersection of all of them.
- mask:SetRotation rotates the clip shape (folded into the matrix), and a
  mask can move/resize each frame, so the clip animates.

State is restored in a co-hook on the indexed submit (FUN_GX_PRIM_INDEXED)
the engine issues after the stream call, not inside the stream hook --
restoring earlier reverts the state the queued draw is flushed with.

Two incidental fixes: a SetTexCoord-cropped base now masks against the
same rect the engine's corner store uses, and a recycled mask region is
detached from every base still holding it.

Offsets.h: add the texgen / coord-transform / texture-matrix / state-journal
/ indexed-submit constants (decoded from the D3D9 backend state flush
FUN_005a2f00), and fix the FUN_GX_TEXUNIT_ENABLE/_DISABLE mislabel (they
are texture-matrix push/pop, not stage enable/disable).
2026-08-22 23:19:33 -05:00
Brues 71d193073a docs(readme): merge modern-behavior highlights; add nameplate and focus rows
Fold the thin "Modern text rendering" and "Modern addon loading" sections into
one "Modern client behaviors" table, and showcase two more event-driven
features:
- Event-driven nameplates: the C_NamePlate API + NAME_PLATE_UNIT_ADDED/REMOVED
  events + nameplateN unit tokens.
- Focus target: FocusUnit/ClearFocus, PLAYER_FOCUS_CHANGED, the focus /
  focustarget tokens, and the predefined FOCUSTARGET / TARGETFOCUS keybinds.

Highlights is now two groups: Lua 5.1 compatibility and Modern client behaviors.
2026-08-22 19:33:54 -05:00
Brues eda44e6d2d docs(readme): drop the SavedVariables highlight; move Installation below the reference
Two README tweaks:
- Remove the "SavedVariables loaded first" row from the Modern addon loading
  highlights -- compatibility plumbing, not a showcase feature. Still fully
  documented in docs/API.md.
- Move Installation below Full API reference, so the page leads with the whole
  showcase (features + the complete API surface) before install steps.
2026-08-22 19:22:00 -05:00
Brues 81f04c82ad docs(readme): trim Lua 5.1 highlights to the features worth showcasing
Drop the Table lengths and Environment protection rows from the Lua 5.1
compatibility table -- both are compatibility plumbing, not features anyone
installs for. They stay fully documented in docs/API.md. The showcase now
lists Syntax, String methods, and Script-handler arguments.
2026-08-22 19:15:21 -05:00
Brues 54516ad22b docs(readme): note the function/event count in the intro
Mention the scale up front (550+ functions and 50+ events) and bump the Calls
catalog summary from the stale "400+" to match. Counts are approximate on
purpose so they don't go stale on every addition.
2026-08-22 19:10:25 -05:00
Brues 7e784005b9 docs(readme): de-stiffen the intro and section blurbs
Rewrite the prose the restructure introduced so it reads naturally instead of
like a controlled-language manual: merge the intro's clipped two-sentence
opener, drop the "transparent / no API to call / addons use it automatically"
clunk from the Highlights blurb, and replace "The complete surface." in the API
reference intro. No structural or content changes.
2026-08-22 19:05:26 -05:00
Brues d12ff75c6c docs(readme): lead with Lua 5.1 highlights, collapse the full API catalog
Restructure so the flagship features surface first. The page used to open
with a ~60-row function catalog and buried the headline behaviors (Lua 5.1,
inline textures, modern addon loading) in a table at the very bottom.

- Promote the former "Behaviors" table to a top "Highlights" section,
  reordered Lua-5.1-first (syntax, string methods, table lengths, environment
  protection, script-handler arguments), then modern text rendering, then
  modern addon loading. No rows dropped.
- Move Installation up, ahead of the reference.
- Group the full catalog under "Full API reference" with each table (Calls,
  GlueXML, Macros, Console, Events, Globals, Unit tokens, Bindings) behind a
  <details> block. Content is byte-for-byte unchanged -- every function,
  event, global, and token stays in the file and all docs/API.md links are
  intact.
2026-08-22 18:56:35 -05:00
Brues 76043fcdef fix(compat): stub WorldMapDeathRelease for Cartographer under the 5.1 probe
Cartographer_Notes gates a WorldMap release-spirit hook behind the Ace2-era
`lua51` probe (loadstring("return function(...) return ... end")), treating a
5.1-capable Lua as a modern client:

    if lua51 then
        WorldMapDeathRelease:SetScript("OnMouseUp", func)
    end

Our 5.1 syntax backport makes that probe pass, so Cartographer takes the modern
branch and hooks WorldMapDeathRelease -- a WorldMap frame 1.12 never had. The
nil index crashes Cartographer_Notes:OnEnable. On real vanilla the probe was
false and the line never ran.

Same 5.1-probe fallout as the Dewdrop table-length heal: making the client pass
5.1 probes makes addons assume modern-client frames exist. Provide an inert,
hidden WorldMapDeathRelease stub (gated on Cartographer being installed) so the
hook succeeds and does nothing -- the vanilla outcome. The embedded
!!!ClassicAPI addon loads first, so the frame exists before Cartographer's
OnEnable.
Fixes #29
2026-08-22 18:30:48 -05:00
Brues b9d23fbc8c feat(texture): add Texture:SetMask(path) alpha-mask backport
Backports the modern Texture:SetMask, clipping a texture to a mask's alpha
shape - round portraits, round minimap buttons, and other shaped art. Vanilla
1.12 has no general masking (only the special-cased minimap disc).

Mirrors the engine's own masked-quad draw (the minimap blip mask FUN_004eae10):
a co-hook on the vertex-stream primitive FUN_0058a2a0 recovers the owning
CSimpleTexture from the batch positions pointer (region = positions -
OFF_SIMPLETEXTURE_CORNERS), and for a region with a mask set binds the mask on
texture unit 1 with a MODULATE combiner, then re-runs the engine's own submit
with uv1 = uv0 so unit 1 samples the mask at the texture's own coordinates. The
unit is enabled/disabled around the draw so the mask never leaks onto other
textures. A cold co-hook on the region ctor clears a region's mask on pooled
reuse; a kill switch and SEH latch guard the draw path.

The recipe was pinned by an in-game probe on the live GL backend and matched
against FUN_004eae10; the full RE is in the "Texture masking" block in Offsets.h
and the Mask.cpp header. Masks must be BLP or uncompressed 32-bit TGA with the
shape in the alpha channel; two round masks ship in the client, other shapes are
addon-supplied content.
2026-08-22 17:14:36 -05:00
Brues a2c51a1ae6 fix(inline-texture): place chat icons pre-render to remove the one-frame lag
A ScrollingMessageFrame re-SetTexts every visible line on each new message, and
the engine builds each line lazily at paint. The icon regions are separate
objects whose placement is only known once a line builds mid-paint - after the
frame's texture pass already drew - so on a content change the new glyphs
appeared one frame before their icons caught up.

Co-hook the SMF display refresh (FUN_00788750), which runs pre-render from the
frame's own methods. After it, force each icon-bearing line to resolve+build via
the engine's own Realize(0) -> RebuildString -> ensure-built path and apply its
icon placement synchronously, before the frame draws. The placement math
(ComputePlacementsForNode) and the per-fs apply (ApplyDirtyBranch) are shared
with the existing paint-flush / FrameTick pipeline, so the later flush dedups to
a no-op.

Additive and best-effort: any line whose rect is not resolvable in time falls
back to the unchanged paint + tick pipeline (correct, one-frame lagged). Gated by
a new _classicapi_InlineTexEarly toggle (default on) with an SEH latch; the whole
feature keeps its _classicapi_InlineTexEnable kill switch.
2026-08-22 13:40:36 -05:00
Brues d5c8db9c2e docs+ci: contract-only table-length docs; rename release map asset
* README behaviors row + API.md section for the 5.1 table-length
  healing now describe the behavior (cleared tables count as empty
  without table.setn; arg tables and setn callers unaffected) instead
  of the implementation and the Dewdrop/FuBar bug story -- that history
  lives in the commit message and the Length.cpp comment.

* Release workflow ships the linker map as
  USE_FOR_DEBUGGING_CAPI_CRASHES.map (copy before upload), so users
  know to attach it to crash reports -- it resolves the logical
  addresses in an ERROR #132 stack trace to function names.
2026-08-22 12:13:25 -05:00
Brues 9c7c8dd8e5 fix(lua): clear the token vector between transpile passes + guard the hook
Tokenize never cleared its output vector, and RunPasses re-lexes into
the SAME vector after each pass that rewrites the buffer -- so every
chunk that triggered two rewriting passes walked a DOUBLED token
stream: stale tokens at old-buffer positions, then fresh tokens
starting back at 0. Two field-real failure modes:

* CRASH (hex pass -> vararg pass): a fresh token's t.start lands
  behind the splice cursor, `t.start - p` underflows as size_t, and
  append() throws std::length_error("string too long") straight
  through the loadbuffer hook into the engine -- fatal ERROR #132 at
  RaiseException. Reported from the field on WeakAuras' bundled
  Chomp\Internal.lua (hex BITS table + varargs); reproduced offline
  byte-for-byte with a standalone harness compiled from this file
  against the actual upstream Internal.lua.

* SILENT CORRUPTION (vararg pass -> ops pass): the same doubled stream
  made the operator pass read stale in-string positions as code -- a
  `%` INSIDE a string literal became __mod, emitting syntactically
  invalid output (diff-proven: Chomp's `channel:gsub("^[^%:]+", ...)`
  shipped as `channel:__mod(__mod(gsub("^[^,:]+",)) ...)`). Such files
  failed to compile behind WoW's soft error() -- dead file, half-loaded
  addon, no visible error. Any chunk combining `...` with `#`/`%` was
  at risk.

Fixes:
1. Tokenize clears at entry (the root fix). Verified on the real
   Chomp file: full pipeline transpiles cleanly -- hex -> exact
   decimals, (...) -> (unpack(arg)), #q -> __len(q), string-literal
   `%` untouched, the previously-mangled gsub line correct.
2. Catch-all guards at both hook boundaries (LoadBuffer_h and the
   _classicapi_Transpile diagnostic): any future C++ exception from
   the rewrite machinery falls back to compiling the ORIGINAL buffer
   untouched -- worst case the chunk behaves as stock 1.12, never a
   process kill.
2026-08-22 11:42:55 -05:00
Brues 813a10f696 fix(lua): heal stale 5.0 table lengths for 5.1-detecting libraries
The 5.1 syntax backport made the Ace2-era Lua-version probe pass:

    local lua51 = loadstring("return function(...) return ... end")
                      and true or false

Every Ace2 library gates on it, and Dewdrop-2.0 (the menu library
behind FuBarPlugin minimap buttons -- SuperAPI, NampowerSettings) uses
it to no-op table.setn: correct on real 5.1, where lengths are computed
borders, but wrong on this VM, whose table functions still read 5.0's
out-of-band length. Cleared-and-reused menu tables kept stale lengths,
table.insert appended past the nil'd slots, and the menus silently
broke ("clicking the minimap button does nothing"). Bisect confirmed:
forcing the probe back to 5.0 (VarargExpansion off) restores them.

Since we made the client PASS 5.1 probes, 5.1 length semantics must
hold. The engine funnels every length read through luaL_getn
(LUAL_GETN = 0x6F5050 -- table.insert/getn/remove/concat/sort/foreachi,
plus our unpack), so one co-hook closes the gap for every consumer:

  1. reported slot populated -> unchanged (one rawgeti of cost);
  2. t[n] nil but t.n is a number -> unchanged: an explicit n field is
     the 5.0 vararg-arg contract, where trailing nils are intentional
     (unpack({10,nil,30,n=3}) keeps all three slots);
  3. otherwise the out-of-band length is stale (keys cleared without
     setn) -> return the true border, the 5.1 answer. Read-only, no
     write-back: reads never mutate state, and the engine's own
     table.insert re-syncs the stored length on the next append.

table.setn keeps working; the heal only changes the answer when the
stored length points past the last populated slot -- exactly the case
where 5.0 and 5.1 disagree.

Docs: API.md "Stale table lengths (5.1 healing)" section + README
behaviors row.

Fixes #29
2026-08-22 11:08:32 -05:00
Brues 7225eeb32c feat(lua): close the remaining 5.0->5.1 stdlib gaps
Systematic sweep of the Lua 5.1 stdlib delta against what ClassicAPI
already backports found five gaps, all confirmed by in-game probes:

* unpack(list [, i [, j]]) -- 5.0's unpack silently ignored the range
  args, so unpack(t, 2) returned the WHOLE table (wrong values, no
  error; the only silent gap). Replaced with a port of 5.1's
  luaB_unpack: ranges via luaL_getn + lua_rawgeti, so a vararg arg
  table's n field keeps its embedded-nil count. Both states.

* string.gsub table replacement -- 5.0 accepts only string/function
  and errors on a table. Shim on string.gsub AND the bare gsub global:
  a table becomes an adapter closure over the engine's own gsub
  (FUN_LUA_STR_GSUB = 0x007FD0E0, pinned by registration-table
  position, name string, and decode). Because this engine's add_value
  drops a non-string function result (match -> empty) where 5.1 keeps
  the match on nil/false, the shim wraps the pattern in one outer
  capture (^/$ anchors kept outside) so the closure receives the whole
  match and can hand it back for missing keys -- exact 5.1 keep-match.
  Patterns with a %1..%9 backref skip the wrap (renumbering hazard);
  their missing-key behavior stays the engine's (documented residual).

* collectgarbage 5.1 options -- "count" -> gcinfo, "collect" ->
  original collectgarbage(0), "step" -> loop-safe true, stop/restart/
  setpause/setstepmul -> no-op 0, numbers forwarded untouched. The
  originals are function objects stashed in the state's registry
  before the overwrite, so no new engine offsets and each /reload
  re-stashes the fresh engine original. Both states.

* table.maxn -- 5.1 luaB_maxn port (largest positive numeric key,
  full next-walk so nil holes don't stop it). Both states.

* coroutine.running -- returns the running coroutine, nil on the main
  thread. 5.0 has no lua_pushthread, so the thread TValue (tag 8 +
  the running L, this build's {tt@0, value@+8} layout) is written to
  the stack raw; main-thread detection via the registration-time L.

Verified in-game: ranged/holed/n-field unpack shapes, all four gsub
shapes (populated table, keep-match on missing key, whole-match key,
anchored pattern), maxn past a nil hole, collectgarbage("count"/
"collect"), and coroutine.running from main + inside a wrap.

Docs: five new API.md sections + TOC entries; README Lua list updated.
2026-08-22 01:46:24 -05:00
Brues 5a1d94c001 feat(lua): harden string methods -- kill switch + fail-closed captures
Follow-up hardening for the 5.1 string-method backport (9b9d80d):

* _classicapi_SetStringMethods(false) -- diagnostic kill switch on both
  states, mirroring _classicapi_SetTranspileOption. While off the hook
  forwards everything to the original resolver: exact stock behavior.

* Close both stale-capture windows by clearing each slot BEFORE its
  state can die, instead of relying on "no Lua runs in the window":
  - game slot: PrepareForReload (new StringMethods.h), called from
    FrameScript_Initialize_h before the world Lua state is reset --
    covers /reload AND /logout;
  - glue slot: cleared at the top of the in-game registration -- once
    world scripts are loading the glue state is gone, and the next glue
    boot re-captures before any glue Lua runs, so a recreated glue
    global_State at the same address can never match a stale capture.
  A cleared slot fails closed (stock "attempt to index" error) until
  its re-capture.

* StringLib.cpp: replace the obsolete "method sugar can't work" header
  NOTE -- both call forms resolve through the same string table now.

* docs: kill-switch bullet in the API.md String methods section.
2026-08-22 00:57:26 -05:00
Brues 1536741594 docs(readme): list Lua 5.1 string methods in the behaviors table
Follow-up to 9b9d80d, which added the API.md section but missed the
README's behaviors table where the other Lua 5.1 backports are listed.
2026-08-22 00:14:40 -05:00
Brues 9b9d80d807 feat(lua): backport Lua 5.1 string methods (("s"):upper(), s:format(...))
Lua 5.0 has no string methods at all -- the shared string metatable with
__index = string is a 5.1 addition, and both halves of the would-be
mechanism in this build match stock 5.0 exactly: lua_setmetatable
(0x006F4020) accepts only tables/userdata, and luaT_gettmbyobj
(0x006F7BD0) hardcodes &nilobject for every other type. So nothing can
be re-enabled; the 5.1 read path has to be added.

Transplant 5.1's mechanism at the one choke every string index reaches:
co-hook FUN_LUA_INDEX_NONTABLE (0x006F7EE0, luaV_gettable's non-table
continuation; 3 call sites -- the gettable else-branch plus
luaV_execute's OP_GETTABLE/OP_SELF fallbacks). When the indexed object
is a string, answer with luaV_gettable(L, <string library table>, key,
loop) -- byte-for-byte what the original's own TM-table branch does had
gettmbyobj returned that table, loop passed through unchanged. The
engine's unmodified lookup then yields exact 5.1 semantics, including
nil (not an error) for unknown keys. Non-strings fall through to the
original; settable on a string is a different continuation, untouched.

The site is cold: table __index dispatch (all frame/OO traffic) reads
the table's own metatable via fasttm (FUN_006F7D60) and never lands
here -- today's only callers are about-to-error non-table indexes.

One captured slot per Lua state (in-game + glue), keyed by global_State
so coroutine threads resolve too; each capture anchors the string table
in that state's registry against GC and re-runs per /reload and per
glue boot. Since baselib/StringLib already registers string.match /
gmatch / reverse into the same table, modern idioms like
msg:match("^!(%w+)") work with no further top-up.

Docs: new API.md "String methods" section; replaced the now-obsolete
"only string.foo(s), never s:foo()" caveat under string.match with a
pointer to it.

Verified in-game: literal and variable receivers, :format, 5.1 nil-miss,
and resolution inside a coroutine thread.
2026-08-22 00:09:59 -05:00
Brues bac27b9b2b fix(chat): auto-size raid-target markers to the chat font
{rtN}/{skull}/etc. emitted a hardcoded :14:14 size, so a marker never
tracked the chat font-size setting. Emit :0:0 instead — the inline
emitter resolves height/width to the target line's font pixel height at
draw time, so markers grow and shrink with the chat font and scale up in
speech bubbles, matching retail's ICON_LIST `...Icon_%d:0`. The sheet
crop is size-independent, so cell selection is unchanged.
2026-08-21 23:06:02 -05:00
Brues 31e85731b6 Rename transpile helper API
Rename the Lua transpile entrypoint from `_classicapi_TranspileLength` to `_classicapi_Transpile` and update the backing C++ function symbol to match.
2026-08-21 22:47:47 -05:00
Brues 58ddcace32 perf(item): settle the item-cache gate on a wall-clock delay, not frames
The startup gate that defers item queries until the engine's dispatch is
ready used a fixed 60-frame count, which is FPS-dependent: on a fast
machine it could fire before dispatch was ready (orphan risk) and on a
slow login it stretched well past its intended ~1s, leaving bag icons as
"?" longer than needed.

Anchor the delay to VAR_IN_WORLD (set by the enter-world initializer,
cleared on every transition) and measure a fixed 1000 ms past world-live,
so it is identical on any machine and only starts once the loading screen
is gone. The engine-response fast-latch is untouched: a real item
response still settles the gate instantly, so a warm WDB has no delay and
only a cold WDB waits the floor. Drops the now-unused frame counter.
2026-08-21 22:25:18 -05:00
Brues 58ee86447b feat(item): proactively cache carried items on enter-world
A bag item's icon comes from its ItemStats cache entry (m_displayInfoID);
on a cold WDB an uncached bag item resolves displayID 0, and the icon
path draws INV_Misc_QuestionMark ("?"). Nothing in the bag-draw path
queries stats (GetContainerItemInfo and the icon getter both do
null-callback lookups), and the server pushes no item stats on login, so
a carried item stayed "?" until a hover, a loot, or an addon GetItemInfo
happened to query it. Equipped items are exempt (icon from the
server-pushed visible-items table).

Seed the request queue with every carried item once the startup gate
settles (one-shot per enter-world, re-armed on login and /reload) on the
existing owned-priority lane. This gives complete carried-item coverage
ahead of any addon's background flood, stays bounded by the same owned
budget so it adds no burst (issue #25), and is still deferred past
g_settled so it cannot orphan a cache entry before the engine's dispatch
is ready.
2026-08-21 22:12:48 -05:00
brues-code a4d6290b80 Lua 5.1 syntax transpile + modern addon-loading compatibility (#28)
* feat(lua): backport 5.1 `#` and `%` operators via source transpile

Vanilla's Lua is 5.0, which lacks the `#` (length) and `%` (modulo)
operators, so modern addon ports that use them fail to COMPILE. Rather than
patch the 5.0 parser/VM (no OP_LEN/OP_MOD, no free opcode), rewrite the
SOURCE before it reaches the parser.

src/luasyntax/Transpile.cpp co-hooks luaL_loadbuffer (0x006F5690) — the one
function every compile funnels through (file scripts, loadstring, XML
handlers) — tokenizes each chunk with a small Lua-aware lexer, and runs a
precedence-climbing parser that rewrites, in one pass:

    #operand  ->  __len(operand)
    a % b     ->  __mod(a, b)

`#` is prefix but `%` is binary infix, so its operands are delimited by
PRECEDENCE: a*b%c -> __mod(a*b,c), a+b%c -> a+__mod(b,c), a%b%c chains
left-assoc as __mod(__mod(a,b),c), a%b^c -> __mod(a,b^c). The two operators
compose in the same pass (#t%2 -> __mod(__len(t),2)). No newlines are
inserted, so error line numbers are preserved.

Runtime helpers (registered on both the in-game and glue states):
 - __len: string length, or a table border via bisection (5.1 `#`
   semantics — ignores any table.setn count).
 - __mod: a - floor(a/b)*b. NOT the engine's math.mod, which is C fmod
   (truncated toward zero) and disagrees with `%` for negatives
   (-1 % 3 == 2, but fmod(-1,3) == -1).

Safety: the rewrite runs only when a chunk contains `#`/`%`, and either as an
operator already fails to compile on 5.0 — so it cannot regress working code
except by misreading one inside a string/comment ("%d", "%a+", --[[ # ]]).
The lexer's string/comment skipping is the one safety-critical part and is
exact. Gated behind default-on toggles (_classicapi_SetLengthOperator /
SetModuloOperator); diagnostic _classicapi_TranspileLength returns the
rewrite. `...` is a separate, larger phase.

Adds FUN_LUAL_LOADBUFFER to Offsets.h with its derivation.

* feat(lua): honor the 5.1 `__environment` metatable field in getfenv/setfenv

Lua 5.1 lets a sandbox hide an environment: put `__environment` in the env
table's metatable and `getfenv` returns that value instead of the real env,
while `setfenv` refuses to change it. 1.12's engine already has this
protection but reads a RAW `__fenv` field on the env table itself — the older
5.0 form. Modern addons ported from 5.1 use the metatable `__environment`
spelling, which 1.12 silently ignored, so their sandboxes leaked.

getfenv (0x00702AC0) and setfenv (0x00702BF8) share one predicate
(0x00702BA0) that reads the marker and, when present, substitutes it / errors
"cannot change a protected environment". src/luaenv/Fenv.cpp co-hooks and
fully replaces that predicate to read `rawget(getmetatable(env),
"__environment")` first, falling back to the raw `env.__fenv` field. One hook
updates both consumers. The reimplementation preserves the predicate's exact
stack contract (push env, push marker, net +2, boolean return) that getfenv
(settop -2) and setfenv (settop -3) depend on; both metatable and env lookups
are RAW, so an `__index` metatable on the env is not disturbed.

The 5.1 mechanism was verified against 3.3.5's `FUN_0084F2F0` (raw
`__environment` off the metatable; no `"__fenv"` string exists there — Blizzard
renamed it and moved it to the metatable at the 5.0->5.1 jump). The change is
additive and inert: nothing on disk sets either marker, so every existing
getfenv/setfenv caller keeps getting the real env — it only activates when
code deliberately marks an environment.

Adds LUA_GET_METATABLE, LUA_GET_FENV, and FUN_LUA_ENV_PROTECT_PREDICATE to
Offsets.h.

* Use Lua length operator for class count

* feat(lua): backport the 5.1 `...` vararg expression via source transpile

This build is pure Lua 5.0 varargs: `function(...)` parses and populates the
`arg` table (with `arg.n`), but `...` used as an EXPRESSION is a parse error
("unexpected symbol near '...'") — verified in-game. So modern 5.1 addon code
using `...` (`{...}`, `local a,b = ...`, `f(...)`, `return ...`,
`select('#', ...)`) fails to compile.

Add a vararg pass to the transpiler (RewriteChunk runs it before the # / %
precedence parser) that rewrites the `...` EXPRESSION to `unpack(arg)`. This is
a faithful, position-independent substitution: `unpack(arg)` is itself a
multi-value expression, so it spreads in tail position and truncates to one
value elsewhere exactly as `...` does, and this build's `unpack(arg)` preserves
embedded nils via `arg.n` (verified: `f(1,nil,3)` -> `1 nil 3`). No precedence
analysis needed — unlike `%`, it's a token substitution once one case is
excluded: the `...` in a function's PARAMETER LIST (the vararg declaration that
creates `arg`) is left intact, found by scanning each `function` keyword and
skipping its param parens. Nested vararg functions work for free — each
`function(...)` makes its own `arg` and `unpack(arg)` binds to the nearest one
(verified: inner arg count 2, outer 3).

Safety mirrors # / %: only chunks containing `...` are touched, and `...` as an
expression already fails to compile on 5.0, so it can't regress working code;
`...` inside strings/comments is skipped by the lexer, and `.` (field) / `..`
(concat) are distinguished by run length. Gated behind the default-on
`_classicapi_SetVarargExpansion` toggle; `_classicapi_TranspileLength` now shows
the full rewrite (vararg + # + %).

File-scope `...` (the `local name, addon = ...` idiom) transpiles fine but only
yields values if the client passes varargs to addon chunks — a loader question,
separate from this syntax rewrite.

* feat(lua): provide addon-args so file-scope `local name, ns = ...` works

Modern addons open files with `local name, ns = ...` — the addon name plus a
per-addon shared namespace table, passed by the loader as the chunk's varargs.
Vanilla 1.12 never passed those (its main chunk has no `arg`), so with the
`...` backport in place the idiom resolved to `unpack(nil)` and errored. This
supplies the args ourselves.

In the luaL_loadbuffer hook, for an addon-file chunk (chunkname
`@Interface\AddOns\<Name>\...`, parsed case-insensitively) that uses a real
`...`, prepend a chunk-local `arg = {"<Name>", __addonns("<Name>"), n=2}`. The
existing `... -> unpack(arg)` pass then makes `local name, ns = ...` yield the
name and the namespace table. The preamble is newline-free (line numbers
preserved), placed after any UTF-8 BOM, and only injected for AddOns\ chunks
(FrameXML and loadstring are untouched); the addon name is rejected if it
contains characters that would break the string literal.

`__addonns(name)` returns the per-addon table — created once and returned
identically for every file of the addon (backed by a name->table map in the
Lua registry), matching modern WoW's shared second vararg. Registered in-game.

Verified in-game: an addon file's `local name, ns = ...` yields
`("<AddonName>", <table>)` with `ns == __addonns(name)` (shared across the
addon's files). File-scope `...` was the last piece; the in-function case was
already handled by the `...` pass.

* feat(lua): add C_AddOns.GetAddOnLocalTable(name)

Public alias of the addon-args namespace: returns the same per-addon table the
`local name, ns = ...` idiom yields — create-once and shared across every file
of the addon, backed by the same registry map as the internal `__addonns`
(both call Script_AddonNS). Lets code fetch an addon's namespace by name.

Registered via RegisterTableFunction on C_AddOns (non-destructive; coexists
with the existing C_AddOns.* functions regardless of module init order).

Currently ungated — it returns a table for any name, create-on-demand. TODO #99
records the follow-up to match retail: return nil unless the addon is loaded
AND its TOC declares `## AllowAddOnTableAccess: 1`, gating only this public
entry point while leaving the internal preamble path ungated.

* feat(lua): gate C_AddOns.GetAddOnLocalTable behind loaded + TOC opt-in

GetAddOnLocalTable was a raw alias of the addon-args namespace accessor:
it returned any addon's private table by name, loaded or not, with no
access control. Retail (11.0) gates this -- one addon reads another's
local table only when the target opts in via the `## AllowAddOnTableAccess`
TOC directive. Mirror that.

The public accessor now lives in addons/LocalTable.cpp and returns the
table only when the named addon is loaded (FUN_ADDON_IS_LOADED, by-name)
AND its .toc declares `## AllowAddOnTableAccess: <nonzero>` (scanned via
FUN_FILE_READ, read as a TOC boolean flag). Either check failing returns
nil. Directive spelling and function name both verified present in the
1.15.8 Classic Era binary.

The internal preamble path (an addon receiving its OWN table via the
injected `local _, ns = ...`) stays ungated -- an addon never needs
permission for its own namespace. Both paths now resolve to the same
per-addon table through LuaSyntax::PushAddonNamespace (AddonNamespace.h),
the single owner of the registry-backed name->table map.

Also close the bypass in the `__addonns` global itself. The map lives in
the Lua registry (unreachable from Lua -- this build ships no
debug.getregistry / debug introspection), so `__addonns` was the sole
Lua-visible door, and it was ungated. It is now context-gated: it answers
only while an addon's own chunk is mid-load, for its own name, one-shot.
At runtime it denies every name. Verified safe against the loader: the
addon file loader is strictly per-file load-then-run
(FUN_006edb90 -> FUN_006ede10 -> FUN_00704bc0 -> FUN_00704ae0, where
luaL_loadbuffer is immediately followed by lua_pcall), so nothing runs
between the grant being set and the preamble consuming it.

Net result: the only runtime path to another addon's local table is the
TOC-gated GetAddOnLocalTable.

* fix(lua): close the __addonns loadstring-forgery vector

The addon-args grant (`g_loadingAddon`) that lets a chunk's injected
preamble fetch its own namespace via the `__addonns` global was armed
whenever luaL_loadbuffer saw an `Interface\AddOns\...` chunkname with a
`...`. That chunkname is forgeable: `loadstring(s, chunkname)` takes a
caller-controlled chunkname (arg 2) and compiles WITHOUT running, so

  loadstring("return ...", "@Interface\AddOns\Victim\x")
  __addonns("Victim")   -- returned Victim's private table

armed the grant and leaked another addon's local table, with no
`## AllowAddOnTableAccess` opt-in.

Fix: arm the grant only when the loadbuffer call comes from the engine's
immediate-run file funnel FUN_00704AE0 (it pcalls the chunk right after
compiling, so the preamble consumes the grant before anything else runs).
The hook checks `_ReturnAddress()` against RET_LUA_FILE_COMPILE
(0x00704B11, the instruction after that funnel's `call luaL_loadbuffer`).
`loadstring` (FUN_00703280) and `RunScript` (FUN_00704C70) call from
different return addresses, so neither can arm — even a loadstring nested
inside a RunScript body that itself runs through the file funnel. The two
callers that DO reach 0x00704B11 (FUN_00704bc0 DoFile, FUN_00704cd0
inline-XML-script) both supply engine-built chunknames, not caller data.
Verified in-game: forged loadstring compiles but yields nil.

Also fix two lexer/scan bugs found in review:
- SkipNumber treated e/E as an exponent marker in hex literals, so
  `0xE-1 % 2` lexed `0xE-1` as one token and rewrote to __mod(0xE-1,2)
  (=1) instead of 0xE-(1%2) (=13). Hex literals now use p/P only.
- The addon-path marker matched a backslash separator only while its
  own terminator scan accepted both; it now matches `addons\` or
  `addons/`, consistent with addons/Embedded.cpp.

* refactor(addons,lua): dedup CI helpers, share TOC scan, table-driven toggles

Review cleanups, behavior-preserving:

- Drop the hand-rolled LowerAscii/EqualsCI copies in luasyntax/Transpile.cpp
  and addons/LocalTable.cpp for `_stricmp`/`_strnicmp` (the codebase norm).
- Add addons/Toc.h `FindValue(buf, size, directive, ...)` — the `## Key:`
  line-start scan in one place. Embedded.cpp (Version) and LocalTable.cpp
  (AllowAddOnTableAccess) both consume it; the file-read stays per-caller
  since Embedded's is entangled with its disk-vs-embedded decision.
- Collapse the six per-switch transpile toggle functions to a {name, bool*}
  table plus one `_classicapi_SetTranspileOption(name, bool)` /
  `_classicapi_GetTranspileOption(name)` pair (name = Length / Modulo /
  VarargExpansion). Nothing outside Transpile.cpp referenced the old names.

* feat(addons): load multi-flavor addons via _Vanilla/_Classic TOC selection

Modern addons target several game flavors from one folder by shipping
`<Name>_Vanilla.toc` / `_Mainline.toc` / `_Cata.toc` / … and frequently no
plain `<Name>.toc`. Vanilla 1.12 only ever opens `<Name>\<Name>.toc`, so such
an addon never registers -- it is invisible, not merely mis-loaded.

Backport the retail client's flavor-file selection at the file-read layer.
The scan's TOC parser FUN_0051C9B0 reads Interface\AddOns\<Name>\<Name>.toc
via FUN_FILE_READ (0x00648620) and registers the addon only if that read
succeeds; the load pass and GetAddOnMetadata read the same path. We already
co-hook FUN_FILE_READ for the embedded addon, so FileRead_h now calls
AddOns::FlavorToc::TryHandle in its non-!!!ClassicAPI branch: for a base-TOC
read it probes <Name>_Vanilla.toc then <Name>_Classic.toc via the original
reader and serves the first that exists in place of the base (the parser
passes outSize=NULL, so it just forwards args with a different path).

Precedence mirrors retail: flavor wins over an existing base, and _Vanilla
beats _Classic. Does NOT fall back to _Mainline/_Cata/etc -- an addon shipping
only those doesn't support vanilla and stays unlisted. Folder enumeration
already lists flavor-only folders, so only the TOC read needed redirecting.

Verified in-game: a folder with only FlavorTest_Vanilla.toc registers and
loads; GetAddOnInfo returns the _Vanilla title/notes.

* feat(addons): prefer _Turtle.toc flavor on Turtle-lineage clients

Extend multi-flavor TOC selection with a _Turtle.toc suffix, a ClassicAPI
extension (not a retail flavor) for addons targeting Turtle's server content.
Probe order becomes _Turtle -> _Vanilla -> _Classic, with _Turtle offered
first and only when Turtle::Detected() is true, so stock 1.12 never prefers
Turtle-only code.

The redirect fires at both the registration scan (glue/char-select) and the
in-world load pass, which must select the same flavor -- so detection has to
read consistently at glue-scan time and in-world. The in-world signal
(TURTLE_WOW_VERSION, engine-set) is absent on the glue state, so Turtle::Detect
now also recognizes Turtle's glue-time TURTLE_* GlueXML string globals
(AUTH_TURTLE_WEBSITE / TURTLE_ARMORY / TURTLE_KNOWLEDGE_DATABASE, set in
GlueStrings.lua at glue boot, before the scan). Clones keep those names and
only rebrand the URL values, so name presence is clone-robust where realmList
is not. Detected() now latches on either signal and works in both states;
Version() still fills from TURTLE_WOW_VERSION once in-world.

Verified in-game: a folder shipping only FlavorTest2_Turtle.toc registers and
loads on Turtle (GetAddOnInfo returns the _Turtle title), while the earlier
_Vanilla-only test still works.

* feat(addons): narrow flavor TOC selection to _Turtle + _ClassicAPI

Drop `_Vanilla`/`_Classic` from the flavor probe list. Those are the 1.15
Classic Era client's suffixes -- a modern engine with the full modern API and
modern FrameXML templates. Adopting them told a multi-flavor addon "I'm 1.15",
so its `_Vanilla` build would assume APIs/templates this 1.12 + partial-backport
client doesn't fully have, load, and then break. A clean non-load is better.

Select only the two flavors that describe THIS environment, most specific first:
  _Turtle      -- Turtle client/content, only when Turtle::Detected();
  _ClassicAPI  -- 1.12 + ClassicAPI, always (ClassicAPI is present by
                  definition -- it is the code doing the redirect).
Both are ClassicAPI conventions the addon author opts into, not an attempt to
impersonate a retail flavor. If neither exists, TryHandle returns false and the
caller does the normal base read, which chains through any other FUN_FILE_READ
hooker before reaching the engine.

Verified in-game: a folder with only `<Name>_ClassicAPI.toc` registers/loads; a
`_Turtle`-only folder registers on Turtle; a `_Vanilla`-only folder no longer
registers (correctly, now that `_Vanilla` is not a selected flavor).

* feat(addons): backport per-line TOC directives (conditions + variables)

Single-TOC multi-flavor addons gate individual file-reference lines and
expand path variables:

    [Family]\Init.lua                              -> Classic\Init.lua
    Vanilla.lua        [AllowLoadGameType vanilla]  kept
    Mainline.lua       [AllowLoadGameType mainline] dropped
    Localization\[TextLocale].lua                  -> Localization\enUS.lua

Vanilla 1.12's TOC readers understand none of it: the scan parser
FUN_0051C9B0 only parses `##` metadata, and the load pass FUN_006edb90
hands each file line to FUN_006ede10 verbatim, where a trailing ` [...]`
breaks the extension check so the file silently never loads.

New module AddOns::TocRewrite rewrites the TOC buffer at the FUN_FILE_READ
chokepoint (alongside FlavorToc/Embedded), so the engine's own load loop
sees clean paths and both parsers are covered by one edit.

On this client the tokens resolve to: [AllowLoadGameType] keeps iff the
list has `vanilla`; [AllowLoadTextLocale] keeps iff it has the client
locale; [AllowLoad] keeps `game` and drops `glue`; [Family]->Classic,
[Game]->Vanilla, [TextLocale]->the client locale code. Locale comes from
VAR_LOCALE_NAME_TABLE (0x008558A4), the exact source Script_GetLocale
reads, so it matches GetLocale() byte-for-byte.

Gated to ...\AddOns\...\*.toc reads (a .lua `t[1]` would otherwise be
mangled); bracket-free TOCs stay zero-copy via a memchr bail; a rewritten
TOC goes into a fresh Storm buffer the engine frees like its own. Unknown
conditions fail safe (drop the line); gated-out lines become `#` comments.

Verified in-game on Turtle: 8 keep-cases loaded, 4 drop-cases (bare
mainline, glue, deDE, unknown keyword) did not.

* docs: document the branch's Lua 5.1 and TOC compatibility features

User-facing docs for the feat/lua-syntax-transpile branch.

docs/API.md:
- New "Lua 5.1 syntax: length, modulo, and vararg" section: the source
  rewrite for `#`, `%`, and `...`-as-expression; what each form becomes
  (`#` = border, ignores table.setn; `%` = 5.1 sign-of-b, contrasted with
  math.mod); the `local name, tbl = ...` addon-args idiom; limits
  (string/comment safety, first-close long-bracket matching, preserved
  line numbers) and the diagnostic kill-switch.
- New "getfenv / setfenv environment protection" section: the 5.1
  `__environment` metatable field, with the `__fenv` fallback noted.
- New "Conditional and multi-flavor TOC loading" section under AddOns:
  `_ClassicAPI` / `_Turtle` flavor file selection and per-line
  `[AllowLoadGameType]` / `[AllowLoadTextLocale]` / `[AllowLoad]`
  conditions and `[Family]` / `[Game]` / `[TextLocale]` path variables,
  with the token resolution table and limits.
- Table-of-contents entries for all three.

README.md:
- Add C_AddOns.GetAddOnLocalTable to the AddOns calls row (was missing).
- Add three rows to the Behaviors table (Lua 5.1 syntax, Lua 5.1
  environment protection, multi-flavor & conditional TOC loading), each
  linking to its API.md section.

* feat(addons): accept multi-flavor `## Interface:` version lists

A retail TOC can list several interface versions on one line
(`## Interface: 120100, 50504, 38002, 20506, 11200`). Vanilla's parse is a
plain atoi (FUN_0064ade0) that reads only the first number into entry+0x1C,
and the loadability resolver FUN_0051e780 compares that to the client
version FUN_0051d7d0() (0x2BC0 = 11200): a mismatch is the OUT_OF_DATE
reason that blocks loading while checkAddonVersion is on. So a list whose
first entry isn't 11200 never loads even when 11200 appears later in it.

TocRewrite now special-cases the `## Interface:` line: parse the whole
list, and if the client version (read live via
FUN_ADDON_CLIENT_INTERFACE_VERSION, not hardcoded) is present but not
first, rewrite the value to that single number so the engine stores 11200
and marks the addon compatible. A list without 11200 (or already leading
with it) is left verbatim, so it stays out of date exactly as before. The
fast-path gate widened to `[` or `,`; the content-compare bail keeps
comma-bearing TOCs (e.g. `## Dependencies: A, B`) zero-copy.

Docs (API.md, README.md) describe the behavior and the 11200-only
compatibility rule.

Verified in-game on Turtle: GetAddOnInfo on an addon listing
120100,50504,38002,20506,11200 returned no reason (loaded); the ...,11509
control returned INTERFACE_VERSION (out of date).

* feat(addons): honor ## LoadSavedVariablesFirst

Modern addons use `## LoadSavedVariablesFirst: 1` so their SavedVariables
are restored before their Lua runs, letting file-scope code read config.
Vanilla always runs an addon's files first, then loads SavedVariables,
then fires ADDON_LOADED (verified in FUN_0051f240), and never parses the
directive — so file-scope SV is always nil.

New module AddOns::SavedVarsFirst co-hooks the per-addon file loader
FUN_ADDON_LOAD_FILES (FUN_006edb90). For an addon whose TOC declares the
directive, it loads the SavedVariables first, mirroring the engine's own
path construction two steps later:

  account : WTF\Account\<account>\SavedVariables\<Name>.lua
  per-char: WTF\Account\<account>\<realm>\<character>\SavedVariables\<Name>.lua
            (falling back to the realm-less path)

account = *VAR_ACCOUNT_NAME_PTR, realm = FUN_GET_REALM_NAME, character =
FUN_GET_LOGIN_ACCOUNT_NAME (whose offset label is a misnomer — it is the
character name). Then it calls the original so the files run with SV
present. FUN_FILE_EXISTS picks the same per-char variant the engine would;
FUN_LUA_LOAD_FILE no-ops on a missing file.

v1 caveat (documented): the engine still runs its own SV load right after
the files, so a file-scope READ works but a file-scope WRITE is overwritten
before ADDON_LOADED. Writes belong in an ADDON_LOADED handler, as usual.

Docs: API.md "SavedVariables loaded first" + a README behaviors row.

Verified in-game on Turtle (login -> /reload): flagged SVFirst saw its DB
at file scope (sawAtFileScope=true), control SVNormal did not (false).

* feat(addons): suppress the redundant SavedVariables re-load (LoadSavedVariablesFirst v2)

v1 loaded a flagged addon's SavedVariables before its files, but the engine
still ran its OWN SV load right after the files — overwriting any value the
addon wrote to its SavedVariable at file scope before ADDON_LOADED.

v2 co-hooks FUN_LUA_LOAD_FILE and skips that one redundant re-load per
pre-loaded path: RunLuaFile loads each SV via the trampoline (bypassing the
hook) and records its full path; LuaLoad_h matches the engine's imminent
FUN_00704bc0(svPath, 0, 0) (case-insensitive, one-shot erase) and returns 0
without loading. The SV-load call sites ignore the return and the SV file
feeds nothing into the TOC checksum, so the skip is side-effect-free.
FUN_00704bc0 loads files only (RunScript runs strings through a different
path), so the extra hook is a cold addon-load-time path.

Now file-scope reads AND writes both survive — matching true
LoadSavedVariablesFirst. Docs (API.md) drop the write-clobber caveat.

Verified in-game on Turtle (fresh SavedVariables, repeated /reload): flagged
SVFirst's file-scope write persists (runs counter climbs 2, 3, 4), control
SVNormal stays 1.

* feat(addons): flavor-specific addon Bindings (Bindings_Turtle/_ClassicAPI.xml)

The Bindings.xml analog of FlavorToc. A modern addon may ship a
flavor-specific keybinding file instead of / alongside a plain
Bindings.xml. Vanilla only ever builds Interface\AddOns\<Name>\Bindings.xml,
gates it on an exists-check (FUN_00648a30), and — only then — loads it via
FUN_004b6f70, which reads through FUN_FILE_READ. So flavor selection needs
two pieces (a FileRead redirect alone can't help a flavor-only addon,
because the exists-check is on the base name):

- AddOns::FlavorBindings::TryHandle, called from the FUN_FILE_READ hook
  after FlavorToc: for a …\AddOns\<Name>\Bindings.xml read, serve a flavor
  variant if present (flavor wins over a plain Bindings.xml). Probe order
  Bindings_Turtle.xml (Turtle only) -> Bindings_ClassicAPI.xml.
- A co-hook on FUN_FILE_EXISTS: report Bindings.xml as existing when the
  base is absent but a flavor variant is present, so the loader runs for a
  flavor-only addon. Non-Bindings paths pass straight through.

Suffixes are _Turtle / _ClassicAPI, never _Vanilla / _Classic (a _Vanilla
bindings file pairs with the 1.15 build we don't load, so its handlers
wouldn't exist). Docs: API.md flavor section + README behaviors row.

Verified in-game: on Turtle all three cases load (flavor-only ClassicAPI,
flavor-only Turtle, flavor-over-base with the flavor winning); on stock the
Turtle-only one correctly drops while the ClassicAPI ones still load.

* feat(lua): backport 5.1 hexadecimal number literals via source transpile

Lua 5.0's lexer reads only decimal digits + one '.' + 'e' exponent, so a
`0x...` literal is a compile error (hence addons resort to
`tonumber("0x...", 16)`). Lua 5.1 added hex literals. As with `#` / `%` /
`...`, we rewrite the SOURCE at the luaL_loadbuffer chokepoint: each hex
INTEGER token becomes the exact decimal it denotes (unsigned), which 5.0's
lexer accepts and reads as the same double 5.1 would.

RewriteHex runs first in the transpile pipeline, gated on a "0x"/"0X"
presence scan; it reuses the tokenizer (so strings/comments are never
touched) and inserts no newlines (line numbers preserved). Hex floats
(0x1.8p3) and > 64-bit literals are left as-is — vanishingly rare, and they
fail exactly as today (no regression). Also fixed SkipNumber to stop a
number at a `..` so `0xFF.."x"` (hex then concat) tokenizes cleanly.
Toggle: _classicapi_SetTranspileOption("HexLiterals", false).

Docs: API.md "Lua 5.1 syntax" section + README behaviors row.

Verified in-game (19/19 harness asserts) and cross-checked against retail:
0xFF->255, 0xFFFFFFFF->4294967295, bit.band(0xF0,0x0F)->0, "0xFF" untouched
all match modern WoW exactly.

* feat(frame): enable modern positional script args by default

Modern handler signatures — `function(self, delta)` / OnEvent
`(self, event, ...)` — are core Lua 5.1 behavior, so flip Frame::ScriptArgs
on by default instead of opt-in. Ports that use them now work with no
`SetModernScriptArgs(true)` call.

It's purely additive (the `this`/`arg1`/`event` globals are still set, so
vanilla-style handlers keep working). `SetModernScriptArgs(false)` remains
the escape valve — it reimplements the tail of the engine's hottest Lua
path (fires for every OnUpdate frame), and disabling it makes both runners
a straight passthrough to exact vanilla dispatch.

Docs + code comments updated from "default OFF (opt-in)" to "default ON".

* docs: surface modern script-handler arguments as a behavior

Add a README Behaviors-table row for the now-default modern positional
script arguments (function(self, delta), OnEvent (self, event, ...)),
alongside the Lua 5.1 syntax row. The SetModernScriptArgs / GetModernScriptArgs
functions stay in the Frame calls table as the off-switch.

* fix(lua): correct transpiler lexer edge cases and operator env-independence

Review findings against the Lua 5.1 syntax transpiler:

- Tokenize `.`/`..`/`...` as multi-char tokens like Lua's own lexer, so a
  `...` vararg adjacent to a `..` concat (e.g. `x .. ...`) is recognized
  instead of forming an unsplittable five-dot run that left `...`
  un-rewritten and failed to compile. Guard the `#`/`%` field-access check
  to a single-char `.` so `a..b` isn't misread as `a.b`.

- Nest level-0 `[[ ]]` long brackets in SkipLongBracket to match the engine's
  own long-string reader (FUN_00700010 keeps a nesting counter). First-close
  matching mis-identified nested-string boundaries and could rewrite `#`/`%`
  bytes the parser treats as string content.

- Capture `__len`/`__mod`/`unpack` as chunk-locals in the load-time preamble
  so the lowered `#`/`%`/`...` operators resolve under a setfenv sandbox with
  no _G fall-through, mirroring real 5.1's environment-independent operators.

- Skip precompiled bytecode (0x1B chunk signature) instead of tokenizing it
  as source and corrupting the chunk.

- Cap parse recursion at 200 (Lua's LUAI_MAXCCALLS) so a pathologically
  nested chunk fails to compile cleanly instead of overflowing the C stack
  inside the hook.

- Case-fold the addon-namespace map key in PushAddonNamespace so a
  case-mismatched C_AddOns.GetAddOnLocalTable request lands on the addon's
  real table instead of minting a fresh empty one.

The `.. ...` vararg and nested-long-bracket fixes verified in-game via
_classicapi_TranspileLength.

* fix(addons): gate LoadSavedVariablesFirst pre-load on the declared scope

LoadSavedVarsEarly chose which SavedVariables files to pre-load from file
existence alone, but the engine loads the account file only when the addon
declares `## SavedVariables` and the per-char file only when it declares
`## SavedVariablesPerCharacter`. So a stale file for a scope the addon no
longer declares got executed early (Lua the engine never runs) and queued in
g_pendingSuppress, which the engine then never drained — a leak of one entry
per stale file per /reload.

Mirror the engine: read the TOC once (ReadSvFlags, replacing
TocWantsSavedVarsFirst) for the reorder flag plus whether each SV scope is
declared, and load each file only when its directive is declared and the file
exists. Every suppress entry we queue is now a file the engine re-loads and
drains.

* fix(turtle): only trust glue markers on the glue Lua state

Probe() checked the `TURTLE_*` glue markers whenever detection wasn't yet
latched, on whatever Lua state was current — including in-world. Those names
are set by Turtle's GlueXML and are only trustworthy on the glue state; in
world any loaded addon can define a global by one of them (a Turtle-UI port
shipping the strings, say), which permanently mis-latched Turtle detection on
a stock client and skewed flavor TOC/Bindings selection for the session.

Gate the marker probe on a `g_onGlue` phase flag driven by the engine's own
per-state script-registration hooks: the in-world ModuleAutoRegister (fires
before the addon load pass) clears it, each glue boot's GlueModuleAutoRegister
sets it. Neither reads a forgeable Lua global. Turtle still detects via the
markers at the glue scan and via TURTLE_WOW_VERSION in-world; a stock client
can no longer be latched by an in-world global.

Also make Version() return nullptr (not "") in the glue-detected-but-no-version
window, matching the header contract.

* revert MAX_CLASSES

* fix(frame): only pass positional script args to handlers that declare them

Default-ON modern script args changed behavior for every handler on the
engine's hottest Lua path, and the docs' "purely additive" claim was wrong:
a handler that DECLARES a parameter received a real value where vanilla always
passed nil.

Gate the reimplemented positional path on the handler's arity: a Lua closure
that declares a parameter or is vararg (function(self, delta) / function(self,
...)) takes it; a param-less handler (the vast majority of vanilla handlers,
which read this/arg1 globals) and a C-closure handler tail-call the original
engine runner unchanged. So param-less handlers are provably identical to
vanilla AND off the hot path (also the efficiency win: OnUpdate handlers that
gain nothing no longer pay the modern tail every frame). Only param-declaring
handlers observe the (self, [event,] arg1..N) shape.

Arity is read from the handler's Proto; offsets verified against the engine's
own luaD_precall (numparams @ +0x45, is_vararg @ +0x46), lua_dump, and
lua_iscfunction/lua_tocfunction (closure @ TValue+8, isC @ +6, Proto @ +0xC).

The param-declaring-vanilla-handler case (a function reused as both a direct
call and a handler) is inherently ambiguous and still gets the value; documented
honestly in ScriptArgs.cpp, docs/API.md, and README with SetModernScriptArgs
(false) as the opt-out. Verified in-game across both runners, both paths, the
toggle, and the caveat.

* refactor(addons): centralize engine-IO typedefs and CI string helpers

The `__stdcall` FUN_FILE_READ / FUN_FILE_EXISTS / Storm-allocator function
types were copied into ~8 places across the addon files, and Lower/EqCI into
three. These are ABI-critical: a copy declared __cdecl by mistake drifts ESP
per call and kills the process with no crash log. Hoist them to one home:

- new addons/EngineIO.h — FileReadFn / FileExistsFn / SMemAllocFn / SMemFreeFn,
  used by Embedded, FlavorToc.h, FlavorBindings.h/.cpp, SavedVarsFirst,
  LocalTable, TocRewrite.
- addons/Toc.h gains Lower / EqCI, used by FlavorBindings, SavedVarsFirst,
  TocRewrite.

Also drop EvalCondition's dead `known` out-parameter in TocRewrite (nothing
read it — an unknown keyword already returns false). Pure refactor, no behavior
change.

* perf(lua): tokenize each chunk once and gate the #/% parse on an operator token

The transpiler ran on every compiled chunk at login/reload, and:
- RewriteHex / RewriteVararg / RewriteAll each re-tokenized the whole chunk, so
  a file that trips more than one pre-check was lexed two or three times;
- RewriteAll's fast-bail was a raw memchr for '%', which is present in nearly
  every chunk via format strings ("%d"), so almost every chunk paid a full
  recursive parse only to discard an empty edit list.

New RunPasses tokenizes once and shares the stream across the passes, re-lexing
only after a pass actually rewrites the buffer (rare). RewriteAll now gates the
parse on an actual '#'/'%' OPERATOR token — a '%' inside a string is a TK_STRING
token, not a punct, so the format-string case skips the parse entirely.
RunPasses also unifies the previously duplicated RewriteChunk / LoadBuffer_h
pipelines. Output is identical (verified in-game via _classicapi_TranspileLength:
vararg-next-to-concat, nested long brackets, and a format-string vs operator '%'
all rewrite exactly as before).

* feat(lua): backport 5.1 leveled long brackets; fix top-level vararg fallback

Adversarial testing of the transpiler surfaced two gaps beyond PR 28:

- Leveled long brackets ([=[ ]=], [==[ ]==]) are the one 5.1 syntax
  construct still missing. The 5.0 engine lexer has no =-level handling
  (verified in FUN_006ff610), so they fail to compile. A new source-walking
  pre-pass rewrites a leveled long string to [[body]] when the body is safe
  (byte-exact, keeps the leading-newline strip) or to a quoted literal with
  \-newline continuations otherwise, and blanks leveled long comments to
  spaces. Line numbers preserved. Gated by the new LongBrackets toggle.

- Top-level ... outside the addon file funnel (RunScript, XML <OnLoad>,
  loadstring) expanded to unpack(arg) and threw on nil arg, where 5.1
  yields nothing. Non-addon vararg chunks now get a local arg={n=0}
  fallback.

Adds a /capitranspile self-test suite (19 cases, all passing in-game) and
updates docs/API.md (also fixing a stale limits note that described plain
long strings as non-nesting: they nest by depth, matching the engine) and
README.
2026-08-21 20:16:54 -05:00
Brues 998deeac4a Fix pfUI HookScript compatibility shim
Tightens the pfUI compatibility shim in AddOnCompat.lua. The temporary CreateFrame wrapper now leaves the maintained brues pfUI fork alone when either its X-Website tag or the pfUI.handlesHookScript capability flag is present, and only applies the HookScript workaround when neither signal exists.
2026-08-21 13:27:00 -05:00
Brues af02ddadb1 fix(loader): stand down the init worker under VanillaFixes
VanillaFixes creates the process suspended and injects every dlls.txt
DLL sequentially via remote threads before resuming. The fallback
worker spawned from DllMain starts the moment our own LoadLibrary
releases the loader lock -- i.e. while VF is still injecting the NEXT
dlls.txt DLL -- so its ~90-prologue install (plus the MH_ApplyQueued
thread-freeze) raced that DLL's DllMain patching the same engine
functions. SuperWoWhook.dll failed to load whenever ClassicAPI
preceded it in dlls.txt.

DllMain now skips the worker when VfPatcher.dll is present in the
process: under VF the `Load` export is the only install trigger, and
it is inherently race-free -- VfPatcher calls it on the game's main
thread after the process resumes, when every DllMain in the injection
chain has already completed, serialized in dlls.txt order. VfPatcher
is always injected before any dlls.txt DLL, and the Load mechanism
shipped in the same VF release (v1.4) as dlls.txt support itself, so
VF present implies Load will fire. Non-VF injectors keep the worker
unchanged.
2026-08-20 10:26:54 -05:00
Brues 93f3131307 fix(text): drop obsolete 1.5-glyph auto-width wrap bail
The wrap-stepper co-hook special-cased auto-width fontstrings (single-anchor,
no explicit width — pfUI addon-list labels, TwitchEmotes labels): when the whole
line fit within 1.5 glyphs of exactly filling its budget it applied zero shrink,
to avoid a spurious wrap from ~1-glyph drift between the width and wrap hooks'
icon-sum computations. The cost was that real-width frames landing within 1.5
glyphs of their edge also gave up their shrink and let icons overflow instead of
wrapping.

The trailing-icon trim, outline-ink threading, and exact-K measure work have
since tightened that drift under half a glyph, so the existing 0.5-glyph
feasibility tolerance now carries the auto-width case on its own. Verified
in-game: pfUI/TwitchEmotes labels no longer spuriously wrap, and near-full
real-frame lines wrap correctly instead of overflowing.
2026-08-19 22:21:24 -05:00
Brues 653a2c6e3a Update README.md 2026-08-19 19:09:37 -05:00
152 changed files with 13160 additions and 2336 deletions
+19 -1
View File
@@ -104,6 +104,24 @@ jobs:
name: ClassicAPI-${{ github.sha }}
path: build/Release/ClassicAPI.dll
# Bundle both debug artifacts into one zip so a crash from a shipped DLL
# can be triaged exactly. The PDB gives WinDbg/x64dbg/VS a line-level
# symbolicated call stack from a crash dump; the map resolves the
# `0001:offset` addresses in WoW's ERROR #132 crash log by hand when no
# dump is available. Both match THIS exact DLL build.
# The filename is deliberately off-putting: it is NOT the addon and has no
# value to a normal player. The loud all-caps name keeps them from
# grabbing it by mistake; the tag suffix lets a dev match symbols to the
# exact build. (pdb/map keep their real names inside so the pdb signature
# still lines up with the DLL.)
- name: Package debug symbols for release
if: steps.ver.outputs.is_release == 'true'
run: >
Compress-Archive
-Path build/Release/ClassicAPI.pdb, build/Release/ClassicAPI.map
-DestinationPath build/Release/Crash-Symbols-Use-For-Debugging-${{ steps.ver.outputs.tag }}.zip
-Force
- name: Release
if: steps.ver.outputs.is_release == 'true'
uses: softprops/action-gh-release@v2
@@ -111,5 +129,5 @@ jobs:
tag_name: ${{ steps.ver.outputs.tag }}
files: |
build/Release/ClassicAPI.dll
build/Release/ClassicAPI.map
build/Release/Crash-Symbols-Use-For-Debugging-${{ steps.ver.outputs.tag }}.zip
generate_release_notes: true
+4
View File
@@ -7,6 +7,7 @@
locales\locales.xml
SecureTemplates.xml
Compat.lua
Util\Print.lua
Util\Mixin.lua
@@ -44,5 +45,8 @@ Util\Vector3D.lua
Util\Vector4D.lua
Util\UIParent.lua
Util\AuraDurationModifiers.lua
Util\AuraUtil.lua
Util\SecureUnitMenu.lua
Util\TextureUtil.lua
Util\MacroOptions.lua
Util\SecureStateDriver.lua
+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>
+37 -5
View File
@@ -15,12 +15,15 @@ if C_AddOns.DoesAddOnExist('pfUI') then
-- written for on their pfActionBar buttons, which breaks them. While
-- the actionbar module builds those buttons, temporarily wrap
-- CreateFrame to shadow HookScript with a falsy field so those forks
-- fall back to their vanilla SetScript path. The maintained "brues"
-- fork handles HookScript itself, so it's detected via the TOC
-- X-Website tag and left alone. The wrapper is removed as soon as the
-- actionbar module finishes loading so it affects nothing else.
-- fall back to their vanilla SetScript path. The wrapper is removed as
-- soon as the actionbar module finishes loading so it affects nothing
-- else. A pfUI is trusted to handle HookScript itself if EITHER signal
-- is present: the maintained "brues" X-Website tag OR the
-- `pfUI.handlesHookScript` capability flag. Without the website the
-- flag is required; if neither is present we apply the shim.
local website = GetAddOnMetadata("pfUI", "X-Website")
if not website or not strfind(website, 'brues') then
local hasBruesWebsite = website and strfind(website, 'brues')
if not hasBruesWebsite and not pfUI.handlesHookScript then
local _createFrame = CreateFrame
local HookedCreateFrame = function(frameType, name, parent, template)
local frame = _createFrame(frameType, name, parent, template)
@@ -81,3 +84,32 @@ if C_AddOns.DoesAddOnExist("Puppeteer") then
CAPI_MouseoverClearedCompat(PTEnemyUpdater)
end)
end
-- Cartographer_Notes gates a WorldMap release-spirit hook behind an
-- `if lua51` probe, treating a 5.1-capable Lua as a modern client that has
-- the WorldMapDeathRelease frame. Our 5.1 syntax backport makes the probe
-- pass, so Cartographer takes the modern branch and hooks a frame 1.12's
-- WorldMap never had (WorldMapDeathRelease:SetScript in OnEnable) -> nil
-- index. Provide an inert, hidden stub so the hook succeeds and does nothing
-- -- the same outcome vanilla had, where the branch never ran. The embedded
-- !!!ClassicAPI addon loads first, so the frame exists before Cartographer's
-- OnEnable.
if C_AddOns.DoesAddOnExist("Cartographer") and not WorldMapDeathRelease then
CreateFrame("Button", "WorldMapDeathRelease", WorldMapButton or UIParent):Hide()
end
-- ModernSpellBook (Spellbook\MSB_Spellbook.lua) re-hooks its "show all ranks"
-- checkbox by calling HookScript as a GLOBAL -- HookScript(frame, "OnClick",
-- handler). That global never existed on any client: HookScript has always
-- been the widget method frame:HookScript, which our DLL provides. MSB even
-- guards the call with `if checkbox.HookScript` (the method IS present), then
-- calls the global (nil) -> "attempt to call global 'HookScript'". Define the
-- global so the call resolves, forwarding to the method. Gated on the addon
-- being present so the global namespace stays clean otherwise.
if C_AddOns.DoesAddOnExist("ModernSpellBook") and not HookScript then
function HookScript(frame, script, handler)
if frame and frame.HookScript then
frame:HookScript(script, handler)
end
end
end
+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
@@ -1,101 +1,100 @@
-- Backport of 3.3.5 FrameXML/GlobalCallbackRegistry.lua to vanilla 1.12 / Lua 5.0.
--
-- Implementation differences from the 3.3.5 source:
-- - 3.3.5 ref-counts WoW frame events via a SetAttribute/OnAttributeChanged
-- dance because attribute mutations are taint-safe in the secure-template
-- model. 1.12 has neither secure templates nor SetAttribute on frames,
-- so we keep an ordinary {[event]=count} table and Register/Unregister
-- on 0<->1 transitions.
-- Implementation notes:
-- - 3.3.5 ref-counts frame events through a SetAttribute/OnAttributeChanged
-- dance (attribute mutations are taint-safe under secure templates).
-- ClassicAPI backports SetAttribute, but routing counters through it is
-- heavier than a plain table: every set lowercases the name, runs the
-- secure-attribute parsing, and fires OnAttributeChanged. So we keep an
-- ordinary {[event]=count} table and Register/Unregister on 0<->1.
-- - OnEvent takes its (self, event, ...) positionally (modern script args,
-- on by default) and forwards straight to TriggerEvent, falling back to
-- the event/arg globals if that switch is turned off.
EventRegistry = CreateFromMixins(CallbackRegistryMixin)
EventRegistry = CreateFromMixins(CallbackRegistryMixin);
function EventRegistry:OnLoad()
CallbackRegistryMixin.OnLoad(self)
self:SetUndefinedEventsAllowed(true)
CallbackRegistryMixin.OnLoad(self);
self:SetUndefinedEventsAllowed(true);
self.eventCounts = {}
self.frameEventFrame = CreateFrame("Frame")
self.frameEventFrame.registry = self
self.frameEventFrame:SetScript("OnEvent", function()
-- 1.12 globals: `event` is the event name; `arg1..argN` are payload.
-- We don't repack them here; CallbackRegistryMixin:TriggerEvent uses
-- Lua-5.0 varargs, so we forward through a small wrapper.
self:DispatchGameEvent(event, arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9)
end)
self.eventCounts = {};
self.frameEventFrame = CreateFrame("Frame");
self.frameEventFrame:SetScript("OnEvent", function(frameEventFrame, evt, ...)
if evt ~= nil then
-- Modern positional script args (default): (self, event, payload...),
-- forwarded with the exact arg count.
self:TriggerEvent(evt, ...);
else
-- SetModernScriptArgs is off: read the event/arg globals 1.12 always
-- sets. This path keeps the historical 9-arg cap.
self:TriggerEvent(event, arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9);
end
end);
-- Stop delivering events once logout begins: a callback firing during the
-- engine's teardown can touch state that's already gone. A dedicated frame
-- owns PLAYER_LOGOUT so this fires regardless of what consumers registered
-- — folding the check into frameEventFrame's OnEvent would depend on the
-- ref-counted event set still holding PLAYER_LOGOUT at logout time.
self.logoutFrame = CreateFrame("Frame")
self.logoutFrame:RegisterEvent("PLAYER_LOGOUT")
self.logoutFrame:SetScript("OnEvent", function()
self.frameEventFrame:UnregisterAllEvents()
self.frameEventFrame:SetScript("OnEvent", nil)
end)
end
-- Helper so we can forward up to nine event args (the most any vanilla
-- event currently emits) into TriggerEvent's vararg machinery.
function EventRegistry:DispatchGameEvent(event, ...)
self:TriggerEvent(event, unpack(arg))
self.logoutFrame = CreateFrame("Frame");
self.logoutFrame:RegisterEvent("PLAYER_LOGOUT");
self.logoutFrame:SetScript("OnEvent", function()
self.frameEventFrame:UnregisterAllEvents();
self.frameEventFrame:SetScript("OnEvent", nil);
end);
end
function EventRegistry:RegisterFrameEvent(frameEvent)
local n = (self.eventCounts[frameEvent] or 0) + 1
self.eventCounts[frameEvent] = n
if n == 1 then
self.frameEventFrame:RegisterEvent(frameEvent)
end
local n = (self.eventCounts[frameEvent] or 0) + 1;
self.eventCounts[frameEvent] = n;
if n == 1 then
self.frameEventFrame:RegisterEvent(frameEvent);
end
end
function EventRegistry:UnregisterFrameEvent(frameEvent)
local n = self.eventCounts[frameEvent] or 0
if n > 0 then
n = n - 1
self.eventCounts[frameEvent] = n
if n == 0 then
self.frameEventFrame:UnregisterEvent(frameEvent)
end
end
local n = self.eventCounts[frameEvent] or 0;
if n > 0 then
n = n - 1;
self.eventCounts[frameEvent] = n;
if n == 0 then
self.frameEventFrame:UnregisterEvent(frameEvent);
end
end
end
function EventRegistry:RegisterFrameEventAndCallback(frameEvent, ...)
self:RegisterFrameEvent(frameEvent)
return self:RegisterCallback(frameEvent, unpack(arg))
self:RegisterFrameEvent(frameEvent);
return self:RegisterCallback(frameEvent, ...);
end
local function CreateCallbackHandle(cbr, cbrHandle, frameEvent)
local handle = {
Unregister = function()
cbr:UnregisterFrameEvent(frameEvent)
cbrHandle:Unregister()
end,
}
return handle
-- Wrapped in a table for future flexibility.
local handle = {
Unregister = function()
cbr:UnregisterFrameEvent(frameEvent);
cbrHandle:Unregister();
end,
};
return handle;
end
function EventRegistry:RegisterFrameEventAndCallbackWithHandle(frameEvent, ...)
self:RegisterFrameEvent(frameEvent)
local cbrHandle = self:RegisterCallbackWithHandle(frameEvent, unpack(arg))
return CreateCallbackHandle(self, cbrHandle, frameEvent)
self:RegisterFrameEvent(frameEvent);
local cbrHandle = self:RegisterCallbackWithHandle(frameEvent, ...);
return CreateCallbackHandle(self, cbrHandle, frameEvent);
end
function EventRegistry:UnregisterFrameEventAndCallback(frameEvent, ...)
self:UnregisterFrameEvent(frameEvent)
self:UnregisterCallback(frameEvent, unpack(arg))
self:UnregisterFrameEvent(frameEvent);
self:UnregisterCallback(frameEvent, ...);
end
function EventRegistry:GetEventCounts(...)
local counts = {}
for i = 1, arg.n do
local frameEvent = arg[i]
local count = self.eventCounts[frameEvent] or "?"
table.insert(counts, frameEvent .. " : " .. tostring(count))
end
local counts = {};
for i = 1, select("#", ...) do
local frameEvent = select(i, ...);
local count = self.eventCounts[frameEvent] or "?";
table.insert(counts, ("%s : %s"):format(frameEvent, tostring(count)));
end
return table.concat(counts, "\n")
return table.concat(counts, "\n");
end
EventRegistry:OnLoad()
EventRegistry:OnLoad();
+488
View File
@@ -0,0 +1,488 @@
-- SecureCmdOptionParse(options) -> value[, target]
--
-- Backports the macro-conditional parser (added in 2.0) so a modern option
-- string like "[combat,@focus] Foo; [nostealth] Bar; Baz" resolves to the
-- value of the first matching clause. Used by Util/SecureStateDriver.lua and
-- callable by any macro / click-verb system that wants Blizzard's grammar.
--
-- Grammar reverse-engineered from the 3.3.5 Wow.exe (functions 0x005f0df0
-- clause splitter, 0x005f0bb0 group parser, 0x005f0040 condition parser,
-- 0x005ef5c0 evaluator), so it matches Blizzard's own precedence exactly:
-- * clauses are split on ';' at bracket depth 0;
-- * a clause is zero or more [group] blocks followed by a value (the text
-- after the last ']', edge-trimmed -- may be empty);
-- * a clause with NO groups always matches; groups are OR'd (first pass
-- wins); conditions inside a group are comma-split and AND'd;
-- * an '@unit' or 'target=unit' piece (case-insensitive) sets the group's
-- target; every other piece is a condition, optionally negated by a 'no'
-- prefix (stripped only when the piece is >= 3 chars) and optionally
-- carrying ':a/b/c' args (each trimmed, multiple args OR'd);
-- * the match returns the clause value plus the passing group's target
-- token (nil when the passing group set none).
--
-- The 33 condition keywords are the 3.3.5 set, matched case-sensitively, plus
-- one ClassicAPI extension: [known:spellID] / [known:name].
-- Where a keyword's underlying state does not exist on 1.12 (flying, flyable,
-- vehicleui, unithasvehicleui) the condition is a constant false; where 1.12
-- only has a partial answer (spec is always primary, button/actionbar default
-- to the resting state) the closest honest mapping is used. An UNKNOWN keyword
-- is a hard false with a one-time warning -- never a silent pass -- so a typo
-- can't wrongly show a frame. See docs/API.md for the full contract.
--
-- Each unique options string is parsed ONCE into a clause structure and cached
-- (a plain table, matching the 3.3.5 C hash -- entries are never evicted, and
-- the set is bounded by the strings addons actually register). Evaluation then
-- just walks the structure calling predicate functions, so the 0.2s driver
-- poll re-parses nothing.
local type = type;
local tonumber = tonumber;
local strlen = string.len;
local strsub = string.sub;
local strlower = string.lower;
local strmatch = string.match;
local gmatch = string.gmatch;
local function Trim(s)
return strmatch(s, "^%s*(.-)%s*$") or "";
end
local function AlwaysTrue()
return true;
end
local function AlwaysFalse()
return false;
end
-- ---------------------------------------------------------------------------
-- Query helpers used by more than one predicate
-- ---------------------------------------------------------------------------
-- The shapeshift-BAR index of the active form (1-based), or 0 when formless.
-- [stance:N] / [form:N] number by this bar order (druid 1 = Bear, 3 = Cat),
-- which is NOT the DBC form id GetShapeshiftFormID() returns -- so the ':N'
-- form deliberately does not use that faster reader.
local function GetCurrentFormIndex()
for i = 1, GetNumShapeshiftForms() do
local _, _, active = GetShapeshiftFormInfo(i);
if active then
return i;
end
end
return 0;
end
-- True when any equipped item (slots 1..19) matches `want` by localized item
-- type, subtype, or equip-location slot name. C_Item.GetItemInfoInstant is
-- synchronous and link-accepting; worn gear is always cached, and a nil field
-- simply fails the match (fail-safe).
local function IsEquippedTypeMatch(want)
want = strlower(want);
for slot = 1, 19 do
local link = GetInventoryItemLink("player", slot);
if link then
local _, itemType, itemSubType, equipLoc = C_Item.GetItemInfoInstant(link);
if (itemType and strlower(itemType) == want)
or (itemSubType and strlower(itemSubType) == want) then
return true;
end
if equipLoc and equipLoc ~= "" then
local slotName = _G[equipLoc];
if slotName and strlower(slotName) == want then
return true;
end
end
end
end
return false;
end
-- True when a spell named `want` (case-insensitive) is in the player's
-- spellbook. This is the name form of [known:...]. There is no name -> id
-- resolver, so a name can only resolve against castable spellbook entries;
-- talents / passives / profession recipes that IsPlayerSpell reports for a
-- spell id are not found by name.
local function IsSpellNameKnown(want)
want = strlower(want);
local i = 1;
while true do
local name = GetSpellName(i, "spell");
if not name then
return false;
end
if strlower(name) == want then
return true;
end
i = i + 1;
end
end
-- ---------------------------------------------------------------------------
-- Condition predicates -- signature f(target, args) -> truthy.
-- `args` is nil for a bare condition, else { n = <count>, [i] = <trimmed> };
-- multiple args are OR'd.
-- ---------------------------------------------------------------------------
local CONDITIONS = {};
CONDITIONS[""] = AlwaysTrue; -- empty keyword ([] / [ ]) always matches
CONDITIONS.combat = function()
return UnitAffectingCombat("player");
end
CONDITIONS.exists = function(target)
return UnitExists(target);
end
CONDITIONS.dead = function(target)
return UnitIsDeadOrGhost(target);
end
CONDITIONS.help = function(target)
return UnitCanAssist("player", target);
end
CONDITIONS.harm = function(target)
return UnitCanAttack("player", target);
end
CONDITIONS.party = function(target)
return UnitPlayerOrPetInParty(target);
end
CONDITIONS.raid = function(target)
return UnitInRaid(target);
end
-- [group] = in any group; [group:party] true in a party OR raid (raid members
-- occupy party subgroups, matching retail); [group:raid] true only in a raid.
CONDITIONS.group = function(target, args)
local inParty = GetNumPartyMembers() > 0;
local inRaid = GetNumRaidMembers() > 0;
if args then
for i = 1, args.n do
local a = strlower(args[i]);
if a == "raid" then
if inRaid then return true; end
elseif a == "party" then
if inParty or inRaid then return true; end
end
end
return false;
end
return inParty or inRaid;
end
CONDITIONS.stance = function(target, args)
if args then
local cur = GetCurrentFormIndex();
for i = 1, args.n do
if tonumber(args[i]) == cur then return true; end
end
return false;
end
return GetShapeshiftFormID() ~= 0;
end
CONDITIONS.stealth = function()
return IsStealthed();
end
CONDITIONS.mounted = function()
return IsMounted();
end
CONDITIONS.swimming = function()
return IsSwimming();
end
CONDITIONS.indoors = function()
return IsIndoors();
end
CONDITIONS.outdoors = function()
return IsOutdoors();
end
-- Vanilla has one permanent spec: [spec] and [spec:1] pass, higher indices fail.
CONDITIONS.spec = function(target, args)
if args then
for i = 1, args.n do
if args[i] == "1" then return true; end
end
return false;
end
return true;
end
CONDITIONS.modifier = function(target, args)
if args then
for i = 1, args.n do
local a = strlower(args[i]);
if a == "shift" then
if IsShiftKeyDown() then return true; end
elseif a == "ctrl" then
if IsControlKeyDown() then return true; end
elseif a == "alt" then
if IsAltKeyDown() then return true; end
end
end
return false;
end
return IsShiftKeyDown() or IsControlKeyDown() or IsAltKeyDown();
end
-- Outside a click context the "current" button is the left button.
CONDITIONS.button = function(target, args)
if args then
for i = 1, args.n do
local a = args[i];
if a == "1" or strlower(a) == "leftbutton" then return true; end
end
return false;
end
return true;
end
CONDITIONS.actionbar = function(target, args)
if args then
local page = CURRENT_ACTIONBAR_PAGE or 1;
for i = 1, args.n do
if tonumber(args[i]) == page then return true; end
end
return false;
end
return true;
end
CONDITIONS.bonusbar = function(target, args)
local offset = GetBonusBarOffset();
if args then
for i = 1, args.n do
if tonumber(args[i]) == offset then return true; end
end
return false;
end
return offset > 0;
end
CONDITIONS.pet = function(target, args)
if args then
local petName = UnitName("pet");
local petFamily = UnitCreatureFamily("pet");
petName = petName and strlower(petName);
petFamily = petFamily and strlower(petFamily);
for i = 1, args.n do
local a = strlower(args[i]);
if a == petName or a == petFamily then return true; end
end
return false;
end
return UnitExists("pet");
end
-- ClassicAPI cedes the global name UnitChannelInfo/ChannelInfo to addons, so the
-- player's channel state is read through the namespaced C_Spell.ChannelInfo().
CONDITIONS.channeling = function(target, args)
local name = C_Spell.ChannelInfo();
if args then
if not name then return false; end
name = strlower(name);
for i = 1, args.n do
if strlower(args[i]) == name then return true; end
end
return false;
end
return name ~= nil;
end
CONDITIONS.equipped = function(target, args)
if not args then return false; end -- [equipped] with no type never matches
for i = 1, args.n do
if IsEquippedTypeMatch(args[i]) then return true; end
end
return false;
end
CONDITIONS.cursor = function()
return GetCursorInfo() ~= nil;
end
-- ClassicAPI extension (not a 3.3.5 conditional). [known:spellID] uses
-- IsPlayerSpell (broad knowledge -- spellbook, talents, recipes, racials);
-- [known:name] falls back to a spellbook name scan (see IsSpellNameKnown).
CONDITIONS.known = function(target, args)
if not args then return false; end
for i = 1, args.n do
local a = args[i];
local id = tonumber(a);
if id then
if IsPlayerSpell(id) then return true; end
elseif IsSpellNameKnown(a) then
return true;
end
end
return false;
end
-- No such state on 1.12.
CONDITIONS.flying = AlwaysFalse;
CONDITIONS.flyable = AlwaysFalse;
CONDITIONS.vehicleui = AlwaysFalse;
CONDITIONS.unithasvehicleui = AlwaysFalse;
-- Aliases (share the function reference).
CONDITIONS.form = CONDITIONS.stance;
CONDITIONS.mod = CONDITIONS.modifier;
CONDITIONS.btn = CONDITIONS.button;
CONDITIONS.bar = CONDITIONS.actionbar;
CONDITIONS.worn = CONDITIONS.equipped;
-- ---------------------------------------------------------------------------
-- Parser
-- ---------------------------------------------------------------------------
local warned = {};
-- piece -> { pred = fn, neg = bool, args = { n, [i] } or nil }
local function ParseCondition(piece)
local neg = false;
if strlen(piece) >= 3 and strsub(piece, 1, 2) == "no" then
neg = true;
piece = strsub(piece, 3);
end
local keyword, argstr = strmatch(piece, "^([^:]*):?(.*)$");
keyword = Trim(keyword);
local args;
if argstr ~= "" then
args = { n = 0 };
for a in gmatch(argstr .. "/", "([^/]*)/") do
args.n = args.n + 1;
args[args.n] = Trim(a);
end
end
local pred = CONDITIONS[keyword];
if not pred then
if not warned[keyword] then
warned[keyword] = true;
print("SecureCmdOptionParse: unknown condition '" .. keyword .. "'");
end
pred = AlwaysFalse;
neg = false; -- an unknown keyword is a hard false
end
return { pred = pred, neg = neg, args = args };
end
-- group body -> { target = str or nil, n, [i] = cond }
local function ParseGroup(body)
local group = { n = 0 };
-- Sentinel comma so a run ending at end-of-body (and an empty body) yields
-- a piece; each piece is (non-comma)* up to a comma.
for piece in gmatch(body .. ",", "([^,]*),") do
piece = Trim(piece);
if strsub(piece, 1, 1) == "@" then
group.target = Trim(strsub(piece, 2));
elseif strlower(strsub(piece, 1, 7)) == "target=" then
group.target = Trim(strsub(piece, 8));
else
group.n = group.n + 1;
group[group.n] = ParseCondition(piece);
end
end
return group;
end
-- clause text -> { value = str, groups = { n, [i] = group } }
local function ParseClause(text)
local groups = { n = 0 };
-- Greedy up to the LAST ']' splits the [group] blocks from the value.
local body, value = strmatch(text, "^%s*(.*%])(.*)$");
if not body then
return { value = Trim(text), groups = groups };
end
for groupBody in gmatch(body, "%[([^%]]*)%]") do
groups.n = groups.n + 1;
groups[groups.n] = ParseGroup(groupBody);
end
return { value = Trim(value), groups = groups };
end
-- options -> { n, [i] = clause }. Splits on ';' at bracket depth 0.
local function ParseOptions(options)
local clauses = { n = 0 };
local len = strlen(options);
local pos = 1;
while pos <= len + 1 do
local i = pos;
local depth = 0;
while i <= len do
local c = strsub(options, i, i);
if c == "[" then
depth = 1;
elseif c == "]" then
depth = 0;
elseif c == ";" and depth == 0 then
break;
end
i = i + 1;
end
clauses.n = clauses.n + 1;
clauses[clauses.n] = ParseClause(strsub(options, pos, i - 1));
pos = i + 1;
end
return clauses;
end
-- ---------------------------------------------------------------------------
-- Evaluation
-- ---------------------------------------------------------------------------
local cache = {};
local function GroupPasses(group)
local target = group.target or "target";
for i = 1, group.n do
local cond = group[i];
local ok = cond.pred(target, cond.args);
if cond.neg then
ok = not ok;
end
if not ok then
return false;
end
end
return true;
end
function SecureCmdOptionParse(options)
if type(options) ~= "string" then
error("Usage: SecureCmdOptionParse(\"options\")");
return nil; -- WoW's error() may not unwind
end
local clauses = cache[options];
if not clauses then
clauses = ParseOptions(options);
cache[options] = clauses;
end
for i = 1, clauses.n do
local clause = clauses[i];
local groups = clause.groups;
if groups.n == 0 then
return clause.value;
end
for j = 1, groups.n do
if GroupPasses(groups[j]) then
return clause.value, groups[j].target;
end
end
end
return nil;
end
@@ -0,0 +1,222 @@
-- SecureStateDriver: automatic frame state / visibility from macro conditionals.
--
-- Backports FrameXML's SecureStateDriverManager. A registered driver re-runs
-- SecureCmdOptionParse (Util/MacroOptions.lua) on a 0.2s poll (plus an instant
-- rescan on the events below) and applies the result:
-- * RegisterStateDriver(frame, "visibility", "[combat] hide; show") -> the
-- value "show"/"hide" drives frame:Show()/Hide();
-- * RegisterStateDriver / RegisterAttributeDriver for any other state ->
-- frame:SetAttribute("state-"..state, value), so an OnAttributeChanged
-- handler reacts to it;
-- * RegisterUnitWatch(frame [, asState]) -> shows/hides (or sets
-- "state-unitexists") as the frame's `unit` attribute comes and goes.
--
-- Base: 3.3.5 FrameXML/SecureStateDriver.lua, with the anniversary refinements
-- grafted in (the resolveDriver() consolidation, tonumber() coercion of driver
-- values, the literal string 'nil' -> nil, and the "statehidden" attribute).
--
-- Three deliberate deviations from upstream, all because 1.12 has no taint and
-- our attribute system fires OnAttributeChanged SYNCHRONOUSLY:
-- 1. No attribute-bootstrap manager. Upstream drives itself through
-- SecureStateDriverManager:SetAttribute("setframe"/"setstate"/...) -- a
-- taint-boundary trick. Our SetAttribute has a recursion guard (a nested
-- SetAttribute stores the value but does NOT fire the handler, see
-- src/frame/Attributes.cpp), so doing the registration-time resolve from
-- inside that handler would silently swallow the target frame's own
-- OnAttributeChanged. Instead the public Register* functions write plain
-- local tables and resolve immediately at top level. Public signatures are
-- unchanged from retail.
-- 2. GetTime()-deadline throttle instead of the OnUpdate `elapsed` arg (1.12
-- handlers take no parameters).
-- 3. UpdateUnitWatch guards its "statehidden"/"state-unitexists" writes on a
-- real change, so a watched frame's handler is not fired at 5 Hz.
-- ---------------------------------------------------------------------------
-- SecureButton_* (minimal -- no taint, no modifier/button suffix machinery)
-- ---------------------------------------------------------------------------
-- SecureButton_GetAttribute and SecureButton_GetUnit are defined in
-- SecureTemplates.lua (loaded earlier via SecureTemplates.xml).
-- ---------------------------------------------------------------------------
-- Private state
-- ---------------------------------------------------------------------------
local secureAttributeDrivers = {}; -- [frame][attribute] = optionsString
local unitExistsWatchers = {}; -- [frame] = doState (boolean)
local unitExistsCache = setmetatable({}, {
__index = function(t, k)
local v = (UnitExists(k) and true) or false;
t[k] = v;
return v;
end,
});
local STATE_DRIVER_UPDATE_THROTTLE = 0.2;
local nextUpdate = 0;
local pairs = pairs;
function UnitWatchRegistered(frame)
return unitExistsWatchers[frame] ~= nil;
end
-- ---------------------------------------------------------------------------
-- Resolvers
-- ---------------------------------------------------------------------------
local function UpdateUnitWatch(frame, doState)
local unit = SecureButton_GetUnit(frame);
local exists = (unit and unitExistsCache[unit]) or false;
if doState then
if frame:GetAttribute("state-unitexists") ~= exists then
frame:SetAttribute("state-unitexists", exists);
end
else
if exists then
frame:Show();
else
frame:Hide();
end
local hidden = (not exists) or nil;
if frame:GetAttribute("statehidden") ~= hidden then
frame:SetAttribute("statehidden", hidden);
end
end
end
local function resolveDriver(frame, attribute, values)
local newValue = SecureCmdOptionParse(values);
if attribute == "state-visibility" then
if newValue == "show" then
frame:Show();
if frame:GetAttribute("statehidden") ~= nil then
frame:SetAttribute("statehidden", nil);
end
elseif newValue == "hide" then
frame:Hide();
if frame:GetAttribute("statehidden") ~= true then
frame:SetAttribute("statehidden", true);
end
end
elseif newValue then
if newValue == "nil" then
newValue = nil;
else
newValue = tonumber(newValue) or newValue;
end
if frame:GetAttribute(attribute) ~= newValue then
frame:SetAttribute(attribute, newValue);
end
end
end
-- ---------------------------------------------------------------------------
-- Manager frame
-- ---------------------------------------------------------------------------
SecureStateDriverManager = CreateFrame("Frame", "SecureStateDriverManager");
SecureStateDriverManager:Hide(); -- hidden -> no OnUpdate until a driver exists
SecureStateDriverManager:SetScript("OnUpdate", function()
local now = GetTime();
if now < nextUpdate then
return;
end
nextUpdate = now + STATE_DRIVER_UPDATE_THROTTLE;
for frame, drivers in pairs(secureAttributeDrivers) do
for attribute, values in pairs(drivers) do
resolveDriver(frame, attribute, values);
end
end
for k in pairs(unitExistsCache) do
unitExistsCache[k] = nil;
end
for frame, doState in pairs(unitExistsWatchers) do
UpdateUnitWatch(frame, doState);
end
end);
SecureStateDriverManager:SetScript("OnEvent", function()
nextUpdate = 0; -- force a rescan on the next frame
end);
SecureStateDriverManager:SetScript("OnAttributeChanged", function()
if arg1 == "updatetime" and tonumber(arg2) then
STATE_DRIVER_UPDATE_THROTTLE = tonumber(arg2);
end
end);
-- Events that warrant an early rescan. Each is guarded so a name this client
-- doesn't define (e.g. UPDATE_STEALTH) is simply skipped.
local rescanEvents = {
"MODIFIER_STATE_CHANGED", -- ClassicAPI-fired
"PLAYER_FOCUS_CHANGED", -- ClassicAPI-fired
"UPDATE_SHAPESHIFT_FORM", -- ClassicAPI-fired (singular)
"UPDATE_SHAPESHIFT_FORMS", -- native 1.12 (plural)
"ACTIONBAR_PAGE_CHANGED",
"UPDATE_BONUS_ACTIONBAR",
"PLAYER_ENTERING_WORLD",
"UPDATE_STEALTH",
"PLAYER_TARGET_CHANGED",
"PLAYER_REGEN_DISABLED",
"PLAYER_REGEN_ENABLED",
"UNIT_PET",
"RAID_ROSTER_UPDATE",
"PARTY_MEMBERS_CHANGED",
};
for _, event in ipairs(rescanEvents) do
if C_EventUtils.IsEventValid(event) then
SecureStateDriverManager:RegisterEvent(event);
end
end
-- ---------------------------------------------------------------------------
-- Public API
-- ---------------------------------------------------------------------------
function RegisterAttributeDriver(frame, attribute, values)
if attribute and values and string.sub(attribute, 1, 1) ~= "_" then
local drivers = secureAttributeDrivers[frame];
if not drivers then
drivers = {};
secureAttributeDrivers[frame] = drivers;
end
drivers[attribute] = values;
SecureStateDriverManager:Show();
resolveDriver(frame, attribute, values);
end
end
function UnregisterAttributeDriver(frame, attribute)
if attribute then
local drivers = secureAttributeDrivers[frame];
if drivers then
drivers[attribute] = nil;
end
else
secureAttributeDrivers[frame] = nil;
end
end
function RegisterStateDriver(frame, state, values)
RegisterAttributeDriver(frame, "state-" .. state, values);
end
function UnregisterStateDriver(frame, state)
UnregisterAttributeDriver(frame, state and ("state-" .. state));
end
function RegisterUnitWatch(frame, asState)
local doState = (asState and true) or false;
unitExistsWatchers[frame] = doState;
SecureStateDriverManager:Show();
UpdateUnitWatch(frame, doState);
end
function UnregisterUnitWatch(frame)
unitExistsWatchers[frame] = nil;
end
@@ -7,19 +7,42 @@ function RegisterNewSlashCommand(callback, command, commandAlias)
end
SlashCmdList["FOCUS"] = function(msg)
if msg == "" then
FocusUnit();
else
FocusUnit(msg);
end
if ( msg == "" ) then
FocusUnit();
else
local action, target = SecureCmdOptionParse(msg);
if ( action ) then
if ( not target or target == "focus" ) then
target = action;
end
FocusUnit(target);
end
end
end
SlashCmdList["CLEARFOCUS"] = function(msg)
ClearFocus()
if ( SecureCmdOptionParse(msg) ) then
ClearFocus();
end
end
SlashCmdList["EQUIP_SET"] = function(msg)
if msg ~= "" then
C_EquipmentSet.UseEquipmentSet(C_EquipmentSet.GetEquipmentSetID(msg))
end
local set = SecureCmdOptionParse(msg);
if ( set and set ~= "" ) then
C_EquipmentSet.UseEquipmentSet(C_EquipmentSet.GetEquipmentSetID(set))
end
end
SlashCmdList["CLICK"] = function(msg)
local action = SecureCmdOptionParse(msg);
if ( action and action ~= "" ) then
local name, mouseButton = string.match(action, "([^%s]+)%s+([^%s]+)");
if ( not name ) then
name = action;
end
local button = GetClickFrame(name);
if ( button and button:IsObjectType("Button") ) then
button:Click(mouseButton);
end
end
end
+3 -1
View File
@@ -1,3 +1,5 @@
function MouseIsOver(region, topOffset, bottomOffset, leftOffset, rightOffset)
return region:IsMouseOver(topOffset, bottomOffset, leftOffset, rightOffset);
if region:IsMouseOver(topOffset, bottomOffset, leftOffset, rightOffset) then
return 1;
end
end
+2
View File
@@ -80,6 +80,8 @@ SET_NOTE = "Notiz eintragen";
SILVER_AMOUNT_SYMBOL = "S";
SLASH_CLEARFOCUS1 = "/fokusaufheben";
SLASH_CLEARFOCUS2 = "/clearfocus";
SLASH_CLICK1 = "/klick";
SLASH_CLICK2 = "/click";
SLASH_EQUIP_SET1 = "/Ausrüstungsset";
SLASH_EQUIP_SET2 = "/equipset";
SLASH_FOCUS1 = "/fokus";
+2
View File
@@ -81,6 +81,8 @@ SILVER_AMOUNT_SYMBOL = "s";
SILVER_AMOUNT_TEXTURE = "%d|TInterface\\MoneyFrame\\UI-MoneyIcons:%d:%d:0:0:4:1:1:2:0:1|t";
SLASH_CLEARFOCUS1 = "/clearfocus";
SLASH_CLEARFOCUS2 = "/clearfocus";
SLASH_CLICK1 = "/click";
SLASH_CLICK2 = "/click";
SLASH_EQUIP_SET1 = "/equipset";
SLASH_EQUIP_SET2 = "/equipset";
SLASH_FOCUS1 = "/focus";
+2
View File
@@ -80,6 +80,8 @@ SET_NOTE = "Establecer nota";
SILVER_AMOUNT_SYMBOL = "p";
SLASH_CLEARFOCUS1 = "/quitarenfoque";
SLASH_CLEARFOCUS2 = "/clearfocus";
SLASH_CLICK1 = "/clic";
SLASH_CLICK2 = "/click";
SLASH_EQUIP_SET1 = "/conjequip";
SLASH_EQUIP_SET2 = "/equipset";
SLASH_FOCUS1 = "/enfoque";
+2
View File
@@ -80,6 +80,8 @@ SET_NOTE = "Écrire une note";
SILVER_AMOUNT_SYMBOL = "a";
SLASH_CLEARFOCUS1 = "/clearfocus";
SLASH_CLEARFOCUS2 = "/annulerfocal";
SLASH_CLICK1 = "/click";
SLASH_CLICK2 = "/cliquer";
SLASH_EQUIP_SET1 = "/equipset";
SLASH_EQUIP_SET2 = "/équipens";
SLASH_FOCUS1 = "/focus";
+2
View File
@@ -80,6 +80,8 @@ SET_NOTE = "Добавить заметку";
SILVER_AMOUNT_SYMBOL = "с";
SLASH_CLEARFOCUS1 = "/clearfocus";
SLASH_CLEARFOCUS2 = "/сбросфокуса";
SLASH_CLICK1 = "/click";
SLASH_CLICK2 = "/щелчок";
SLASH_EQUIP_SET1 = "/комплект";
SLASH_EQUIP_SET2 = "/equipset";
SLASH_FOCUS1 = "/focus";
+2
View File
@@ -80,6 +80,8 @@ SET_NOTE = "设置备注";
SILVER_AMOUNT_SYMBOL = "银";
SLASH_CLEARFOCUS1 = "/clearfocus";
SLASH_CLEARFOCUS2 = "/clearfocus";
SLASH_CLICK1 = "/click";
SLASH_CLICK2 = "/click";
SLASH_EQUIP_SET1 = "/equipset";
SLASH_EQUIP_SET2 = "/equipset";
SLASH_FOCUS1 = "/focus";
+2
View File
@@ -80,6 +80,8 @@ SET_NOTE = "設定註記";
SILVER_AMOUNT_SYMBOL = "銀";
SLASH_CLEARFOCUS1 = "/clearfocus";
SLASH_CLEARFOCUS2 = "/清除專注目標";
SLASH_CLICK1 = "/click";
SLASH_CLICK2 = "/點擊";
SLASH_EQUIP_SET1 = "/equipset";
SLASH_EQUIP_SET2 = "/裝備套裝";
SLASH_FOCUS1 = "/focus";
+21 -7
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"
)
+87 -46
View File
@@ -1,31 +1,61 @@
# ClassicAPI
A small DLL for World of Warcraft 1.12.1 (Vanilla / Turtle WoW) that adds a
collection of Lua API calls Blizzard never exposed in 1.12 but which make
addon authoring noticeably less painful — primarily for backporting addons
written against later API versions (3.3.5+) where these calls do exist.
ClassicAPI is a DLL for World of Warcraft 1.12.1 (Vanilla / Turtle WoW). It
backports a large part of the modern WoW API — 550+ functions and 50+ events,
plus much of Lua 5.1 — into the 1.12 client, so addons written for later
versions (3.3.5+ or Classic Era) run with little or no change.
The DLL hooks the FrameScript engine after WoW boots and registers its
extensions through the same in-engine mechanisms WoW uses for its own Lua
functions. No companion addon is required.
It hooks the engine after WoW boots and registers everything the same way WoW
registers its own Lua functions. Nothing else to install — the DLL is
self-contained.
## What's added
## Highlights
Full per-function reference: **[docs/API.md](docs/API.md)**.
The headline features. Each one takes effect as soon as the DLL loads, so your
existing addons benefit without any code changes. The complete per-function
reference is in **[docs/API.md](docs/API.md)**.
### Calls
### Lua 5.1 compatibility
The flagship feature: run modern Lua 5.1 addon code on 1.12's Lua 5.0 VM.
| Feature | Effect |
|---------|--------|
| [Syntax](docs/API.md#lua-51-syntax) | Compiles the Lua 5.1 length (`#`), modulo (`%`), `...`-expression, `0x` hex-literal, and leveled long bracket (`[=[ ]=]`) syntax that vanilla's Lua 5.0 rejects, by rewriting addon source before it compiles. Each addon file also receives its `(name, table)` through `...` (`local name, tbl = ...`). |
| [String methods](docs/API.md#string-methods-supper-sformat) | Every string value accepts method calls — `("asd"):upper()`, `("%d gold"):format(n)`, `msg:match("^!(%w+)")` — resolving through the `string` table, the way Lua 5.1 works. Works on literals and variables, in-world and on the login screen, and inside coroutines. |
| [Script-handler arguments](docs/API.md#setmodernscriptargsenable--getmodernscriptargs) | Frame-script handlers receive their values as positional arguments — `OnMouseWheel(self, delta)`, `OnClick(self, button)`, `OnEvent(self, event, ...)`, etc. — so modern addon ports work unmodified. The `this` / `arg1` globals stay set. A handler that declares no parameters is unaffected. A handler that declared a parameter and expected nil now receives its real value. On by default. `SetModernScriptArgs(false)` restores exact vanilla dispatch. |
### Modern client behaviors
| Feature | Effect |
|---------|--------|
| Inline textures | Draws inline texture markup (`\|T…\|t`) as icons in FontStrings, chat, and tooltips. This covers item and spell icons, raid-target markers, and the coin icons in money strings. `GetStringWidth` and `GetStringHeight` count the icons, so measured width and text wrapping stay correct. Done in pure C++ by hooking the engine's text pipeline — no addon. |
| Tooltip line cap | Lifts `GameTooltip`'s hard 30-line limit to 60 for every `GameTooltipTemplate` frame (`GameTooltip`, `ShoppingTooltip1/2`, `ItemRefTooltip`, AtlasLoot, …). Stat-heavy tooltips and comparison blocks (e.g. pfUI's eqcompare) no longer have their extra lines silently dropped. Done in pure C++ by growing the engine's FontString pool at tooltip-creation time. |
| [Event-driven nameplates](docs/API.md#nameplate) | The modern `C_NamePlate` API, driven by real events. `NAME_PLATE_UNIT_ADDED` / `NAME_PLATE_UNIT_REMOVED` fire as plates appear and vanish, `nameplate1`..`nameplateN` tokens resolve with every `UnitX` function (and fire `UNIT_HEALTH`, `UNIT_AURA`, … as `arg1 == "nameplateN"`), and `C_NamePlate.GetNamePlateForUnit` / `GetNamePlates` hand back the live frames. |
| [Focus target](docs/API.md#focus) | A sticky focus unit. `FocusUnit("target")` pins it and `ClearFocus()` drops it, with `PLAYER_FOCUS_CHANGED` on every change. The `focus` / `focustarget` tokens resolve with every `UnitX` function and fire unit events (`UNIT_HEALTH`, `UNIT_AURA`, … as `arg1 == "focus"`), and the predefined `FOCUSTARGET` / `TARGETFOCUS` keybinds are ready to set. |
| [Retail-like `/reload`](docs/API.md#reload-picks-up-new-addons-and-new-files) | `/reload` picks up addon changes made while the game runs. A new folder under `Interface\AddOns\` registers and loads as a normal addon, new files added to an existing addon's TOC load, a newly installed addon's first SavedVariables save survives `/reload`, `##` metadata edits (`## SavedVariables:`, `## Dependencies:`, `## Title:`, …) take effect, and a deleted addon folder drops from the addon list. |
| [Multi-flavor & conditional TOC](docs/API.md#conditional-and-multi-flavor-toc-loading) | Loads modern multi-flavor addons that ship one folder. Selects a version-specific TOC (`<Name>_ClassicAPI.toc` or `<Name>_Turtle.toc`) and the matching keybinding file (`Bindings_ClassicAPI.xml` / `Bindings_Turtle.xml`), accepts a comma-separated `## Interface:` version list (compatible when it includes the client version `11200`), and honors per-line `[AllowLoadGameType]` / `[AllowLoadTextLocale]` conditions and `[Family]` / `[Game]` / `[TextLocale]` path variables inside a TOC. |
## Full API reference
Everything ClassicAPI adds, in full. Each section is collapsed — click a
heading to expand it. For signatures and return values, see the per-function
reference in **[docs/API.md](docs/API.md)**.
<details>
<summary><b>In-game Lua calls</b> — 550+ functions across ~60 namespaces</summary>
| Namespace | Calls |
|-----------|-------|
| [Action](docs/API.md#action) | `GetActionInfo` |
| [AddOns](docs/API.md#addons) | `C_AddOns.DoesAddOnExist`, `C_AddOns.GetAddOnName`, `C_AddOns.GetAddOnNotes`, `C_AddOns.GetAddOnOptionalDependencies`, `C_AddOns.GetAddOnSecurity`, `C_AddOns.GetAddOnTitle`, `C_AddOns.IsAddOnLoadable`, `C_AddOns.IsAddOnLoaded` |
| [AddOns](docs/API.md#addons) | `C_AddOns.DoesAddOnExist`, `C_AddOns.GetAddOnLocalTable`, `C_AddOns.GetAddOnName`, `C_AddOns.GetAddOnNotes`, `C_AddOns.GetAddOnOptionalDependencies`, `C_AddOns.GetAddOnSecurity`, `C_AddOns.GetAddOnTitle`, `C_AddOns.IsAddOnLoadable`, `C_AddOns.IsAddOnLoaded` |
| [AuctionHouse](docs/API.md#auctionhouse) | `C_AuctionHouse.PostItem` |
| [Bindings](docs/API.md#bindings) | `SetBindingSpell`, `SetBindingItem`, `SetBindingMacro`, `SetBindingClick`, `SetOverrideBinding`, `SetOverrideBindingSpell`, `SetOverrideBindingItem`, `SetOverrideBindingMacro`, `SetOverrideBindingClick`, `ClearOverrideBindings` |
| [Chat](docs/API.md#chat) | `GetCurrentChatGUID` |
| [ChatBubbles](docs/API.md#chatbubbles) | `C_ChatBubbles.GetAllChatBubbles` |
| [Class](docs/API.md#class) | `FillLocalizedClassList` |
| [ColorUtil](docs/API.md#colorutil) | `C_ColorUtil.ConvertRGBToHSV`, `C_ColorUtil.ConvertHSVToRGB`, `C_ColorUtil.ConvertHSVToHSL`, `C_ColorUtil.ConvertHSLToHSV`, `C_ColorUtil.ConvertHSLToRGB`, `C_ColorUtil.GenerateTextColorCode`, `C_ColorUtil.WrapTextInColor`, `C_ColorUtil.WrapTextInColorCode` |
| [Combat](docs/API.md#combat) | `InCombatLockdown` |
| [Combat](docs/API.md#combat) | `InCombatLockdown`, `StartAttack`, `StopAttack` |
| [CVar](docs/API.md#cvar) | `C_CVar.GetCVarBool` |
| [Cursor](docs/API.md#cursor) | `GetCursorInfo` |
| [Container](docs/API.md#container) | `C_Container.CalculateTotalNumberOfFreeBagSlots`, `C_Container.GetContainerItemCharges`, `C_Container.GetContainerItemDurability`, `C_Container.GetContainerItemID`, `C_Container.GetContainerItemInfo`, `C_Container.GetContainerItemRepairCost`, `C_Container.GetContainerNumFreeSlots`, `C_Container.GetItemCooldown`, `C_Container.HasContainerItem`, `C_Container.IsContainerItemOpenable`, `C_Container.MoveItem`, `C_Container.PlayerHasHearthstone`, `C_Container.SwapItems`, `C_Container.UseHearthstone`, `GetItemCooldown` |
@@ -37,7 +67,7 @@ Full per-function reference: **[docs/API.md](docs/API.md)**.
| [Expansion](docs/API.md#expansion) | `ClassicExpansionAtLeast`, `ClassicExpansionAtMost`, `GetClassicExpansionLevel` |
| [Faction](docs/API.md#faction) | `C_Reputation.GetFactionDataByIndex`, `C_Reputation.GetFactionStandings`, `C_Reputation.GetLastStandingChange`, `C_Reputation.GetWatchedFactionData`, `C_Reputation.SetWatchedFactionByID`, `GetFactionIDByIndex`, `GetFactionInfoByID`, `GetFactionParentID` |
| [Focus](docs/API.md#focus) | `ClearFocus`, `FocusUnit` |
| [Frame](docs/API.md#frame) | `region:SetPoint("point")` (one-arg form), `region:SetSize`, `region:GetSize`, `region:IsMouseOver`, `region:GetRect`, `region:IsDragging`, `GetMouseFoci`, `frame:SetShown`, `fontstring:GetStringHeight`, `texture:SetRotation`, `texture:GetRotation`, `texture:SetVertexOffset`, `texture:GetVertexOffset`, `texture:SetColorTexture`, `fontstring:SetRotation`, `fontstring:GetRotation`, `frame:SetResizeBounds`, `frame:HookScript`, `frame:IsEventRegistered`, `frame:GetEffectiveAlpha`, `frame:SetAttribute`, `frame:SetAttributeNoHandler`, `frame:ClearAttribute`, `frame:GetAttribute`, `OnAttributeChanged` (script), `SetModernScriptArgs`, `GetModernScriptArgs` |
| [Frame](docs/API.md#frame) | `region:SetPoint("point")` (one-arg form), `region:SetSize`, `region:GetSize`, `region:IsMouseOver`, `region:GetRect`, `region:IsDragging`, `GetMouseFoci`, `frame:SetShown`, `fontstring:GetStringHeight`, `fontstring:GetUnboundedStringWidth`, `fontstring:GetWrappedWidth`, `fontstring:GetNumLines`, `fontstring:GetLineHeight`, `fontstring:IsTruncated`, `fontstring:SetMaxLines`, `fontstring:GetMaxLines`, `fontstring:SetFormattedText`, `texture:SetRotation`, `texture:GetRotation`, `texture:SetVertexOffset`, `texture:GetVertexOffset`, `texture:SetColorTexture`, `texture:SetMask`, `frame:CreateMaskTexture`, `texture:AddMaskTexture`, `texture:RemoveMaskTexture`, `texture:GetNumMaskTextures`, `texture:GetMaskTexture`, `fontstring:SetRotation`, `fontstring:GetRotation`, `editBox:SetCursorPosition`, `editBox:GetCursorPosition`, `editBox:GetUTF8CursorPosition`, `editBox:ClearHighlightText`, `editBox:HasFocus`, `editBox:HasText`, `editBox:SetHighlightColor`, `editBox:GetHighlightColor`, `editBox:ClearHistory`, `frame:SetResizeBounds`, `frame:HookScript`, `frame:IsEventRegistered`, `frame:GetEffectiveAlpha`, `frame:SetAttribute`, `frame:SetAttributeNoHandler`, `frame:ClearAttribute`, `frame:GetAttribute`, `OnAttributeChanged` (script), `SetModernScriptArgs`, `GetModernScriptArgs`, `PreClick` (script), `PostClick` (script), `GetClickFrame` |
| [FriendList](docs/API.md#friendlist) | `C_FriendList.GetFriendInfo`, `C_FriendList.GetFriendInfoByIndex`, `C_FriendList.GetNumFriends`, `C_FriendList.GetNumOnlineFriends`, `C_FriendList.GetNumWhoResults`, `C_FriendList.GetWhoInfo`, `C_FriendList.IsFriend`, `C_FriendList.IsIgnored`, `C_FriendList.IsIgnoredByGuid`, `C_FriendList.IsWhoQueryPending`, `C_FriendList.SendWhoQueryByName`, `C_FriendList.SetFriendNotes`, `C_FriendList.SetFriendNotesByIndex` |
| [GameObject](docs/API.md#gameobject) | `C_GameObjectInfo.GetGameObjectInfoByID`, `C_GameObjectInfo.RequestLoadGameObjectByID`, `ClosestGameObjectPosition` |
| [Glue](docs/API.md#glue) | `C_Glue.IsOnGlueScreen` |
@@ -50,7 +80,7 @@ Full per-function reference: **[docs/API.md](docs/API.md)**.
| [Loot](docs/API.md#loot) | `C_Loot.GetNearbyLootableUnits`, `C_Loot.GetLastScanResults`, `C_Loot.IsScanInProgress`, `C_Loot.LootAllCorpses`, `C_Loot.LootUnit`, `C_Loot.LootUnitItem`, `C_Loot.ScanNearbyLoot` |
| [LootHistory](docs/API.md#loothistory) | `C_LootHistory.GetNumItems`, `C_LootHistory.GetItem`, `C_LootHistory.GetPlayerInfo`, `C_LootHistory.Clear` |
| [LossOfControl](docs/API.md#lossofcontrol) | `C_LossOfControl.GetActiveLossOfControlData`, `C_LossOfControl.GetActiveLossOfControlDataCount` |
| [Lua](docs/API.md#lua) | `coroutine.create`, `coroutine.resume`, `coroutine.status`, `coroutine.wrap`, `coroutine.yield`, `CreateFromMixins`, `math.fmod`, `math.huge`, `math.modf`, `Mixin`, `select`, `string.gmatch`, `string.match`, `string.reverse`, `strjoin`, `strreplace`, `strrev`, `strsplit`, `strtrim`, `table.count`, `table.wipe` |
| [Lua](docs/API.md#lua) | `collectgarbage` (5.1 options), `coroutine.create`, `coroutine.resume`, `coroutine.running`, `coroutine.status`, `coroutine.wrap`, `coroutine.yield`, `CreateFromMixins`, `math.fmod`, `math.huge`, `math.modf`, `Mixin`, `select`, `string.gmatch`, `string.gsub` (table replacement), `string.match`, `string.reverse`, `strjoin`, `strreplace`, `strrev`, `strsplit`, `strtrim`, `table.count`, `table.maxn`, `table.wipe`, `unpack` (range args) |
| [Macros](docs/API.md#macros) | `C_Macro.CreateMacro`, `C_Macro.EditMacro`, `GetLooseMacroIcons`, `GetLooseMacroItemIcons`, `GetMacroIcons`, `GetMacroItemIcons`, `GetMacroSpell` |
| [Mail](docs/API.md#mail) | `GetInboxItemLink`, `GetSendMailItemLink` |
| [Map](docs/API.md#map) | `C_Map.GetAreaInfo`, `C_Map.GetAreas`, `C_Map.GetAreaTriggerInfo`, `C_Map.GetAreaTriggers`, `C_Map.GetBestMapForUnit`, `C_Map.GetMapAreaIDs`, `C_Map.GetMapOverlays`, `C_Map.GetMapWorldSize` |
@@ -63,28 +93,30 @@ Full per-function reference: **[docs/API.md](docs/API.md)**.
| [Quest](docs/API.md#quest) | `C_QuestLog.GetNumQuestObjectives`, `C_QuestLog.GetQuestDetails`, `C_QuestLog.GetHeaderIndexForQuest`, `C_QuestLog.GetLogIndexForQuestID`, `C_QuestLog.GetQuestIDForLogIndex`, `C_QuestLog.GetTitleForQuestID`, `C_QuestLog.IsOnQuest`, `C_QuestLog.IsQuestDataCachedByID`, `C_QuestLog.IsUnitOnQuest`, `C_QuestLog.RequestLoadQuestByID`, `GetQuestLogLeaderBoardID` |
| [Spell](docs/API.md#spell) | `C_Spell.CancelSpellByID`, `C_Spell.CastAtCursor`, `C_Spell.CastAtUnit`, `C_Spell.CastingInfo`, `C_Spell.ChannelInfo`, `C_Spell.DoesSpellExist`, `C_Spell.GetSchoolString`, `C_Spell.GetSpellCooldown`, `C_Spell.GetSpellDescription`, `C_Spell.GetSpellDispelType`, `C_Spell.GetSpellEffectMechanics`, `C_Spell.GetSpellInfo`, `C_Spell.GetSpellLevelInfo`, `C_Spell.GetSpellLink`, `C_Spell.GetSpellMechanicByID`, `C_Spell.GetSpellName`, `C_Spell.GetSpellPowerCost`, `C_Spell.GetSpellRadius`, `C_Spell.GetSpellReagents`, `C_Spell.GetSpellRequiredTargetLevel`, `C_Spell.GetSpellSubtext`, `C_Spell.GetSpellTexture`, `C_Spell.IsAutoAttackSpell`, `C_Spell.IsCurrentSpell`, `C_Spell.IsNextMeleeSpell`, `C_Spell.IsRangedAutoAttackSpell`, `C_Spell.IsSelfBuff`, `C_Spell.IsSpellHarmful`, `C_Spell.IsSpellHelpful`, `C_Spell.IsSpellInRange`, `C_Spell.IsSpellPassive`, `C_Spell.IsSpellUsable`, `C_Spell.ResetsMeleeSwing`, `C_Spell.SpellHasRange`, `C_Spell.UnitCastingInfo`, `C_Spell.UnitChannelInfo`, `CanDualWield`, `CancelSpellByName`, `CastSpellNoToggle`, `GetCraftSpellID`, `GetSpellBonusDamage`, `GetSpellBonusHealing`, `GetSpellInfo`, `GetSpellLink`, `GetSpellRadius`, `GetSpellRequiredTargetLevel`, `GetSpellSchool`, `IsHarmfulSpell`, `IsHelpfulSpell`, `IsPassiveSpell`, `IsPlayerSpell`, `IsSpellKnown`, `IsUsableSpell`, `SpellHasRange` |
| [SpellBook](docs/API.md#spellbook) | `C_SpellBook.GetCurrentLevelSpells`, `C_SpellBook.GetSkillLineName`, `C_SpellBook.GetSkillLineRank`, `C_SpellBook.GetSpellBookItemInfo`, `C_SpellBook.GetSpellLevelLearned`, `C_SpellBook.GetSpellSkillLine`, `C_SpellBook.IsAutoAttackSpellBookItem`, `C_SpellBook.IsRangedAutoAttackSpellBookItem`, `FindSpellBookSlotByID` |
| [State](docs/API.md#state) | `CancelShapeshiftForm`, `Dismount`, `GetMirrorTimerInfo`, `GetMirrorTimerProgress`, `GetShapeshiftFormID`, `IsAssistingRitual`, `IsFalling`, `IsIndoors`, `IsInGroup`, `IsInRaid`, `IsLoggedIn`, `IsMounted`, `IsOutdoors`, `IsStealthed`, `IsSwimming` |
| [State](docs/API.md#state) | `CancelShapeshiftForm`, `Dismount`, `GetMirrorTimerInfo`, `GetMirrorTimerProgress`, `GetShapeshiftFormID`, `GetSheathState`, `IsAssistingRitual`, `IsFalling`, `IsIndoors`, `IsInGroup`, `IsInRaid`, `IsLoggedIn`, `IsMounted`, `IsOutdoors`, `IsStealthed`, `IsSwimming` |
| [System](docs/API.md#system) | `CopyToClipboard`, `GetPhysicalScreenSize` |
| [Talent](docs/API.md#talent) | `GetTalentIDByIndex`, `GetTalentSpellID` |
| [Targeting](docs/API.md#targeting) | `GetPlayerFacing`, `TargetDirectionEnemy`, `TargetDirectionFriend`, `TargetNearest`, `TargetNearestEnemyPlayer`, `TargetNearestFriendPlayer` |
| [TaxiMap](docs/API.md#taximap) | `C_TaxiMap.GetTaxiNodesForMap`, `C_TaxiMap.GetAllTaxiNodes`, `C_TaxiMap.GetTaxiPaths`, `C_TaxiMap.GetTaxiPathWaypoints`, `C_TaxiMap.GetTaxiRoute` |
| [Time](docs/API.md#time) | `C_DateAndTime.AdjustTimeByDays`, `C_DateAndTime.AdjustTimeByMinutes`, `C_DateAndTime.CompareCalendarTime`, `C_DateAndTime.GetCalendarTimeFromEpoch`, `C_DateAndTime.GetCurrentCalendarTime`, `C_DateAndTime.GetSecondsUntilDailyReset`, `C_DateAndTime.GetServerTimeLocal`, `C_Timer.After`, `C_Timer.NewTicker`, `C_Timer.NewTimer`, `GetServerTime`, `GetTimeCached` |
| [Totem](docs/API.md#totem) | `GetTotemInfo`, `GetTotemTimeLeft`, `GetTotemDuration`, `TargetTotem` |
| [Tracking](docs/API.md#tracking) | `GetNumTrackingTypes`, `GetTrackingInfo`, `SetTracking` |
| [TradeSkillUI](docs/API.md#tradeskillui) | `C_TradeSkillUI.GetTradeSkillListLink`, `C_TradeSkillUI.GetCraftListLink`, `C_TradeSkillUI.GetTradeSkillListRecipes` |
| [UIColor](docs/API.md#uicolor) | `C_UIColor.GetColors` |
| [Unit](docs/API.md#unit) | `ClosestUnitPosition`, `GetUnitSpeed`, `UnitClassBase`, `UnitCreatureFamilyID`, `UnitCreatureID`, `UnitCreatureTypeID`, `UnitDistanceSquared`, `UnitGUID`, `UnitHealthMissing`, `UnitInLineOfSight`, `UnitInRange`, `UnitIsAFK`, `UnitIsDND`, `UnitIsFeignDeath`, `UnitIsInMyGuild`, `UnitIsMinion`, `UnitIsOtherPlayersPet`, `UnitIsPet`, `UnitIsPossessed`, `UnitOwnerGUID`, `UnitPosition`, `UnitPower`, `UnitPowerMax`, `UnitPowerMissing`, `UnitPowerType`, `UnitRaceBase`, `UnitSpellHaste`, `UnitSpellTargetName`, `UnitStandState`, `UnitSubName`, `UnitTokenFromGUID` |
| [UnitAuras](docs/API.md#unitauras) | `C_UnitAuras.GetAuraDataByIndex`, `C_UnitAuras.GetAuraDataBySpellName`, `C_UnitAuras.GetAuraDispelTypeColor`, `C_UnitAuras.GetBuffDataByIndex`, `C_UnitAuras.GetDebuffDataByIndex`, `C_UnitAuras.GetPlayerAuraBySpellID`, `C_UnitAuras.GetUnitAuraBySpellID`, `C_UnitAuras.GetUnitAuras`, `C_UnitAuras.RegisterAuraDurationModifierByTrigger`, `C_UnitAuras.RegisterComboDuration` |
| [UnitAuras](docs/API.md#unitauras) | `C_UnitAuras.GetAuraDataByIndex`, `C_UnitAuras.GetAuraDataBySpellName`, `C_UnitAuras.GetAuraDispelTypeColor`, `C_UnitAuras.GetBuffDataByIndex`, `C_UnitAuras.GetDebuffDataByIndex`, `C_UnitAuras.GetPlayerAuraBySpellID`, `C_UnitAuras.GetUnitAuraBySpellID`, `C_UnitAuras.GetUnitAuras`, `C_UnitAuras.RegisterAuraDurationModifierByTrigger`, `C_UnitAuras.RegisterComboDuration`, `C_UnitAuras.UnitAura`, `C_UnitAuras.UnitBuff`, `C_UnitAuras.UnitDebuff` |
| [VoiceChat](docs/API.md#voicechat) | `C_VoiceChat.GetTtsVoices`, `C_VoiceChat.GetRemoteTtsVoices`, `C_VoiceChat.SpeakText`, `C_VoiceChat.StopSpeakingText`, `C_TTSSettings.GetSpeechRate`, `C_TTSSettings.GetSpeechVolume`, `C_TTSSettings.GetSpeechVoiceID`, `C_TTSSettings.GetVoiceOptionName`, `C_TTSSettings.SetSpeechRate`, `C_TTSSettings.SetSpeechVolume`, `C_TTSSettings.SetVoiceOption`, `C_TTSSettings.SetVoiceOptionByName`, `C_TTSSettings.SetDefaultSettings`, `C_TTSSettings.RefreshVoices` |
| [XMLUtil](docs/API.md#xmlutil) | `C_XMLUtil.DoesTemplateExist`, `C_XMLUtil.GetTemplateInfo`, `C_XMLUtil.GetTemplates` |
### GlueXML calls
</details>
<details>
<summary><b>GlueXML calls</b> — login / realm-select / character-select screens</summary>
Registered on the **glue** Lua state (the engine that runs the login,
realm-select, and character-select screens). The persistence entries
in the first row are *glue-only* — they exist to support GlueXML
patches that need a small persistence surface across sessions, since
vanilla 1.12 glue ships no general-purpose persistence API beyond
`GetSavedAccountName`/`SetSavedAccountName` (saturated by autologin).
patches that need a small persistence surface across sessions.
The rest of the table is in-game calls that we also mirror onto
the glue state because GlueXML had no way to reach them otherwise.
@@ -97,10 +129,13 @@ the glue state because GlueXML had no way to reach them otherwise.
| Script | `RunScript` (compile and run a Lua chunk in the glue state's globals — useful for slash-command-style helpers in GlueXML) |
| State | `IsLoggedIn` |
### Macros
</details>
<details>
<summary><b>Macros</b> — engine-level parsing extensions</summary>
Engine-level extensions to macro parsing and dispatch — no new Lua
functions, just behavior the stock 1.12 engine didn't have. See the
functions. See the
[Macros section in the Lua reference](docs/API.md#macros) for details.
| Form | What it does |
@@ -109,7 +144,10 @@ functions, just behavior the stock 1.12 engine didn't have. See the
| `CastSpellByName("<spellID>")` | Same — numeric strings resolve through the engine's name resolver |
| `CastSpellNoToggle("<name>")` in a macro | Engine's macro parser now recognizes it as a primary-spell line, so the macro slot in an addon like pfUI highlights when its spell is auto-repeating or its self-aura is active |
### Console commands
</details>
<details>
<summary><b>Console commands</b> — developer console (<code>-console</code>)</summary>
Registered on the engine's developer console (the `~` console available
when launching with `-console`), not as Lua functions. See the
@@ -121,7 +159,10 @@ when launching with `-console`), not as Lua functions. See the
| `ExportInterfaceFiles art` | Extracts Blizzard's UI art (`.blp`/`.tga`) from the MPQs to `BlizzardInterfaceArt\` |
| `ExportDBCFiles` | Extracts the client's `.dbc` tables from the MPQs to `DBFilesClient\` |
### Events
</details>
<details>
<summary><b>Events</b> — new events fired to addons</summary>
| Event | Payload |
|-------|---------|
@@ -177,11 +218,14 @@ when launching with `-console`), not as Lua functions. See the
| `VOICE_CHAT_TTS_PLAYBACK_FAILED` | `status, utteranceID, destination` |
| `VOICE_CHAT_TTS_VOICES_UPDATE` | *(none)* |
### Globals
</details>
<details>
<summary><b>Globals & enums</b></summary>
| Group | Constants |
|-------|-----------|
| Version | `CLASSIC_API_VERSION` |
| Version | `CLASSIC_API_VERSION`, `INTERFACE_VERSION` |
| Expansion | `LE_EXPANSION_LEVEL_CURRENT`, `LE_EXPANSION_CLASSIC` … `LE_EXPANSION_MIDNIGHT` |
| Item quality | `LE_ITEM_QUALITY_POOR` … `LE_ITEM_QUALITY_WOWTOKEN` |
| Unit stat | `LE_UNIT_STAT_STRENGTH` … `LE_UNIT_STAT_SPIRIT` |
@@ -193,7 +237,10 @@ when launching with `-console`), not as Lua functions. See the
| Spellbook bank | `Enum.SpellBookSpellBank.{Player,Pet}` (0–1) |
| Spellbook item type | `Enum.SpellBookItemType.{None,Spell,FutureSpell,PetAction,Flyout}` (0–4; 1.12 only yields `Spell`/`PetAction`) |
### Unit tokens
</details>
<details>
<summary><b>Unit tokens</b> — <code>nameplateN</code>, <code>focus</code>, <code>markN</code></summary>
| Token | Resolves to |
|-------|-------------|
@@ -201,9 +248,12 @@ when launching with `-console`), not as Lua functions. See the
| `focus` / `focustarget` | Sticky target set via [`FocusUnit`](docs/API.md#focusunitunit), cleared via [`ClearFocus`](docs/API.md#clearfocus). Same `UnitX` coverage as `nameplateN`. Fires [`PLAYER_FOCUS_CHANGED`](docs/API.md#player_focus_changed-event) on transition. See [Focus](docs/API.md#focus). |
| `mark1`..`mark8` | Unit currently wearing the Nth raid-target marker (`mark1` = star … `mark8` = skull). Same `UnitX` coverage as `nameplateN`, and fires unit events (`UNIT_HEALTH`, `UNIT_AURA`, …) with `arg1 == "markN"`. `UnitExists("markN")` is `false` when the marker is unset, out of range, or on a non-unit. Suffix chains (`mark1target`) compose. See [Unit tokens (`markN`)](docs/API.md#unit-tokens-markn). |
### Bindings
</details>
ClassicAPI backports the later-client direct-action and temporary override
<details>
<summary><b>Bindings</b> — direct-action and override binding families</summary>
ClassicAPI backports the direct-action and temporary override
binding families. Permanent bindings use the standard `SPELL`, `ITEM`,
`MACRO`, and `CLICK` command strings and can be saved normally. Overrides are
session-only, frame-owned, and support the usual priority flag.
@@ -212,11 +262,10 @@ See the [Bindings API reference](docs/API.md#bindings) for signatures,
precedence, execution behavior, macro-text usage, and 1.12 compatibility
notes.
#### Predefined focus bindings
Injected into the engine's **Targeting Functions** group at FrameXML
Bindings.xml load time, so they appear in the keybind UI alongside
native targeting bindings instead of orphaned at the bottom.
**Predefined focus bindings.** Injected into the engine's **Targeting
Functions** group at FrameXML Bindings.xml load time, so they appear in the
keybind UI alongside native targeting bindings instead of orphaned at the
bottom.
| Binding | Action |
|---------|--------|
@@ -226,14 +275,7 @@ native targeting bindings instead of orphaned at the bottom.
See [Predefined focus bindings](docs/API.md#predefined-focus-bindings-focustarget--targetfocus)
for the implementation note.
### Behaviors
Transparent engine tweaks — no API to call, they just fix a vanilla limitation:
| Tweak | Effect |
|-------|--------|
| Tooltip line cap | Lifts `GameTooltip`'s hard 30-line limit to 60 for every `GameTooltipTemplate` frame (`GameTooltip`, `ShoppingTooltip1/2`, `ItemRefTooltip`, AtlasLoot, …). Stat-heavy tooltips and comparison blocks (e.g. pfUI's eqcompare) no longer have their extra lines silently dropped. Done in pure C++ by growing the engine's FontString pool at tooltip-creation time. |
| Inline textures | Draws inline texture markup (`\|T…\|t`) as icons in FontStrings, chat, and tooltips, the way 4.3.4+ clients do. Vanilla 1.12 shows the raw escape as literal text instead. This covers item and spell icons, raid-target markers, and the coin icons in money strings. `GetStringWidth` and `GetStringHeight` count the icons, so measured width and text wrapping stay correct. Done in pure C++ by hooking the engine's text pipeline — no addon. |
</details>
## Installation
@@ -292,7 +334,7 @@ identically in both cases.
## Bundled addon: DebugTools
A 1.12.1 / Lua 5.0 backport of Blizzard's `Blizzard_DebugTools` addon
(originally shipped with the 3.0 client) lives in [`AddOns/DebugTools/`](AddOns/DebugTools/).
lives in [`AddOns/DebugTools/`](AddOns/DebugTools/).
It's an independent addon — it doesn't use anything ClassicAPI adds, and
ClassicAPI works fine without it. It's bundled here because it's the
natural companion for testing and debugging anything written against the
@@ -307,12 +349,11 @@ Slash commands provided:
| `/framestack` (or `/fstack`) | Tooltip showing the frame hierarchy under the mouse cursor. |
| `/luaerrors` (or `/scripterrors`) | Lua error display window. |
Lua globals provided (backports of 3.3.5 helpers that don't exist in
1.12):
Lua globals provided (backports of Blizzard helpers):
| Global | Purpose |
|--------|---------|
| `print(...)` | Backport of the 3.3.5 `print` — concats varargs with `" "` and pushes to `DEFAULT_CHAT_FRAME`. Routes through `setprinthandler`'s handler; falls back via `geterrorhandler` if the handler errors. |
| `print(...)` | Backport of Blizzard's `print` — concats varargs with `" "` and pushes to `DEFAULT_CHAT_FRAME`. Routes through `setprinthandler`'s handler; falls back via `geterrorhandler` if the handler errors. |
| `setprinthandler(func)` / `getprinthandler()` | Install / query a custom print handler. Useful for redirecting print output in tests. |
| `tostringall(...)` | Apply `tostring()` to every vararg, preserving the count. Lua 5.0-compatible (uses `arg.n` since `select` doesn't exist in 5.0). |
+39
View File
@@ -3815,3 +3815,42 @@ estimate. Anyone who wants vanilla threat should run an existing threat
addon. ClassicAPI's only worthwhile contribution in this space is an
unrelated precise-combat-log event feed — filed as its own thing, not as
"threat".
## 99. `C_AddOns.GetAddOnLocalTable(name)` — gate on loaded + opt-in TOC flag — DONE
Shipped gated (branch `feat/lua-syntax-transpile`,
[src/addons/LocalTable.cpp](src/addons/LocalTable.cpp)). Returns the addon's
private namespace table **only** when BOTH hold, else `nil`:
1. the addon `name` is **loaded** (`FUN_ADDON_IS_LOADED`, by-name), and
2. its `.toc` declares `## AllowAddOnTableAccess: <nonzero>` (scanned via
`FUN_FILE_READ` on `Interface\AddOns\<name>\<name>.toc`, read as a TOC
boolean flag; approach (a) from the notes below — no parser hook).
The two paths stay separate, as required:
- **Internal** `__addonns` (the `local name, ns = ...` preamble) stays
**ungated** — an addon always gets its own namespace. Still in
`luasyntax/Transpile.cpp`.
- **Public** `C_AddOns.GetAddOnLocalTable` carries the gate, in
`addons/LocalTable.cpp`.
Both resolve to the SAME table per addon via `LuaSyntax::PushAddonNamespace`
(the one owner of the registry-backed name→table map, exposed by
[src/luasyntax/AddonNamespace.h](src/luasyntax/AddonNamespace.h)) — so an addon
reads back exactly what it (or its files' `...`) stored.
Directive spelling + function name verified present in the 1.15.8 Classic Era
binary (`AllowAddOnTableAccess:` TOC key, `GetAddOnLocalTable`).
Deliberate scope choices (revisit only if an addon needs otherwise):
- **Name (string) input only.** A numeric index returns `nil` — cross-addon
table access is by name, and the map is name-keyed. Every other `C_AddOns.*`
wrapper accepts name-or-index; this one does not.
- **`nil` on denial, not a Lua error.** Matches the project's modern-API
convention (nil over `lua_error`). Retail may raise instead; switch the
denial branches to `Game::Lua::Error` if strict parity is wanted.
- **No per-name cache** on the TOC scan — GetAddOnLocalTable is not a hot path.
Not shipped by default: no addon opts in yet. The embedded `!!!ClassicAPI`
addon could add `## AllowAddOnTableAccess: 1` to its `.toc` to expose its own
namespace as a worked example, but that's a product/security decision left to
the maintainer.
+2238 -1401
View File
File diff suppressed because it is too large Load Diff
+20 -3
View File
@@ -30,6 +30,21 @@ The DLL hooks the engine's own text pipeline — no companion addon:
- **Rendering** — the paint-pass co-hook `FUN_005c8fe0` walks the icon records
and queues each to `Text::InlineTexturePool`, which places the region on the
next frame tick (never mid-render).
- **Chat has no one-frame lag** — a ScrollingMessageFrame re-`SetText`s every
visible line on each new message, and the engine builds each line lazily at
paint, so a line's icons would land one frame after its glyphs (glyphs draw
synchronously; the icon regions are separate objects whose placement is only
known once the line builds mid-paint, after the frame's texture pass already
drew). A co-hook on the SMF display refresh (`FUN_00788750`, which runs
pre-render from the frame's own methods) forces each icon-bearing line to
resolve+build and applies its icon placement synchronously — before the frame
draws — so icons move in lockstep with their text. It reuses the engine's own
`Realize(0)` → `RebuildString` → ensure-built path and the same placement math
the paint flush uses (`ComputePlacementsForNode`), so the later flush dedups to
a no-op. Additive and best-effort: any line whose rect isn't resolvable in time
falls back to the paint + tick pipeline (correct, one-frame lagged). A pure
scroll or drag never lagged — the icons are anchored to the line and the engine
moves them for free; only content changes on a reused line needed this.
- **Placement coordinates** — text pen space IS render-target pixels. The
engine stores node positions NORMALIZED (fs-local anchor ÷
`[0x832A44]`/`[0x832A48]` per axis, `FUN_0041ade0`), and the origin finalize
@@ -85,9 +100,11 @@ Every fs-level measure path now counts icons: width (`FUN_00772890`), height
refute that: the GameTooltip auto-size measures each WRAPPED SEGMENT of a
wrap-enabled line through it, so icon-bearing wrapped tooltip lines
undersized their tooltip until it was hooked too). The Lua control surface is
one kill switch, `_classicapi_InlineTexEnable` (the SEH latch trips it on a
flush fault); the bring-up tune / stat / probe functions were removed
(`5e5677b`, `6800a9a`).
two toggles: `_classicapi_InlineTexEnable` (the whole-feature kill switch; the
SEH latch trips it on a flush fault) and `_classicapi_InlineTexEarly` (the
SMF-refresh early apply above — on by default, toggle off to A/B the chat lag
against the baseline paint+tick pipeline, which keeps running either way). The
bring-up tune / stat / probe functions were removed (`5e5677b`, `6800a9a`).
## REMOVED (bring-up record) — the raw-GxU-quad rendering primitive
+42 -44
View File
@@ -16,11 +16,10 @@
#include "MinHook.h"
#include "Offsets.h"
#include "event/Custom.h"
#include "model/DisplayInfo.h"
#include "nameplate/Walk.h"
#include "player/NameCache.h"
#include "text/InlineTexture.h"
#include "text/InlineTexturePool.h"
#include "texture/Mask.h"
#include "texture/Transform.h"
static Game::FrameScript_Initialize_t FrameScript_Initialize_o = nullptr;
@@ -37,38 +36,17 @@ static FrameRegisterEvent_t FrameRegisterEvent_o = nullptr;
static void __fastcall InvalidFunctionPtrCheck_h() {}
static bool __fastcall FrameScript_Initialize_h() {
// BEFORE the engine tears down the old event table (at the start of
// FrameScript_Initialize), invalidate our cached slot indices. The
// table is rebuilt at a fresh allocation; the old slots are stale.
Event::Custom::PrepareForReload();
// Fire every module's PrepareForReload BEFORE the engine tears down the old
// event table / Lua state. Each module that keeps reload-fragile state (Lua
// refs, frame-pointer-keyed maps, cached event-slot indices) self-registers
// via `static const Game::ReloadAutoRegister _reload{&PrepareForReload};`,
// so this one call clears them all — a new module can't forget to wire in
// (see the ReloadAutoRegister doc in Game.h). This hook fires on both
// `/reload` and `/logout` (the engine re-inits the Lua state in both).
Game::RunReloadCleanups();
// Clear the nameplate diff state so currently-visible plates
// refire CREATED and UNIT_ADDED on the next tick — re-presents
// them to the freshly reloaded UI, matching modern WoW. The
// wrappers themselves now live in the engine's own registry
// (via `FrameScript_Object::ScriptRegister`), which the engine
// tears down and rebuilds across the Lua reset — no per-module
// cache to clear here.
NamePlate::Events::PrepareForReload();
// The reload teardown destroys every icon pool texture, fontstring,
// and text node — forget all inline-icon pointers now so nothing
// touches them (see InlineTexture.h / InlineTexturePool.h).
Text::InlineTexture::PrepareForReload();
Text::InlineTexturePool::PrepareForReload();
// Drop per-region corner transforms (SetRotation / SetVertexOffset) — the
// reload destroys every addon texture and the region pool reuses their pointers.
Texture::Transform::PrepareForReload();
// Drop pending character-dress jobs and their engine compositors — the
// reload destroys every Model frame the jobs point at.
Model::DisplayInfo::PrepareForReload();
// Persist the name cache before the engine starts tearing down.
// This hook fires on both `/reload` and `/logout` (the engine
// re-initializes Lua state in both cases), giving us a clean
// deterministic save point for the common lifecycle events.
// Persist the name cache before the engine tears down — a SAVE, not a clear,
// so it stays an explicit call rather than a ReloadAutoRegister.
Player::NameCache::Flush();
FrameScript_Initialize_o();
@@ -96,6 +74,7 @@ static void __stdcall LoadGlueScriptFunctions_h() {
Text::InlineTexture::PrepareForReload();
Text::InlineTexturePool::PrepareForReload();
Texture::Transform::PrepareForReload();
Texture::Mask::PrepareForReload();
}
// Every Lua-side `frame:RegisterEvent(...)` is a chance to claim a slot
@@ -127,9 +106,20 @@ static void __fastcall FrameRegisterEvent_h(void *frame, void *edx,
// the latched EnsureInitialized():
// * VanillaFixes' `Load` export, called on the game's main thread after
// injection (the sanctioned VF extension point), or
// * a fallback worker thread we spawn from DllMain, for any other injector
// that never calls `Load`.
// Whichever arrives first installs; the other observes the cached result.
// * a fallback worker thread we spawn from DllMain, ONLY when VanillaFixes
// is not the loader (no VfPatcher.dll in the process), for injectors that
// never call `Load`.
//
// The worker MUST stand down under VanillaFixes. VF creates the process
// suspended and injects every dlls.txt DLL sequentially via remote threads;
// a worker spawned from our DllMain starts the moment our own LoadLibrary
// releases the loader lock — i.e. while VF is still injecting the NEXT
// dlls.txt DLL. Its ~90-prologue install (plus the MH_ApplyQueued
// thread-freeze) then races that DLL's DllMain patching the same engine
// functions, which made SuperWoWhook.dll fail to load whenever ClassicAPI
// preceded it in dlls.txt. `Load` has no such race: VfPatcher calls it on the
// game's main thread after the process resumes, when every DllMain in the
// injection chain has already completed, serialized in dlls.txt order.
// ---------------------------------------------------------------------------
static volatile LONG g_initClaimed = 0; // 0 until a thread takes the installer role
@@ -218,14 +208,22 @@ BOOL APIENTRY DllMain(HMODULE hModule, DWORD reason, LPVOID lpReserved) {
DisableThreadLibraryCalls(hModule);
// Install nothing here (loader lock — see the block comment above).
// Spawn a worker that installs off the lock; the new thread cannot
// run its body until DllMain returns and the loader lock releases,
// and we never wait on it. Under VanillaFixes the `Load` export
// usually wins the latch first; this worker covers every other
// injector. DisableThreadLibraryCalls suppressed its THREAD_ATTACH.
HANDLE worker = CreateThread(nullptr, 0, InitWorker, nullptr, 0, nullptr);
if (worker != nullptr)
CloseHandle(worker);
//
// Under VanillaFixes, `Load` is the ONLY install trigger. VfPatcher.dll
// is injected before any dlls.txt DLL, so its presence identifies VF as
// the loader; and the `Load` mechanism shipped in the same VF release
// (v1.4) as dlls.txt support itself, so any VF that loaded us will call
// it. Spawning the worker here would race the DllMains of the dlls.txt
// DLLs VF injects after us (see the block comment above).
if (GetModuleHandleW(L"VfPatcher.dll") == nullptr) {
// Any other injector: no `Load` caller exists, so install from a
// worker off the lock. The new thread cannot run its body until
// DllMain returns and the loader lock releases, and we never wait
// on it. DisableThreadLibraryCalls suppressed its THREAD_ATTACH.
HANDLE worker = CreateThread(nullptr, 0, InitWorker, nullptr, 0, nullptr);
if (worker != nullptr)
CloseHandle(worker);
}
} else if (reason == DLL_PROCESS_DETACH) {
// Clean /quit path — only tear down if we actually initialized.
// Flush before MH_Uninitialize — the cache's file I/O uses Win32
+17
View File
@@ -17,6 +17,12 @@
namespace Game {
void *ResolveUnitToken(const char *token) {
using ResolveUnitToken_t = void *(__fastcall *)(const char *token);
auto fn = reinterpret_cast<ResolveUnitToken_t>(Offsets::FUN_RESOLVE_UNIT_TOKEN);
return fn(token);
}
namespace Lua {
// Each entry binds a typed function pointer in `Game::Lua::` to the
// corresponding raw VA in `Offsets`. The X-macro keeps the column-aligned
@@ -40,6 +46,7 @@ namespace Lua {
F(RawGet, lua_rawget, LUA_RAW_GET) \
F(SetTable, lua_settable, LUA_SET_TABLE) \
F(RawSet, lua_rawset, LUA_RAW_SET) \
F(SetMetatable,lua_setmetatable,LUA_SET_METATABLE)\
F(Insert, lua_insert, LUA_INSERT) \
F(Remove, lua_remove, LUA_REMOVE) \
F(GetTop, lua_gettop, LUA_GET_TOP) \
@@ -288,6 +295,7 @@ namespace {
ModuleAutoRegister *g_moduleHead = nullptr;
GlueModuleAutoRegister *g_glueModuleHead = nullptr;
HookAutoRegister *g_hookHead = nullptr;
ReloadAutoRegister *g_reloadHead = nullptr;
} // namespace
ModuleAutoRegister::ModuleAutoRegister(Fn f) : fn(f), next(g_moduleHead) {
@@ -309,6 +317,15 @@ void RunGlueModuleRegistrations() {
node->fn();
}
ReloadAutoRegister::ReloadAutoRegister(Fn f) : fn(f), next(g_reloadHead) {
g_reloadHead = this;
}
void RunReloadCleanups() {
for (auto *node = g_reloadHead; node != nullptr; node = node->next)
node->fn();
}
HookAutoRegister::HookAutoRegister(uintptr_t target, void *hook, void **original)
: target(target), hook(hook), original(original), next(g_hookHead) {
g_hookHead = this;
+48
View File
@@ -57,6 +57,19 @@ inline T &Ref(uintptr_t address) {
return *reinterpret_cast<T *>(address);
}
// --- Engine primitives ------------------------------------------------------
// Resolve a unit token (`"player"`, `"target"`, `"party3"`, `"nameplate1"`,
// …) to its live `CGUnit_C *`, through the engine's own resolver
// (`FUN_RESOLVE_UNIT_TOKEN`). Returns null for a valid token that currently
// has no unit (e.g. `"target"` with nothing targeted). NOTE: the engine
// RAISES a Lua error for a string that isn't a valid token form (an arbitrary
// name like `"Bob"`), so only pass real unit tokens — the same contract every
// stock `Unit*` global carries. Centralizes the
// `reinterpret_cast<…>(FUN_RESOLVE_UNIT_TOKEN)` idiom that was copy-pasted
// across a dozen modules.
void *ResolveUnitToken(const char *token);
using FrameScript_Initialize_t = bool(__fastcall *)();
using LoadScriptFunctions_t = void(__fastcall *)();
@@ -110,6 +123,9 @@ using lua_gettable_t = void(__fastcall *)(void *L, int idx);
using lua_rawget_t = void(__fastcall *)(void *L, int idx);
using lua_settable_t = void(__fastcall *)(void *L, int idx);
using lua_rawset_t = void(__fastcall *)(void *L, int idx);
// Pops the table at the top and installs it as the metatable of the value
// at idx; returns 1.
using lua_setmetatable_t = int(__fastcall *)(void *L, int idx);
using lua_insert_t = void(__fastcall *)(void *L, int idx);
using lua_remove_t = void(__fastcall *)(void *L, int idx);
using lua_gettop_t = int(__fastcall *)(void *L);
@@ -165,6 +181,7 @@ extern const lua_gettable_t GetTable;
extern const lua_rawget_t RawGet;
extern const lua_settable_t SetTable;
extern const lua_rawset_t RawSet;
extern const lua_setmetatable_t SetMetatable;
extern const lua_insert_t Insert;
extern const lua_remove_t Remove;
extern const lua_gettop_t GetTop;
@@ -412,4 +429,35 @@ struct HookAutoRegister {
// and stops on first failure.
bool RunHookRegistrations();
// Self-registration for per-/reload state cleanup. A module that keeps
// file-static state which goes STALE across a game /reload declares a
// file-scope `static const Game::ReloadAutoRegister _reload{&PrepareForReload};`
// right after its `void PrepareForReload()`. `RunReloadCleanups()` — called
// from the FrameScript_Initialize hook, BEFORE the engine tears down the Lua
// state — fires them all, so DllMain no longer hand-maintains the list and a
// new module can't forget to wire itself in.
//
// State is "reload-fragile" when it survives in C++ but its meaning does not:
// - Lua registry refs / handler cells (the Lua reset invalidates them);
// - maps/sets keyed by a frame/object POINTER (the allocator recycles
// addresses, so an old entry aliases an unrelated new object);
// - cached event-slot indices (the engine rebuilds the event table).
// Firing a stale entry is the recurring bug this prevents (Tooltip::SetEvents
// issue #33, Frame::Attributes). A pure-C++ cache keyed by spellID/itemID is
// NOT fragile and must not register.
//
// Same lifetime + ordering rules as ModuleAutoRegister: static-init chaining,
// undefined cross-TU order — fine, because each callback only clears its OWN
// state. This is the "clear stale state" category only; a save-before-teardown
// like Player::NameCache::Flush stays an explicit DllMain call (it persists
// rather than clears, and also runs on the glue-return path).
struct ReloadAutoRegister {
using Fn = void (*)();
explicit ReloadAutoRegister(Fn fn);
Fn fn;
ReloadAutoRegister *next;
};
void RunReloadCleanups();
} // namespace Game
+685 -2
View File
@@ -226,6 +226,35 @@ enum Offsets {
// so a pointer-keyed cell never goes stale). Same pattern + ABI modeling as
// FUN_GAMETOOLTIP_SCRIPT_RESOLVER.
FUN_FRAME_SCRIPT_RESOLVER = 0x0076A0D0,
// Button widget script-name resolver — __thiscall(button, const char *name)
// -> int* slot, 0 for an unknown name. Sits at vtable+0xC on all four
// button-family vtables (Button + subclasses). Delegates to the base frame
// resolver FUN_FRAME_SCRIPT_RESOLVER first, then maps the two button
// scripts: OnClick -> button+0x4CC, OnDoubleClick -> button+0x4D4 (each an
// 8-byte {handler, context} slot). Vanilla has no PreClick/PostClick, so
// `Frame::ClickEvents` co-hooks this to hand out an external per-button cell
// for those two names — same technique as FUN_GAMETOOLTIP_SCRIPT_RESOLVER.
FUN_BUTTON_SCRIPT_RESOLVER = 0x00778C50,
// Button OnClick script slot offset (the resolver's OnClick return). Used
// by `Frame::ClickEvents` to recognize an OnClick fire at the runner hook
// below: the fire passes slotPtr == button + this offset.
OFF_BUTTON_ONCLICK_HANDLER = 0x4CC,
// Frame-script runner WITH exec-context stamping — __cdecl(void *frame,
// int *slotPtr, const char *fmt, void *vaPtr). Saves DAT_00ceeac0, stamps
// it from slotPtr[1] (the cell's context), then calls FUN_FRAME_RUN_SCRIPT_ARGS
// (the arg'd runner Frame::ScriptArgs hooks) and restores. Every arg'd
// input-script fire funnels through here via FUN_007026F0 (its variadic
// forwarder); the event dispatcher FUN_00703F50 is the only other caller.
// `Frame::ClickEvents` co-hooks it, gates on `slotPtr == frame +
// OFF_BUTTON_ONCLICK_HANDLER` (an exact OnClick match — no other fire passes
// that slot address), and fires PreClick before / PostClick after by
// re-invoking with the same (fmt, vaPtr) so the button-name arg is reused.
// Deliberately NOT the button click vmethod FUN_00779540, which SuperWoW
// inline-hooks for click-casting (a second hook there faults, ERROR #132 —
// see the note near FUN_SCRIPT_FRAME_GET_STRATA); this runner is uncontested.
FUN_FRAME_RUN_SCRIPT_WITH_CONTEXT = 0x00702710,
// The other per-object tooltip builders, co-hooked the same way as
// FUN_GAMETOOLTIP_BUILD_ITEM to back OnTooltipSetSpell / OnTooltipSetUnit /
// OnTooltipSetGameObject (see Tooltip::SetEvents). Each is the single funnel
@@ -270,7 +299,12 @@ enum Offsets {
// the pointer at +0xB8 (both verified from the setters below). We read a
// line's text/color and re-apply them one slot down, then set line 0.
FUN_FONTSTRING_SET_TEXT = 0x00771D80, // __thiscall(fs, text, flag=0)
FUN_FONTSTRING_SET_COLOR = 0x0077F750, // __thiscall(fs, colorBGRA*)
FUN_FONTSTRING_SET_COLOR = 0x0077F750, // __thiscall(region, colorBGRA*) — generic region color setter (fontstrings + textures)
// Generic region color GETTER — `void __thiscall(region, uint32* outBGRA)`,
// writes the packed {b,g,r,a} (0xFFFFFFFF when the region has no explicit
// color). The read half of Texture:GetVertexColor (FUN_0079aa50); paired
// with FUN_FONTSTRING_SET_COLOR above. Backs EditBox:GetHighlightColor.
FUN_REGION_GET_COLOR = 0x0077F8C0,
OFF_FONTSTRING_TEXT = 0xF0, // char* — current text buffer
OFF_FONTSTRING_COLOR_PTR = 0xB8, // ptr → 4-byte {b,g,r,a} color storage
// A line's FontString is only positioned once shown: set the desired
@@ -419,6 +453,14 @@ enum Offsets {
VAR_SIMPLETEXTURE_POOL = 0x00CF4CE0, // &DAT_00cf4ce0 CSimpleTexture free-list pool (`this`)
VAR_SIMPLETEXTURE_CLASS_TAG = 0x00846588, // ".?AVCSimpleTexture@@" (alloc debug tag)
FUN_SIMPLETEXTURE_CTOR = 0x0076FC40, // __thiscall(mem, parent, layer, sublayer) -> tex
// --- MaskTexture object API (frame:CreateMaskTexture / Texture:AddMaskTexture) ---
// CreateMaskTexture mints its mask region by calling the engine's own
// Script_CreateTexture (reads (frame,name,layer,template) off the Lua stack and
// pushes the new CSimpleTexture), then hides it — the mask is a source, never drawn.
// The draw hook reads the mask's HTEXTURE (OFF_SIMPLETEXTURE_HTEXTURE, +0xCC, set
// by SetTexture and valid while hidden) + its rect (FUN_REGION_GET_RECT); the
// +0xC4 shown flag is OFF_REGION_DESIRED_SHOWN (defined below).
FUN_SCRIPT_CREATE_TEXTURE = 0x00773A20, // Script frame:CreateTexture, __fastcall(L)->int
// CSimpleTexture::SetTexture(path). Loads via FUN_00449D90, stores the owned
// HTEXTURE at +0xCC (releases the old via DecRef FUN_0041AED0), marks dirty.
// `__thiscall(tex, path, 0, *VAR_TEXTURE_BLEND_DEFAULT, 0) -> u32`. The
@@ -482,6 +524,10 @@ enum Offsets {
// rect (see FUN_00770410's SetTexCoord path); texture/Rotation.cpp reads it to
// rebuild axis-aligned corners before rotating.
FUN_REGION_GET_RECT = 0x00768320,
// __thiscall(region+OFF_REGION_ANCHOR) -> int (nonzero = layout dirty, needs a
// resolve). GetLeft gates its resolve on this. Paired with the flag-1 resolve
// below to read a hidden region's rect synchronously.
FUN_REGION_LAYOUT_DIRTY = 0x00768310,
// __thiscall(region+OFF_REGION_ANCHOR, int flag /*stack; pass 0*/) — force a
// synchronous layout resolve: recomputes the region's rect (+0x64) from its
@@ -557,6 +603,34 @@ enum Offsets {
// stale-owner bugs (ghost icons, orphaned records) structurally impossible
// instead of heuristically guarded.
FUN_TEXT_NODE_FREE = 0x005CD950,
// Ensure a text node is laid out: `__fastcall(node)` -> FUN_005cd3f0 + the
// draw builder FUN_005CDC20 (runs the glyph emitter and finalizes the node
// origin +0x70/+0x74), then clears node+0xc0. Same "ensure built" the paint
// pass (FUN_005c8fe0) calls per node; safe to call directly (the builder
// re-lays the node — a clean node just re-emits the same glyphs). Verified
// callers: the paint pass and FUN_005cd4d0 (the width/resize helper).
// Text::InlineTexture drives it PRE-RENDER from the SMF-refresh early-apply
// so a chat line's icons are recorded before the frame draws.
FUN_TEXT_ENSURE_BUILT = 0x005CD6A0,
// CScrollingMessageFrame display refresh — `__thiscall(smf, int newestIdx)`.
// Rebuilds the visible line set bottom-up: per slot it SetTexts the line
// fontstring (FUN_00788af0 -> SetText FUN_00771d80 + enqueue-resolve
// FUN_007680e0 flag 1), (re)anchors newly-allocated lines, shows/hides by
// message. Every new message shifts the newest index, so EVERY visible slot
// is re-SetText'd -> its inline icons must be recomputed; the engine builds
// the line lazily at paint, so icons land one frame after the glyphs (the
// one-frame lag). Text::InlineTexture co-hooks this and, post-refresh,
// forces each icon-bearing line to resolve+build and applies its icon
// placement synchronously — pre-render (the refresh runs from the SMF's
// Lua/event-driven methods, never mid-render), so icons draw with their
// glyphs the same frame. Line-entry layout verified from the decompile:
// count@+0x3A0, stride-0x10 entry array@+0x3A4, entry+8 = the line fs.
FUN_SMF_DISPLAY_REFRESH = 0x00788750,
OFF_SMF_LINE_COUNT = 0x3A0, // int: allocated visible-line count
OFF_SMF_LINE_ARRAY = 0x3A4, // base of the stride-0x10 line-entry array
SMF_LINE_STRIDE = 0x10, // per-line-entry stride
OFF_SMF_LINE_FONTSTRING = 0x8, // entry+8 = the line's CSimpleFontString
// THE pen↔anchor unit bridge. RebuildString (0x7724A0) multiplies every
// text-unit quantity by region+0x7C when crossing into node creation:
// nodePos = inset×s + rect corner, nodeFontH = fontPx×s, spacing = +0xF4×s,
@@ -1020,6 +1094,19 @@ enum Offsets {
OFF_UNIT_FIELD_CHARMEDBY = 0x10,
OFF_UNIT_FIELD_CREATEDBY = 0x20,
// UNIT_CREATED_BY_SPELL — the spell that summoned this unit (descriptor
// byte +0x230, field index 0x8C). Set for every spell-summoned unit:
// totems (the shaman's totem-drop spell), pets, guardians, wild summons.
// Verified two ways: the unit-title builder `FUN_0052FD30` reads
// `[desc+0x230]` as the summoning spell (then switches on its Spell.dbc
// Effect[0] to pick "Guardian"/"Creation"), and the server stamps it
// with `m_spellInfo->Id` (tortoise-wow `EffectSummonTotem`/
// `EffectSummonGuardian`/`EffectSummon`). A broadcast field, so it's
// readable for any unit in range. `Unit::Pet` exposes it as
// `UnitCreatedBySpell`. NB: this is the SUMMONING spell — distinct from
// the spell a totem casts (that's server-side only, never broadcast).
OFF_UNIT_FIELD_CREATED_BY_SPELL = 0x230,
// Pet-vs-minion discriminator for an owned unit: `int __fastcall(unit)`.
// `0x00605570` is really the engine's **creature-type resolver** — it's
// `Script_UnitCreatureType`'s (`0x0051A280`) inner helper and returns the
@@ -1715,6 +1802,15 @@ enum Offsets {
// from SMSG_SPELL_START, not server-broadcast state).
OFF_UNIT_CAST_SPELL = 0xC8C,
// CGUnit field: the unit's sheath state as a plain int member (NOT a
// descriptor UpdateField). 0 = sheathed / no weapon shown, 1 = melee
// weapon drawn, 2 = ranged weapon drawn. Read and written by the engine's
// own sheath toggler `FUN_005EB480` (behind `ToggleSheath`) and its setter
// `FUN_00611CF0` (`*(this+0xD40) = newState`, saving the prior state to
// `+0xD3C`). `Unit::Sheath::GetSheathState` reads the local player's value
// and returns it 1-based (Lua's GetSheathState is 1=None/2=Melee/3=Ranged).
OFF_UNIT_SHEATH_STATE = 0xD40,
// NetClient message dispatch — `__thiscall void(void *conn, uint32_t
// arg, CDataStore *packet)`. Reads the u16 opcode off the packet
// cursor, then invokes `[conn + opcode*4 + 0x74]` (the handler table
@@ -1745,6 +1841,13 @@ enum Offsets {
SMSG_SPELL_CHANNEL_UPDATE = 0x13A,
SMSG_SPELL_DELAYED = 0x1E2,
SMSG_SPELL_FAILED_OTHER = 0x2A6,
// Melee swing result broadcast. Body (verified against the server's own
// writer, tortoise-wow Unit::SendAttackStateUpdate): u32 hitInfo,
// packedGuid attacker, packedGuid victim, u32 totalDamage, u8 subCount,
// per-sub {u32 school, f32 coeff, u32 damage, u32 absorb, i32 resist},
// u32 targetState, u32, u32 spellId, u32 blocked. `Aura::JudgementRefresh`
// reads through totalDamage.
SMSG_ATTACKERSTATEUPDATE = 0x14A,
// NetClient send — `__thiscall void(void *conn, CDataStore *packet)`.
// The outgoing counterpart of FUN_NET_MESSAGE_DISPATCH: every CMSG the
@@ -1803,6 +1906,11 @@ enum Offsets {
VAR_FRAME_METHOD_REGISTRY = 0x00CF4D38,
VAR_TEXTURE_METHOD_REGISTRY = 0x00CF5434,
VAR_FONTSTRING_METHOD_REGISTRY = 0x00CF5400,
// EditBox — 48 methods (table 0x0087BB68), ctx per
// docs/BlizzardScriptAPI.md. Backs `EditBox:SetCursorPosition` /
// `GetCursorPosition` (`EditBox::Methods`), the modern cursor / focus /
// text-state methods vanilla's EditBox lacks.
VAR_EDITBOX_METHOD_REGISTRY = 0x00CF5378,
// Model frame — backs `Model:SetDisplayInfo(creatureDisplayID)`
// (`Model::DisplayInfo`). Registry ctx per docs/raw_methods.txt (table
@@ -4062,6 +4170,15 @@ enum Offsets {
VAR_PET_SPELLBOOK = 0x00B6F098,
SPELLBOOK_MAX_SLOTS = 0x400,
// Active minimap-tracking spell ID (0 = none). Vanilla tracks exactly
// one active tracker as a plain int here, derived from the player's
// active Track Creatures/Resources/Stealthed aura by `FUN_004E4170`
// (an aura-descriptor observer registered by `FUN_004E4100`). Read by
// `Script_GetTrackingTexture` (`0x004E4A20`), `Script_CancelTrackingBuff`
// (`0x004E4A80`), and `GameTooltip:SetTrackingSpell` (`0x00532C50`);
// `Spell::Tracking::GetTrackingInfo` reads it to report `active`.
VAR_ACTIVE_TRACKING_SPELL = 0x00BC6378,
// Player-spell-knowledge bitmap — `[VAR_PLAYER_SPELL_BITMAP]` is a
// pointer to a dword bitmap covering all spellIDs the player has
// learned, including talent passives, racials, profession recipes,
@@ -4332,6 +4449,15 @@ enum Offsets {
// results). Callable directly from C++ — push args on stack, call.
FUN_SCRIPT_CAST_SPELL_BY_NAME = 0x004B4AB0,
// `Script_CastSpell` — engine's Lua wrapper for the slot-based
// `CastSpell(spellbookSlot, bookType)` global. `int __fastcall(void *L)`.
// Parses (slot, bookType) from stack[1]/[2] via `FUN_004B3EC0`, then
// dispatches through `FUN_SPELL_CAST_DISPATCH` with the current target
// (or self). Raises "Invalid spell slot in CastSpell" for a bad slot.
// Callable from C++ by preparing the stack and tail-calling it (the
// `Spell::Tracking::SetTracking` "select = cast" path does this).
FUN_SCRIPT_CAST_SPELL = 0x004B42F0,
// Inner spell-cast dispatcher — `__fastcall(slot, bookType, targetGuidLo,
// targetGuidHi)`. `Script_CastSpellByName` calls this after resolving
// the spell name to a spellbook slot. For ground-target spells, the
@@ -4679,6 +4805,17 @@ enum Offsets {
VAR_LOCALE_INDEX = 0x00C0E080, // 0..8, picks one of the 9 localized strings
// Locale-name string table — a parallel `const char *[8]` indexed by
// VAR_LOCALE_INDEX, holding the exact codes `GetLocale()` returns:
// enUS / koKR / frFR / deDE / zhCN / zhTW / esES / xxYY. This is the
// source `Script_GetLocale` (FUN_0048d8b0) reads:
// lua_pushstring(L, (&PTR_DAT_008558a4)[DAT_00c0e080]);
// Verified by resolving all 8 entries from the binary. Index range is
// 0..7 (VAR_LOCALE_INDEX is documented 0..8, but slot 8 overreads into
// the string pool — clamp to 0..7 before reading). Used by the TOC
// `[TextLocale]` / `[AllowLoadTextLocale]` directives (AddOns::TocRewrite).
VAR_LOCALE_NAME_TABLE = 0x008558A4,
LUA_IS_NUMBER = 0x6F34D0,
LUA_IS_STRING = 0x6F3510,
LUA_TO_NUMBER = 0x6F3620,
@@ -4734,6 +4871,20 @@ enum Offsets {
// CObjects — which is exactly why the engine pushes handler/frame
// through it rather than a plain rawget.
LUA_RAWGETI = 0x6F3BC0,
// `lua_getmetatable(L, objindex)` — pushes the metatable of the value at
// objindex and returns 1, or pushes nothing and returns 0 when it has none.
// Verified in docs/LuaCAPI.md and by the getfenv/getmetatable base-lib
// functions that call it. Used by the `__environment` env-protection hook.
LUA_GET_METATABLE = 0x6F3CF0,
// `lua_setmetatable(L, objindex)` — pops the table at the top of the
// stack and installs it as the metatable of the value at objindex;
// returns 1. Catalogued (★-verified) in docs/LuaCAPI.md. Used by
// Table::Length to build its weak-keyed mark table from C.
LUA_SET_METATABLE = 0x6F4020,
// `lua_getfenv(L, idx)` — pushes the environment table of the function
// (or userdata/thread) at `idx`. The getfenv/setfenv base-lib code calls
// it to read a function's environment before the protection check.
LUA_GET_FENV = 0x6F3D50,
LUA_SET_TABLE = 0x6F3E20,
LUA_RAW_SET = 0x6F3EA0,
LUA_INSERT = 0x6F31A0,
@@ -4800,6 +4951,95 @@ enum Offsets {
// `rawset(nil)` clear leaves it stale and subsequent `table.insert`
// appends past the wiped slots.
LUAL_SETN = 0x6F4EA0,
// `luaL_getn(L, tableIndex)` — Lua 5.0 auxlib table-length getter,
// `int __fastcall(L /*ecx*/, idx /*edx*/)`, plain RET (no stack
// args), length in eax. Verified by disassembly: a negative idx is
// normalized via lua_gettop; tries the `t.n` field (rawget "n"),
// then the registry LUA_SIZES path, then falls back to a linear
// rawgeti scan for the first nil. Counterpart of LUAL_SETN above
// (named there since the table.insert trace). Used by the 5.1
// `unpack(list, i, j)` upgrade for the default range end.
LUAL_GETN = 0x6F5050,
// `table.insert` (5.0 `luaB_tinsert`), `int __fastcall(L)`. Found two
// independent ways: the table-lib luaL_reg pair in .data (entry
// {name 0x008821B4 "insert", func} at 0x00822D70) and the LUAL_SETN
// caller trace above. Decompile matches 5.0.2 tinsert exactly:
// lua_gettop → luaL_checktype(1, TABLE) → n = luaL_getn(1)+1 →
// optional-pos shift-up loop (rawgeti/rawseti) → luaL_setn(1, n) →
// lua_pushvalue(value) → lua_rawseti(1, pos). Both `table.insert` and
// the FrameXML `tinsert` alias resolve to this one C function. Hooked
// by Table::Length to mark deliberate trailing-nil appends (#36/#39).
FUN_LUA_TABLE_INSERT = 0x7FB6B0,
// `luaV_gettable(L, t, key, loop)` — the VM's generic index resolver.
// __fastcall(L /*ecx*/, t /*edx*/, key /*stack*/, loop /*stack*/),
// RET 8. Returns the result TValue* in eax (Ghidra types it void; the
// table-hit path demonstrably reaches the epilogue with eax =
// luaH_get's return — `CALL 0x006fa7a0; CMP [EAX],0; JNZ epilogue` at
// 0x006F7D1D..25). Table case: luaH_get (0x006FA7A0); nil result →
// the metatable continuation FUN_006F7D60, which reads the TABLE'S
// OWN metatable (hvalue+0x08, fast-TM flag byte hvalue+0x06 — 5.0
// fasttm) and returns &nilobject (0x00811BC0) when there is none
// (miss = nil, never an error). Non-table case → the continuation
// below, loop+1. Depth-guarded at 100 ("loop in gettable").
FUN_LUA_V_GETTABLE = 0x006F7CF0,
// The non-table branch of luaV_gettable — stock Lua 5.0's "index a
// non-table value" continuation. Same convention/return as
// FUN_LUA_V_GETTABLE (result TValue* in eax, RET 8). Body: __index
// tag method via luaT_gettmbyobj (0x006F7BD0 — which HARDCODES
// &nilobject for anything but table/userdata; stock 5.0, nothing
// stripped); nil → luaG_typeerror "attempt to index" (0x006FC620);
// function TM → call it; table TM → recurse FUN_LUA_V_GETTABLE with
// `loop` passed through UNCHANGED (0x006F7F23). Exactly 3 call
// sites: luaV_gettable's else-branch + luaV_execute's OP_GETTABLE /
// OP_SELF non-table fallbacks (0x006F8A14 / 0x006F8C17 in
// FUN_006F8720) — so it fires ONLY when code indexes a non-table,
// which in stock 5.0 is always about to raise "attempt to index"
// (table `__index` dispatch goes through FUN_006F7D60's fasttm and
// never lands here). A cold, uncontested hook site. Prologue is
// plain PUSH/MOV — MinHook-relocatable. Co-hooked by
// LuaSyntax::StringMethods to backport Lua 5.1 string methods
// (`("x"):upper()`): strings get the `string` library table as their
// __index, exactly 5.1's shared-string-metatable mechanism.
FUN_LUA_INDEX_NONTABLE = 0x006F7EE0,
// `luaL_loadbuffer(L, buff, size, chunkname)` — compiles a source
// buffer into a Lua function on the stack (does NOT run it). Returns
// an int status (0 = ok) in eax; Ghidra types it void because the
// status flows through `lua_load`. __fastcall(L /*ecx*/, buff /*edx*/,
// size /*stack*/, chunkname /*stack*/); strips a leading UTF-8 BOM.
// This is the UNIVERSAL Lua compile chokepoint — every path that turns
// source text into bytecode funnels through it: `luaL_loadstring`
// (0x006F57C0, backs the `loadstring` global) and the three FrameScript
// execute/load funnels (0x00704AE0 / 0x00704C70 / 0x00703280, backing
// `RunScript`, XML `<OnLoad>`/`<OnClick>` handlers, and file-loaded
// addon chunks). Verified via xrefs to `lua_load` (0x006F4320) and its
// two callers (this = loadbuffer, 0x006F5490 = loadfile). Hooking here
// lets a source-level transform see every chunk before the 5.0 parser.
FUN_LUAL_LOADBUFFER = 0x006F5690,
// Return address of the `call luaL_loadbuffer` inside the compile-AND-run
// funnel FUN_00704AE0 (the CALL is at 0x00704B0C; next instruction, and so
// the pushed return address, is 0x00704B11). FUN_00704AE0 compiles a buffer
// then IMMEDIATELY `lua_pcall`s it (0x00704B42) — it is the funnel every
// addon/FrameXML FILE load reaches (FUN_006ede10 -> FUN_00704bc0 -> here)
// AND what `RunScript` runs through. LuaSyntax's `luaL_loadbuffer` hook
// reads `_ReturnAddress()` and arms the addon-args grant ONLY when the
// caller is THIS site — i.e. a chunk that will be run before anything else,
// so its preamble consumes the grant immediately. `loadstring` (0x00703280,
// returns to 0x007032B5) compiles WITHOUT running and takes a
// caller-forgeable chunkname, so it must NOT arm; the call-site check
// excludes it even when nested inside a RunScript body.
RET_LUA_FILE_COMPILE = 0x00704B11,
// Shared environment-protection predicate for `getfenv`/`setfenv`
// (`bool __fastcall(L /*ecx*/)`). Pushes the function-at-top's environment
// via `lua_getfenv(L, -1)`, then pushes the protection marker
// `env["__fenv"]` (a RAW field on the env table) and returns whether it is
// non-nil — net stack effect +2 (env, marker). `getfenv` (`0x00702AC0`)
// returns the marker in place of the real env when set; `setfenv`
// (`0x00702BF8`) raises "cannot change a protected environment" when set.
// We co-hook this ONE predicate to read `getmetatable(env).__environment`
// (raw) instead — the Lua 5.1 form (verified against 3.3.5's
// `FUN_0084F2F0`) — with the raw `__fenv` field kept as a fallback, so both
// consumers gain the 5.1 semantics with the 1.12 behavior preserved.
FUN_LUA_ENV_PROTECT_PREDICATE = 0x00702BA0,
// `lua_checkstack(L, n)` — `int __fastcall(L /*ecx*/, n /*edx*/)`.
// Ensures room for `n` more stack values, growing if needed; returns 0
// (without growing) when `(top-base)/16 + n` would exceed LUA_MAXCSTACK
@@ -4823,6 +5063,19 @@ enum Offsets {
// `BaseLib::StringLib` registers `string.gmatch` as a direct alias of
// this function pointer — no wrapper.
FUN_LUA_STR_GFIND = 0x007FCFA0,
// `str_gsub` — the Lua 5.0 `string.gsub` C function (strlib entry
// at `0x00822DE0`, name string "gsub" at `0x00882230` — the last
// entry before the registration table's NULL terminator, matching
// stock 5.0 order). Verified by decode: args (s, pat, repl, maxN),
// `^` anchor pre-strip, and the luaL_argerror(3) "string or
// function expected" gate. Its add_value helper (`FUN_007fd2a0`)
// matches stock 5.0: a FUNCTION replacement's non-string result is
// popped and NOTHING is appended (match → empty) — 5.1 instead
// keeps the original match for nil/false. That difference is why
// `BaseLib::StringLib`'s 5.1 table-replacement shim wraps the
// pattern in an outer capture (arg 1 = whole match) so its adapter
// closure can hand the match back for missing keys.
FUN_LUA_STR_GSUB = 0x007FD0E0,
// `str_format` — the Lua 5.0 `string.format` C function (`int
// __fastcall(void *L)`), strlib entry at `0x00822dc0`. Verified by decode:
// reads the format at arg 1, handles `%%`, `%d/i/c`, `%e/E/f/g/G`,
@@ -5139,6 +5392,43 @@ enum Offsets {
// call is a no-op when the user has the addon installed on disk.
FUN_TOC_PARSER = 0x0051C9B0,
// Client interface version — `__cdecl() -> uint32` (no args), a bare
// `return 0x2BC0` (11200). The addon loadability resolver `FUN_0051e780`
// compares an addon's parsed `## Interface:` number (entry+0x1C) against
// this; a mismatch is the OUT_OF_DATE reason (code 7) that blocks loading
// while the `checkAddonVersion` cvar is on. `AddOns::TocRewrite` calls it
// to recognize the client version inside a multi-flavor comma-list.
FUN_ADDON_CLIENT_INTERFACE_VERSION = 0x0051D7D0,
// Per-addon file loader — `__fastcall(char *tocPath, int *bindingsCtx,
// int *progress) -> uint32`. `FUN_0051f240` calls it once per addon with
// `Interface\AddOns\<Name>\<Name>.toc`; it reads the TOC and runs each
// referenced Lua/XML file. Vanilla runs it BEFORE the addon's
// SavedVariables are loaded (files execute with SV nil), so
// `Addons::SavedVarsFirst` co-hooks it to honor `## LoadSavedVariablesFirst`
// by loading the SV first for flagged addons.
FUN_ADDON_LOAD_FILES = 0x006EDB90,
// Read + run a Lua file — `__fastcall(const char *path, void *checksumOut,
// void *errCtx) -> uint32`. Reads via `FUN_FILE_READ`, then compiles +
// pcalls; returns 0 (and fires errCtx's callback only when errCtx != null)
// if the file is missing, so it is safe to call on an absent path. How the
// engine loads each SavedVariables `.lua` (and how addon files run).
FUN_LUA_LOAD_FILE = 0x00704BC0,
// File-exists probe — `__stdcall(const char *path, int mode)` (RET 8);
// nonzero when the file exists. `mode = 1` on the addon / SavedVariables
// paths. Used to mirror the engine's SV path fallback (prefer the
// realm-scoped per-character file, else the realm-less one).
FUN_FILE_EXISTS = 0x00648A30,
// `realmName` CVar value reader — no-arg, `-> const char *` (last-connected
// realm's display name; lazily registers the cvar). The realm segment of
// the per-character SavedVariables path. (The character segment is
// FUN_GET_LOGIN_ACCOUNT_NAME, whose label is a misnomer — it returns the
// 0x00C27D88 buffer, NULL until a character is logged in.)
FUN_GET_REALM_NAME = 0x005AB7D0,
// AddOn registry init — `__fastcall(accountName)`. Documented in
// CLAUDE.md ("AddOn registry & hot-reload"). Hooked post-call so
// we can splice the embedded addon entry into the freshly-populated
@@ -5184,8 +5474,12 @@ enum Offsets {
//
// `VAR_ADDON_LIST_CTRL` is just the address of the control struct
// (i.e., `0x00BE1B64`). Pass `&VAR_ADDON_LIST_CTRL` as the `this`
// arg to `FUN_INTRUSIVE_LIST_INSERT`.
// arg to `FUN_INTRUSIVE_LIST_INSERT`. `VAR_ADDON_LIST_HEAD` is the
// control's +0x08 head slot; walk entries via
// `next = *(entry + [VAR_ADDON_LIST_CTRL] + 4)` (= `entry+0x10`),
// stopping on a low-bit-1 sentinel or NULL.
VAR_ADDON_LIST_CTRL = 0x00BE1B64,
VAR_ADDON_LIST_HEAD = 0x00BE1B6C,
// `SStrDup(const char *src, const char *file, int line)`. `__stdcall`.
// Storm's string-copy wrapper around `SMemAlloc` — used by the engine
@@ -5457,6 +5751,178 @@ enum Offsets {
VAR_ADDON_ARRAY = 0x00BE1B94,
VAR_ADDON_COUNT = 0x00BE1B90,
// AddOnEntry fields shared by `Addons::Embedded` and `Addons::Rescan`
// (the full per-field derivation lives in CLAUDE.md's AddOn registry
// section):
// +0x14 — canonical name pointer (what the load pass passes to the
// by-name loader `FUN_0051F240`).
// +0x29 — filter-out byte; the flat display-array builder skips
// entries where it's non-zero (how `!!!ClassicAPI` hides).
// LoadWith desc@+0x58 (count@+0x5C, data@+0x60) — `## LoadWith:`
// names, parsed by `FUN_TOC_PARSER` (append site writes
// rec[0x16..0x19]).
// reverse-LoadWith desc@+0x88 (count@+0x8C, data@+0x90,
// quantum@+0x94) — `AddOnEntry *`s of every addon that
// declared `## LoadWith: <this>`; built by the scan
// `FUN_0051C760`'s tail loop (rec[0x22..0x25]), consumed by
// the loader's post-ADDON_LOADED loop.
OFF_ADDON_ENTRY_NAME_PTR = 0x14,
OFF_ADDON_ENTRY_FILTER_OUT = 0x29,
OFF_ADDON_LOADWITH_COUNT = 0x5C,
OFF_ADDON_LOADWITH_ARRAY = 0x60,
OFF_ADDON_REVLOADWITH_DESC = 0x88,
// `## Secure:` flag byte. Parser write site verified in
// `FUN_TOC_PARSER`'s decompile (`*(bool *)(entry_dwords + 10)` =
// `+0x28`); also set by the SMSG_ADDON_INFO secure path at login.
// Entries with this set are SMSG-managed (Blizzard addons on stock
// clients) — never evict/re-register them: the parser cannot restore
// packet-delivered state (and their MPQ TOCs never change anyway).
OFF_ADDON_ENTRY_SECURE = 0x28,
// Complete single-`AddOnEntry` destructor — vtable slot 0 of the
// registry's node factory (`[0x00BE1B60]` = `&PTR_FUN_00808c7c`,
// installed by the boot init `FUN_0051C360`; the login teardown
// `FUN_0051FA40` dispatches per entry through it).
// `__stdcall(AddOnEntry *)`, RET 4 (arg read from `[EBP+8]`,
// verified by disassembly). Body (`FUN_005200A0` → field cleanup
// `FUN_005200D0`, both decompiled):
// - frees EVERY owned allocation: name (+0x14), the +0x30 string,
// all five dep/SV string arrays AND their buffers
// (+0x38/+0x48/+0x58/+0x68/+0x78), the reverse-LoadWith buffer
// (+0x88), and the per-entry `##` metadata hash table (+0x98 —
// the Title/Notes/Author/X-* nodes `GetAddOnMetadata` reads);
// - SELF-UNLINKS from both registry structures: the intrusive
// list (link node +0xC via `FUN_00520A60`, which neighbor-
// patches from the node alone and no-ops when already unlinked)
// and the hash bucket chain (+0x4);
// - frees the entry struct itself (`__AUUIADDON__` tag).
// What it does NOT clean: THIS entry's pointer inside OTHER
// entries' reverse-LoadWith lists — the caller must scrub those
// first (`Addons::Rescan`), or the loader's post-ADDON_LOADED loop
// (`FUN_0051F240` tail: reads +0x8C/+0x90, derefs each entry's
// +0x14) reads freed memory.
FUN_ADDON_ENTRY_DESTROY = 0x005200A0,
// `u8` set to 1 by `FUN_ADDON_INIT` after the login scan completes,
// cleared by the registry teardown `FUN_0051FA40`. Gates anything
// that touches the registry outside the login path.
VAR_ADDON_INITIALIZED = 0x00BE1C08,
// ── The frozen loose-file index (why new files need a restart) ────
//
// Every relative-path file open resolves through `FUN_00647E60`,
// which consults a HASH TABLE OF LOOSE FILES built ONCE per process:
// `FUN_00646EA0` (lazy, latched by `VAR_VFS_INDEX_READY`) walks the
// whole game directory tree via `FUN_VFS_INDEX_SUBTREE`, keying each
// file by its base-relative path and storing the joined on-disk path.
// Index hits open the mapped path LIVE from disk (edits to existing
// files take effect on /reload); index misses fall through to the
// MPQ search ONLY — the archive layer's live disk probe
// (`FUN_00654DD0` → GetFileAttributesA) is dead code at runtime
// because its enable global `DAT_00865B44` (ships as 1 in the image)
// is unconditionally cleared by early boot (`FUN_00402210` →
// `FUN_00648C20`). File WRITES (`FUN_0042A460` = raw CreateFileW)
// never register in the index either. Net effect on a stock client:
// any file created after boot — a new addon's TOC, a new .lua in an
// existing addon, even a freshly written SavedVariables file — is
// INVISIBLE to every relative-path read until the client restarts.
//
// `FUN_VFS_INDEX_SUBTREE` — the recursive indexer itself:
// void __fastcall IndexSubtree(
// const char *basePath, // ecx — index keys are relative to
// // this (the game dir)
// const char *relSubdir, // edx — subtree below basePath; ""
// // for the boot-time full walk
// void *findHandle);// stack — from FUN_VFS_FIND_OPEN on
// // join(basePath, relSubdir)
// Dedup-safe: every insert is preceded by a hash lookup, so
// re-running it on a subtree registers only genuinely new files —
// which is exactly what `Addons::Rescan` does per /reload for
// `Interface\AddOns` and `WTF\Account`.
//
// `FUN_VFS_FIND_OPEN` — `__stdcall(const char *dirPath) -> void*`
// (RET 4). Allocates a find block, strips trailing backslashes,
// requires the path to be an EXISTING DIRECTORY
// (GetFileAttributes & 0x10) — returns NULL otherwise — then arms
// it with "\*". Release with `FUN_VFS_FIND_CLOSE`
// (`__stdcall(void*)`, RET 4; NULL-safe).
//
// `VAR_VFS_BASE_PATH` — the game base dir recorded at boot
// (`FUN_006488B0`); `FUN_00646EA0` uses it as the walk base when
// `FUN_VFS_FIND_OPEN` accepts it, else falls back to ".".
// `VAR_VFS_INDEX_READY` — `u8` latch; nonzero once the boot walk ran.
FUN_VFS_INDEX_SUBTREE = 0x00646910,
FUN_VFS_FIND_OPEN = 0x006674A0,
FUN_VFS_FIND_CLOSE = 0x006676A0,
VAR_VFS_BASE_PATH = 0x00C52418,
VAR_VFS_INDEX_READY = 0x00C5251C,
// ── Login scan walk #2 (loose-addon discovery) — replayed per /reload
//
// `FUN_ADDON_SCAN_DISK_DIRS` — generic disk directory walker
// (resolves to kernel32 FindFirstFileW; the DISK sibling of
// `FUN_MPQ_ENUM_FILES`):
// int __fastcall ScanDiskDirs(
// const char *basePath, // ecx — e.g. "Interface\AddOns\"
// const char *pattern, // edx — appended to basePath; the
// // scan passes the "*" at
// // VAR_ADDON_SCAN_PATTERN
// Callback cb, // stack — see below
// void *userParam, // stack — passed to cb in edx
// int includeHidden); // stack — 0 skips hidden files
// RET 0xC. Callback: `int __fastcall(FindInfo * /*ecx*/,
// void *userParam /*edx*/)` with FindInfo `{+0x4 attrs (0x10 =
// directory), +0x8 char name[]}`; returning nonzero STOPS the walk
// (note: OPPOSITE of `FUN_MPQ_ENUM_FILES`' convention).
//
// `FUN_ADDON_DISK_DIR_CB` — the engine's own per-entry callback for
// this walk (defined + verified in Ghidra): for every directory not
// starting with '.', calls `FUN_TOC_PARSER` on the name; returns 0.
// `Addons::Rescan` replays the scan's exact call —
// `ScanDiskDirs(VAR_ADDON_PATH_PREFIX, VAR_ADDON_SCAN_PATTERN,
// FUN_ADDON_DISK_DIR_CB, 0, 0)` — so new-folder discovery is 100%
// engine code; the parser's dedup guard makes it a no-op per
// already-registered addon.
FUN_ADDON_SCAN_DISK_DIRS = 0x0042AD10,
FUN_ADDON_DISK_DIR_CB = 0x0051D7B0,
VAR_ADDON_SCAN_PATTERN = 0x00833180, // "*" (walker config block)
VAR_ADDON_PATH_PREFIX = 0x00853724, // "Interface\AddOns\"
// ── Flat display array + descriptor grow helpers ──────────────────
//
// The `GetNumAddOns`/`GetAddOnInfo(i)` array (`VAR_ADDON_ARRAY` /
// `VAR_ADDON_COUNT` above) is a full `{cap, count, data, quantum}`
// descriptor at `VAR_ADDON_ARRAY_CAP` (cap@0x00BE1B8C,
// count@0x00BE1B90, data@0x00BE1B94, quantum@0x00BE1B98) holding
// NAME POINTERS (entry+0x14), rebuilt from scratch and qsorted by
// `FUN_0051DA70` phase 2 whenever SMSG_ADDON_INFO lands.
// `Addons::Rescan` mirrors that phase (never calls `FUN_0051DA70`
// itself — its phase 1 consumes the packet) so newly appended
// registry entries show up in the by-index Lua surface.
//
// Grow helpers (one template instantiation per desc — use each at
// its verified site): `__thiscall(desc /*ecx*/, uint newCap)`,
// RET 4 — Storm-reallocs `desc->data` to newCap*4 and writes
// `desc->cap = newCap`.
// FUN_ADDON_ARRAY_GROW — flat display array (used by
// `FUN_0051DA70` phase 2)
// FUN_ADDON_REVLOADWITH_GROW — reverse-LoadWith descs (used by the
// scan tail loop)
// `FUN_DESC_QUANTUM_CALC` — `__thiscall(desc /*ecx*/, uint needed)`,
// RET 4: returns the append-rounding quantum (highest power of two
// ≤ needed, min 1; ≥ 0x40 caps at 0x40 and stores desc->quantum).
// `FUN_CRT_QSORT` — the CRT `qsort` (`__cdecl(base, num, width,
// cmp)`); `FUN_ADDON_NAME_COMPARE` is the engine's `__cdecl`
// name-pointer comparator `FUN_0051DA70` sorts the array with.
VAR_ADDON_ARRAY_CAP = 0x00BE1B8C,
VAR_ADDON_ARRAY_QUANTUM = 0x00BE1B98,
FUN_ADDON_ARRAY_GROW = 0x004755F0,
FUN_ADDON_REVLOADWITH_GROW = 0x005213C0,
FUN_DESC_QUANTUM_CALC = 0x00521C50,
FUN_CRT_QSORT = 0x0073F727,
FUN_ADDON_NAME_COMPARE = 0x0051DEB0,
// NOTE: an earlier revision defined OFF_PLAYER_FIELD_VIS_BYTES /
// PLAYER_VIS_BIT_STEALTH here at 0x17C, believing bit 0x02 was a
// "stealth bit". That was wrong: 0x17C is UNIT_FIELD_AURALEVELS
@@ -6158,6 +6624,23 @@ enum Offsets {
FUN_GET_OBJECT_BY_GUID = 0x00468460,
OBJECT_TYPE_ITEM = 2,
// Melee auto-attack primitives (back StartAttack / StopAttack). 1.12
// exposes only the toggling AttackTarget (0x00489B50 -> FUN_006131A0);
// these are the non-toggling start/stop it calls internally.
// START: __thiscall(player, const uint64_t *targetGuid). Validates
// CanAttack on the target, sends CMSG_ATTACKSWING (opcode 0x141), and
// stamps the victim GUID at [player+0xC48]. Idempotent / switch-aware
// on a valid target (never stops an in-progress attack); an
// unattackable target while attacking routes to STOP.
// STOP: __fastcall(player). Clears the attack state; self-guards on the
// attack flag, so it is a no-op (safe) when not attacking.
// RESOLVE_TARGET: __thiscall(player, uint64_t *outGuid) -> int. Validates
// the player's CURRENT target (charm/fear/confuse redirect + CanAttack)
// and writes its GUID; returns 0 (and posts the UI error) if invalid.
FUN_ATTACK_START = 0x005ECB70,
FUN_ATTACK_STOP = 0x005ECAC0,
FUN_ATTACK_RESOLVE_TARGET = 0x00612DF0,
// Inbox — array of mail-entry pointers behind a slot indirection.
// The address itself holds a heap pointer (`MOV ECX, [imm32]` in
// the engine), so reading the array requires one extra deref
@@ -6310,6 +6793,12 @@ enum Offsets {
// SPELL_ATTR_EX3_IGNORE_CASTER_MODIFIERS (verified via disassembly; the
// server enum agrees). NOT AttributesEx2's bit 29 (that's CANT_CRIT).
SPELL_ATTR_EX3_IGNORE_CASTER_MODIFIERS = 0x20000000,
// AttributesEx3 bit 18 — the paladin judgement-debuff marker. The server's
// melee-swing judgement refresh selects auras by SPELLFAMILY_PALADIN +
// this bit (tortoise-wow SpellDefines.h SPELL_ATTR_EX3_ALWAYS_HIT,
// Unit::DealMeleeDamage). Verified in the client Spell.dbc: all 14
// Judgement of Light/Wisdom/Justice/Crusader debuff ranks carry it.
SPELL_ATTR_EX3_ALWAYS_HIT = 0x00040000,
// AttributesEx channel bits (CHANNELED_1 0x4 | CHANNELED_2 0x40) and the
// AttributesEx2 autorepeat flag (bit 5, Auto Shot / Shoot) — each shared
// by more than one module, so kept here rather than redefined locally.
@@ -6711,6 +7200,31 @@ enum Offsets {
// (case-insensitive, hashed). See FUN_006ee6f0 in Templates.cpp notes.
FUN_XML_TEMPLATE_LOOKUP = 0x006EE6F0,
// `CreateFrame(type [, name, parent, template])` — the global FrameScript
// function at table 0x00872E74 slot 3. Standard `int __fastcall(void *L)`
// shape. Vanilla accepts only a SINGLE template name; the hook in
// `Frame::CreateFrame` splits comma-separated inherits strings so modern
// multi-template calls work (e.g. "UIPanelButtonTemplate,
// SecureActionButtonTemplate"). It builds the frame with the first
// resolved template through the engine, then applies each remaining
// template onto the created object with the engine's OWN inherit
// primitive: `frameObj->vtable[+8](templateDefNode, status)` for content
// (FUN_00769820) and `(frameObj+0x24)->vtable[+8](...)` for the template's
// `<Frames>` children (FUN_0076a060) — exactly the two `__thiscall`
// recursion calls the builder FUN_006ee280 emits when resolving an
// `inherits=`. Finalize/OnLoad (`vtable[+0x24]`) runs once during the
// engine build and is NOT re-run.
FUN_SCRIPT_CREATEFRAME = 0x007060B0,
// XML-build "status" object — a printf-style error/warning accumulator the
// appliers log through (`status->vtable[+0xc](status, level, fmt, ...)`,
// __cdecl varargs). Script_CreateFrame builds one on its stack as the
// 5-dword `{PTR_TEXLOAD_DESC_VTBL, 8, &self+8, (&self+8)|1, 0}` (same shape
// the texture loader reuses; see PTR_TEXLOAD_DESC_VTBL). Reset/emptied by
// `FUN_FRAMESCRIPT_STATUS_RESET` (`__thiscall(this)`), which frees any
// accumulated message nodes and re-stamps the vtable.
FUN_FRAMESCRIPT_STATUS_RESET = 0x00419E30,
// --- Inline texture escape (`|Tpath:h:w:...|t`) backport -------------------
// See src/text/InlineTexture.cpp and docs/InlineTextureEscapes.md. The
// 1.12 text pipeline is a shared `|`-tokenizer feeding per-purpose loops.
@@ -6732,6 +7246,126 @@ enum Offsets {
// Script_Texture_SetTexture's own call site does.
VAR_TEXTURE_BLEND_DEFAULT = 0x00878CF0,
// --- Texture masking (`Texture:SetMask` backport) ---------------------------
// Mirrors the engine's own canonical masked-quad draw: the minimap BLIP mask
// FUN_004eae10 (a sibling of the disc FUN_004ec440; both fed by the mask that
// Minimap:SetMaskTexture at 0x004EE4A0 loads into DAT_00bc7968). FUN_004eae10
// is the exact template — bind base on unit 0, mask on unit 1, force each
// unit's combiner to MODULATE, submit with SEPARATE uv0/uv1, then unbind
// unit 1:
// GxRs(0x17, base); GxRs(0x1F, 1); // unit 0: bind + MODULATE preset
// GxRs(0x18, mask); GxRs(0x20, 1); // unit 1: bind + MODULATE preset
// FUN_0058a2a0(4, corners, …, uv0, uv1); // explicit second UV stream
// GxRs(0x18, 0); // unbind unit 1
// Unit 1's MODULATE combiner multiplies the mask's alpha into the base's
// alpha, so a white-with-alpha mask clips the base to the mask's shape.
//
// The recipe was also pinned by an in-game probe (git history's
// MaskProbe.cpp) on the live backend — findings, load-bearing:
// * The COMBINER PRESET selectors are 0x1F+unit (GXRS_COMBINER0), value 1 =
// MODULATE/MODULATE (the .rdata preset tables at 0x0080a25c/0x0080a274,
// consumed by the D3D9 combiner apply FUN_005a2dd0: COLOROP table
// {4,4,13,7,5,12}, ALPHAOP {3,4,3,7,5,3} — value 1 is the only preset
// that MODULATEs the alpha channel too, which is what masking rides).
// Binding a texture on unit 1 alone happens to give MODULATE by default,
// but FUN_004eae10 sets it EXPLICITLY — we mirror that.
// * The SECOND vertex UV stream (uv1) DOES feed unit 1 (FUN_004eae10
// passes a distinct uv1 = this+0x50), so uv1 = uv0 makes unit 1 sample
// the mask at the base's own texcoords. SetMask originally rode this
// recipe but was moved to texgen (below): texcoord-relative sampling
// breaks for SetTexCoord'd sprites (the mask gets sampled at the same
// sub-rect OF THE MASK IMAGE), and uv1 is the LAST explicit UV slot
// that exists — the vertex-format component table at 0x008097A8
// (13 formats × 13 components) has no format with a third UV set, so
// masks past the first cannot ride an explicit stream anyway.
GXRS_TEXTURE0 = 0x17, // texture bind, +unit 0..7 (via FUN_GX_RS_SET_PTR)
GXRS_COMBINER0 = 0x1F, // combiner preset, +unit 0..7 (via FUN_GX_RS_SET)
GXRS_COMBINE_MODULATE = 1, // preset value: COLOROP=MODULATE, ALPHAOP=MODULATE
// GxRs setters — `__fastcall(selector, value)`. The int form (0x00589E60)
// writes combiner presets and the like; the pointer form (0x00589E80) writes
// texture binds.
FUN_GX_RS_SET = 0x00589E60,
FUN_GX_RS_SET_PTR = 0x00589E80,
// --- Texgen + per-unit texture matrices (multi-mask / rotated masks) -------
// Decoded from the D3D9 backend's draw-time state flush FUN_005a2f00 (the
// selector → SetTextureStageState/SetTransform Rosetta stone) and the
// minimap DISC draw FUN_004ec440, which is the engine's own texgen-mask
// template (it submits positions+colors ONLY and feeds BOTH units via
// texgen + a per-unit texture matrix). Selector namespace is five per-unit
// families, each 8 units wide (device supports 8 fixed-function stages;
// live cap at [VAR_GX_DEVICE]+OFF_GXDEV_STAGE_CAP):
// 0x17+u bind 0x1F+u combiner 0x27+u mip-bias 0x2F+u texgen
// 0x37+u coord-transform mode
// Texgen values (FUN_005a2b40): 0 = passthrough (stage reads vertex UV set
// #u — the default; why stage 1 sees the explicit uv1 stream), 1 = OBJECT-
// space position (D3D TCI_CAMERASPACEPOSITION + an auto inv(view)·inv(model)
// basis matrix — UV becomes a pure function of the RAW vertex position,
// immune to whatever model/view the pass set), 2 = world-space position,
// 3 = camera-space position (identity basis; what the disc draw uses),
// 5/4/6 = normal/reflection.
// Coord-transform values (FUN_005a14e0): 0 = off, 1 = apply the unit's
// texture matrix (basis × user matrix, D3DTTFF_COUNT3), 2 = projected.
// Per-unit texture-matrix STACKS (4 levels, base ctx+0xFD0+unit*0x118;
// slot 8 = model, 10 = view) — the disc draw's push/translate/scale/pop:
GXRS_TEXGEN0 = 0x2F, // texgen mode, +unit (int form)
GXRS_TEXGEN_OBJECT_POS = 1,
GXRS_TEXXFORM0 = 0x37, // coord-transform mode, +unit (int form)
GXRS_TEXXFORM_MATRIX = 1,
// Texture-matrix stack ops — `__fastcall(int unit, ...)`. PUSH duplicates
// the top (max depth 4); PUSH_LOAD pushes then loads an arbitrary 4×4
// (row-vector D3D layout: out = pos·M, translation in row 3). POP restores.
// (PUSH/POP were previously mislabeled FUN_GX_TEXUNIT_ENABLE/_DISABLE —
// they never enabled anything; binding a texture is what enables a stage,
// binding null disables it, per FUN_005a29d0.)
FUN_GX_TEXMTX_PUSH = 0x0058B200, // (unit)
FUN_GX_TEXMTX_PUSH_LOAD = 0x0058B210, // (unit, const float m[16])
FUN_GX_TEXMTX_POP = 0x0058B220, // (unit)
// Gx render-state journal push/pop — brackets a state scope; pop replays
// every selector written since the push back to its prior value. The engine
// wraps each region-layer draw (FUN_0076fb00) and the disc draw in a pair.
FUN_GX_STATE_PUSH = 0x00589F40,
FUN_GX_STATE_POP = 0x00589F50,
// The indexed submit that actually queues the draw for the verts staged by
// FUN_GX_PRIM_STREAMS — `__fastcall(int primType, int indexCount, const
// void *indices)`. Every FUN_GX_PRIM_STREAMS caller invokes it immediately
// after (region layer: FUN_0076fb00; disc: FUN_004ec440). State/matrix
// RESTORES must run AFTER this call, not after the stream call — the disc
// draw pops its matrices/state only past this point, and restoring earlier
// reverts the state the queued draw will be flushed with.
FUN_GX_PRIM_INDEXED = 0x0058A2E0,
// The device/context singleton the Gx wrappers route through (set at
// backend creation, FUN_00589ac0). +0x23C holds the live texture-stage cap
// the per-stage applies clamp against (FUN_005a29d0/FUN_005a2b40).
VAR_GX_DEVICE = 0x00C0ED38,
OFF_GXDEV_STAGE_CAP = 0x23C,
// The vertex-stream primitive every textured region draws through
// (FUN_0076fb00 calls it per region). Decompiler signature (13 params):
// (count, positions, posStride, s3, s3Stride, colors, colorStride, drop8,
// drop9, uv0, uv0Stride, uv1, uv1Stride); RET 0x2C. The inner FUN_0058a3d0
// builds an interleaved vertex from whichever of {s3, colors, uv0, uv1} are
// non-null — so uv1 is a real, buildable second UV slot no engine caller
// populates. `positions` for a region draw is the region's corner array
// (region + OFF_SIMPLETEXTURE_CORNERS), which is how the mask co-hook
// recovers the owning region: region = positions - OFF_SIMPLETEXTURE_CORNERS
// (verified: FUN_00772fd0 appends the batch entry with positions =
// region+0xD4, and FUN_0076fb00 forwards that pointer here).
FUN_GX_PRIM_STREAMS = 0x0058A2A0,
// Path → HTEXTURE loader trio (re-adds of the quad-era offsets removed in
// 56c2670, with the same derivations — see docs/InlineTextureEscapes.md):
// FUN_TEXTURE_LOAD_BY_PATH(path, &desc, flags, 0, 1) __fastcall, desc =
// the 5-dword {PTR_TEXLOAD_DESC_VTBL, 8, &self8, (&self8)|1, 0} on-stack
// node FUN_00770200 builds; flags via FUN_GX_TEXFLAGS_INIT(&flags, blend,
// 0,0, 0,0,0, 1, 0) __thiscall. Never returns null (engine substitutes a
// fallback texture). FUN_TEXTURE_GET_RENDERABLE(hTex, 1, 0) __fastcall
// returns the bindable CGxTex at [hTex+0x140] and drives the streaming
// load — calling it each frame IS the residency reference (what the
// minimap's masked draw does for its mask at 0x004ec65c).
FUN_TEXTURE_LOAD_BY_PATH = 0x00449D90,
PTR_TEXLOAD_DESC_VTBL = 0x007FFA10,
FUN_GX_TEXFLAGS_INIT = 0x0058A980,
FUN_TEXTURE_GET_RENDERABLE = 0x0044ACF0,
// --- Inline-texture positioning (slice 1: measure/emit integration) --------
// The 1.12 text pipeline builds a layout as a list of render "nodes" (one
// per wrapped line); each node owns per-font-page glyph vertex batches and a
@@ -6950,6 +7584,55 @@ enum Offsets {
// *(this+0xF0) via SStrDup.
OFF_EDITBOX_TEXT_FONTSTRING = 0x328, // CSimpleFontString* — the display text object
// CSimpleEditBox cursor / selection internals (backs EditBox::Methods'
// SetCursorPosition / GetCursorPosition). Verified from the engine's own
// HighlightText (FUN_00798870 → FUN_0077cca0) and Insert (FUN_00798400 →
// FUN_0077bee0) paths:
// +0x31c dirty-flag dword — bit 2 = selection/highlight changed, bit 4 =
// cursor changed (both trigger the next-paint relayout).
// +0x338 text length in BYTES (the clamp bound below).
// +0x35c / +0x360 selection start / end (byte offsets).
// +0x36c cursor insertion point, a BYTE offset — matches modern WoW's
// byte-based EditBox cursor (GetCursorPosition returns bytes; for
// ASCII text byte == character).
OFF_EDITBOX_FLAGS = 0x31c,
OFF_EDITBOX_TEXT_LENGTH = 0x338, // current text length in bytes
OFF_EDITBOX_CURSOR_BYTE = 0x36c,
// `void __thiscall(editbox, byteOffset)` — sets the cursor to a byte
// offset, clamping to [0, textByteLen] and OR-ing the +0x31c cursor-dirty
// bit. The engine's own "restore cursor" primitive (called from the insert
// path FUN_0077bee0 to put the caret back after splicing text).
FUN_EDITBOX_SET_CURSOR_BYTE = 0x0077e360,
// `void __thiscall(editbox)` — collapses the selection onto the cursor
// (selStart = selEnd = +0x36c). The engine's own "clear selection to the
// caret" helper (FUN_0077ccf0), used before extending a shift-selection.
FUN_EDITBOX_COLLAPSE_SELECTION = 0x0077ccf0,
// `int __thiscall(editbox, startByteIndex, byteCount)` — counts CHARACTERS
// (UTF-8 codepoints) in a byte range, walking the per-byte char-class table
// at +0x330 (a byte begins a codepoint when its class is 2/3/6). So
// `COUNT_CHARS(eb, 0, cursorByteOffset)` is the cursor's character index —
// backs GetUTF8CursorPosition. Verified from the arrow-key mover
// FUN_0077cb20's own `FUN_0077bc80(this, byteOff, cursorByte - byteOff)`.
FUN_EDITBOX_COUNT_CHARS = 0x0077bc80,
// Selection-highlight regions — three consecutive CSimpleTexture pointers
// (start-line +0x350, middle-block +0x354, end-line +0x358) the editbox
// shows/positions to paint the selection (FUN_0077d950). Their vertex
// color IS the highlight color — Set/GetHighlightColor read/write it via
// the generic region color accessors. The blinking caret is a SEPARATE
// region at +0x3d4, deliberately left alone.
OFF_EDITBOX_HIGHLIGHT_REGION = 0x350,
// Input-history ring buffer: max lines (ring capacity) at +0x388 (what
// GetHistoryLines reads), write position at +0x38c, container
// {size@+0x390, alloc@+0x394, data@+0x398} of {char* text, int exec}
// entries. AddHistoryLine writes at +0x38c and advances modulo +0x388.
OFF_EDITBOX_HISTORY_MAX = 0x388,
// `void __thiscall(editbox, int maxLines)` — the SetHistoryLines internal
// (FUN_0077cd90). Reallocates the history ring to maxLines; maxLines == 0
// frees the whole buffer and zeroes the container. ClearHistory calls it
// with 0 then the saved max — empties every entry and resets the write
// position while keeping the limit (so recording still works after).
FUN_EDITBOX_SET_HISTORY_MAX = 0x0077cd90,
// --- FontString measure internals (GetStringWidth icon fix + GetStringHeight) ---
// CSimpleFontString::GetStringWidthInternal — `float(__fastcall)(fs /*ecx*/)`,
// returns in x87 ST0. Lazily computes into the fs+0xFC cache (ANCHOR units,
+90 -89
View File
@@ -47,6 +47,12 @@
#include "Game.h"
#include "Offsets.h"
#include "addons/EngineIO.h"
#include "addons/FlavorBindings.h"
#include "addons/FlavorToc.h"
#include "addons/Registry.h"
#include "addons/Toc.h"
#include "addons/TocRewrite.h"
#include "bindings/Inject.h"
#include <cstdint>
@@ -111,30 +117,15 @@ const ClassicAPIFiles::File *LookupEmbedded(const char *suffix) {
return nullptr;
}
// Storm allocator — same one `FUN_FILE_READ` uses internally. Buffer
// allocated here is freed cleanly by the caller's standard `SMemFree`
// (`FUN_STORM_SMEM_FREE`). `__stdcall` per the function's `RET 0x10`
// (4 args × 4 bytes = 16) epilogue.
// __stdcall void *SMemAlloc(size_t size, const char *file, int line, int flags)
using SMemAlloc_t = void *(__stdcall *)(size_t size, const char *file, int line, int flags);
// Engine file I/O + Storm allocator — see addons/EngineIO.h for the shapes and
// the __stdcall / ESP-drift hazard. A buffer we SMemAlloc here is freed cleanly
// by the caller's standard SMemFree in turn.
using AddOns::EngineIO::FileReadFn;
using AddOns::EngineIO::SMemAllocFn;
using AddOns::EngineIO::SMemFreeFn;
// `FUN_FILE_READ` — same shape derived from the Octo decompile.
// Calling convention is `__stdcall` (callee cleans 28 bytes via
// `RET 0x1C`), not `__cdecl` — confirmed via the function epilogue.
// Getting this wrong silently corrupts the caller's stack frame.
// __stdcall int FileRead(int unused, const char *path, void **outBuf,
// size_t *outSize, size_t extraBytes,
// int flag1, int flag2)
using FileRead_t = int(__stdcall *)(int unused, const char *path, void **outBuf,
size_t *outSize, size_t extraBytes,
int flag1, int flag2);
FileRead_t FileRead_o = nullptr;
// Storm-allocator free. Used to release the disk-TOC buffer after we
// scrape its version line — same path the engine itself uses when
// `FileRead_o` succeeded and the caller's done with the result.
// __stdcall void SMemFree(void *buf, const char *file, int line, int flags)
using SMemFree_t = void(__stdcall *)(void *buf, const char *file, int line, int flags);
// The trampoline to the original FUN_FILE_READ (installed by the co-hook below).
FileReadFn FileRead_o = nullptr;
// Extracts the value of the `## Version: X` line from a TOC byte
// buffer. Writes into `out` (size `outSize`) and returns true on
@@ -142,25 +133,19 @@ using SMemFree_t = void(__stdcall *)(void *buf, const char *file, int line, int
// sensitive on the key. Returns false if no Version line is found.
bool ExtractTocVersion(const char *content, size_t size,
char *out, size_t outSize) {
static const char kKey[] = "## Version:";
constexpr size_t kKeyLen = sizeof(kKey) - 1;
for (size_t i = 0; i + kKeyLen <= size; ++i) {
const bool atLineStart = (i == 0) || content[i - 1] == '\n';
if (!atLineStart) continue;
if (std::memcmp(content + i, kKey, kKeyLen) != 0) continue;
const char *p = content + i + kKeyLen;
const char *end = content + size;
while (p < end && (*p == ' ' || *p == '\t')) ++p;
size_t j = 0;
while (p < end && *p != '\r' && *p != '\n' && j + 1 < outSize) {
out[j++] = *p++;
}
while (j > 0 && (out[j - 1] == ' ' || out[j - 1] == '\t')) --j;
out[j] = '\0';
return j > 0;
const char *v = nullptr;
size_t n = 0;
if (outSize == 0)
return false;
if (!AddOns::Toc::FindValue(content, size, "## Version:", &v, &n) || n == 0) {
out[0] = '\0';
return false;
}
if (outSize > 0) out[0] = '\0';
return false;
if (n >= outSize)
n = outSize - 1;
std::memcpy(out, v, n);
out[n] = '\0';
return true;
}
// Returns -1/0/+1 for `a < b` / `a == b` / `a > b`. "DEV" is the
@@ -229,7 +214,7 @@ bool DiskHasDevMarker() {
const int ok = FileRead_o(0, fullPath, &buf, &size, 1, 1, 0);
if (ok == 0 || buf == nullptr)
return false;
auto SMemFree = reinterpret_cast<SMemFree_t>(Offsets::FUN_STORM_SMEM_FREE);
auto SMemFree = reinterpret_cast<SMemFreeFn>(Offsets::FUN_STORM_SMEM_FREE);
SMemFree(buf, __FILE__, __LINE__, 0);
return true;
}
@@ -274,7 +259,7 @@ void DecideSource() {
// Free the disk buffer we just borrowed — we only needed it for
// the version-line scrape. The actual content for the engine's
// TOC read comes through the regular hook path below.
auto SMemFree = reinterpret_cast<SMemFree_t>(Offsets::FUN_STORM_SMEM_FREE);
auto SMemFree = reinterpret_cast<SMemFreeFn>(Offsets::FUN_STORM_SMEM_FREE);
SMemFree(diskBuf, __FILE__, __LINE__, 0);
// Missing or unparseable disk version → assume it's older than
@@ -301,10 +286,33 @@ int __stdcall FileRead_h(int unused, const char *path, void **outBuf,
const char *suffix = StripAddonPrefix(path);
if (suffix == nullptr) {
// Path isn't under `Interface\AddOns\!!!ClassicAPI\` — straight
// pass-through, we don't care.
return FileRead_o(unused, path, outBuf, outSize, extraBytes,
flag1, flag2);
// Not our embedded addon. If this is a base-TOC read for a multi-flavor
// addon that ships only `<Name>_Turtle.toc` / `_ClassicAPI.toc`, serve
// that in place of the missing `<Name>.toc` so it registers and loads
// (see addons/FlavorToc.h).
int result;
if (AddOns::FlavorToc::TryHandle(unused, path, outBuf, outSize,
extraBytes, flag1, flag2, FileRead_o)) {
result = 1;
} else if (AddOns::FlavorBindings::TryHandle(unused, path, outBuf, outSize,
extraBytes, flag1, flag2,
FileRead_o)) {
// Flavor-specific addon keybindings: serve `Bindings_Turtle.xml` /
// `Bindings_ClassicAPI.xml` for a `…\AddOns\<Name>\Bindings.xml`
// read (see addons/FlavorBindings.h). The paired exists-check
// co-hook lives in that module.
result = 1;
} else {
result = FileRead_o(unused, path, outBuf, outSize, extraBytes,
flag1, flag2);
}
// Per-line TOC directives: evaluate `[Condition]` and expand
// `[Variable]` tokens on this addon TOC's file lines so the load
// pass sees clean paths (see addons/TocRewrite.h). No-op unless
// `path` is an addon `.toc` that actually contains directives.
if (result != 0)
AddOns::TocRewrite::Transform(path, outBuf, outSize);
return result;
}
DecideSource();
@@ -331,7 +339,7 @@ int __stdcall FileRead_h(int unused, const char *path, void **outBuf,
flag1, flag2);
}
auto SMemAlloc = reinterpret_cast<SMemAlloc_t>(
auto SMemAlloc = reinterpret_cast<SMemAllocFn>(
Offsets::FUN_STORM_SMEM_ALLOC);
const size_t totalSize = entry->size + extraBytes;
void *buf = SMemAlloc(totalSize, __FILE__, __LINE__, 0);
@@ -370,26 +378,22 @@ using ListInsert_t = void(__thiscall *)(void *listCtrl, void *entry,
// engine's own insert helper handles remove-then-insert atomically,
// so it's safe to call on an entry that's already in the list.
//
// Entries are pointed to by `[VAR_ADDON_LIST_HEAD]` (`0x00BE1B6C`),
// with the next-pointer field at `entry + 0x10` (computed as
// `*(value-at-VAR_ADDON_LIST_CTRL) + entry + 4` = `0xC + entry + 4`
// = `entry + 0x10`). Low-bit-1 means "sentinel" (end of list).
// Name pointer lives at `entry + 0x14`.
constexpr uintptr_t VAR_ADDON_LIST_HEAD = 0x00BE1B6C;
constexpr int OFF_ADDON_ENTRY_NAME_PTR = 0x14;
// `entry+0x29` — the "filter-out" byte. The flat display-array builder
// `FUN_0051da70` walks the linked list and copies an entry into
// `GetNumAddOns`/`GetAddOnInfo`'s array ONLY when this byte is 0, so
// setting it hides `!!!ClassicAPI` from the character-select AddOns
// list — there's no checkbox, so it can't be toggled off. The builder
// only ever *writes* this byte on the secure/SMSG path (`entry+0x28 !=
// 0`); our entry is non-secure, so a manual `1` here is stable across
// rebuilds. Hiding does NOT stop loading (the load pass `FUN_0051f600`
// never reads `+0x29`) and does NOT break dependency resolution or
// by-name queries (those use the addon hash table, which keeps the
// entry regardless).
constexpr int OFF_ADDON_ENTRY_FILTER_OUT = 0x29;
// The list is walked via the shared `Addons::ForEachEntry`
// (`addons/Registry.h`); the callback stops the walk once it finds and
// re-links our entry, since the re-link invalidates the current
// next-pointer.
//
// `entry + OFF_ADDON_ENTRY_FILTER_OUT` is the "filter-out" byte. The
// flat display-array builder `FUN_0051da70` walks the linked list and
// copies an entry into `GetNumAddOns`/`GetAddOnInfo`'s array ONLY when
// this byte is 0, so setting it hides `!!!ClassicAPI` from the
// character-select AddOns list — there's no checkbox, so it can't be
// toggled off. The builder only ever *writes* this byte on the
// secure/SMSG path (`entry+0x28 != 0`); our entry is non-secure, so a
// manual `1` here is stable across rebuilds. Hiding does NOT stop
// loading (the load pass `FUN_0051f600` never reads `+0x29`) and does
// NOT break dependency resolution or by-name queries (those use the
// addon hash table, which keeps the entry regardless).
// `entry+0x2b` — DefaultState (`## DefaultState: enabled/disabled`).
// The enable resolver `FUN_ADDON_ENABLE_RESOLVE` falls back to this
@@ -404,29 +408,26 @@ constexpr int OFF_ADDON_ENTRY_DEFAULT_STATE = 0x2b;
// with the `FUN_ADDON_ENABLE_RESOLVE` co-hook, `!!!ClassicAPI` always
// loads and never appears as a toggleable entry.
void FinalizeEmbeddedEntry() {
uintptr_t entry = *reinterpret_cast<uintptr_t *>(VAR_ADDON_LIST_HEAD);
const int linkOffset = *reinterpret_cast<const int *>(
static_cast<uintptr_t>(Offsets::VAR_ADDON_LIST_CTRL));
while ((entry & 1) == 0 && entry != 0) {
Addons::ForEachEntry([](uintptr_t entry) -> bool {
const char *name = *reinterpret_cast<const char *const *>(
entry + OFF_ADDON_ENTRY_NAME_PTR);
if (name != nullptr && std::strcmp(name, kAddonName) == 0) {
// Hide from the character-select AddOns list and default it
// to enabled.
*reinterpret_cast<uint8_t *>(entry + OFF_ADDON_ENTRY_FILTER_OUT) = 1;
*reinterpret_cast<uint8_t *>(entry + OFF_ADDON_ENTRY_DEFAULT_STATE) = 1;
entry + Offsets::OFF_ADDON_ENTRY_NAME_PTR);
if (name == nullptr || std::strcmp(name, kAddonName) != 0)
return true; // keep walking
auto fn = reinterpret_cast<ListInsert_t>(
Offsets::FUN_INTRUSIVE_LIST_INSERT);
fn(reinterpret_cast<void *>(
static_cast<uintptr_t>(Offsets::VAR_ADDON_LIST_CTRL)),
reinterpret_cast<void *>(entry),
/*position=*/ 1, /*anchor=*/ 0);
return;
}
entry = *reinterpret_cast<const uintptr_t *>(
entry + linkOffset + 4);
}
// Hide from the character-select AddOns list and default it to
// enabled, then move it to the head of the load order.
*reinterpret_cast<uint8_t *>(
entry + Offsets::OFF_ADDON_ENTRY_FILTER_OUT) = 1;
*reinterpret_cast<uint8_t *>(entry + OFF_ADDON_ENTRY_DEFAULT_STATE) = 1;
auto fn = reinterpret_cast<ListInsert_t>(
Offsets::FUN_INTRUSIVE_LIST_INSERT);
fn(reinterpret_cast<void *>(
static_cast<uintptr_t>(Offsets::VAR_ADDON_LIST_CTRL)),
reinterpret_cast<void *>(entry),
/*position=*/ 1, /*anchor=*/ 0);
return false; // stop — the re-link invalidated our next-pointer
});
}
void __fastcall AddonInit_h(const char *accountName) {
+41
View File
@@ -0,0 +1,41 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
#pragma once
#include <cstddef>
// One home for the engine file-I/O and Storm-allocator function-pointer types
// the addon modules call. These are ABI-critical: FUN_FILE_READ /
// FUN_FILE_EXISTS / the Storm allocator are all `__stdcall` (callee cleans the
// stack), and declaring one `__cdecl` by mistake drifts ESP a few bytes per
// call and kills the process with no crash log. The shape lived in ~6 copies
// across the addon files; centralize it so the convention cannot drift.
namespace AddOns::EngineIO {
// FUN_FILE_READ (0x00648620) — __stdcall, callee cleans 28 bytes (RET 0x1C).
// arg0 is an optional archive handle (0 = merged VFS); outSize may be null.
using FileReadFn = int(__stdcall *)(int unused, const char *path, void **outBuf,
size_t *outSize, size_t extraBytes,
int flag1, int flag2);
// FUN_FILE_EXISTS (0x00648A30) — __stdcall, RET 8.
using FileExistsFn = int(__stdcall *)(const char *path, int mode);
// FUN_STORM_SMEM_ALLOC / FUN_STORM_SMEM_FREE — __stdcall, RET 0x10. Buffers
// FUN_FILE_READ hands out are freed with SMemFreeFn; a buffer we allocate with
// SMemAllocFn is freed cleanly by the engine's own SMemFree in turn.
using SMemAllocFn = void *(__stdcall *)(size_t size, const char *file, int line, int flags);
using SMemFreeFn = void(__stdcall *)(void *buf, const char *file, int line, int flags);
} // namespace AddOns::EngineIO
+130
View File
@@ -0,0 +1,130 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
#include "addons/EngineIO.h"
#include "addons/FlavorBindings.h"
#include "addons/Toc.h"
#include "Game.h"
#include "Offsets.h"
#include "turtle/Detect.h"
#include <cstddef>
#include <cstdint>
#include <cstring>
namespace AddOns::FlavorBindings {
namespace {
using AddOns::Toc::EqCI;
using AddOns::Toc::Lower;
constexpr size_t NPOS = static_cast<size_t>(-1);
// If `path` is an addon Bindings read (`…\AddOns\<Name>\Bindings.xml`, CI),
// return the length of the prefix up to the `Bindings.xml` filename (so a
// flavor filename can be appended in its place); else NPOS.
size_t AddonBindingsPrefix(const char *path) {
if (path == nullptr) return NPOS;
const size_t len = std::strlen(path);
const char *kFile = "bindings.xml"; // 12 chars, compared case-insensitively
const size_t flen = 12;
if (len < flen + 1) return NPOS;
for (size_t i = 0; i < flen; ++i)
if (Lower(path[len - flen + i]) != kFile[i]) return NPOS;
const size_t prefixLen = len - flen;
if (path[prefixLen - 1] != '\\' && path[prefixLen - 1] != '/')
return NPOS; // "Bindings.xml" must be a whole path segment
for (size_t i = 0; i + 8 <= len; ++i)
if (EqCI(path + i, 8, "\\AddOns\\")) return prefixLen;
return NPOS;
}
// Write `<prefix><flavorFile>` (e.g. `…\AddOns\Foo\Bindings_Turtle.xml`) into
// `out`. Returns false if it would not fit.
bool BuildFlavorPath(const char *path, size_t prefixLen, const char *flavorFile,
char *out, size_t outSize) {
const size_t flen = std::strlen(flavorFile);
if (prefixLen + flen + 1 > outSize) return false;
std::memcpy(out, path, prefixLen);
std::memcpy(out + prefixLen, flavorFile, flen + 1); // incl. NUL
return true;
}
// Fills `order` with the flavor filenames to probe, most specific first, and
// returns the count. `Bindings_Turtle.xml` only on a Turtle client.
int FlavorOrder(const char *(&order)[2]) {
int count = 0;
if (Turtle::Detected())
order[count++] = "Bindings_Turtle.xml";
order[count++] = "Bindings_ClassicAPI.xml";
return count;
}
// Engine file-exists probe FUN_00648a30 — __stdcall(path, mode) (RET 8).
using EngineIO::FileExistsFn;
FileExistsFn g_origExists = nullptr;
// Co-hook the exists-check so `Interface\AddOns\<Name>\Bindings.xml` reports as
// existing when the base file is absent but a flavor variant is present — so
// the loader (`FUN_004b6f70`, then our TryHandle redirect) runs for a
// flavor-only addon. Every non-Bindings path passes straight through.
int __stdcall FileExists_h(const char *path, int mode) {
const size_t prefixLen = AddonBindingsPrefix(path);
if (prefixLen == NPOS)
return g_origExists(path, mode);
if (g_origExists(path, mode) != 0)
return 1; // the plain Bindings.xml exists
const char *order[2];
const int count = FlavorOrder(order);
char cand[512];
for (int i = 0; i < count; ++i) {
if (BuildFlavorPath(path, prefixLen, order[i], cand, sizeof cand) &&
g_origExists(cand, mode) != 0)
return 1;
}
return 0;
}
const Game::HookAutoRegister _hookExists{
Offsets::FUN_FILE_EXISTS,
reinterpret_cast<void *>(&FileExists_h),
reinterpret_cast<void **>(&g_origExists)};
} // namespace
bool TryHandle(int unused, const char *path, void **outBuf, size_t *outSize,
size_t extraBytes, int flag1, int flag2, FileReadFn orig) {
const size_t prefixLen = AddonBindingsPrefix(path);
if (prefixLen == NPOS)
return false;
const char *order[2];
const int count = FlavorOrder(order);
char cand[512];
for (int i = 0; i < count; ++i) {
if (!BuildFlavorPath(path, prefixLen, order[i], cand, sizeof cand))
continue;
// Serve the flavor file in place of Bindings.xml (a selected flavor
// wins over a plain Bindings.xml). No recursion: `orig` is the original
// reader passed by the FUN_FILE_READ hook.
if (orig(unused, cand, outBuf, outSize, extraBytes, flag1, flag2) != 0)
return true;
}
return false; // no flavor variant — caller reads the base Bindings.xml
}
} // namespace AddOns::FlavorBindings
+55
View File
@@ -0,0 +1,55 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
#pragma once
#include <cstddef>
#include "addons/EngineIO.h"
namespace AddOns::FlavorBindings {
using EngineIO::FileReadFn;
// Flavor-specific addon Bindings selection — the Bindings.xml analog of
// AddOns::FlavorToc.
//
// A modern addon can ship a flavor-specific keybinding file
// (`Bindings_Turtle.xml` / `Bindings_ClassicAPI.xml`) instead of / alongside a
// plain `Bindings.xml`. The per-addon loader `FUN_0051f240` only ever builds
// `Interface\AddOns\<Name>\Bindings.xml`, gates it on an exists-check
// (`FUN_00648a30`), and — when that passes — loads it via `FUN_004b6f70`, which
// reads the file through `FUN_FILE_READ`. Two pieces make flavor files work:
//
// 1. This TryHandle, called from the FUN_FILE_READ hook: when `path` is an
// addon `…\AddOns\<Name>\Bindings.xml` read, serve a flavor variant in its
// place if one exists (a selected flavor wins over a plain `Bindings.xml`,
// matching FlavorToc). Probe order `Bindings_Turtle.xml` (only when
// Turtle::Detected) then `Bindings_ClassicAPI.xml`.
// 2. A co-hook on the exists-check `FUN_00648a30` (inside FlavorBindings.cpp):
// report `Bindings.xml` as existing when the base is absent but a flavor
// variant is present — otherwise the loader is never called for a
// flavor-only addon.
//
// Only `_Turtle` / `_ClassicAPI` are selected — never `_Vanilla` / `_Classic`
// (which target the 1.15 Classic Era build; its bindings reference handlers
// that build defines and this client does not load, so they would bind to
// missing functions). Both suffixes are ClassicAPI conventions, opt-in by the
// addon author, and mirror FlavorToc.
//
// Returns false when `path` is not an addon Bindings.xml read or no flavor
// variant exists — the caller then performs the normal base read.
bool TryHandle(int unused, const char *path, void **outBuf, size_t *outSize,
size_t extraBytes, int flag1, int flag2, FileReadFn orig);
} // namespace AddOns::FlavorBindings
+108
View File
@@ -0,0 +1,108 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
#include "addons/FlavorToc.h"
#include "turtle/Detect.h"
#include <cstring>
namespace AddOns::FlavorToc {
namespace {
// If `path` is `…\AddOns\<Name>\<Name>.toc` (case-insensitive), set `*prefixLen`
// to the byte length of the path up to and including the base `<Name>` filename
// (so a `_Vanilla.toc` suffix can be appended in place) and return true.
bool ParseBaseToc(const char *path, size_t *prefixLen) {
const size_t len = std::strlen(path);
if (len < 5 || _strnicmp(path + len - 4, ".toc", 4) != 0)
return false;
// Last separator -> "<Name>.toc" file part.
size_t lastSep = static_cast<size_t>(-1);
for (size_t i = len; i-- > 0;)
if (path[i] == '\\' || path[i] == '/') { lastSep = i; break; }
if (lastSep == static_cast<size_t>(-1))
return false;
const char *file = path + lastSep + 1; // "<Name>.toc"
const size_t nameLen = (len - (lastSep + 1)) - 4; // strip ".toc"
if (nameLen == 0)
return false;
// Previous separator -> "<Name>" folder segment; it must equal the base name.
size_t prevSep = static_cast<size_t>(-1);
for (size_t i = lastSep; i-- > 0;)
if (path[i] == '\\' || path[i] == '/') { prevSep = i; break; }
if (prevSep == static_cast<size_t>(-1))
return false;
const char *folder = path + prevSep + 1;
const size_t folderLen = lastSep - (prevSep + 1);
if (folderLen != nameLen || _strnicmp(folder, file, nameLen) != 0)
return false;
// Grandparent segment must be "AddOns" (so FrameXML/other .toc reads pass through).
size_t prev2Sep = static_cast<size_t>(-1);
for (size_t i = prevSep; i-- > 0;)
if (path[i] == '\\' || path[i] == '/') { prev2Sep = i; break; }
if (prev2Sep == static_cast<size_t>(-1))
return false;
const char *gp = path + prev2Sep + 1;
const size_t gpLen = prevSep - (prev2Sep + 1);
if (gpLen != 6 || _strnicmp(gp, "AddOns", 6) != 0)
return false;
*prefixLen = lastSep + 1 + nameLen; // "…\AddOns\<Name>\<Name>"
return true;
}
} // namespace
bool TryHandle(int unused, const char *path, void **outBuf, size_t *outSize,
size_t extraBytes, int flag1, int flag2, FileReadFn orig) {
if (path == nullptr)
return false;
size_t prefixLen = 0;
if (!ParseBaseToc(path, &prefixLen))
return false;
char cand[512];
if (prefixLen + 16 >= sizeof cand) // "_ClassicAPI.toc" + NUL is 16 (longest)
return false; // pathological path length — let base read run
std::memcpy(cand, path, prefixLen);
// Only flavors that describe THIS environment, most specific first:
// `_Turtle` — Turtle client/content, only when Turtle is detected;
// `_ClassicAPI` — 1.12 + ClassicAPI, ALWAYS (ClassicAPI is present by
// definition — it is the code doing this redirect).
// Deliberately NOT `_Vanilla`/`_Classic` (those target the 1.15 Classic Era
// client — a modern engine + full modern API — which this 1.12 +
// partial-backport client is not), nor `_Mainline`/`_Cata`/etc.
const char *order[2];
int count = 0;
if (Turtle::Detected())
order[count++] = "_Turtle.toc";
order[count++] = "_ClassicAPI.toc";
for (int i = 0; i < count; ++i) {
std::memcpy(cand + prefixLen, order[i], std::strlen(order[i]) + 1); // incl. NUL
if (orig(unused, cand, outBuf, outSize, extraBytes, flag1, flag2) != 0)
return true; // served the flavor TOC in place of the base
}
// No matching flavor TOC — fall through: the caller reads the base path,
// which chains through any other FUN_FILE_READ hooker before the engine.
return false;
}
} // namespace AddOns::FlavorToc
+60
View File
@@ -0,0 +1,60 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
#pragma once
#include <cstddef>
#include "addons/EngineIO.h"
namespace AddOns::FlavorToc {
using EngineIO::FileReadFn;
// Modern-client multi-flavor TOC selection, backported.
//
// A modern addon that targets several game flavors from one folder ships
// `<Name>_Vanilla.toc` / `_Mainline.toc` / `_Cata.toc` / … and often NO plain
// `<Name>.toc`. Vanilla 1.12 only ever opens `<Name>\<Name>.toc`, so such an
// addon never registers — it is invisible, not merely mis-loaded.
//
// Called from the FUN_FILE_READ hook. If `path` is an addon base-TOC read
// (`…\AddOns\<Name>\<Name>.toc`), probe the flavor TOCs that describe THIS
// environment and, if one exists, serve it via `orig` (as if it were the base
// TOC) and return true. Order, most specific first:
// `<Name>_Turtle.toc` — Turtle client/content, ONLY when Turtle is
// detected (Turtle::Detected); a ClassicAPI extension;
// `<Name>_ClassicAPI.toc` — 1.12 + ClassicAPI, ALWAYS (ClassicAPI is present
// by definition — it is the code doing this redirect).
// Both are ClassicAPI conventions, not retail flavors. We deliberately do NOT
// map to `_Vanilla`/`_Classic` (those target the 1.15 Classic Era client — a
// modern engine + full modern API — which a 1.12 + partial-backport client is
// not), nor `_Mainline`/`_Cata`/etc. A selected flavor wins over an existing
// base `<Name>.toc`. If neither flavor exists, TryHandle returns false and the
// caller does the normal base read — which chains through any other DLL that
// hooked FUN_FILE_READ before reaching the engine.
//
// The redirect fires at BOTH the registration scan (glue/char-select) and the
// in-world load pass, which must agree on the flavor. `Turtle::Detected()` is
// therefore consistent across both: it reads the in-world `TURTLE_WOW_VERSION`
// global AND Turtle's glue-time `TURTLE_*` GlueXML globals, so it returns true
// at the glue scan too (see turtle/Detect.h).
//
// Returns false when `path` is not a base-TOC read, or no vanilla flavor TOC
// exists — the caller then performs the normal base read. Because the redirect
// is at the read layer, the addon scan (FUN_0051C9B0), the load pass
// (FUN_006edb90) and GetAddOnMetadata all see the same selected TOC.
bool TryHandle(int unused, const char *path, void **outBuf, size_t *outSize,
size_t extraBytes, int flag1, int flag2, FileReadFn orig);
} // namespace AddOns::FlavorToc
+132
View File
@@ -0,0 +1,132 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
// `C_AddOns.GetAddOnLocalTable(name)` — the public, gated view of an addon's
// private namespace table (the `local _, addonTable = ...` second vararg the
// transpiler injects; see luasyntax/AddonNamespace.h).
//
// Retail added this in 11.0 alongside the `## AllowAddOnTableAccess` TOC
// directive: one addon can read another's local table ONLY when the target
// opts in. We mirror that gate:
// 1. the named addon is currently LOADED, and
// 2. its `.toc` declares `## AllowAddOnTableAccess: 1`.
// Either check failing returns nil. The internal preamble path (an addon
// receiving its OWN table) is a different, ungated function (`__addonns`) —
// an addon never needs permission for its own namespace.
//
// Directive spelling verified against the 1.15.8 Classic Era binary (both the
// `AllowAddOnTableAccess:` TOC key and the `GetAddOnLocalTable` name are
// present there). The vanilla 1.12 TOC parser drops unknown `##` directives,
// so we scan the `.toc` ourselves via `FUN_FILE_READ`.
#include "Game.h"
#include "Offsets.h"
#include "addons/EngineIO.h"
#include "addons/Toc.h"
#include "luasyntax/AddonNamespace.h"
#include <cstddef>
#include <cstdint>
#include <cstdio>
namespace AddOns::LocalTable {
namespace {
// IS_LOADED is a by-name hash lookup returning the loaded byte (`entry+0x18`);
// passing the name string works because the lookup hashes it. See Offsets.h.
using IsLoadedFn_t = uint8_t(__fastcall *)(const char *name);
// FUN_FILE_READ / the Storm free (see addons/EngineIO.h). Calling the raw
// FUN_FILE_READ address routes through Embedded's file-read hook, which forwards
// every non-`!!!ClassicAPI` path to the original untouched — what we want here.
using AddOns::EngineIO::FileReadFn;
using AddOns::EngineIO::SMemFreeFn;
// True iff the addon's `.toc` has a line `## AllowAddOnTableAccess: <nonzero>`.
// The value is read as a TOC boolean flag: parse the leading integer, true iff
// non-zero (so `1` opts in, `0`/absent do not) — matching WoW's numeric TOC
// flag semantics. Not cached: GetAddOnLocalTable is not a hot path.
bool TocAllowsTableAccess(const char *name) {
// A registered, loaded addon name never carries a path separator; reject
// any that does so the read stays bounded to the addon's own folder.
for (const char *p = name; *p; ++p)
if (*p == '\\' || *p == '/')
return false;
char path[300];
std::snprintf(path, sizeof path, "Interface\\AddOns\\%s\\%s.toc", name, name);
void *buf = nullptr;
size_t size = 0;
auto fileRead = reinterpret_cast<FileReadFn>(Offsets::FUN_FILE_READ);
if (fileRead(0, path, &buf, &size, 1, 1, 0) == 0 || buf == nullptr)
return false;
const char *v = nullptr;
size_t n = 0;
bool allowed = false;
if (AddOns::Toc::FindValue(static_cast<const char *>(buf), size,
"## AllowAddOnTableAccess:", &v, &n)) {
int value = 0;
bool anyDigit = false;
for (size_t k = 0; k < n && v[k] >= '0' && v[k] <= '9'; ++k) {
value = value * 10 + (v[k] - '0');
anyDigit = true;
}
allowed = anyDigit && value != 0;
}
auto smemFree = reinterpret_cast<SMemFreeFn>(Offsets::FUN_STORM_SMEM_FREE);
smemFree(buf, __FILE__, __LINE__, 0);
return allowed;
}
// `C_AddOns.GetAddOnLocalTable(name)` -> table, or nil.
//
// Name (string) input only — the local table is a by-name concept (one addon
// asking for another by name), and the namespace map is name-keyed. Numeric
// index is not accepted (returns nil); no addon references another's table by
// load order.
int __fastcall Script_GetAddOnLocalTable(void *L) {
const char *name = Game::Lua::ToString(L, 1);
if (name == nullptr || *name == '\0') {
Game::Lua::PushNil(L);
return 1;
}
auto isLoaded = reinterpret_cast<IsLoadedFn_t>(Offsets::FUN_ADDON_IS_LOADED);
if (isLoaded(name) == 0) { // not loaded (covers "does not exist")
Game::Lua::PushNil(L);
return 1;
}
if (!TocAllowsTableAccess(name)) { // opted out / no directive
Game::Lua::PushNil(L);
return 1;
}
LuaSyntax::PushAddonNamespace(L, name);
return 1;
}
void RegisterLuaFunctions() {
Game::Lua::RegisterTableFunction("C_AddOns", "GetAddOnLocalTable",
&Script_GetAddOnLocalTable);
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace
} // namespace AddOns::LocalTable
+50
View File
@@ -0,0 +1,50 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
#pragma once
#include "Offsets.h"
#include <cstdint>
#include <type_traits>
namespace Addons {
// Walk the engine's intrusive AddOn-registry linked list, calling `fn(entry)`
// for each `AddOnEntry *`. Head at `[VAR_ADDON_LIST_HEAD]`; the next-pointer
// field is at `entry + [VAR_ADDON_LIST_CTRL] + 4` (= `entry + 0x10`); a
// low-bit-1 sentinel or NULL terminates. This is the same traversal the
// engine's own load pass (`FUN_0051F600`) and scan tail loop use.
//
// `fn` may return `void` (always continue) or `bool` (return `false` to STOP
// early). Early stop is load-bearing for a visitor that mutates the list —
// e.g. re-linking an entry to the head invalidates the current next-pointer,
// so the walk must not follow it. Shared by `Addons::Embedded` (find + re-link
// the embedded entry) and `Addons::Rescan` (snapshot / diff / fix-up passes).
template <typename Fn> void ForEachEntry(Fn fn) {
const int linkOffset = *reinterpret_cast<const int *>(
static_cast<uintptr_t>(Offsets::VAR_ADDON_LIST_CTRL));
uintptr_t entry = *reinterpret_cast<const uintptr_t *>(
static_cast<uintptr_t>(Offsets::VAR_ADDON_LIST_HEAD));
while ((entry & 1) == 0 && entry != 0) {
if constexpr (std::is_void_v<decltype(fn(entry))>) {
fn(entry);
} else {
if (!fn(entry))
return;
}
entry = *reinterpret_cast<const uintptr_t *>(entry + linkOffset + 4);
}
}
} // namespace Addons
+523
View File
@@ -0,0 +1,523 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
// Retail-like `/reload`: pick up new addon folders and new files without
// restarting the client. Two independent engine limitations block that on
// a stock 1.12 client, each fixed here with the engine's own machinery:
//
// 1. FILE VISIBILITY. Every relative-path read resolves through a
// loose-file hash index built ONCE at boot (see Offsets.h,
// `FUN_VFS_INDEX_SUBTREE`); files created after boot — a new
// addon's TOC, a new .lua in an existing addon, a freshly written
// SavedVariables file — are invisible until restart. The boot
// indexer is dedup-safe and works on subtrees, so we re-run it per
// /reload on the only two subtrees whose contents can change what
// a /reload loads: `Interface\AddOns` and `WTF\Account`. New files
// in EXISTING addons need nothing more — the per-addon loader
// `FUN_0051F240` re-reads the TOC from disk on every load pass, so
// once the files are visible they load.
//
// 2. REGISTRY MEMBERSHIP. The addon registry is built once at login;
// new folders are never walked. We replay the login scan's disk
// walk verbatim (`FUN_ADDON_SCAN_DISK_DIRS` with the engine's own
// per-directory callback, which feeds the dedup-safe TOC parser),
// so new folders register as completely normal entries — then
// mirror the two registry structures the scan's OTHER passes would
// have filled: the reverse-LoadWith lists (scan tail loop) and the
// flat `GetNumAddOns` display array (`FUN_0051DA70` phase 2).
//
// 3. METADATA IMMUTABILITY. The parser's dedup guard makes a
// registered entry's `##` metadata read-once: an edited
// `## SavedVariables:` / `## Dependencies:` / any `##` line never
// re-parses. The engine's own complete per-entry destructor
// (`FUN_ADDON_ENTRY_DESTROY` — frees every owned allocation and
// self-unlinks from both the list and the hash; see Offsets.h)
// makes evict + re-register safe: destroy the entry so the dedup
// guard MISSES, re-feed the name to `FUN_TOC_PARSER`, and the
// entry rebuilds from the edited TOC exactly like a fresh
// registration. Gated on an actual `##` change (hash of the TOC's
// `##` lines only — file-reference edits load natively and never
// trigger the evict path). A TOC that no longer reads at all
// (folder deleted) evicts without re-registering — the deletion
// analog; a folder the scan still finds simply re-registers.
//
// Runs from the `ModuleAutoRegister` callback — LoadScriptFunctions
// post-hook, which fires inside FrameXML init (`FUN_0048FBF0`) BEFORE
// the AddOns.txt enable-state re-read, the load-progress count, and the
// addon load pass, on both login and every /reload. The engine then
// loads new entries natively: dep ordering, Bindings.xml, flavor TOCs,
// SavedVariables, ADDON_LOADED, the load-screen "Loading add-on %s".
// The window is also what makes eviction safe: the unload pass already
// ran (SavedVariables written from the OLD entry's SV lists, loaded
// bytes cleared), and the load pass hasn't — nothing holds entry state.
//
// Excluded from the evict path (everything else is fair game):
// - `!!!ClassicAPI` — embedded; `Addons::Embedded` owns its entry
// (head re-link + filter byte);
// - `## Secure:` / SMSG-managed entries — the parser cannot restore
// packet-delivered state (see OFF_ADDON_ENTRY_SECURE).
//
// NOTE the surgical discipline. A previous attempt re-invoked the
// login-only teardown+rescan (`FUN_ADDON_INIT`) mid-/reload and
// corrupted the registry (duplicate loads + Lua memory explosion on the
// NEXT login). This module never runs bulk teardown; the only entry
// mutation beyond what a login scan produces is the per-entry evict
// above, which is (a) the engine's own complete destructor, (b) always
// preceded by the reverse-LoadWith scrub (the one reference the dtor
// can't clean), and (c) always followed by the same fix-ups a new
// registration gets. New entries come from the same parser call
// `Addons::Embedded` has exercised every login.
//
// Thread note: the loose-file index has no lock, but the engine itself
// builds it lazily on first access and we mutate it only during the
// reload loading screen on the main thread — the same context in which
// the engine does all its own file work.
#include "Game.h"
#include "Offsets.h"
#include "addons/EngineIO.h"
#include "addons/Registry.h"
#include <algorithm>
#include <cstdint>
#include <cstdio>
#include <cstring>
#include <string>
#include <unordered_map>
#include <vector>
namespace Addons::Rescan {
namespace {
// ── Engine entry points (all verified by disassembly; see Offsets.h) ──
using FindOpen_t = void *(__stdcall *)(const char *dirPath);
using FindClose_t = void(__stdcall *)(void *findBlock);
using IndexSubtree_t = void(__fastcall *)(const char *basePath,
const char *relSubdir,
void *findHandle);
using ScanDiskDirs_t = int(__fastcall *)(const char *basePath,
const char *pattern, void *callback,
void *userParam, int includeHidden);
using ResolveByName_t = void *(__fastcall *)(const char *name);
using DescGrow_t = void(__thiscall *)(void *desc, uint32_t newCap);
using QuantumCalc_t = uint32_t(__thiscall *)(void *desc, uint32_t needed);
using Qsort_t = void(__cdecl *)(void *base, uint32_t num, uint32_t width,
void *compare);
using TocParser_t = void(__fastcall *)(const char *name);
using EntryDestroy_t = void(__stdcall *)(void *entry);
// The engine's ubiquitous growable-array descriptor.
struct Desc {
uint32_t cap;
uint32_t count;
uint32_t *data;
uint32_t quantum;
};
// Append one dword to a descriptor, mirroring the engine's inline
// grow-and-append (the scan tail loop and `FUN_0051DA70` phase 2 share
// it): round the needed cap up to the quantum — computed by the
// engine's own `FUN_DESC_QUANTUM_CALC` when the desc has none — grow
// via the site's grow instantiation (which reallocs `data` and writes
// `cap`), then `data[count++] = value`.
void DescAppend(Desc *desc, uint32_t value, DescGrow_t grow) {
uint32_t needed = desc->count + 1;
if (desc->cap < needed) {
uint32_t quantum = desc->quantum;
if (quantum == 0) {
auto calc =
reinterpret_cast<QuantumCalc_t>(Offsets::FUN_DESC_QUANTUM_CALC);
quantum = calc(desc, needed);
}
if (needed % quantum != 0)
needed += quantum - needed % quantum;
grow(desc, needed);
}
desc->data[desc->count++] = value;
}
// The registry linked-list walk lives in `addons/Registry.h`
// (`Addons::ForEachEntry`), shared with `Addons::Embedded`.
// By-name registry lookup via the engine's own hash resolver (returns
// the entry's RequiredDeps desc, so subtract its offset — same recovery
// `Addons::Info` uses).
uintptr_t ResolveEntryByName(const char *name) {
auto resolve =
reinterpret_cast<ResolveByName_t>(Offsets::FUN_ADDON_RESOLVE_REQ_DEPS);
const uintptr_t desc = reinterpret_cast<uintptr_t>(resolve(name));
return desc != 0 ? desc - Offsets::OFF_ADDON_REQDEPS_DESC : 0;
}
// ── Step 1: loose-file index refresh ──────────────────────────────────
// Register new on-disk files under `relSubdir` with the frozen index,
// exactly as the boot walk would have (the indexer skips every file
// already present). No-op when the directory doesn't exist —
// `FUN_VFS_FIND_OPEN` requires an existing directory and returns NULL.
void ReindexSubtree(const char *base, const char *relSubdir) {
char dir[260];
std::snprintf(dir, sizeof(dir), "%s\\%s", base, relSubdir);
auto findOpen = reinterpret_cast<FindOpen_t>(Offsets::FUN_VFS_FIND_OPEN);
auto findClose = reinterpret_cast<FindClose_t>(Offsets::FUN_VFS_FIND_CLOSE);
void *handle = findOpen(dir);
if (handle == nullptr)
return;
auto index =
reinterpret_cast<IndexSubtree_t>(Offsets::FUN_VFS_INDEX_SUBTREE);
index(base, relSubdir, handle);
findClose(handle);
}
void RefreshLooseFileIndex() {
// The boot walk's base choice (`FUN_00646EA0`): the recorded game
// dir if it opens as a directory, else ".". Index keys are relative
// to this, so it must match or the new keys would never be hit.
auto findOpen = reinterpret_cast<FindOpen_t>(Offsets::FUN_VFS_FIND_OPEN);
auto findClose = reinterpret_cast<FindClose_t>(Offsets::FUN_VFS_FIND_CLOSE);
const char *base =
reinterpret_cast<const char *>(Offsets::VAR_VFS_BASE_PATH);
void *probe = findOpen(base);
if (probe != nullptr)
findClose(probe);
else
base = ".";
// The only subtrees whose post-boot changes affect what a /reload
// loads: addon content, and the SavedVariables the unload pass
// wrote moments ago (a first-ever SV file is otherwise invisible —
// the stock client loses a freshly installed addon's settings on
// /reload for exactly this reason).
ReindexSubtree(base, "Interface\\AddOns");
ReindexSubtree(base, "WTF\\Account");
}
// ── Step 1.5: `##` metadata refresh (evict + re-register on change) ──
// The embedded addon's name — its entry is owned by `Addons::Embedded`
// (head re-link + filter byte) and never refreshed here.
constexpr const char *kEmbeddedAddon = "!!!ClassicAPI";
// name → FNV-1a hash of the addon's `##` lines, as last parsed.
// Per-process on purpose: the registry persists across /reload, and a
// stale hash after a full re-login scan only causes one harmless
// re-parse of already-current metadata.
std::unordered_map<std::string, uint32_t> g_metaHash;
// Read `<name>`'s base TOC exactly as the parser does — through the
// (hooked) `FUN_FILE_READ`, so flavor selection and TOC rewriting are
// reflected in what gets hashed. Caller frees `*buf` via Storm.
bool ReadToc(const char *name, void **buf, size_t *size) {
char path[260];
std::snprintf(path, sizeof(path), "Interface\\AddOns\\%s\\%s.toc", name,
name);
auto read = reinterpret_cast<AddOns::EngineIO::FileReadFn>(
static_cast<uintptr_t>(Offsets::FUN_FILE_READ));
*buf = nullptr;
*size = 0;
return read(0, path, buf, size, 1, 1, 0) != 0 && *buf != nullptr;
}
// FNV-1a over the TOC's `##` lines only. File-reference lines are
// excluded on purpose — the load pass re-reads those from disk every
// /reload anyway, so an edit there must not trigger the evict path.
// Line terminators are excluded (a CRLF↔LF conversion is not a
// metadata change); a per-line separator keeps adjacent lines from
// concatenating into the same hash. The BOM skip mirrors the parser.
uint32_t HashTocMetadata(const char *buf, size_t size) {
uint32_t h = 2166136261u;
size_t i = 0;
if (size >= 3 && static_cast<uint8_t>(buf[0]) == 0xEF &&
static_cast<uint8_t>(buf[1]) == 0xBB &&
static_cast<uint8_t>(buf[2]) == 0xBF)
i = 3;
while (i < size) {
size_t end = i;
while (end < size && buf[end] != '\n')
++end;
size_t stop = end;
while (stop > i && buf[stop - 1] == '\r')
--stop;
if (stop - i >= 2 && buf[i] == '#' && buf[i + 1] == '#') {
for (size_t k = i; k < stop; ++k) {
h ^= static_cast<uint8_t>(buf[k]);
h *= 16777619u;
}
h ^= '\n';
h *= 16777619u;
}
i = end + 1;
}
return h;
}
// True when the entry never takes the evict path: the embedded addon
// (owned by `Addons::Embedded`) and SMSG-managed secure entries (the
// parser cannot restore packet-delivered state).
bool IsRefreshExempt(uintptr_t entry, const char *name) {
return name == nullptr || std::strcmp(name, kEmbeddedAddon) == 0 ||
*reinterpret_cast<const uint8_t *>(
entry + Offsets::OFF_ADDON_ENTRY_SECURE) != 0;
}
// Read + hash `<name>`'s `##` lines. False when the TOC doesn't read.
bool TryHashToc(const char *name, uint32_t *outHash) {
void *buf = nullptr;
size_t size = 0;
if (!ReadToc(name, &buf, &size))
return false;
*outHash = HashTocMetadata(static_cast<const char *>(buf), size);
reinterpret_cast<AddOns::EngineIO::SMemFreeFn>(
static_cast<uintptr_t>(Offsets::FUN_STORM_SMEM_FREE))(
buf, __FILE__, __LINE__, 0);
return true;
}
// Remove every pointer to `victim` from the OTHER entries'
// reverse-LoadWith lists — the one reference `FUN_ADDON_ENTRY_DESTROY`
// cannot clean (it frees the victim's own list, not the victim's
// presence in others'). Without this, the loader's post-ADDON_LOADED
// loop dereferences freed memory.
void ScrubReverseLoadWith(uintptr_t victim) {
ForEachEntry([victim](uintptr_t entry) {
if (entry == victim)
return;
auto *desc = reinterpret_cast<Desc *>(
entry + Offsets::OFF_ADDON_REVLOADWITH_DESC);
uint32_t w = 0;
for (uint32_t r = 0; r < desc->count; ++r)
if (desc->data[r] != static_cast<uint32_t>(victim))
desc->data[w++] = desc->data[r];
desc->count = w;
});
}
// The refresh pass. Walks the registry comparing each entry's current
// `##` hash against the last-parsed one; on change (or an unreadable
// TOC — deleted folder), evicts via the engine's own destructor and,
// for changes, re-feeds the name to the parser. Re-registered entries
// are appended to `refreshed` so they join the same reverse-LoadWith /
// display-array fix-ups a new folder gets. Returns true when anything
// was actually evicted (the display array holds a freed name pointer
// until it's rebuilt).
//
// Victims are collected during the walk and processed after it — the
// destructor unlinks the entry the iterator stands on (its next-pointer
// is zeroed), so evicting mid-walk would silently truncate the walk.
// Names are copied for the same reason: the dtor frees the entry's
// name string. `g_metaHash` is committed only once the re-registration
// actually happened — updating it during the walk would absorb the edit
// forever if any later step failed, with no retry on the next /reload.
bool RefreshChangedMetadata(std::vector<uintptr_t> &refreshed) {
struct Victim {
std::string name;
uint32_t newHash;
bool reRegister; // false: TOC unreadable — evict only
};
std::vector<Victim> victims;
ForEachEntry([&victims](uintptr_t entry) {
const char *name = *reinterpret_cast<const char *const *>(
entry + Offsets::OFF_ADDON_ENTRY_NAME_PTR);
if (IsRefreshExempt(entry, name))
return;
uint32_t h = 0;
if (!TryHashToc(name, &h)) {
victims.push_back({name, 0, false});
return;
}
auto it = g_metaHash.find(name);
if (it == g_metaHash.end())
g_metaHash.emplace(name, h); // first sighting — seed only
else if (it->second != h)
victims.push_back({name, h, true});
});
bool anyEvicted = false;
auto destroy =
reinterpret_cast<EntryDestroy_t>(Offsets::FUN_ADDON_ENTRY_DESTROY);
auto parse = reinterpret_cast<TocParser_t>(Offsets::FUN_TOC_PARSER);
for (const Victim &v : victims) {
const uintptr_t entry = ResolveEntryByName(v.name.c_str());
if (entry == 0)
continue;
ScrubReverseLoadWith(entry);
destroy(reinterpret_cast<void *>(entry));
anyEvicted = true;
if (!v.reRegister) {
g_metaHash.erase(v.name);
continue;
}
// The parser appends the fresh entry at the list TAIL, so a
// refreshed addon's position in the load pass changes for this
// reload. Declared dependencies still force-load first; only
// undeclared load-order assumptions between unrelated addons
// could observe the difference.
parse(v.name.c_str());
const uintptr_t fresh = ResolveEntryByName(v.name.c_str());
if (fresh != 0) {
refreshed.push_back(fresh);
g_metaHash[v.name] = v.newHash;
} else {
// Evicted but the parse didn't restore it (TOC became
// unreadable between hash and parse) — treat as deleted so
// a reappearing folder re-seeds instead of diffing against
// a hash the registry never absorbed.
g_metaHash.erase(v.name);
}
}
return anyEvicted;
}
// ── Step 2: registry rescan + mirrored fix-ups ────────────────────────
// The scan tail loop's reverse-LoadWith build, restricted to pairs
// involving a newly registered entry. Login already linked old→old
// pairs; a pair is missing exactly when one side is new (the login
// walk skipped `## LoadWith:` names that didn't resolve, and new
// entries were never walked at all) — so appending just those pairs
// reproduces what a login scan would have built, with no duplicates in
// the engine's no-dedup lists.
void FixupReverseLoadWith(const std::vector<uintptr_t> &added) {
auto isNew = [&added](uintptr_t e) {
return std::find(added.begin(), added.end(), e) != added.end();
};
auto grow =
reinterpret_cast<DescGrow_t>(Offsets::FUN_ADDON_REVLOADWITH_GROW);
ForEachEntry([&](uintptr_t entry) {
const bool entryIsNew = isNew(entry);
const uint32_t count = *reinterpret_cast<const uint32_t *>(
entry + Offsets::OFF_ADDON_LOADWITH_COUNT);
auto names = *reinterpret_cast<const char *const *const *>(
entry + Offsets::OFF_ADDON_LOADWITH_ARRAY);
for (uint32_t i = 0; i < count; ++i) {
const uintptr_t target = ResolveEntryByName(names[i]);
if (target == 0 || (!entryIsNew && !isNew(target)))
continue;
DescAppend(reinterpret_cast<Desc *>(
target + Offsets::OFF_ADDON_REVLOADWITH_DESC),
static_cast<uint32_t>(entry), grow);
}
});
}
// `FUN_0051DA70` phase 2, mirrored (never call that function itself —
// its phase 1 consumes an SMSG_ADDON_INFO packet): rebuild the
// `GetNumAddOns`/`GetAddOnInfo(i)` name-pointer array from the linked
// list, skipping filtered entries (keeps `!!!ClassicAPI` hidden), then
// sort with the engine's own comparator to restore alphabetical order.
void RebuildDisplayArray() {
auto desc = reinterpret_cast<Desc *>(
static_cast<uintptr_t>(Offsets::VAR_ADDON_ARRAY_CAP));
auto grow = reinterpret_cast<DescGrow_t>(Offsets::FUN_ADDON_ARRAY_GROW);
desc->count = 0;
ForEachEntry([&](uintptr_t entry) {
if (*reinterpret_cast<const uint8_t *>(
entry + Offsets::OFF_ADDON_ENTRY_FILTER_OUT) != 0)
return;
DescAppend(desc,
*reinterpret_cast<const uint32_t *>(
entry + Offsets::OFF_ADDON_ENTRY_NAME_PTR),
grow);
});
auto qsort = reinterpret_cast<Qsort_t>(Offsets::FUN_CRT_QSORT);
qsort(desc->data, desc->count, 4,
reinterpret_cast<void *>(Offsets::FUN_ADDON_NAME_COMPARE));
}
void Run() {
// Registry populated (login scan ran) and loose index built — both
// always true by the first in-world LoadScriptFunctions, but these
// are the states the steps below mutate, so gate explicitly.
if (*reinterpret_cast<const uint8_t *>(
static_cast<uintptr_t>(Offsets::VAR_ADDON_INITIALIZED)) == 0 ||
*reinterpret_cast<const uint8_t *>(
static_cast<uintptr_t>(Offsets::VAR_VFS_INDEX_READY)) == 0)
return;
RefreshLooseFileIndex();
// Metadata refresh runs BEFORE the snapshot: a re-registered entry
// is alive by snapshot time, so it lands in `before` and can never
// double-count in `added` — even if the allocator reuses the old
// entry's address, `refreshed` tracks it independently of the diff.
std::vector<uintptr_t> refreshed;
const bool evicted = RefreshChangedMetadata(refreshed);
std::vector<uintptr_t> before;
ForEachEntry([&before](uintptr_t entry) { before.push_back(entry); });
// Replay login scan walk #2 verbatim: engine walker + engine
// callback + engine parser. The parser's dedup guard makes this a
// hash lookup per already-registered addon; new folders register
// as complete, normal entries (their TOC is readable now — and it
// goes through the FlavorToc/TocRewrite read hooks like any other).
//
// The dedup guard is why this replay can NEVER refresh or corrupt
// an existing entry's `##` metadata (verified in the parser's
// decompile): the guard is the function's FIRST block — hash
// lookup + name compare, early `return` on match BEFORE the TOC
// path is even built, so no file read and no entry write happen
// for a registered name. Both the Storm hash (`FUN_0064B3F0`,
// uppercases a–z and folds '/'→'\\' before hashing) and the
// compare (`SStrCmpI`) are case-insensitive, so a case-renamed
// folder still matches its existing entry rather than registering
// a duplicate.
auto scan =
reinterpret_cast<ScanDiskDirs_t>(Offsets::FUN_ADDON_SCAN_DISK_DIRS);
scan(reinterpret_cast<const char *>(Offsets::VAR_ADDON_PATH_PREFIX),
reinterpret_cast<const char *>(Offsets::VAR_ADDON_SCAN_PATTERN),
reinterpret_cast<void *>(Offsets::FUN_ADDON_DISK_DIR_CB),
/*userParam=*/nullptr, /*includeHidden=*/0);
std::vector<uintptr_t> added;
ForEachEntry([&](uintptr_t entry) {
if (std::find(before.begin(), before.end(), entry) == before.end())
added.push_back(entry);
});
// Seed the metadata map for entries this reload registered, so the
// next reload diffs a `##` edit against what was actually parsed
// (waiting for the next pass to seed would silently absorb an edit
// made between now and then).
for (uintptr_t entry : added) {
const char *name = *reinterpret_cast<const char *const *>(
entry + Offsets::OFF_ADDON_ENTRY_NAME_PTR);
uint32_t h = 0;
if (!IsRefreshExempt(entry, name) && TryHashToc(name, &h))
g_metaHash[name] = h;
}
if (added.empty() && refreshed.empty() && !evicted)
return;
// Refreshed entries need the same link fix-ups a new folder gets:
// their old reverse-LoadWith pairs died with the old entry (scrub +
// dtor), and the parser doesn't build reverse links.
std::vector<uintptr_t> linked = added;
linked.insert(linked.end(), refreshed.begin(), refreshed.end());
if (!linked.empty())
FixupReverseLoadWith(linked);
// Rebuild whenever membership OR an entry identity changed — after
// any eviction (including a pure deletion) the display array still
// holds the dead entry's freed name pointer.
RebuildDisplayArray();
}
const Game::ModuleAutoRegister _autoreg{&Run};
} // namespace
} // namespace Addons::Rescan
+270
View File
@@ -0,0 +1,270 @@
// 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/>.
// `## LoadSavedVariablesFirst: 1` — modern TOC directive, backported.
//
// Retail loads a flagged addon's SavedVariables BEFORE its Lua runs, so
// file-scope code (`local db = MyAddonDB`) sees restored config immediately.
// Vanilla 1.12 always does the opposite: the per-addon loader `FUN_0051f240`
// runs the addon's files (`FUN_ADDON_LOAD_FILES`), THEN loads SavedVariables,
// THEN fires `ADDON_LOADED`. So file-scope SV is always nil, and vanilla never
// parses the directive at all.
//
// We co-hook `FUN_ADDON_LOAD_FILES` (the per-addon file load, once per addon,
// in-world). For an addon whose TOC declares `## LoadSavedVariablesFirst`
// (nonzero), load its SavedVariables first — mirroring the engine's OWN path
// construction two steps later in `FUN_0051f240` (verified by disassembly):
// account : WTF\Account\<account>\SavedVariables\<Name>.lua
// per-char: WTF\Account\<account>\<realm>\<character>\SavedVariables\<Name>.lua,
// falling back to WTF\Account\<account>\<character>\SavedVariables\<Name>.lua
// (account = *VAR_ACCOUNT_NAME_PTR, realm = FUN_GET_REALM_NAME,
// character = FUN_GET_LOGIN_ACCOUNT_NAME) — then call the original so the
// files run with SV present. `FUN_LUA_LOAD_FILE` no-ops on a missing file, and
// `FUN_FILE_EXISTS` picks the same per-char variant the engine would.
//
// The engine STILL runs its own SavedVariables load at the normal step, right
// after the files. Left alone, that re-load would overwrite any value the
// addon wrote to its SavedVariable at file scope before ADDON_LOADED. So we
// also co-hook FUN_LUA_LOAD_FILE and suppress that one redundant re-load: each
// path we pre-load is remembered, and the engine's imminent load of that exact
// path is skipped once. This makes the behavior match true
// LoadSavedVariablesFirst — file-scope reads AND writes both survive. Our own
// early load calls the trampoline directly, so it is never self-suppressed.
// FUN_LUA_LOAD_FILE loads FILES only (not RunScript string execution), so the
// extra hook is a cold, addon-load-time path.
#include "Game.h"
#include "Offsets.h"
#include "addons/EngineIO.h"
#include "addons/Toc.h"
#include <cstddef>
#include <cstdint>
#include <cstdio>
#include <cstring>
#include <string>
#include <vector>
namespace AddOns::SavedVarsFirst {
namespace {
constexpr size_t NPOS = static_cast<size_t>(-1);
// Engine file I/O via the hooked FUN_FILE_READ (so a flavor TOC's flag is read —
// FlavorToc applies), the exists-check, and the Storm free. See addons/EngineIO.h.
using AddOns::EngineIO::FileExistsFn;
using AddOns::EngineIO::FileReadFn;
using AddOns::EngineIO::SMemFreeFn;
using LoadTocFilesFn = uint32_t(__fastcall *)(char *tocPath, int *a2, int *a3);
using LuaLoadFileFn = uint32_t(__fastcall *)(const char *path, void *a2, void *a3);
using NameFn = const char *(*)(); // no-arg readers (return in EAX)
LoadTocFilesFn g_orig = nullptr;
LuaLoadFileFn g_origLuaLoad = nullptr;
// Full SV paths we pre-loaded whose imminent engine re-load must be skipped
// once. Populated in LoadSavedVarsEarly, drained by LuaLoad_h. Only ever holds
// a handful of entries (one addon's SV files, briefly). Main-thread only.
std::vector<std::string> g_pendingSuppress;
using AddOns::Toc::EqCI;
using AddOns::Toc::Lower;
bool SamePathCI(const char *a, const char *b) {
for (; *a != '\0' && *b != '\0'; ++a, ++b)
if (Lower(*a) != Lower(*b)) return false;
return *a == *b;
}
// Extract "<Name>" from an addon base-TOC path `…\AddOns\<Name>\<Name>.toc`.
// Returns false for anything else (FrameXML.toc, nested paths, …) so the
// SavedVariables reorder only touches real addon loads.
bool AddonNameFromToc(const char *path, char *out, size_t outSize) {
const size_t len = std::strlen(path);
if (len < 4 || !(path[len - 4] == '.' && Lower(path[len - 3]) == 't' &&
Lower(path[len - 2]) == 'o' && Lower(path[len - 1]) == 'c'))
return false;
size_t lastSep = NPOS;
for (size_t i = len; i-- > 0;)
if (path[i] == '\\' || path[i] == '/') { lastSep = i; break; }
if (lastSep == NPOS) return false;
size_t prevSep = NPOS;
for (size_t i = lastSep; i-- > 0;)
if (path[i] == '\\' || path[i] == '/') { prevSep = i; break; }
if (prevSep == NPOS) return false;
size_t gpSep = NPOS;
for (size_t i = prevSep; i-- > 0;)
if (path[i] == '\\' || path[i] == '/') { gpSep = i; break; }
if (gpSep == NPOS) return false;
if (prevSep - (gpSep + 1) != 6 || !EqCI(path + gpSep + 1, 6, "AddOns"))
return false;
const size_t nameLen = lastSep - (prevSep + 1);
if (nameLen == 0 || nameLen + 1 > outSize) return false;
std::memcpy(out, path + prevSep + 1, nameLen);
out[nameLen] = '\0';
return true;
}
// True iff the leading integer of a TOC value is nonzero (WoW numeric flag).
bool NonzeroFlag(const char *v, size_t n) {
long val = 0;
bool digit = false;
for (size_t i = 0; i < n && v[i] >= '0' && v[i] <= '9'; ++i) {
val = val * 10 + (v[i] - '0');
digit = true;
}
return digit && val != 0;
}
// The SavedVariables directives that decide what LoadSavedVarsEarly does.
struct SvFlags {
bool first = false; // `## LoadSavedVariablesFirst:` is nonzero
bool account = false; // `## SavedVariables:` declares at least one variable
bool perChar = false; // `## SavedVariablesPerCharacter:` declares at least one
};
// Read the addon's TOC once (through the hooked FUN_FILE_READ, so a flavor
// TOC's directives are honored) and extract the three flags above.
SvFlags ReadSvFlags(const char *tocPath) {
SvFlags f;
auto FileRead =
reinterpret_cast<FileReadFn>(static_cast<uintptr_t>(Offsets::FUN_FILE_READ));
void *buf = nullptr;
size_t size = 0;
if (FileRead(0, tocPath, &buf, &size, 1, 1, 0) == 0 || buf == nullptr)
return f;
const char *b = static_cast<const char *>(buf);
const char *v = nullptr;
size_t n = 0;
if (AddOns::Toc::FindValue(b, size, "## LoadSavedVariablesFirst:", &v, &n))
f.first = NonzeroFlag(v, n);
// Gate each SV file on the DECLARATION, not mere file existence — mirroring
// the engine, which loads the account file iff `## SavedVariables` is
// declared and the per-char file iff `## SavedVariablesPerCharacter` is.
// (The trailing `:` keeps "## SavedVariables:" from matching the
// "## SavedVariablesPerCharacter:" line.) Otherwise a stale SV file from a
// scope the addon no longer declares would be executed early — Lua the
// engine never runs — and queued for a suppression the engine never drains.
f.account = AddOns::Toc::FindValue(b, size, "## SavedVariables:", &v, &n) && n > 0;
f.perChar =
AddOns::Toc::FindValue(b, size, "## SavedVariablesPerCharacter:", &v, &n) && n > 0;
reinterpret_cast<SMemFreeFn>(static_cast<uintptr_t>(Offsets::FUN_STORM_SMEM_FREE))(
buf, __FILE__, __LINE__, 0);
return f;
}
bool FileExists(const char *path) {
return reinterpret_cast<FileExistsFn>(
static_cast<uintptr_t>(Offsets::FUN_FILE_EXISTS))(path, 1) != 0;
}
// Load a SavedVariables file we found, then mark its path so the engine's own
// re-load of it (right after the addon's files) is suppressed — preserving any
// file-scope write. Calls the trampoline directly so this load is never itself
// suppressed by our LuaLoad_h hook.
void RunLuaFile(const char *path) {
if (g_origLuaLoad == nullptr)
return;
g_origLuaLoad(path, nullptr, nullptr);
g_pendingSuppress.emplace_back(path);
}
// Load the addon's SavedVariables early, mirroring FUN_0051f240's own paths.
// Each scope loads only when its directive is declared (`hasAccount` /
// `hasPerChar`) AND the file exists — exactly what the engine's own SV step
// would re-load, so every suppress entry we queue is later drained.
void LoadSavedVarsEarly(const char *addonName, bool hasAccount, bool hasPerChar) {
const char *account = *reinterpret_cast<const char *const *>(
static_cast<uintptr_t>(Offsets::VAR_ACCOUNT_NAME_PTR));
if (account == nullptr || *account == '\0')
return;
char path[260];
// Account-wide (`## SavedVariables`).
if (hasAccount) {
std::snprintf(path, sizeof path, "WTF\\Account\\%s\\SavedVariables\\%s.lua",
account, addonName);
if (FileExists(path))
RunLuaFile(path);
}
// Per-character (`## SavedVariablesPerCharacter`): prefer the realm-scoped
// file, else the realm-less one — the engine's own fallback order.
if (!hasPerChar)
return;
const char *character = reinterpret_cast<NameFn>(
static_cast<uintptr_t>(Offsets::FUN_GET_LOGIN_ACCOUNT_NAME))();
if (character == nullptr || *character == '\0')
return;
const char *realm = reinterpret_cast<NameFn>(
static_cast<uintptr_t>(Offsets::FUN_GET_REALM_NAME))();
if (realm != nullptr && *realm != '\0') {
std::snprintf(path, sizeof path,
"WTF\\Account\\%s\\%s\\%s\\SavedVariables\\%s.lua", account,
realm, character, addonName);
if (FileExists(path)) {
RunLuaFile(path);
return;
}
}
std::snprintf(path, sizeof path, "WTF\\Account\\%s\\%s\\SavedVariables\\%s.lua",
account, character, addonName);
if (FileExists(path))
RunLuaFile(path);
}
uint32_t __fastcall LoadTocFiles_h(char *tocPath, int *a2, int *a3) {
char name[128];
if (tocPath != nullptr && AddonNameFromToc(tocPath, name, sizeof name)) {
const SvFlags f = ReadSvFlags(tocPath);
if (f.first)
LoadSavedVarsEarly(name, f.account, f.perChar);
}
return g_orig(tocPath, a2, a3);
}
// Skip the engine's one redundant SavedVariables re-load per pre-loaded path.
// Every other file load passes straight through. Match is by full path
// (case-insensitive); a path is suppressed exactly once, so nothing else is
// affected. Our own early load bypasses this via the trampoline.
uint32_t __fastcall LuaLoad_h(const char *path, void *a2, void *a3) {
if (path != nullptr) {
for (size_t i = 0; i < g_pendingSuppress.size(); ++i) {
if (SamePathCI(g_pendingSuppress[i].c_str(), path)) {
g_pendingSuppress.erase(g_pendingSuppress.begin() +
static_cast<std::ptrdiff_t>(i));
return 0; // suppress — the addon's file-scope SV writes survive
}
}
}
return g_origLuaLoad(path, a2, a3);
}
const Game::HookAutoRegister _hook{
Offsets::FUN_ADDON_LOAD_FILES,
reinterpret_cast<void *>(&LoadTocFiles_h),
reinterpret_cast<void **>(&g_orig)};
const Game::HookAutoRegister _hookLua{
Offsets::FUN_LUA_LOAD_FILE,
reinterpret_cast<void *>(&LuaLoad_h),
reinterpret_cast<void **>(&g_origLuaLoad)};
} // namespace
} // namespace AddOns::SavedVarsFirst
+69
View File
@@ -0,0 +1,69 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
#pragma once
#include <cstddef>
#include <cstring>
namespace AddOns::Toc {
// Fold an ASCII letter to lower case (leaves every other byte unchanged).
inline char Lower(char c) {
return (c >= 'A' && c <= 'Z') ? static_cast<char>(c + 32) : c;
}
// Case-insensitive equality of the `n` bytes at `s` against the NUL-terminated
// `lit` (ASCII). True only when the lengths match too. Shared by the addon
// modules that scan `…\AddOns\<Name>\…` paths and TOC directive lines.
inline bool EqCI(const char *s, size_t n, const char *lit) {
for (size_t i = 0; i < n; ++i)
if (lit[i] == '\0' || Lower(s[i]) != Lower(lit[i]))
return false;
return lit[n] == '\0';
}
// Scans an in-memory TOC buffer for `directive` (the full line prefix, e.g.
// "## Version:") at a line start, case-insensitively, and returns the value
// that follows via out-params: [*valStart, *valStart + *valLen), trimmed of
// leading and trailing spaces/tabs and excluding the CR/LF/EOF terminator.
// Returns false (out-params untouched) when the directive is absent. Pure —
// reads no files; the caller owns the buffer (via FUN_FILE_READ, or an embedded
// copy). Shared by addons/LocalTable.cpp (AllowAddOnTableAccess) and
// addons/Embedded.cpp (Version) so the line-scan lives in one place.
inline bool FindValue(const char *buf, size_t size, const char *directive,
const char **valStart, size_t *valLen) {
const size_t dlen = std::strlen(directive);
if (dlen == 0)
return false;
for (size_t i = 0; i + dlen <= size; ++i) {
const bool atLineStart = (i == 0) || buf[i - 1] == '\n';
if (!atLineStart || _strnicmp(buf + i, directive, dlen) != 0)
continue;
const char *p = buf + i + dlen;
const char *end = buf + size;
while (p < end && (*p == ' ' || *p == '\t'))
++p;
const char *v = p;
while (p < end && *p != '\r' && *p != '\n')
++p;
while (p > v && (p[-1] == ' ' || p[-1] == '\t'))
--p;
*valStart = v;
*valLen = static_cast<size_t>(p - v);
return true;
}
return false;
}
} // namespace AddOns::Toc
+297
View File
@@ -0,0 +1,297 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
#include "addons/EngineIO.h"
#include "addons/TocRewrite.h"
#include "addons/Toc.h"
#include "Offsets.h"
#include <cstddef>
#include <cstdint>
#include <cstring>
#include <string>
namespace AddOns::TocRewrite {
namespace {
using AddOns::EngineIO::SMemAllocFn;
using AddOns::EngineIO::SMemFreeFn;
using AddOns::Toc::EqCI;
using AddOns::Toc::Lower;
bool IsSpace(char c) { return c == ' ' || c == '\t'; }
// The client locale code ("enUS", "frFR", …) — the exact string
// GetLocale() returns (Offsets.h VAR_LOCALE_NAME_TABLE derivation).
const char *ClientLocale() {
uint32_t idx = *reinterpret_cast<const uint32_t *>(
static_cast<uintptr_t>(Offsets::VAR_LOCALE_INDEX));
if (idx > 7) idx = 0; // slot 8 overreads the string pool — clamp
const char *const *table = reinterpret_cast<const char *const *>(
static_cast<uintptr_t>(Offsets::VAR_LOCALE_NAME_TABLE));
const char *s = table[idx];
return (s != nullptr) ? s : "enUS";
}
// True iff the comma-separated `list` (spaces allowed) contains `token` (CI).
bool ListContains(const char *list, size_t listLen, const char *token) {
size_t i = 0;
while (i < listLen) {
while (i < listLen && (IsSpace(list[i]) || list[i] == ',')) ++i;
const size_t start = i;
while (i < listLen && list[i] != ',') ++i;
size_t end = i; // [start, end) is one item, maybe with trailing space
while (end > start && IsSpace(list[end - 1])) --end;
if (end > start && EqCI(list + start, end - start, token)) return true;
}
return false;
}
// A single-word `[Token]` variable expansion, or nullptr if `head` is not
// a known variable. This 1.12 + ClassicAPI client is the Classic family,
// the Vanilla game type, and whatever the client text locale is.
const char *VariableValue(const char *head, size_t headLen) {
if (EqCI(head, headLen, "Family")) return "Classic";
if (EqCI(head, headLen, "Game")) return "Vanilla";
if (EqCI(head, headLen, "TextLocale")) return ClientLocale();
return nullptr;
}
// The client interface version the loadability check compares against
// (FUN_0051d7d0 -> 0x2BC0 = 11200). Read live so we mirror the engine's own
// value rather than hardcoding it.
using ClientIfaceVer_t = uint32_t(__cdecl *)();
uint32_t ClientInterfaceVersion() {
return reinterpret_cast<ClientIfaceVer_t>(
static_cast<uintptr_t>(Offsets::FUN_ADDON_CLIENT_INTERFACE_VERSION))();
}
// If `line` is a `## Interface:` directive whose value is a comma-list that
// contains the client version but does NOT lead with it, append a normalized
// `<prefix>: <clientVersion>` line to `out` and return true. Vanilla's parser
// reads only the first number, so without this a later 11200 is never seen and
// the addon is marked out of date. Returns false for any other line, and for
// an interface line that needs no change (single value, no client version, or
// client version already first) — the caller then copies the line verbatim.
bool TryRewriteInterfaceLine(const char *line, size_t len, std::string &out) {
size_t s = 0;
while (s < len && IsSpace(line[s])) ++s;
if (s + 1 >= len || line[s] != '#' || line[s + 1] != '#') return false;
size_t p = s + 2;
while (p < len && IsSpace(line[p])) ++p;
static const char kKey[] = "interface"; // matched case-insensitively
const size_t klen = 9;
if (p + klen > len) return false;
for (size_t i = 0; i < klen; ++i)
if (Lower(line[p + i]) != kKey[i]) return false;
p += klen;
while (p < len && IsSpace(line[p])) ++p;
if (p >= len || line[p] != ':') return false;
++p; // past ':'
const size_t prefixEnd = p; // "## Interface:" (author spacing preserved)
const uint32_t client = ClientInterfaceVersion();
long first = -1;
bool hasComma = false, hasClient = false;
size_t q = p;
while (q < len) {
while (q < len && (IsSpace(line[q]) || line[q] == ',')) {
if (line[q] == ',') hasComma = true;
++q;
}
long v = 0;
bool digit = false;
while (q < len && line[q] >= '0' && line[q] <= '9') {
v = v * 10 + (line[q] - '0');
++q;
digit = true;
}
if (digit) {
if (first < 0) first = v;
if (static_cast<uint32_t>(v) == client) hasClient = true;
} else if (q < len) {
++q; // skip a stray non-digit so the scan always advances
}
}
if (!hasComma || !hasClient || first == static_cast<long>(client))
return false; // single value / no client version / already first
out.append(line, prefixEnd);
out.push_back(' ');
out += std::to_string(client);
return true;
}
// Evaluate a `[keyword args]` condition — the return is whether the line should
// load. An unrecognized keyword returns false so the caller drops the line (a
// condition we cannot confirm must not load — fail safe).
bool EvalCondition(const char *head, size_t headLen, const char *args,
size_t argsLen) {
if (EqCI(head, headLen, "AllowLoadGameType"))
return ListContains(args, argsLen, "vanilla");
if (EqCI(head, headLen, "AllowLoadTextLocale"))
return ListContains(args, argsLen, ClientLocale());
if (EqCI(head, headLen, "AllowLoad"))
return ListContains(args, argsLen, "game"); // addon files: in-game only
return false;
}
// Rewrite one file-reference line into `out`: expand `[Variable]` tokens,
// evaluate and strip `[Condition]` directives. Returns true to KEEP the
// line (all conditions passed); on true `out` holds the cleaned,
// right-trimmed path.
bool ProcessFileLine(const char *line, size_t len, std::string &out) {
out.clear();
bool keep = true;
size_t i = 0;
while (i < len) {
const char c = line[i];
if (c != '[') {
out.push_back(c);
++i;
continue;
}
size_t close = i + 1;
while (close < len && line[close] != ']') ++close;
if (close >= len) { // unterminated '[' — copy the rest verbatim
out.append(line + i, len - i);
break;
}
const char *inner = line + i + 1;
const size_t innerLen = close - (i + 1);
size_t h = 0;
while (h < innerLen && !IsSpace(inner[h])) ++h; // [0,h) = head word
size_t a = h;
while (a < innerLen && IsSpace(inner[a])) ++a; // skip separating ws
const char *head = inner;
const size_t headLen = h;
const char *args = inner + a;
const size_t argsLen = innerLen - a;
if (argsLen == 0) {
// Single word: a variable to expand, else leave verbatim (an
// unknown token becomes a path the engine cannot open — the
// file just does not load).
const char *val = VariableValue(head, headLen);
if (val != nullptr) out.append(val);
else out.append(line + i, (close + 1) - i);
} else {
// Keyword + args: a condition. Strip it from the path (drop a
// space we already emitted before it so nothing dangles).
keep = keep && EvalCondition(head, headLen, args, argsLen);
if (!out.empty() && out.back() == ' ') out.pop_back();
}
i = close + 1;
}
while (!out.empty() && IsSpace(out.back())) out.pop_back();
return keep;
}
// Walk the whole TOC, preserving line terminators and any UTF-8 BOM.
// `#` comment and `##` metadata lines pass through verbatim; only file
// reference lines are rewritten. A gated-out line becomes a `#` comment
// so the load loop skips it (and a dumped TOC shows why it was dropped).
void RebuildToc(const char *src, size_t srcLen, std::string &out) {
out.clear();
out.reserve(srcLen + 32);
size_t i = 0;
if (srcLen >= 3 && static_cast<uint8_t>(src[0]) == 0xEF &&
static_cast<uint8_t>(src[1]) == 0xBB &&
static_cast<uint8_t>(src[2]) == 0xBF) {
out.append(src, 3);
i = 3;
}
std::string lineOut;
while (i < srcLen) {
const size_t lineStart = i;
while (i < srcLen && src[i] != '\r' && src[i] != '\n') ++i;
const char *line = src + lineStart;
const size_t len = i - lineStart;
const size_t termStart = i;
if (i < srcLen && src[i] == '\r') ++i;
if (i < srcLen && src[i] == '\n') ++i;
size_t s = 0;
while (s < len && IsSpace(line[s])) ++s;
if (s >= len) {
out.append(line, len); // blank
} else if (line[s] == '#') {
// Comment / `##` metadata — verbatim, except a multi-flavor
// `## Interface:` list that hides the client version.
if (!TryRewriteInterfaceLine(line, len, out))
out.append(line, len);
} else if (ProcessFileLine(line, len, lineOut)) {
out.append(lineOut);
} else {
out.push_back('#'); // gated out — comment it so the loader skips it
out.append(line, len);
}
out.append(src + termStart, i - termStart);
}
}
// True iff `path` is an addon `.toc` read (…\AddOns\…\*.toc). Gates the
// rewrite off every non-TOC read (a `.lua` with a `t[1]` subscript would
// otherwise be mangled) and off non-addon TOCs (FrameXML.toc, etc.).
bool IsAddonToc(const char *path) {
const size_t len = std::strlen(path);
if (len < 4) return false;
if (!(path[len - 4] == '.' && Lower(path[len - 3]) == 't' &&
Lower(path[len - 2]) == 'o' && Lower(path[len - 1]) == 'c'))
return false;
for (size_t i = 0; i + 8 <= len; ++i)
if (EqCI(path + i, 8, "\\addons\\")) return true;
return false;
}
} // namespace
void Transform(const char *path, void **outBuf, size_t *outSize) {
if (path == nullptr || outBuf == nullptr || *outBuf == nullptr) return;
if (!IsAddonToc(path)) return;
const char *content = static_cast<const char *>(*outBuf);
const size_t size = (outSize != nullptr && *outSize != 0)
? *outSize
: std::strlen(content);
if (size == 0) return;
// Fast path: no '[' (directives) and no ',' (a possible `## Interface:`
// list) means nothing to do — leave the engine buffer alone.
if (std::memchr(content, '[', size) == nullptr &&
std::memchr(content, ',', size) == nullptr)
return;
std::string rebuilt;
RebuildToc(content, size, rebuilt);
if (rebuilt.size() == size &&
std::memcmp(rebuilt.data(), content, size) == 0)
return; // brackets/commas were only in unaffected lines — nothing changed
auto SMemAlloc = reinterpret_cast<SMemAllocFn>(Offsets::FUN_STORM_SMEM_ALLOC);
auto SMemFree = reinterpret_cast<SMemFreeFn>(Offsets::FUN_STORM_SMEM_FREE);
const size_t newLen = rebuilt.size();
void *buf = SMemAlloc(newLen + 1, __FILE__, __LINE__, 0); // +1 NUL (extraBytes=1)
if (buf == nullptr) return; // OOM — keep the original
std::memcpy(buf, rebuilt.data(), newLen);
static_cast<char *>(buf)[newLen] = '\0';
SMemFree(*outBuf, __FILE__, __LINE__, 0);
*outBuf = buf;
if (outSize != nullptr) *outSize = newLen;
}
} // namespace AddOns::TocRewrite
+79
View File
@@ -0,0 +1,79 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
#pragma once
#include <cstddef>
namespace AddOns::TocRewrite {
// Modern-client per-line TOC directives, backported.
//
// A single-TOC multi-flavor addon gates individual file lines with
// conditions and expands path variables:
//
// [Family]\Init.lua # -> Classic\Init.lua
// Vanilla.lua [AllowLoadGameType vanilla]
// Mainline.lua [AllowLoadGameType mainline] # dropped on this client
// Localization\[TextLocale].lua # -> Localization\enUS.lua
// deDE.lua [AllowLoadTextLocale deDE]
//
// Vanilla 1.12's TOC parsers never understood any of this. The scan
// parser (FUN_0051C9B0) reads only `##` metadata; the load pass
// (FUN_006edb90) hands each non-`#` line to the per-file loader verbatim,
// where the trailing ` [...]` breaks the extension check and the file
// silently never loads. `[Variable]` tokens likewise become part of a
// path that does not exist.
//
// This closes the gap at the FUN_FILE_READ layer (alongside FlavorToc /
// Embedded): after an addon TOC is read, rewrite its file-reference lines
// so the engine's own load loop sees clean paths. `##` metadata and `#`
// comment lines are copied verbatim, with ONE exception (`## Interface:`,
// below); metadata-line conditions, added in retail 12.0.7, are out of
// scope. What the tokens resolve to on this 1.12 client:
//
// [AllowLoadGameType ...] keep the line iff the list contains `vanilla`
// [AllowLoadTextLocale ...] keep the line iff the list contains the
// client locale (GetLocale() code, e.g. enUS)
// [AllowLoad ...] addon files only load in-game, so `game`
// keeps and `glue` drops
// [Family] -> Classic
// [Game] -> Vanilla
// [TextLocale] -> the client locale code
//
// All conditions on a line must pass for it to load (AND). An unknown
// `[keyword args]` condition fails safe (drops the line); an unknown
// single-word `[Token]` is left verbatim (the engine then fails to find
// that path, so the file does not load).
//
// Multi-flavor `## Interface:` list. Retail lets a TOC list several
// interface versions (`## Interface: 120100, 50504, 11200`). Vanilla's
// parser is a plain atoi that reads only the FIRST number, so it never
// sees a later 11200 and marks the addon out of date. When the client
// version (FUN_ADDON_CLIENT_INTERFACE_VERSION, 11200) appears anywhere in
// the list but is not first, the `## Interface:` line is rewritten to that
// single value so the engine marks the addon compatible. A list WITHOUT
// the client version is left unchanged — it still parses to its first
// value and stays out of date, exactly as before.
//
// Called from the FUN_FILE_READ hook after the read succeeds. Does
// nothing unless `path` is an addon `.toc` read AND its content contains a
// `[` (directives) or a `,` (a possible `## Interface:` list) — the
// fast-path bail keeps every unaffected TOC zero-copy, and a rebuild that
// changes nothing is discarded by a content compare. When it does rewrite,
// it allocates a fresh Storm buffer, frees the original, and updates
// `*outBuf` / `*outSize` — the caller frees the replacement exactly as it
// would the engine's own read buffer.
void Transform(const char *path, void **outBuf, size_t *outSize);
} // namespace AddOns::TocRewrite
+6 -9
View File
@@ -68,12 +68,9 @@ namespace AuctionHouse::PostItem {
namespace {
constexpr const char *kEvtStart = "AUCTION_MULTISELL_START";
constexpr const char *kEvtUpdate = "AUCTION_MULTISELL_UPDATE";
constexpr const char *kEvtFailure = "AUCTION_MULTISELL_FAILURE";
const Event::Custom::AutoReserve _r1{kEvtStart};
const Event::Custom::AutoReserve _r2{kEvtUpdate};
const Event::Custom::AutoReserve _r3{kEvtFailure};
const Event::Custom::AutoReserve _evtStart{"AUCTION_MULTISELL_START"};
const Event::Custom::AutoReserve _evtUpdate{"AUCTION_MULTISELL_UPDATE"};
const Event::Custom::AutoReserve _evtFailure{"AUCTION_MULTISELL_FAILURE"};
// Generous per-step abort bound. A split or a post is one server round-trip
// (~tens of frames); 600 frames (~10s at 60fps) means "something's wrong"
@@ -206,7 +203,7 @@ bool FindFreeGeneralSlot(void *L, int *outBag, int *outSlot) {
void Finish(bool success) {
g_job.active = false;
if (!success)
Event::Custom::Fire(Event::Custom::Lookup(kEvtFailure), "");
Event::Custom::Fire(_evtFailure.Slot(), "");
// success needs no event: the last PostWait already fired
// AUCTION_MULTISELL_UPDATE(total, total).
}
@@ -279,7 +276,7 @@ void Tick() {
// Post confirmed once the server pulls the item out of the slot.
if (w.item == nullptr || w.guid != g_job.postGuid) {
++g_job.postedStacks;
Event::Custom::Fire(Event::Custom::Lookup(kEvtUpdate), "%d%d",
Event::Custom::Fire(_evtUpdate.Slot(), "%d%d",
g_job.postedStacks, g_job.totalStacks);
g_job.phase = Phase::Idle;
g_job.waitTicks = 0;
@@ -400,7 +397,7 @@ int __fastcall Script_C_AuctionHouse_PostItem(void *L) {
g_job.runTime = runTime;
g_job.phase = Phase::Idle;
Event::Custom::Fire(Event::Custom::Lookup(kEvtStart), "");
Event::Custom::Fire(_evtStart.Slot(), "");
Game::Lua::PushBool(L, true);
return 1;
}
+76 -5
View File
@@ -46,14 +46,10 @@ namespace Aura::Api {
namespace {
using ResolveUnitToken_t = void *(__fastcall *)(const char *token);
const uint8_t *ResolveUnit(const char *token) {
if (token == nullptr)
return nullptr;
auto fn = reinterpret_cast<ResolveUnitToken_t>(
static_cast<uintptr_t>(Offsets::FUN_RESOLVE_UNIT_TOKEN));
return static_cast<const uint8_t *>(fn(token));
return static_cast<const uint8_t *>(Game::ResolveUnitToken(token));
}
// Resolves a token to its GUID for the out-of-range (no live CGUnit) path.
@@ -183,6 +179,31 @@ int PushAuraByIndex(void *L, const char *unitToken, int index,
return 1;
}
// Positional (multi-return) sibling of `PushAuraByIndex` for the
// `C_UnitAuras.UnitAura` family: pushes the Classic-Era `UnitAura` 15-tuple with
// NO table allocation (the zero-GC path). Returns 15 on a hit, or 1 (a single
// nil) on a miss. The bool-returning push helpers leave exactly the 15-tuple on a
// hit and nothing on a miss, so the miss `PushNil` + `return 1` needs no cleanup.
int PushUnitAuraPositional(void *L, const char *unitToken, int index,
Data::Filter filter, Data::Match match = {}) {
const uint8_t *unit = ResolveUnit(unitToken);
if (unit != nullptr) {
const int slot = Data::FindNthSlot(unit, index, filter, match);
if (slot >= 0) {
Data::Push(L, unit, slot, Data::Emit::Positional);
return 15;
}
if (Data::PushNthCacheFallback(L, unit, index, filter, match,
Data::Emit::Positional))
return 15;
} else if (Data::PushNthGroupAura(L, GuidForOutOfRange(unitToken), index,
filter, match, Data::Emit::Positional)) {
return 15;
}
Game::Lua::PushNil(L);
return 1;
}
int __fastcall Script_GetAuraDataByIndex(void *L) {
const char *unit = ArgUnit(L, 1);
const int index = ArgInt(L, 2);
@@ -215,6 +236,50 @@ int __fastcall Script_GetDebuffDataByIndex(void *L) {
return PushAuraByIndex(L, unit, index, Data::Filter::Harmful);
}
// Zero-allocation positional accessors — Classic-Era `UnitAura`/`UnitBuff`/
// `UnitDebuff` shape, namespaced under `C_UnitAuras` to avoid clashing with the
// native `UnitBuff`/`UnitDebuff` globals (which keep their vanilla short return).
// `UnitAura` reads the range from the filter (helpful by default); `UnitBuff` /
// `UnitDebuff` lock the range and still honor the filter's PLAYER/DISPELLABLE/
// CROWD_CONTROL predicates.
int __fastcall Script_UnitAura(void *L) {
const char *unit = ArgUnit(L, 1);
const int index = ArgInt(L, 2);
const char *filterStr = ArgOptString(L, 3);
if (unit == nullptr || index < 1) {
Game::Lua::PushNil(L);
return 1;
}
const ParsedFilter pf = ParseFilters(filterStr);
return PushUnitAuraPositional(L, unit, index, RangeFilter(pf), pf.ToMatch());
}
int __fastcall Script_UnitBuff(void *L) {
const char *unit = ArgUnit(L, 1);
const int index = ArgInt(L, 2);
const char *filterStr = ArgOptString(L, 3);
if (unit == nullptr || index < 1) {
Game::Lua::PushNil(L);
return 1;
}
const ParsedFilter pf = ParseFilters(filterStr);
return PushUnitAuraPositional(L, unit, index, Data::Filter::Helpful,
pf.ToMatch());
}
int __fastcall Script_UnitDebuff(void *L) {
const char *unit = ArgUnit(L, 1);
const int index = ArgInt(L, 2);
const char *filterStr = ArgOptString(L, 3);
if (unit == nullptr || index < 1) {
Game::Lua::PushNil(L);
return 1;
}
const ParsedFilter pf = ParseFilters(filterStr);
return PushUnitAuraPositional(L, unit, index, Data::Filter::Harmful,
pf.ToMatch());
}
int __fastcall Script_GetUnitAuraBySpellID(void *L) {
const char *unitToken = ArgUnit(L, 1);
const int spellID = ArgInt(L, 2);
@@ -426,6 +491,12 @@ static void RegisterLuaFunctions() {
&Script_GetBuffDataByIndex);
Game::Lua::RegisterTableFunction("C_UnitAuras", "GetDebuffDataByIndex",
&Script_GetDebuffDataByIndex);
Game::Lua::RegisterTableFunction("C_UnitAuras", "UnitAura",
&Script_UnitAura);
Game::Lua::RegisterTableFunction("C_UnitAuras", "UnitBuff",
&Script_UnitBuff);
Game::Lua::RegisterTableFunction("C_UnitAuras", "UnitDebuff",
&Script_UnitDebuff);
Game::Lua::RegisterTableFunction("C_UnitAuras", "GetUnitAuraBySpellID",
&Script_GetUnitAuraBySpellID);
Game::Lua::RegisterTableFunction("C_UnitAuras", "GetPlayerAuraBySpellID",
+102 -31
View File
@@ -77,13 +77,9 @@ const char *SpellIconPath(const uint8_t *spellRecord) {
Offsets::OFF_SPELLICON_PATH);
}
using ResolveUnitToken_t = void *(__fastcall *)(const char *);
// Returns the local player's CGUnit pointer, or nullptr pre-login.
const uint8_t *LocalPlayer() {
auto fn = reinterpret_cast<ResolveUnitToken_t>(
static_cast<uintptr_t>(Offsets::FUN_RESOLVE_UNIT_TOKEN));
return static_cast<const uint8_t *>(fn("player"));
return static_cast<const uint8_t *>(Game::ResolveUnitToken("player"));
}
// Looks up the player-buff-table entry for a given spellID. The table
@@ -449,23 +445,49 @@ const char *DispelName(uint32_t dispelTypeID) {
return (name == nullptr || *name == '\0') ? nullptr : name;
}
// The display fields resolved from a spell record + caster, shared by the table
// emitter (`BuildTable`) and the positional emitter (`PushPositional`) so the
// name / icon / dispel-name lookups live in exactly one place. `castByPlayer`
// is the positional tuple's field #13 and the table's
// `isFromPlayerOrPlayerPet` — same value, retail's two names for the two aura
// shapes. Both mean "applied by ANY player or a player's pet" (Era
// semantics), not the local player — that's the `PLAYER` filter's job. The
// answer is the caster GUID's HIGHGUID prefix: player 0x0000 / pet 0xF140
// (server-confirmed in tortoise-wow ObjectGuid.h). Vanilla totems carry the
// creature prefix, so totem buffs read false. False on a cache miss (cast not
// observed), since a GUID of 0 classifies as neither.
struct Display {
const char *name;
const char *icon;
const char *dispelName;
bool castByPlayer;
};
static Display ResolveDisplay(uint32_t spellID, uint64_t casterGuid) {
const uint8_t *spellRecord = SpellRecord(spellID);
uint32_t dispelTypeID = 0;
if (spellRecord != nullptr) {
dispelTypeID = *reinterpret_cast<const uint32_t *>(
spellRecord + Offsets::OFF_SPELL_DISPEL_TYPE);
}
Display d;
d.name = LocalizedSpellName(spellRecord);
d.icon = SpellIconPath(spellRecord);
d.dispelName = DispelName(dispelTypeID);
d.castByPlayer = Guid::IsPlayer(casterGuid) || Guid::IsPet(casterGuid);
return d;
}
// Writes the full modern AuraData table on top of the Lua stack from
// already-resolved values. Shared by the descriptor path (`Push`) and the
// cache-fallback path (`PushFromCache`). Net stack effect: +1 (the table).
static void BuildTable(void *L, uint32_t spellID, int applications,
bool isHelpful, double duration, double expirationTime,
uint64_t casterGuid) {
const uint8_t *spellRecord = SpellRecord(spellID);
uint32_t dispelTypeID = 0;
if (spellRecord != nullptr) {
dispelTypeID = *reinterpret_cast<const uint32_t *>(
spellRecord + Offsets::OFF_SPELL_DISPEL_TYPE);
}
const char *name = LocalizedSpellName(spellRecord);
const char *icon = SpellIconPath(spellRecord);
const char *dispel = DispelName(dispelTypeID);
const Display disp = ResolveDisplay(spellID, casterGuid);
const char *name = disp.name;
const char *icon = disp.icon;
const char *dispel = disp.dispelName;
Game::Lua::NewTable(L);
@@ -502,10 +524,11 @@ static void BuildTable(void *L, uint32_t spellID, int applications,
Game::Lua::SetFieldNumber(L, "maxCharges", 0);
Game::Lua::SetFieldNumber(L, "timeMod", 1);
Game::Lua::SetFieldBool(L, "isFromPlayerOrPlayerPet", disp.castByPlayer);
// Boolean fields whose modern semantics don't apply in vanilla.
Game::Lua::SetFieldBool(L, "isStealable", false);
Game::Lua::SetFieldBool(L, "isBossAura", false);
Game::Lua::SetFieldBool(L, "isFromPlayerOrPlayerPet", false);
Game::Lua::SetFieldBool(L, "isNameplateOnly", false);
Game::Lua::SetFieldBool(L, "nameplateShowAll", false);
Game::Lua::SetFieldBool(L, "nameplateShowPersonal", false);
@@ -518,6 +541,45 @@ static void BuildTable(void *L, uint32_t spellID, int applications,
// yields nil, so the table doesn't need an explicit entry.
}
// Emits the Classic-Era `UnitAura` 15-value tuple onto the Lua stack from the
// same already-resolved values `BuildTable` takes. Net stack effect: +15 — NO
// table allocation, the zero-GC path behind `C_UnitAuras.UnitAura`. Field order
// matches Classic-Era `UnitAura`: name, icon, count, dispelType(string),
// duration, expirationTime, source, isStealable, nameplateShowPersonal, spellId,
// canApplyAura, isBossDebuff, castByPlayer, nameplateShowAll, timeMod. Values
// mirror the AuraData table exactly so packed / non-packed callers agree: name /
// icon / dispelType push `""` when absent (as `SetFieldString` does), while
// `source` pushes nil when there's no caster (as the table omits `sourceUnit`).
// `isHelpful` is unused (the tuple has no isHelpful/isHarmful) — kept so the leaf
// dispatch calls either emitter with a uniform arg list.
static void PushPositional(void *L, uint32_t spellID, int applications,
bool /*isHelpful*/, double duration,
double expirationTime, uint64_t casterGuid) {
const Display disp = ResolveDisplay(spellID, casterGuid);
const char *source = nullptr;
char tokenBuf[32];
if (casterGuid != 0)
source = Unit::Identity::TokenFromGUID(casterGuid, tokenBuf,
sizeof tokenBuf);
Game::Lua::PushString(L, disp.name ? disp.name : ""); // 1 name
Game::Lua::PushString(L, disp.icon ? disp.icon : ""); // 2 icon
Game::Lua::PushNumber(L, static_cast<double>(applications)); // 3 count
Game::Lua::PushString(L, disp.dispelName ? disp.dispelName : ""); // 4 dispelType
Game::Lua::PushNumber(L, duration); // 5 duration
Game::Lua::PushNumber(L, expirationTime); // 6 expirationTime
Game::Lua::PushString(L, source); // 7 source (nil if none)
Game::Lua::PushBool(L, false); // 8 isStealable
Game::Lua::PushBool(L, false); // 9 nameplateShowPersonal
Game::Lua::PushNumber(L, static_cast<double>(spellID)); // 10 spellId
Game::Lua::PushBool(L, false); // 11 canApplyAura
Game::Lua::PushBool(L, false); // 12 isBossDebuff
Game::Lua::PushBool(L, disp.castByPlayer); // 13 castByPlayer
Game::Lua::PushBool(L, false); // 14 nameplateShowAll
Game::Lua::PushNumber(L, 1); // 15 timeMod
}
// Enriches a bare `(guid, spellID)` into a full AuraData table — the shared
// core behind every push path (descriptor, cache fallback, group-array). No
// dependence on a live descriptor: the descriptor path passes what it read
@@ -546,7 +608,8 @@ static void BuildTable(void *L, uint32_t spellID, int applications,
static void PushEnriched(void *L, uint64_t guid, uint32_t spellID,
bool isHelpful, int applications, int unitLevel,
bool isPlayer, int slot,
const Attribution *known = nullptr) {
const Attribution *known = nullptr,
Emit emit = Emit::Table) {
double duration = 0.0;
double expirationTime = 0.0;
uint64_t casterGuid = 0;
@@ -578,8 +641,12 @@ static void PushEnriched(void *L, uint64_t guid, uint32_t spellID,
duration = static_cast<double>(a.durationMs) * 0.001;
casterGuid = a.caster;
}
BuildTable(L, spellID, applications, isHelpful, duration, expirationTime,
casterGuid);
if (emit == Emit::Positional)
PushPositional(L, spellID, applications, isHelpful, duration,
expirationTime, casterGuid);
else
BuildTable(L, spellID, applications, isHelpful, duration, expirationTime,
casterGuid);
}
// Attribution held directly by an `Aura::Source` cache entry (from `Enumerate`),
@@ -588,12 +655,13 @@ static Attribution CachedAttribution(const Aura::Source::CachedAura &c) {
return {c.casterGuid, c.expirationMs, c.durationMs};
}
void Push(void *L, const uint8_t *unit, int slot) {
void Push(void *L, const uint8_t *unit, int slot, Emit emit) {
const uint32_t spellID = ReadSpellID(unit, slot);
const bool isHelpful = slot < Offsets::UNIT_AURA_BUFF_COUNT;
const int unitLevel = (unit != nullptr) ? PlayerLevel(unit) : 0;
PushEnriched(L, UnitGuid(unit), spellID, isHelpful, ReadStacks(unit, slot),
unitLevel, unit != nullptr && unit == LocalPlayer(), slot);
unitLevel, unit != nullptr && unit == LocalPlayer(), slot,
/*known*/ nullptr, emit);
}
namespace {
@@ -603,7 +671,8 @@ namespace {
// in SMSG_SPELL_GO, so `applications` defaults to 1. `duration` uses the
// applied (caster-modified) ms when known, else the Spell.dbc base.
void PushFromCache(void *L, const uint8_t *unit,
const Aura::Source::CachedAura &c, bool isHelpful) {
const Aura::Source::CachedAura &c, bool isHelpful,
Emit emit = Emit::Table) {
// `c` already carries this exact instance's caster + timing (Enumerate
// copied it out), so hand it to `PushEnriched` as `known` rather than
// re-resolving by (guid, spell, slot) — an unbound entry sharing its spell
@@ -612,7 +681,7 @@ void PushFromCache(void *L, const uint8_t *unit,
const int unitLevel = (unit != nullptr) ? PlayerLevel(unit) : 0;
const Attribution a = CachedAttribution(c);
PushEnriched(L, UnitGuid(unit), c.spellId, isHelpful, 1, unitLevel, false,
c.slot, &a);
c.slot, &a, emit);
}
// Reconciles the `Aura::Source` cache against `unit`'s descriptor: when the
@@ -698,7 +767,7 @@ bool FallbackEligible(const uint8_t *unit, const Aura::Source::CachedAura &c,
} // namespace
bool PushNthCacheFallback(void *L, const uint8_t *unit, int oneBasedIndex,
Filter filter, Match match) {
Filter filter, Match match, Emit emit) {
if (unit == nullptr)
return false;
ReconcileCache(unit); // drop entries the (synced) descriptor contradicts
@@ -721,7 +790,7 @@ bool PushNthCacheFallback(void *L, const uint8_t *unit, int oneBasedIndex,
if (!FallbackEligible(unit, buf[i], filter, match))
continue;
if (++seen == target) {
PushFromCache(L, unit, buf[i], filter == Filter::Helpful);
PushFromCache(L, unit, buf[i], filter == Filter::Helpful, emit);
return true;
}
}
@@ -828,7 +897,8 @@ bool GroupFallbackEligible(const uint16_t *arr, const Aura::Source::CachedAura &
// Nth (1-based) cache-fallback aura of `filter`'s kind for an out-of-range
// member whose group array is empty. Pushes the AuraData table on a hit.
bool PushNthGroupCacheFallback(void *L, uint64_t guid, const uint16_t *arr,
int oneBasedIndex, Filter filter, Match match) {
int oneBasedIndex, Filter filter, Match match,
Emit emit = Emit::Table) {
Aura::Source::CachedAura buf[kFallbackMax];
const int n =
Aura::Source::Enumerate(guid, filter == Filter::Harmful, buf, kFallbackMax);
@@ -839,7 +909,7 @@ bool PushNthGroupCacheFallback(void *L, uint64_t guid, const uint16_t *arr,
if (++seen == oneBasedIndex) {
const Attribution a = CachedAttribution(buf[i]);
PushEnriched(L, guid, buf[i].spellId, filter == Filter::Helpful, 1,
GroupMemberLevel(guid), false, buf[i].slot, &a);
GroupMemberLevel(guid), false, buf[i].slot, &a, emit);
return true;
}
}
@@ -893,7 +963,7 @@ bool PushGroupCacheFallbackMatch(void *L, uint64_t guid, const uint16_t *arr,
} // namespace
bool PushNthGroupAura(void *L, uint64_t guid, int oneBasedIndex, Filter filter,
Match match) {
Match match, Emit emit) {
if (guid == 0 || oneBasedIndex < 1)
return false;
const uint16_t *arr = Group::MemberStats::AuraArray(guid);
@@ -909,14 +979,15 @@ bool PushNthGroupAura(void *L, uint64_t guid, int oneBasedIndex, Filter filter,
if (++matches == oneBasedIndex) {
PushEnriched(L, guid, GroupSpellID(arr, slot),
filter == Filter::Helpful, 1, GroupMemberLevel(guid),
false, Aura::Source::SLOT_UNBOUND);
false, Aura::Source::SLOT_UNBOUND, /*known*/ nullptr,
emit);
return true;
}
}
// Empty array (server delta-resend gap): supplement from the cache.
if (!GroupArrayHasVisibleAura(arr))
return PushNthGroupCacheFallback(L, guid, arr, oneBasedIndex, filter,
match);
match, emit);
return false;
}
+19 -7
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
+78
View File
@@ -0,0 +1,78 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
// Paladin judgement melee refresh — client mirror of the server's white-swing
// rule (pfUI#45: the judgement debuff's timer ran to zero while the debuff
// stayed up through the paladin's auto-attacks).
//
// The server rule (tortoise-wow Unit::DealMeleeDamage, vmangos lineage): a
// melee swing that dealt ANY damage (`totalDamage > 0` — misses/dodges/parries
// deal none) refreshes every aura on the victim that the attacker cast whose
// spell is SPELLFAMILY_PALADIN with SPELL_ATTR_EX3_ALWAYS_HIT — the judgement
// marker (all 14 Judgement of Light/Wisdom/Justice/Crusader debuff ranks carry
// it, verified in the client Spell.dbc). RefreshHolder resets the holder to
// its own full duration.
//
// The edit itself is packet-silent (UpdateAuraDuration is self-scoped — the
// same blindness every Turtle::DurationMods rule works around), but its
// TRIGGER is not: DealMeleeDamage's only caller is Unit::AttackerStateUpdate
// (white swings), which then broadcasts SMSG_ATTACKERSTATEUPDATE to everyone
// in range — attacker, victim, and totalDamage are its first fields (wire
// format verified against the server's own SendAttackStateUpdate writer; see
// Offsets.h at SMSG_ATTACKERSTATEUPDATE). So the swing packet is an exact
// client-side stand-in for the refresh, for ANY paladin's judgements — ours
// or a raid member's.
//
// Ability damage (Crusader/Holy Strike) does NOT reach that server block —
// melee-class spell damage never routes through DealMeleeDamage on this core
// — so no rule is registered for it. Re-judging is covered separately: the
// judgement debuff is cast as a real triggered spell, its SMSG_SPELL_GO
// refreshes the cache entry via StoreFromCast.
//
// Not gated on Turtle::Detected(): the block is vmangos-lineage core (the
// classic "judgements are refreshed by the paladin's melee attacks"
// mechanic), and the refresh only ever touches existing judgement entries
// with a matching known caster — on a server without the mechanic the debuff
// would genuinely expire, removing the entry via OnAuraRemoved before drift
// could show.
#include "Offsets.h"
#include "aura/Source.h"
#include "net/PacketDispatch.h"
#include "net/PacketReader.h"
#include <cstdint>
namespace Aura::JudgementRefresh {
namespace {
void OnPacket(uint32_t opcode, Net::CDataStore *packet) {
if (opcode != Offsets::SMSG_ATTACKERSTATEUPDATE || packet == nullptr)
return;
Net::Read<uint32_t>(packet); // hitInfo — the damage gate below subsumes it
const uint64_t attacker = Net::ReadPackedGuid(packet);
const uint64_t victim = Net::ReadPackedGuid(packet);
const uint32_t totalDamage = Net::Read<uint32_t>(packet);
// The server's exact gate: any nonzero melee damage refreshes; a fully
// avoided/absorbed swing (totalDamage 0) does not.
if (totalDamage == 0 || attacker == 0 || victim == 0)
return;
Aura::Source::RefreshJudgements(victim, attacker);
}
const Net::PacketDispatch::AutoSubscribe _sub{&OnPacket};
} // namespace
} // namespace Aura::JudgementRefresh
+627 -13
View File
@@ -238,14 +238,28 @@ Entry *FindBySlot(uint64_t targetGuid, uint32_t spellId, int slot) {
return nullptr;
}
// The newest cast capture for this aura still awaiting a descriptor slot —
// what an `OnAuraAdded` for `(target, spell)` is seating. Newest wins because
// SpellGo precedes the application by a packet, so the freshest unbound entry
// is the cast that just landed; an older one is a capture that never seated
// (fully resisted, immune) and is left to expire on its own. "Newest" is the
// smallest `Time::Clock::Elapsed(stamp, now)` — a wrap-safe signed difference;
// a raw `stampMs >` compare inverts across the 2^32 ms tick wrap.
Entry *FindFreshestUnbound(uint64_t targetGuid, uint32_t spellId, uint32_t now) {
// A capture older than this is not the one an arriving application seats. An
// instant aura's application follows its SpellGo within a server tick plus
// latency (a few hundred ms); a projectile's (Fireball's DoT) follows at
// impact — up to ~1.5s of flight at max range. 3s absorbs both plus lag, yet
// stays far below any DoT's duration, so a capture whose application never
// came (hit but aura-rejected: death race, effect-level immunity on a mixed
// spell) can't be seated by a later same-spell cast's application. Fully
// resisted/immune casts never create captures at all — they sit in
// SMSG_SPELL_GO's miss list, and ParseSpellGo reads only the hit list.
constexpr uint32_t kSeatWindowMs = 3000;
// The cast capture an `OnAuraAdded` for `(target, spell)` is seating: the
// OLDEST unbound capture still inside the seat window — FIFO. When two
// casters' same-spellID DoTs land in one descriptor-update batch, the server
// assigned their slots in cast order (`_AddSpellAuraHolder`'s ascending
// first-free-slot scan) and the client's diff dispatcher (`FUN_00604d00`)
// fires OnAuraAdded in ascending slot order — verified: both its loops are
// plain `slot = 0..0x2F` walks. So the FIRST application belongs to the
// EARLIEST cast; seating newest-first paired the two casters' timers and
// attribution backwards. Elapsed times use `Time::Clock::Elapsed` — a raw
// `stampMs` compare inverts across the 2^32 ms tick wrap.
Entry *FindOldestUnbound(uint64_t targetGuid, uint32_t spellId, uint32_t now) {
Entry *best = nullptr;
uint32_t bestElapsed = 0;
for (int i = 0; i < g_usedHigh; ++i) {
@@ -254,7 +268,9 @@ Entry *FindFreshestUnbound(uint64_t targetGuid, uint32_t spellId, uint32_t now)
e.slot != SLOT_UNBOUND)
continue;
const uint32_t elapsed = Time::Clock::Elapsed(e.stampMs, now);
if (best == nullptr || elapsed < bestElapsed) {
if (elapsed > kSeatWindowMs)
continue;
if (best == nullptr || elapsed > bestElapsed) {
best = &e;
bestElapsed = elapsed;
}
@@ -340,6 +356,10 @@ Entry *Claim(uint32_t now) {
// ---- Writes --------------------------------------------------------------
// Defined in the duration-modifier section below; used by StoreFromCast's
// refresh path too.
void SignalAuraChanged(uint64_t guid);
// The SpellGo hook: authoritative caster + caster-modified (talented) timing.
// Identity is `(target, spell, caster)`, so a second caster of the same spell
// opens its own entry instead of overwriting the first's, and a recast by the
@@ -373,6 +393,12 @@ void StoreFromCast(uint64_t targetGuid, uint32_t spellId, uint64_t casterGuid,
e->casterGuid = casterGuid;
e->expirationMs = expirationMs;
e->durationMs = durationMs;
// An in-place recast refresh changes no descriptor field, so the engine
// fires no UNIT_AURA — event-driven consumers (aura-bar addons counting
// down a once-read duration) would never re-read the new timing. Signal it
// ourselves (deferred to the world tick, coalesced). A NEW application
// doesn't need this: its descriptor write fires the engine's own event.
SignalAuraChanged(targetGuid);
}
// The OnAuraAdded / OnAuraStacksChanged application hooks: a descriptor slot,
@@ -396,7 +422,7 @@ void StoreFromApplication(uint64_t targetGuid, uint32_t spellId,
Entry *e = FindBySlot(targetGuid, spellId, slot);
if (e == nullptr)
e = FindFreshestUnbound(targetGuid, spellId, now);
e = FindOldestUnbound(targetGuid, spellId, now);
if (e == nullptr && slot < 0)
e = FindSole(targetGuid, spellId);
if (e == nullptr) {
@@ -742,6 +768,168 @@ void RegisterDurationModLua() {
const Game::ModuleAutoRegister _autoregDurationMod{&RegisterDurationModLua};
// ---- Tick-speed compression (Turtle: Dark Harvest) -------------------------
//
// A channel that accelerates the caster's periodic auras on its target
// consumes them FASTER on the live server: each dot keeps its tick count, so
// ticking pct% faster drains its real remaining time at (100+pct)% rate while
// the trigger aura is up, and it falls off early by pct% of the overlap — with
// no client-visible aura change whatsoever. (tortoise-wow's recreation only
// re-times the tick timers and leaves holder duration alone, which does NOT
// reproduce the observed early fall-off; the compression model here matches
// Cursive's community calibration — `remaining -= 0.30 × overlap` — and the
// reported ~2.4–3 s loss over a full 8 s Dark Harvest.)
//
// The math is anchored, not incremental: each compressed entry remembers the
// expiration it had when compression began and is set to
// `anchor − elapsed × pct/100` every tick, so frame pacing can't drift it. An
// external write to the entry (dot recast → SpellGo re-stamp, a duration mod)
// is detected by comparing against the last value WE wrote and re-anchors from
// the fresh expiration — matching the server, where a recast dot is a new
// holder that compresses from its own full duration.
struct TickCompression {
uint32_t triggerFamily;
uint64_t triggerMask;
uint32_t affectedFamily;
uint64_t affectedMask;
int32_t pct;
};
constexpr int kCompressionMax = 4;
TickCompression g_compressions[kCompressionMax];
int g_compressionCount = 0;
// Live per-entry compression state, keyed the way the cache identifies an
// aura instance — (target, spell, caster) — so two warlocks' same-spell dots
// on one target compress independently. `lastWrittenMs` is the expiration this
// module wrote last tick — a mismatch means someone else re-stamped the entry.
struct CompressState {
uint64_t targetGuid;
uint64_t casterGuid;
uint32_t spellId;
uint32_t startMs; // compression (re-)anchor time
uint32_t anchorMs; // entry expiration at the anchor
uint32_t lastWrittenMs;
uint32_t lastSignalMs; // event-consumer nudge cadence (~1/s)
bool touched; // trigger still live this tick
bool used;
};
constexpr int kCompressStateMax = 16;
CompressState g_compressStates[kCompressStateMax];
CompressState *FindOrClaimCompressState(uint64_t targetGuid,
uint64_t casterGuid,
uint32_t spellId) {
CompressState *spare = nullptr;
for (auto &s : g_compressStates) {
if (s.used && s.targetGuid == targetGuid &&
s.casterGuid == casterGuid && s.spellId == spellId)
return &s;
if (!s.used && spare == nullptr)
spare = &s;
}
return spare; // unused slot for the caller to claim; nullptr = table full
}
// The server's affected-aura test is on the RUNTIME aura type (PERIODIC_DAMAGE
// / PERIODIC_LEECH), which the record mirrors as EffectApplyAuraName 3 / 53 on
// some effect. Load-bearing beyond the family mask: Turtle's Atrocity (45904)
// shares Corruption's 0x2 bit but is a proc-trigger aura (42) — the mask alone
// would compress it, this gate excludes it exactly as the server's does.
// (Drain Soul passes via its effect-2 periodic damage: aura=[86,3,0].)
bool HasPeriodicDamageOrLeech(const uint8_t *rec) {
if (rec == nullptr)
return false;
constexpr int32_t kPeriodicDamage = 3, kPeriodicLeech = 53;
for (int i = 0; i < 3; ++i) {
const int32_t aura = *reinterpret_cast<const int32_t *>(
rec + Offsets::OFF_SPELL_RECORD_EFFECT_APPLY_AURA_NAME + i * 4);
if (aura == kPeriodicDamage || aura == kPeriodicLeech)
return true;
}
return false;
}
// Compression pairs by CASTER, mirroring the server (each warlock's Dark
// Harvest accelerates that warlock's own dots): the trigger entry's caster
// must own the affected entry too. Another warlock's DH + dots are both
// broadcast SPELL_GO, so their timing compresses for us as well; caster-less
// entries (application seen, cast unobserved) can't be paired and stay
// untouched, same as every other caster-strict mechanic here.
void CompressTarget(const TickCompression &c, uint64_t targetGuid,
uint64_t casterGuid, uint32_t now) {
for (int i = 0; i < g_usedHigh; ++i) {
Entry &e = g_cache[i];
if (!e.used || e.targetGuid != targetGuid ||
e.casterGuid != casterGuid || e.expirationMs == 0)
continue;
const uint8_t *rec =
Spell::Lookup::RecordForID(static_cast<int>(e.spellId));
if (!AffectedMatchesRaw(rec, c.affectedFamily, c.affectedMask,
/*icon*/ 0) ||
!HasPeriodicDamageOrLeech(rec))
continue;
CompressState *s =
FindOrClaimCompressState(e.targetGuid, e.casterGuid, e.spellId);
if (s == nullptr)
continue;
if (!s->used) {
*s = {e.targetGuid, e.casterGuid, e.spellId, now,
e.expirationMs, e.expirationMs, 0, false, true};
} else if (e.expirationMs != s->lastWrittenMs) {
s->startMs = now; // externally re-stamped — restart from fresh
s->anchorMs = e.expirationMs;
}
s->touched = true;
const uint32_t cut = static_cast<uint32_t>(
static_cast<uint64_t>(now - s->startMs) *
static_cast<uint32_t>(c.pct) / 100);
e.expirationMs = s->anchorMs - cut;
s->lastWrittenMs = e.expirationMs;
// Event-driven consumers (aura bars that read once and count down
// locally) need periodic nudges to re-read the moving expiration;
// pollers pick it up on their own. First write nudges immediately.
if (s->lastSignalMs == 0 || now - s->lastSignalMs >= 1000) {
s->lastSignalMs = now;
SignalAuraChanged(e.targetGuid);
}
}
}
void ApplyTickCompressions(uint32_t now) {
if (g_compressionCount == 0)
return;
for (auto &s : g_compressStates)
s.touched = false;
for (int r = 0; r < g_compressionCount; ++r) {
const TickCompression &c = g_compressions[r];
for (int i = 0; i < g_usedHigh; ++i) {
const Entry &t = g_cache[i];
if (!t.used || t.casterGuid == 0)
continue;
// The trigger aura is live exactly while its entry is: unexpired
// (channel pushback already folded in via RestampPlayerChannel)
// and not yet evicted by OnAuraRemoved (interrupt / early end /
// target death).
if (t.expirationMs != 0 &&
Time::Clock::Reached(now, t.expirationMs))
continue;
if (!Spell::Lookup::IsFitToFamily(
Spell::Lookup::RecordForID(static_cast<int>(t.spellId)),
c.triggerFamily, c.triggerMask))
continue;
CompressTarget(c, t.targetGuid, t.casterGuid, now);
}
}
// Trigger gone → the entry keeps whatever expiration it drained to; one
// last nudge publishes the final position to event-driven consumers.
for (auto &s : g_compressStates) {
if (s.used && !s.touched) {
SignalAuraChanged(s.targetGuid);
s.used = false;
}
}
}
// Wipe the whole cache. Used on a map transition (see OnWorldTick).
void FlushAll() {
for (int i = 0; i < g_usedHigh; ++i)
@@ -751,6 +939,9 @@ void FlushAll() {
// should be treated as first-sight (unknown age), not diffed as new.
for (auto &s : g_groupSnaps)
s.used = false;
// Compressed entries are gone with the cache; their states go too.
for (auto &s : g_compressStates)
s.used = false;
}
// -1 = no map seen yet (never a valid Map.dbc id), so the first tick just
@@ -782,6 +973,9 @@ void OnWorldTick() {
}
const uint32_t now = NowMs();
// Tick-speed compression first, so a drained expiration is visible to the
// same tick's eviction sweep and signal flush.
ApplyTickCompressions(now);
for (int i = 0; i < g_usedHigh; ++i) {
Entry &e = g_cache[i];
if (!e.used || e.expirationMs == 0 || !Time::Clock::Reached(now, e.expirationMs))
@@ -810,6 +1004,279 @@ void OnWorldTick() {
const Tick::WorldTick::AutoSubscribe _tickSub{&OnWorldTick};
// ---- Paladin judgement identity + refresh (pfUI#45) ----------------------
//
// The judgement debuff (Judgement of Light/Wisdom/Justice/Crusader) is cast
// SERVER-side as a triggered spell whose SMSG_SPELL_GO never reaches the
// client, so its cache entry is seated caster-less by OnAuraAdded — and the
// server's refresh mechanics are keyed by caster. The one attributable
// signal is the Judgement CAST itself (SPELL_PALADIN_JUDGEMENT, the single
// spell every seal judges through): its SPELL_GO names caster and victim.
// HandleSpellGo uses it to (a) refresh existing judgement entries WITH
// adoption — a re-judge is an in-place RefreshHolder server-side, no
// descriptor change, no OnAuraAdded — and (b) arm a short window so the
// fresh debuff's application gets attributed to the judging caster. From
// then on the caster-STRICT swing refresh (Aura::JudgementRefresh's
// SMSG_ATTACKERSTATEUPDATE subscriber) can do its job.
constexpr uint32_t kSpellJudgement = 20271; // server SPELL_PALADIN_JUDGEMENT
// The server's judgement-debuff selector, verbatim from its melee-refresh
// block (tortoise-wow Unit::DealMeleeDamage): SPELLFAMILY_PALADIN +
// SPELL_ATTR_EX3_ALWAYS_HIT. All 14 debuff ranks carry it (client DBC
// verified); no shared family-flag mask exists (each seal's debuff has its
// own bit), which is why this isn't an AddDurationMod rule.
bool IsJudgementSpell(const uint8_t *rec) {
constexpr uint32_t kPaladinFamily = 10; // SPELLFAMILY_PALADIN
if (rec == nullptr)
return false;
if (*reinterpret_cast<const uint32_t *>(
rec + Offsets::OFF_SPELL_RECORD_FAMILY_NAME) != kPaladinFamily)
return false;
return (*reinterpret_cast<const uint32_t *>(
rec + Offsets::OFF_SPELL_RECORD_ATTRIBUTES_EX3) &
Offsets::SPELL_ATTR_EX3_ALWAYS_HIT) != 0;
}
// Armed attribution: a Judgement cast went off from `caster` at `victim`;
// the triggered debuff's OnAuraAdded lands ~a server tick later (SPELL_GO
// precedes the aura's SMSG_UPDATE_OBJECT — the same ordering kEvictGraceMs
// exists for). One-shot, short TTL so a judge whose debuff never lands
// can't mis-attribute a later application.
struct JudgeArm {
uint64_t victim;
uint64_t caster;
uint32_t untilMs;
};
constexpr int kJudgeArmMax = 4; // simultaneous judging paladins in view
constexpr uint32_t kJudgeArmTtlMs = 2000;
JudgeArm g_judgeArms[kJudgeArmMax];
void ArmJudgementAttribution(uint64_t victim, uint64_t caster) {
const uint32_t now = NowMs();
JudgeArm *slot = nullptr;
for (auto &a : g_judgeArms) {
if (a.victim == victim && a.caster == caster) {
slot = &a; // re-judge inside the TTL — refresh in place
break;
}
if (slot == nullptr &&
(a.victim == 0 || Time::Clock::Reached(now, a.untilMs)))
slot = &a;
}
if (slot == nullptr)
slot = &g_judgeArms[0]; // all live (>4 paladins?) — steal the first
*slot = {victim, caster, now + kJudgeArmTtlMs};
}
// The caster armed for `victim`, or 0. One-shot: consumed on hit.
uint64_t ConsumeJudgementAttribution(uint64_t victim, uint32_t now) {
for (auto &a : g_judgeArms) {
if (a.victim != victim || a.victim == 0 ||
Time::Clock::Reached(now, a.untilMs))
continue;
const uint64_t caster = a.caster;
a = {};
return caster;
}
return 0;
}
// Shared body of the two refresh triggers. `adopt` lets the Judgement-cast
// path claim a caster-less entry (the cast is proof of ownership); the swing
// path stays caster-strict — a swing gives no proof, and adopting there
// would let any tank's white swings pin another paladin's judgement timer.
int RefreshJudgementsImpl(uint64_t unitGuid, uint64_t attackerGuid, bool adopt) {
if (unitGuid == 0 || attackerGuid == 0)
return 0;
const uint32_t now = NowMs();
int refreshed = 0;
for (int i = 0; i < g_usedHigh; ++i) {
Entry &e = g_cache[i];
if (!e.used || e.targetGuid != unitGuid)
continue;
if (e.casterGuid != attackerGuid && !(adopt && e.casterGuid == 0))
continue;
if (e.durationMs == 0)
continue; // never invent a duration
if (!IsJudgementSpell(
Spell::Lookup::RecordForID(static_cast<int>(e.spellId))))
continue;
if (e.casterGuid == 0)
e.casterGuid = attackerGuid; // adoption claim
e.expirationMs = now + e.durationMs; // RefreshHolder → own duration
e.stampMs = now;
SignalAuraChanged(e.targetGuid);
++refreshed;
}
return refreshed;
}
// ---- Triggered applications (server AddAura inference) --------------------
//
// Some server mechanics APPLY an aura as a side effect of another spell via a
// direct AddAura — no cast, no SMSG_SPELL_GO for the applied aura, and a
// duration the packet stream never carries. The application itself reaches
// the client only as a descriptor change (OnAuraAdded → caster-less, base
// duration). But the TRIGGER's SMSG_SPELL_GO is visible, so — same shape as
// the judgement attribution above — a registered trigger cast by the local
// player arms its hit targets for a short window, and the caster-less
// application that follows consumes the arm: player attribution + the rule's
// percentage of the aura's own (player-modified) base duration, mirroring
// the server's `max(1, CalculateDuration(caster) * pct / 100)`.
//
// Registered from src/turtle (Stinging Nettle: Mongoose Bite / fire-trap
// effects apply the highest known Serpent Sting rank at 20/40%). Local
// player only — a rule may be gated on a talent-granted passive
// (`gateSpellId`), and other players' talents aren't client-knowable.
struct TriggeredApplication {
uint32_t triggerSpellId; // exact trigger spell; 0 = match by family+mask below
uint32_t triggerFamily; // (triggerSpellId==0) trigger's SpellFamilyName
uint64_t triggerMask; // (triggerSpellId==0) family-flag overlap — rank-proof
uint32_t gateSpellId; // rule live only while the player knows this; 0 = always
uint32_t affectedFamily; // applied aura's SpellFamilyName
uint64_t affectedMask; // + family-flag overlap
int32_t durationPct; // applied duration = pct% of its own base
};
constexpr int kTrigAppMax = 32;
TriggeredApplication g_trigApps[kTrigAppMax];
int g_trigAppCount = 0;
// Talent gate: does the player currently know `spellID`? The engine's
// spell-knowledge bitmap (same store IsPlayerSpell reads — covers talent
// passives), checked live at trigger time so respecs are honored.
bool PlayerKnowsSpell(uint32_t spellID) {
if (spellID == 0)
return false;
auto *bitmap = Game::Read<const uint32_t *>(
static_cast<uintptr_t>(Offsets::VAR_PLAYER_SPELL_BITMAP));
if (bitmap == nullptr)
return false;
const uint32_t spellCount = Game::Read<uint32_t>(
static_cast<uintptr_t>(Offsets::VAR_SPELL_RECORD_COUNT));
if (spellID > spellCount)
return false;
return (bitmap[spellID >> 5] & (1u << (spellID & 31))) != 0;
}
// Armed rule instance awaiting its application on `victim`. Caster is
// implicitly the local player (only player triggers arm).
struct TrigAppArm {
uint64_t victim; // 0 = empty
uint32_t affectedFamily;
uint64_t affectedMask;
int32_t durationPct;
uint32_t untilMs;
};
constexpr int kTrigAppArmMax = 8;
constexpr uint32_t kTrigAppArmTtlMs = 2000;
TrigAppArm g_trigAppArms[kTrigAppArmMax];
// Mirror the server's re-apply on an EXISTING matching aura at trigger time
// (tortoise-wow ApplyStingingNettle): an existing holder from this caster
// whose remaining time exceeds the scaled duration is kept; otherwise the
// server re-AddAura's at the scaled duration — a remove+add of the same
// spellID that reuses the descriptor slot, so NO OnAuraAdded fires and the
// armed window is never consumed (verified in-game: chained Mongoose Bites
// left the first application's timer running out). The trigger is the only
// audible signal, so the refresh happens here. A caster-less matching entry
// is adopted and stamped unconditionally: it's a nettle application whose
// arm was missed, seated by the application hook at the WRONG base duration,
// so its remaining time can't gate the mirror — and the player's trigger is
// proof of ownership, same as the judgement-cast adoption.
void RefreshTriggeredExisting(uint64_t victim, const TriggeredApplication &r,
uint32_t now) {
for (int i = 0; i < g_usedHigh; ++i) {
Entry &e = g_cache[i];
if (!e.used || e.targetGuid != victim)
continue;
const bool mine = e.casterGuid == Unit::Identity::PlayerGuid();
if (!mine && e.casterGuid != 0)
continue; // another caster's aura — not ours to touch
const uint8_t *rec =
Spell::Lookup::RecordForID(static_cast<int>(e.spellId));
if (!Spell::Lookup::IsFitToFamily(rec, r.affectedFamily,
r.affectedMask))
continue;
const uint32_t base = SpellDurationMs(rec, /*casterIsPlayer*/ true);
if (base == 0)
continue;
uint32_t newMs = base * static_cast<uint32_t>(r.durationPct) / 100;
if (newMs == 0)
newMs = 1;
// The server's keep-the-longer guard, applied only to trusted
// (player-stamped) timing. An elapsed timer yields Remaining 0 and
// refreshes — the invisible server re-adds are exactly what let it
// elapse while the aura stayed up.
if (mine && e.expirationMs != 0 &&
Time::Clock::Remaining(now, e.expirationMs) > newMs)
continue;
e.casterGuid = Unit::Identity::PlayerGuid();
e.durationMs = newMs;
e.expirationMs = now + newMs;
e.stampMs = now;
SignalAuraChanged(victim);
break; // one aura instance per (target, spell, caster) — server touches one holder
}
}
// A player trigger cast landed on `victim`: arm the first live rule for this
// trigger (rules sharing a trigger are registration-ordered — the higher
// talent rank registers first, so its gate wins while known). Also refreshes
// an existing matching aura in place (see RefreshTriggeredExisting) — the arm
// only covers applications that reach the descriptor as a change.
void ArmTriggeredApplications(uint64_t victim, uint32_t triggerSpellId,
const uint8_t *triggerRec) {
for (int i = 0; i < g_trigAppCount; ++i) {
const TriggeredApplication &r = g_trigApps[i];
if (r.triggerSpellId != 0
? r.triggerSpellId != triggerSpellId
: !Spell::Lookup::IsFitToFamily(triggerRec, r.triggerFamily,
r.triggerMask))
continue;
if (r.gateSpellId != 0 && !PlayerKnowsSpell(r.gateSpellId))
continue;
const uint32_t now = NowMs();
RefreshTriggeredExisting(victim, r, now);
TrigAppArm *slot = nullptr;
for (auto &a : g_trigAppArms) {
if (a.victim == victim && a.affectedFamily == r.affectedFamily &&
a.affectedMask == r.affectedMask) {
slot = &a; // re-trigger inside the TTL — refresh in place
break;
}
if (slot == nullptr &&
(a.victim == 0 || Time::Clock::Reached(now, a.untilMs)))
slot = &a;
}
if (slot == nullptr)
slot = &g_trigAppArms[0]; // all live — steal the first
*slot = {victim, r.affectedFamily, r.affectedMask, r.durationPct,
now + kTrigAppArmTtlMs};
return;
}
}
// The armed duration percent for a caster-less application of `rec` on
// `victim`, or 0. One-shot: consumed on hit.
int32_t ConsumeTriggeredApplication(uint64_t victim, const uint8_t *rec,
uint32_t now) {
if (victim == 0 || rec == nullptr)
return 0;
for (auto &a : g_trigAppArms) {
if (a.victim != victim || Time::Clock::Reached(now, a.untilMs))
continue;
if (!Spell::Lookup::IsFitToFamily(rec, a.affectedFamily,
a.affectedMask))
continue;
const int32_t pct = a.durationPct;
a = {};
return pct;
}
return 0;
}
// ---- SMSG_SPELL_GO co-hook ----------------------------------------------
// The hit-target list is all we need: each entry is a unit the cast landed
@@ -847,7 +1314,27 @@ void HandleSpellGo(uint64_t caster, uint32_t spellId, const uint64_t *targets,
// before the aura gate below: the trigger spell applies no aura itself.
ApplyDurationModifiers(spellId, caster, targets, numTargets);
// Judgement: refresh-with-adoption for a re-judge (in-place server
// refresh, no aura event) and arm attribution for the fresh debuff that
// follows. Before the aura gate — Judgement itself applies no aura.
if (spellId == kSpellJudgement) {
for (int i = 0; i < numTargets; ++i) {
RefreshJudgementsImpl(targets[i], caster, /*adopt*/ true);
ArmJudgementAttribution(targets[i], caster);
}
}
const uint8_t *rec = Spell::Lookup::RecordForID(static_cast<int>(spellId));
// Triggered-application rules (Stinging Nettle): a registered trigger
// cast by the local player arms its hit targets so the server-added aura
// that follows (no SPELL_GO of its own) gets player attribution + the
// rule's scaled duration. Before the aura gate — Mongoose Bite applies
// no aura of its own.
if (g_trigAppCount != 0 && caster == Unit::Identity::PlayerGuid())
for (int i = 0; i < numTargets; ++i)
ArmTriggeredApplications(targets[i], spellId, rec);
if (!SpellAppliesAura(rec))
return;
@@ -933,9 +1420,38 @@ void StampApplication(void *unit, uint32_t spellId, int slot) {
// (and attribute the caster) — SpellGo couldn't always land the talented
// value on this target's entry. Otherwise base (we lack other casters'
// mods). See WasRecentPlayerCast.
const bool byPlayer = WasRecentPlayerCast(spellId);
const uint32_t durationMs = SpellDurationMs(rec, byPlayer);
const uint64_t caster = byPlayer ? Unit::Identity::PlayerGuid() : 0;
uint64_t caster = 0;
uint32_t durationMs = 0;
// A triggered-application arm (Stinging Nettle): the server AddAura'd
// this aura off the player's trigger cast at a scaled duration —
// attribute the player and mirror the server's max(1, base × pct / 100),
// player-modified base since the player is the caster. Checked BEFORE
// the recent-cast window below: the arm names this exact TARGET, while
// WasRecentPlayerCast is spell-scoped only — a real Serpent Sting cast
// at unit A must not stamp its full duration onto a nettle application
// landing on unit B moments later. The order is safe for a real cast's
// own application: its entry is already cast-owned (StoreFromCast), and
// StoreFromApplication never overwrites an owned entry's timing.
if (const int32_t pct = ConsumeTriggeredApplication(unitGuid, rec, NowMs())) {
const uint32_t playerBase = SpellDurationMs(rec, /*player*/ true);
if (playerBase > 0) {
caster = Unit::Identity::PlayerGuid();
durationMs = playerBase * static_cast<uint32_t>(pct) / 100;
if (durationMs == 0)
durationMs = 1;
}
}
if (caster == 0) {
const bool byPlayer = WasRecentPlayerCast(spellId);
durationMs = SpellDurationMs(rec, byPlayer);
caster = byPlayer ? Unit::Identity::PlayerGuid() : 0;
// A judgement debuff is cast server-side (no SPELL_GO, so neither
// path above can name its caster) — attribute it to the paladin
// whose Judgement cast just armed this victim. See the judgement
// section.
if (caster == 0 && IsJudgementSpell(rec))
caster = ConsumeJudgementAttribution(unitGuid, NowMs());
}
const uint32_t expirationMs = durationMs > 0 ? NowMs() + durationMs : 0;
StoreFromApplication(unitGuid, spellId, caster, expirationMs, durationMs,
slot, KindForSlot(slot));
@@ -1043,6 +1559,85 @@ bool AddDurationMod(uint32_t triggerSpellId, uint32_t affectedFamily,
affectedIcon, op, valueMs);
}
namespace {
bool RegisterTriggeredApplication(uint32_t triggerSpellId,
uint32_t triggerFamily, uint64_t triggerMask,
uint32_t gateSpellId, uint32_t affectedFamily,
uint64_t affectedMask, int32_t durationPct) {
// Trigger must be identified one way or the other.
if ((triggerSpellId == 0 && (triggerFamily == 0 || triggerMask == 0)) ||
affectedMask == 0 || durationPct <= 0)
return false;
for (int i = 0; i < g_trigAppCount; ++i) { // replace an identical rule
TriggeredApplication &r = g_trigApps[i];
if (r.triggerSpellId == triggerSpellId &&
r.triggerFamily == triggerFamily && r.triggerMask == triggerMask &&
r.gateSpellId == gateSpellId && r.affectedFamily == affectedFamily &&
r.affectedMask == affectedMask) {
r.durationPct = durationPct;
return true;
}
}
if (g_trigAppCount >= kTrigAppMax)
return false;
g_trigApps[g_trigAppCount++] = {triggerSpellId, triggerFamily, triggerMask,
gateSpellId, affectedFamily,
affectedMask, durationPct};
return true;
}
} // namespace
bool AddTriggeredApplication(uint32_t triggerSpellId, uint32_t gateSpellId,
uint32_t affectedFamily, uint64_t affectedMask,
int32_t durationPct) {
return RegisterTriggeredApplication(triggerSpellId, /*triggerFamily*/ 0,
/*triggerMask*/ 0, gateSpellId,
affectedFamily, affectedMask,
durationPct);
}
bool AddTriggeredApplicationByFamily(uint32_t triggerFamily,
uint64_t triggerMask, uint32_t gateSpellId,
uint32_t affectedFamily,
uint64_t affectedMask,
int32_t durationPct) {
return RegisterTriggeredApplication(/*triggerSpellId*/ 0, triggerFamily,
triggerMask, gateSpellId,
affectedFamily, affectedMask,
durationPct);
}
bool AddTickCompression(uint32_t triggerFamily, uint64_t triggerMask,
uint32_t affectedFamily, uint64_t affectedMask,
int32_t pct) {
if (triggerFamily == 0 || triggerMask == 0 || affectedMask == 0 ||
pct <= 0 || pct > 100)
return false;
for (int i = 0; i < g_compressionCount; ++i) { // replace an identical rule
TickCompression &r = g_compressions[i];
if (r.triggerFamily == triggerFamily && r.triggerMask == triggerMask &&
r.affectedFamily == affectedFamily &&
r.affectedMask == affectedMask) {
r.pct = pct;
return true;
}
}
if (g_compressionCount >= kCompressionMax)
return false;
g_compressions[g_compressionCount++] = {triggerFamily, triggerMask,
affectedFamily, affectedMask, pct};
return true;
}
int RefreshJudgements(uint64_t unitGuid, uint64_t attackerGuid) {
// Caster-strict (no adoption) — the Judgement-cast path inside
// HandleSpellGo is the one with proof of ownership; see
// RefreshJudgementsImpl.
return RefreshJudgementsImpl(unitGuid, attackerGuid, /*adopt*/ false);
}
uint32_t RefreshDurationByFamily(uint64_t unitGuid, uint32_t family,
uint64_t mask, uint32_t icon,
uint64_t casterGuid) {
@@ -1077,6 +1672,25 @@ uint32_t RefreshDurationByFamily(uint64_t unitGuid, uint32_t family,
return 0;
}
void RestampPlayerChannel(uint32_t spellId, uint32_t remainingMs) {
if (spellId == 0 || remainingMs == 0)
return;
const uint64_t player = Unit::Identity::PlayerGuid();
if (player == 0)
return;
const uint32_t now = NowMs();
// Every hit target's entry, matching DelaySpellAuraHolder's loop over the
// channel's m_UniqueTargetInfo (an AoE channel shortens them all).
for (int i = 0; i < g_usedHigh; ++i) {
Entry &e = g_cache[i];
if (!e.used || e.casterGuid != player || e.spellId != spellId)
continue;
e.expirationMs = now + remainingMs;
e.stampMs = now;
SignalAuraChanged(e.targetGuid);
}
}
void EvictAbsent(uint64_t unitGuid, const uint32_t *slotSpellIds) {
if (unitGuid == 0 || slotSpellIds == nullptr)
return;
+82
View File
@@ -69,6 +69,36 @@ uint32_t RefreshDurationByFamily(uint64_t unitGuid, uint32_t family,
uint64_t mask, uint32_t icon,
uint64_t casterGuid);
// Mirrors the server's melee-swing judgement refresh (tortoise-wow
// Unit::DealMeleeDamage): a white swing from `attackerGuid` that dealt any
// damage refreshes every aura it cast on `unitGuid` whose spell is
// SPELLFAMILY_PALADIN with SPELL_ATTR_EX3_ALWAYS_HIT (the judgement-debuff
// marker — all Judgement of Light/Wisdom/Justice/Crusader ranks) back to its
// own applied duration. Caster-strict: entries with an unknown caster are
// left alone (a swing gives no proof of ownership). The caster comes from
// the Judgement-cast attribution inside the SpellGo path — the debuff itself
// is cast server-side with no client-visible SPELL_GO, so the Judgement cast
// (20271) is what names the owner: it refresh-with-adoption's existing
// entries (re-judge = in-place server refresh, no aura event) and arms the
// fresh application's attribution. Returns the number refreshed. Fed by
// Aura::JudgementRefresh's SMSG_ATTACKERSTATEUPDATE subscriber.
int RefreshJudgements(uint64_t unitGuid, uint64_t attackerGuid);
// Re-anchors the expiration of every cached aura the local player cast with
// `spellId` to `now + remainingMs`. Mirrors the server's channel pushback
// (Spell::DelayedChannel → Unit::DelaySpellAuraHolder): a hit shortens the
// channel AND every hit target's holder of the channel spell by the same
// amount, then tells the caster the new remaining time via MSG_CHANNEL_UPDATE
// — the packet Spell::Cast feeds this from. Without it a pushed-back channel's
// target-side aura (Dark Harvest, the drains) keeps its full-length SpellGo
// expiration and reads late by the accumulated pushback. `durationMs` (the
// applied total, the bar's full width) is deliberately untouched. Self-target
// holders are also corrected natively by the server (it re-sends
// SMSG_UPDATE_AURA_DURATION), so restamping those entries just keeps the two
// sources agreeing. Caster-only packet ⇒ only the player's channels are
// correctable; remote pushback stays invisible (see spell/Cast.cpp).
void RestampPlayerChannel(uint32_t spellId, uint32_t remainingMs);
// Op codes for AddDurationMod (mirror the Lua op strings).
enum DurationModOp {
DURATION_MOD_REFRESH = 0,
@@ -88,6 +118,58 @@ bool AddDurationMod(uint32_t triggerSpellId, uint32_t affectedFamily,
uint64_t affectedMask, uint32_t affectedIcon, int op,
int32_t valueMs);
// Register a server "triggered application" rule from C++: `triggerSpellId`'s
// SMSG_SPELL_GO — local player casts only — arms its hit targets for a short
// window, and a caster-less aura application on an armed target matching
// `affectedFamily` + a family-flag overlap with `affectedMask` is attributed
// to the player at `durationPct` percent of the aura's own player-modified
// base duration (the server's `max(1, CalculateDuration(caster) * pct / 100)`).
// For server mechanics that AddAura an aura off another spell's hit with no
// cast packet of its own (Turtle's Stinging Nettle: Mongoose Bite / fire-trap
// effects apply the highest known Serpent Sting rank at 20/40% duration).
// A re-trigger over an EXISTING matching aura is also handled: the server's
// re-add reuses the descriptor slot (no application event), so the trigger
// additionally refreshes the player's existing cached entry in place,
// honoring the server's keep-the-longer-remaining guard.
// `gateSpellId` (0 = none) keeps the rule live only while the player knows
// that spell (a talent-granted passive), read from the spell-knowledge bitmap
// at trigger time so respecs are honored; rules matching the same trigger are
// tried in registration order and the first live one arms (register the
// higher talent rank first). Returns false on bad input or a full table.
bool AddTriggeredApplication(uint32_t triggerSpellId, uint32_t gateSpellId,
uint32_t affectedFamily, uint64_t affectedMask,
int32_t durationPct);
// Family-mask trigger form of the above: the trigger matches any cast whose
// spell is SpellFamilyName `triggerFamily` with a family-flag overlap of
// `triggerMask` — rank-proof when the bit selects exactly the trigger set
// (verify against the DBC that no unrelated spell shares it first: hunter
// 0x4 is exactly the fire-trap effects, but 0x2 is Mongoose Bite AND Raptor
// Strike, so that trigger needs the exact-ID form).
bool AddTriggeredApplicationByFamily(uint32_t triggerFamily,
uint64_t triggerMask, uint32_t gateSpellId,
uint32_t affectedFamily,
uint64_t affectedMask, int32_t durationPct);
// Register a tick-speed compression rule from C++: while an aura matching
// (triggerFamily, triggerMask) is live on a target, every cached aura on that
// target CAST BY THE SAME CASTER matching (affectedFamily, affectedMask) — and
// whose record carries a periodic damage/leech effect, the server's aura-type
// gate — drains an extra `pct`% of real time: its expiration moves earlier by
// pct% of the elapsed overlap. Mirrors a server that preserves an accelerated
// dot's tick COUNT, so ticking pct% faster consumes it pct% faster with no
// client-visible aura change (Turtle's Dark Harvest; see
// src/turtle/DarkHarvest.cpp for the live-behavior derivation). Caster-paired
// like the server's own check, so another warlock's observed DH compresses
// THEIR dots' cached timing too; caster-less entries never compress. The
// trigger's cache-entry lifetime is the compression window — created by its
// SpellGo, shortened by channel pushback (RestampPlayerChannel), evicted by
// OnAuraRemoved on interrupt / early end — so the window tracks every edge the
// server's has. Returns false on bad input or a full table.
bool AddTickCompression(uint32_t triggerFamily, uint64_t triggerMask,
uint32_t affectedFamily, uint64_t affectedMask,
int32_t pct);
// One cached aura, as returned by `Enumerate`.
struct CachedAura {
uint32_t spellId;
+1 -1
View File
@@ -143,7 +143,7 @@ void OnWorldTick() {
static_cast<uintptr_t>(Offsets::VAR_IN_WORLD)) == 0)
return;
g_pending = false;
const int slot = Event::Custom::Lookup(kEventName);
const int slot = _reserve.Slot();
if (slot >= 0)
Event::Custom::Fire(slot, "");
// Notify C++ subscribers of the bag-change frame (see UpdateDelayed.h).
+145
View File
@@ -0,0 +1,145 @@
// 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/>.
// `collectgarbage(opt [, arg])` — accept Lua 5.1's string options on the
// 5.0 collector. 5.0's collectgarbage takes only an optional NUMBER (a KB
// threshold for the next cycle) and errors on every 5.1 option (verified
// in-game: `collectgarbage("count")` → "bad argument #1 (number expected,
// got string)"). Shim dispatch:
//
// "count" → KB of Lua memory in use — the engine's own gcinfo()
// value, the same number 5.1's "count" reports.
// "collect" → original collectgarbage(0): threshold 0 forces a full
// cycle, the canonical 5.0 "collect now". Returns 0.
// "step" → no-op returning true ("cycle finished"): the 5.0 GC
// cannot step incrementally, and true is the loop-safe
// sentinel (`repeat until collectgarbage("step")` exits
// immediately instead of spinning forever).
// "stop" / "restart" / "setpause" / "setstepmul"
// → accepted no-ops returning 0 — the 5.0 GC has no toggle
// or tuning; callers just lose the optimization.
// number / no argument
// → forwarded to the original untouched (5.0 threshold
// semantics preserved for vanilla-era callers).
//
// The originals (`collectgarbage`, `gcinfo`) are function objects stashed
// in the state's registry at registration, BEFORE the global is
// overwritten — no raw engine address needed, and each /reload re-stashes
// the freshly re-registered engine original (module callbacks run after
// the engine's own registration pass). Registered on BOTH states.
#include "Game.h"
#include <cstring>
namespace BaseLib::CollectGarbage {
namespace {
constexpr const char *kOrigCollect = "__classicapi_orig_collectgarbage";
constexpr const char *kOrigGcInfo = "__classicapi_orig_gcinfo";
// Pushes registry[key]; true when it holds a function (left on the
// stack), false otherwise (stack unchanged).
bool PushStashed(void *L, const char *key) {
Game::Lua::PushString(L, key);
Game::Lua::RawGet(L, Game::Lua::REGISTRY_INDEX);
if (Game::Lua::Type(L, -1) == Game::Lua::TYPE_FUNCTION)
return true;
Game::Lua::SetTop(L, -2);
return false;
}
int __fastcall Script_collectgarbage(void *L) {
if (Game::Lua::Type(L, 1) == Game::Lua::TYPE_STRING) {
const char *opt = Game::Lua::ToString(L, 1);
if (std::strcmp(opt, "count") == 0) {
if (PushStashed(L, kOrigGcInfo)) {
Game::Lua::Call(L, 0, 1); // gcinfo's first return = KB in use
return 1;
}
Game::Lua::PushNumber(L, 0.0); // no gcinfo on this state
return 1;
}
if (std::strcmp(opt, "collect") == 0) {
if (PushStashed(L, kOrigCollect)) {
Game::Lua::PushNumber(L, 0.0); // threshold 0 = collect now
Game::Lua::Call(L, 1, 0);
}
Game::Lua::PushNumber(L, 0.0); // 5.1 returns 0 for "collect"
return 1;
}
if (std::strcmp(opt, "step") == 0) {
Game::Lua::PushBool(L, true); // "cycle finished" — see header
return 1;
}
if (std::strcmp(opt, "stop") == 0 || std::strcmp(opt, "restart") == 0 ||
std::strcmp(opt, "setpause") == 0 ||
std::strcmp(opt, "setstepmul") == 0) {
Game::Lua::PushNumber(L, 0.0);
return 1;
}
Game::Lua::Error(L, "bad argument #1 to 'collectgarbage' (invalid option '%s')",
opt);
return 0; // unreachable
}
// Number or no argument: original 5.0 threshold behavior, forwarded.
const int n = Game::Lua::GetTop(L);
if (!PushStashed(L, kOrigCollect))
return 0; // no original captured — inert
for (int idx = 1; idx <= n; ++idx)
Game::Lua::PushValue(L, idx);
Game::Lua::Call(L, n, Game::Lua::MULTRET);
return Game::Lua::GetTop(L) - n;
}
// Stashes the function at `_G[name]` under registry[key]; true when a
// function was actually stashed.
bool Stash(void *L, const char *name, const char *key) {
Game::Lua::PushString(L, name);
Game::Lua::GetTable(L, Game::Lua::GLOBALS_INDEX); // [fn?]
if (Game::Lua::Type(L, -1) != Game::Lua::TYPE_FUNCTION) {
Game::Lua::SetTop(L, -2);
return false;
}
Game::Lua::PushString(L, key); // [fn, key]
Game::Lua::Insert(L, -2); // [key, fn]
Game::Lua::RawSet(L, Game::Lua::REGISTRY_INDEX);
return true;
}
void DoRegister(bool glue) {
void *L = Game::Lua::State();
if (L == nullptr)
return;
// Stash BEFORE overwriting; if the engine original is somehow absent,
// leave the global untouched rather than installing a dead shim.
if (!Stash(L, "collectgarbage", kOrigCollect))
return;
Stash(L, "gcinfo", kOrigGcInfo); // optional; "count" degrades to 0
if (glue)
Game::Lua::RegisterGlueFunction("collectgarbage", &Script_collectgarbage);
else
Game::Lua::RegisterGlobalFunction("collectgarbage", &Script_collectgarbage);
}
void RegisterInGame() { DoRegister(false); }
void RegisterGlue() { DoRegister(true); }
const Game::ModuleAutoRegister _autoreg{&RegisterInGame};
const Game::GlueModuleAutoRegister _autoregGlue{&RegisterGlue};
} // namespace
} // namespace BaseLib::CollectGarbage
+133 -6
View File
@@ -15,6 +15,7 @@
//
// - `string.match(s, pattern [, init])` — first-match extraction (5.1).
// - `string.gmatch(s, pattern)` — match iterator (5.1).
// - `string.gsub` TABLE replacement — 5.1 form (see the shim below).
// - `strsplit(sep, str [, pieces])` — WoW global; split on any char.
// - `strjoin(delimiter, ...)` — WoW global; join varargs.
// - `strtrim(str [, chars])` — WoW global; trim a char set.
@@ -28,12 +29,11 @@
// it as a direct alias of the engine's `gfind` C function.
// `strsplit` is a hand-rolled port of 3.3.5's `strsplit` (see below).
//
// NOTE: only the `string.foo(s, ...)` call form works — NOT the
// `("x"):foo(...)` method sugar. WoW's Lua VM strips type-metatables for
// every value except tables/userdata (both `lua_setmetatable` and the VM's
// `luaT_gettmbyobj` hard-return "no metamethod" for strings), so strings have
// no `__index`, and there's no client-side way to add one short of hooking a
// hot VM-core function. This is the long-standing vanilla constraint.
// NOTE: both call forms work — `string.foo(s, ...)` AND the `("x"):foo(...)`
// method sugar. Method calls resolve through this same `string` table via
// LuaSyntax::StringMethods (the Lua 5.1 string-metatable backport), so
// everything registered here is automatically reachable as a method, on
// both states.
#include "Game.h"
#include "Offsets.h"
@@ -261,11 +261,138 @@ int __fastcall Script_string_reverse(void *L) {
return 1;
}
// --- string.gsub table-replacement upgrade (Lua 5.1) ------------------------
//
// 5.1 allows a TABLE as gsub's replacement: each match looks up the first
// capture (the whole match when the pattern declares no captures) in the
// table; a nil/false value keeps the original match; string/number values
// substitute. This engine's gsub (FUN_LUA_STR_GSUB) is stock 5.0 —
// string/function only ("string or function expected" otherwise), and its
// add_value appends NOTHING when a replacement function returns a
// non-string (match → empty; verified by decode, see Offsets.h). So a
// plain table→closure adapter would EAT unmatched tokens where 5.1 keeps
// them, and the closure can't reconstruct the whole match from capture
// args alone.
//
// The shim therefore rewrites the pattern with one OUTER capture —
// `pat` → `(pat)`, keeping a leading `^` and an unescaped trailing `$`
// outside (both are anchors only at their positional ends; inside the
// parens they'd turn literal) — so the adapter closure always receives
// the whole match as arg 1, the pattern's own first capture as arg 2, and
// can hand arg 1 back to reproduce 5.1's keep-match for missing keys.
// Patterns containing a `%1`..`%9` back-reference can't be wrapped (the
// outer capture would renumber what `%N` refers to); those fall back to
// an unwrapped adapter whose missing-key behavior is the engine's own
// (match → empty) — the one documented divergence, and the combination
// (table replacement + pattern back-references) is essentially absent
// from real code. String/function replacements tail-call the engine gsub
// untouched.
// True when the pattern contains a `%1`..`%9` back-reference. Escape-aware:
// the char after any `%` is consumed, so `%%1` (literal percent, then '1')
// doesn't count.
bool PatternHasBackref(const char *p, unsigned len) {
for (unsigned i = 0; i + 1 < len; ++i) {
if (p[i] != '%')
continue;
const char c = p[i + 1];
if (c >= '1' && c <= '9')
return true;
++i; // skip the escaped char
}
return false;
}
// Adapter for the wrapped-pattern path: arg 1 = whole match, arg 2 = the
// pattern's own first capture (when it declared any). Upvalue 1 = the
// replacement table. Missing/false value → return the whole match (5.1's
// keep-match).
int __fastcall GsubTableLookupWrapped(void *L) {
const int nargs = Game::Lua::GetTop(L);
Game::Lua::PushValue(L, nargs >= 2 ? 2 : 1); // the 5.1 lookup key
Game::Lua::GetTable(L, Game::Lua::UpvalueIndex(1)); // tbl[key]
const int t = Game::Lua::Type(L, -1);
if (t == Game::Lua::TYPE_NIL ||
(t == Game::Lua::TYPE_BOOLEAN && Game::Lua::ToBoolean(L, -1) == 0))
Game::Lua::PushValue(L, 1); // keep the original match
return 1; // top value is the result
}
// Adapter for back-reference patterns (unwrapped): arg 1 is already the
// 5.1 key. A missing key returns nil → the engine appends nothing.
int __fastcall GsubTableLookupPlain(void *L) {
Game::Lua::PushValue(L, 1);
Game::Lua::GetTable(L, Game::Lua::UpvalueIndex(1));
return 1;
}
int __fastcall Script_string_gsub(void *L) {
const auto engineGsub =
reinterpret_cast<Game::Lua::CFunction>(Offsets::FUN_LUA_STR_GSUB);
if (Game::Lua::Type(L, 3) != Game::Lua::TYPE_TABLE)
return engineGsub(L); // string/function/error paths untouched
const char *pat = Game::Lua::ToString(L, 2);
if (pat == nullptr)
return engineGsub(L); // let the engine raise its own arg error
const unsigned patLen = Game::Lua::StrLen(L, 2);
Game::Lua::SetTop(L, 4); // normalize to (s, pat, tbl, n) — gsub reads ≤4
if (!PatternHasBackref(pat, patLen)) {
// Build `(body)` with the anchors outside the capture.
unsigned b = 0, e = patLen;
std::string wrapped;
wrapped.reserve(patLen + 3);
if (e > b && pat[0] == '^') {
wrapped += '^';
b = 1;
}
bool anchoredBack = false;
if (e > b && pat[e - 1] == '$') {
// `$` is an anchor only when not %-escaped: an even number of
// `%` immediately before it means the `$` itself is unescaped.
unsigned pc = 0;
for (int idx = static_cast<int>(e) - 2;
idx >= static_cast<int>(b) && pat[idx] == '%'; --idx)
++pc;
anchoredBack = (pc % 2) == 0;
if (anchoredBack)
--e;
}
wrapped += '(';
wrapped.append(pat + b, e - b);
wrapped += ')';
if (anchoredBack)
wrapped += '$';
Game::Lua::PushLString(L, wrapped.data(),
static_cast<unsigned int>(wrapped.size())); // 5
Game::Lua::PushValue(L, 3); // 6: tbl
Game::Lua::PushCClosure(L, &GsubTableLookupWrapped, 1); // 6: closure
Game::Lua::Insert(L, 2); // closure→2: s, closure, pat, tbl, n, wrapped
Game::Lua::Insert(L, 2); // wrapped→2: s, wrapped, closure, pat, tbl, n
Game::Lua::Remove(L, 4); // drop the original pattern
Game::Lua::Remove(L, 4); // drop the table → (s, wrapped, closure, n)
} else {
Game::Lua::PushValue(L, 3); // 5: tbl
Game::Lua::PushCClosure(L, &GsubTableLookupPlain, 1); // 5: closure
Game::Lua::Insert(L, 3); // → s, pat, closure, tbl, n
Game::Lua::Remove(L, 4); // → s, pat, closure, n
}
return engineGsub(L);
}
void RegisterFns() {
Game::Lua::RegisterTableFunction("string", "match", &Script_string_match);
Game::Lua::RegisterTableFunction("string", "gmatch", Script_string_gmatch);
Game::Lua::RegisterTableFunction("string", "gsub", &Script_string_gsub);
Game::Lua::RegisterTableFunction("string", "reverse", &Script_string_reverse);
Game::Lua::RegisterTableFunction("string", "split", &Script_strsplit);
// The bare `gsub` global (1.12 exposes the string functions as globals
// too) must match `string.gsub`, so addons calling either form get the
// table-replacement upgrade.
Game::Lua::RegisterGlobalFunction("gsub", &Script_string_gsub);
Game::Lua::RegisterGlobalFunction("strsplit", &Script_strsplit);
Game::Lua::RegisterGlobalFunction("strjoin", &Script_strjoin);
Game::Lua::RegisterGlobalFunction("strtrim", &Script_strtrim);
+86
View File
@@ -0,0 +1,86 @@
// 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/>.
// `unpack(list [, i [, j]])` — upgrade 1.12's Lua 5.0 `unpack` to the 5.1
// signature. 5.0's unpack takes NO range arguments and silently ignores
// extras, so 5.1 code calling `unpack(t, 2)` got the WHOLE table back —
// wrong values with no error, the worst failure mode (verified in-game:
// `unpack({10,20,30}, 2)` returned 10, 20, 30). This replacement is a
// port of 5.1's `luaB_unpack` (lbaselib.c): `i` defaults to 1 and `j` to
// the table length via luaL_getn — which reads a `t.n` field first, so
// the transpiler's `unpack(arg)` vararg expansion keeps its
// embedded-nil-aware count exactly as before.
//
// Registered on BOTH states (language-level helper, both are 5.0).
#include "Game.h"
#include "Offsets.h"
namespace BaseLib::Unpack {
namespace {
// Engine auxlib/API entries (see Offsets.h for derivations).
using LuaLGetN_t = int(__fastcall *)(void *L, int idx);
using RawGetI_t = void(__fastcall *)(void *L, int idx, int n);
// Range argument at `idx`: absent/nil → `fallback`; a number (or numeric
// string, matching luaL_optint's coercion) → its value; anything else
// errors like 5.1's luaL_optint.
int OptRange(void *L, int idx, int fallback) {
if (Game::Lua::Type(L, idx) <= Game::Lua::TYPE_NIL) // none or nil
return fallback;
if (!Game::Lua::IsNumber(L, idx)) {
Game::Lua::Error(L, "bad argument #%d to 'unpack' (number expected)", idx);
return 0; // unreachable
}
return static_cast<int>(Game::Lua::ToNumber(L, idx));
}
int __fastcall Script_unpack(void *L) {
if (Game::Lua::Type(L, 1) != Game::Lua::TYPE_TABLE) {
Game::Lua::Error(L, "bad argument #1 to 'unpack' (table expected)");
return 0; // unreachable
}
const int i = OptRange(L, 2, 1);
const int e = OptRange(
L, 3, reinterpret_cast<LuaLGetN_t>(Offsets::LUAL_GETN)(L, 1));
if (i > e)
return 0;
// `n <= 0` guards the int overflow of `e - i + 1` (i <= e holds here),
// mirroring 5.1's luaB_unpack.
const int n = e - i + 1;
if (n <= 0 || Game::Lua::CheckStack(L, n) == 0) {
Game::Lua::Error(L, "too many results to unpack");
return 0; // unreachable
}
const auto rawGetI = reinterpret_cast<RawGetI_t>(Offsets::LUA_RAWGETI);
for (int k = i; k <= e; ++k)
rawGetI(L, 1, k);
return n;
}
void RegisterInGame() {
Game::Lua::RegisterGlobalFunction("unpack", &Script_unpack);
}
void RegisterGlue() {
Game::Lua::RegisterGlueFunction("unpack", &Script_unpack);
}
const Game::ModuleAutoRegister _autoreg{&RegisterInGame};
const Game::GlueModuleAutoRegister _autoregGlue{&RegisterGlue};
} // namespace
} // namespace BaseLib::Unpack
+7 -7
View File
@@ -61,8 +61,8 @@ constexpr int kMaxWaitTicks = 1200; // give up and fire failure after this many
struct Registered {
void *cache;
const char *eventName;
uintptr_t getRecord; // this cache class's _GetRecord
const Event::Custom::AutoReserve *reserve; // completion event's reservation
uintptr_t getRecord; // this cache class's _GetRecord
Pending pending[kMaxPending];
int pendingCount;
};
@@ -79,8 +79,7 @@ Registered *Find(void *cache) {
}
void Fire(const Registered *r, uint32_t id, bool success) {
Event::Custom::FireIdSuccess(Event::Custom::Lookup(r->eventName),
static_cast<int>(id), success);
Event::Custom::FireIdSuccess(r->reserve->Slot(), static_cast<int>(id), success);
}
int FindPending(const Registered *r, uint32_t id) {
@@ -176,15 +175,16 @@ const Tick::WorldTick::AutoSubscribe _tickSub{&OnWorldTick};
} // namespace
void Register(void *cacheInstance, const char *eventName, uintptr_t getRecordAddr) {
if (cacheInstance == nullptr || eventName == nullptr || getRecordAddr == 0)
void Register(void *cacheInstance, const Event::Custom::AutoReserve *reserve,
uintptr_t getRecordAddr) {
if (cacheInstance == nullptr || reserve == nullptr || getRecordAddr == 0)
return;
if (Find(cacheInstance) != nullptr) // idempotent (safe across /reload)
return;
if (g_cacheCount >= kMaxCaches)
return;
g_caches[g_cacheCount].cache = cacheInstance;
g_caches[g_cacheCount].eventName = eventName;
g_caches[g_cacheCount].reserve = reserve;
g_caches[g_cacheCount].getRecord = getRecordAddr;
g_caches[g_cacheCount].pendingCount = 0;
++g_cacheCount;
+14 -10
View File
@@ -15,6 +15,10 @@
#include <cstdint>
namespace Event::Custom {
struct AutoReserve;
}
// Shared async-load machinery for the client query caches that aren't
// item/quest — creature, gameobject, and (potentially) the other
// SMSG_*_QUERY caches. Modeled on `Item::Data`'s pending/timeout/event
@@ -30,10 +34,9 @@
// functions, so hooking creature's and gameobject's separately is fine);
// item/quest have their own parsers and aren't touched.
//
// static const char *kEvent = "CREATURE_DATA_LOAD_RESULT";
// static const Event::Custom::AutoReserve _r{kEvent}; // reserve the event
// static const Event::Custom::AutoReserve _r{"CREATURE_DATA_LOAD_RESULT"};
// ...
// Cache::QueryLoad::Register(cacheInstance, kEvent,
// Cache::QueryLoad::Register(cacheInstance, &_r,
// Offsets::FUN_CREATURE_GET_RECORD); // at init
// Cache::QueryLoad::RequestLoad(cacheInstance, id); // fire a load
//
@@ -44,13 +47,14 @@ namespace Cache::QueryLoad {
// Register a query cache for async-load tracking. `cacheInstance` is the
// cache global (e.g. `Offsets::VAR_CREATURE_CACHE` cast to void*);
// `eventName` is a Custom event the caller has reserved via
// `Event::Custom::AutoReserve`, fired `(id, success)` on completion;
// `getRecordAddr` is the cache class's `_GetRecord` (e.g.
// `Offsets::FUN_CREATURE_GET_RECORD`). `cacheInstance`/`eventName` must
// outlive the process (pass static values). Idempotent — calling again
// for the same cache is a no-op (safe across /reload).
void Register(void *cacheInstance, const char *eventName, uintptr_t getRecordAddr);
// `reserve` is the caller's `Event::Custom::AutoReserve` for the
// completion event, fired `(id, success)`; `getRecordAddr` is the cache
// class's `_GetRecord` (e.g. `Offsets::FUN_CREATURE_GET_RECORD`).
// `cacheInstance`/`reserve` must outlive the process (pass statics).
// Idempotent — calling again for the same cache is a no-op (safe across
// /reload).
void Register(void *cacheInstance, const Event::Custom::AutoReserve *reserve,
uintptr_t getRecordAddr);
// True if `id` is already in the cache (pure peek, no network query).
bool IsCached(void *cacheInstance, uint32_t id);
+7 -7
View File
@@ -40,11 +40,6 @@ namespace Chat::RaidMarkers {
namespace {
// On-screen pixel size of an inline marker. The default chat font is ~14px, so 14
// keeps a marker about one line tall. Fixed (the substitution is a static string
// rewrite and can't see the target font); tunable here if it reads too small/large.
constexpr int kMarkerSizePx = 14;
struct Token {
const char *name; // lowercase alias, no braces
int marker; // raid-target index 1..8
@@ -126,11 +121,16 @@ const char *Substitute(const char *msg, char *buf, size_t bufSize) {
// and renders). Anti-spoof Sanitize runs before this, so these
// are the only `||T` openers a player can produce.
char markup[128];
// `:0:0` = auto-size. The inline emitter resolves height/width
// to the target line's font pixel height at draw time, so a
// marker tracks the chat font size (and grows in speech bubbles)
// exactly like retail's ICON_LIST `…Icon_%d:0`. The rest of the
// payload crops the 256x256 sheet to the cell (l,r,t,b).
const int m = std::snprintf(
markup, sizeof markup,
"||TInterface\\TargetingFrame\\UI-RaidTargetingIcons:%d:%d:0:0:256:256:"
"||TInterface\\TargetingFrame\\UI-RaidTargetingIcons:0:0:0:0:256:256:"
"%d:%d:%d:%d||t",
kMarkerSizePx, kMarkerSizePx, l, r, t, b);
l, r, t, b);
// Reserve room for the literal tail after this token
// (msg[k+1..]) so the rest of the message always fits.
const size_t tail = msgLen - (k + 1);
+111
View File
@@ -0,0 +1,111 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
// `StartAttack([target])` / `StopAttack()` — the non-toggling melee
// auto-attack verbs. Vanilla 1.12 ships only `AttackTarget()`, which
// *toggles* (start <-> stop each call), so a `/startattack` can't guarantee
// it won't cancel an in-progress swing. These split the engine's own attack
// machinery into the two explicit, idempotent verbs modern clients expose.
//
// 1.12's `AttackTarget` (`0x00489B50`) is `FUN_006131A0`: resolve the target,
// then `isAttacking ? stop : start`. We call the same start/stop primitives
// directly (see the `FUN_ATTACK_*` notes in Offsets.h), skipping the toggle:
// StartAttack -> the "start" leg only (idempotent; switches or no-ops on a
// valid target, never stops it).
// StopAttack -> the "stop" leg only (self-guards, so a no-op when idle).
#include "Game.h"
#include "Offsets.h"
#include "unit/Identity.h"
#include <cstdint>
namespace Combat::Attack {
namespace {
using Start_t = void(__thiscall *)(void *player, const uint64_t *targetGuid);
using Stop_t = void(__fastcall *)(void *player);
using ResolveTarget_t = int(__thiscall *)(void *player, uint64_t *outGuid);
// Resolve an optional unit-token argument to a GUID without raising.
// `Unit::Identity::GuidForToken` raises for non-token strings (a name or
// garbage — 1.12 has no name->GUID resolver), so run it under `pcall`; a raise
// or an empty token yields 0, which the caller treats as "use current target".
int __fastcall ResolveTokenThunk(void *L) {
using namespace Game::Lua;
const char *token = ToString(L, 1);
const uint64_t g = (token && *token) ? Unit::Identity::GuidForToken(token) : 0;
PushNumber(L, static_cast<double>(static_cast<uint32_t>(g)));
PushNumber(L, static_cast<double>(static_cast<uint32_t>(g >> 32)));
return 2;
}
uint64_t SafeResolveToken(void *L, const char *token) {
using namespace Game::Lua;
if (token == nullptr || *token == '\0')
return 0;
const int base = GetTop(L);
PushCClosure(L, &ResolveTokenThunk, 0);
PushString(L, token);
uint64_t guid = 0;
if (PCall(L, 1, 2, 0) == 0) {
const uint32_t lo = static_cast<uint32_t>(ToNumber(L, -2));
const uint32_t hi = static_cast<uint32_t>(ToNumber(L, -1));
guid = (static_cast<uint64_t>(hi) << 32) | lo;
}
SetTop(L, base); // drop the results (or the caught error message)
return guid;
}
} // namespace
static int __fastcall Script_StartAttack(void *L) {
auto *player = const_cast<uint8_t *>(Unit::Identity::PlayerObject());
if (player == nullptr)
return 0;
uint64_t target = SafeResolveToken(L, Game::Lua::ToString(L, 1));
if (target == 0) {
// No explicit target given (or unresolvable): validate and take the
// current target. Returns 0 — and posts the engine's own UI error —
// when there's nothing valid to attack.
auto resolve =
reinterpret_cast<ResolveTarget_t>(Offsets::FUN_ATTACK_RESOLVE_TARGET);
if (resolve(player, &target) == 0)
return 0;
}
reinterpret_cast<Start_t>(Offsets::FUN_ATTACK_START)(player, &target);
return 0;
}
static int __fastcall Script_StopAttack(void *L) {
(void)L;
auto *player = const_cast<uint8_t *>(Unit::Identity::PlayerObject());
if (player == nullptr)
return 0;
reinterpret_cast<Stop_t>(Offsets::FUN_ATTACK_STOP)(player);
return 0;
}
static void RegisterLuaFunctions() {
Game::Lua::RegisterGlobalFunction("StartAttack", &Script_StartAttack);
Game::Lua::RegisterGlobalFunction("StopAttack", &Script_StopAttack);
}
static const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace Combat::Attack
+30
View File
@@ -211,12 +211,42 @@ static int __fastcall Script_Coroutine_Wrap(void *L) {
return 1;
}
// `coroutine.running()` — Lua 5.1 addition: the running coroutine, or
// nil when called from the main thread. The 5.0 C API has no
// `lua_pushthread` (also a 5.1 addition), so the thread TValue is
// written to the stack raw: this build's TValue is 16 bytes with
// {tt @ +0, value @ +8} (see LuaSyntax::StringMethods for the layout
// derivation), tag 8 = thread, and the value is the running `lua_State`
// itself — exactly what `lua_newthread` stores and what LUA_TO_POINTER
// reads back at tvalue[2]. No stack-overflow check needed: every C
// function is guaranteed LUA_MINSTACK free slots and this pushes one.
// The running thread is trivially GC-alive, so no anchoring either.
// Main-thread detection: module callbacks run on the state's main
// thread, so the L captured at registration IS the main thread;
// coroutine bodies run on their own lua_State.
static void *g_mainL = nullptr;
static constexpr uintptr_t OFF_L_TOP = 0x8; // L->top (push helpers' [ecx+8])
static int __fastcall Script_Coroutine_Running(void *L) {
if (L == g_mainL) {
Game::Lua::PushNil(L);
return 1;
}
uint8_t *top = Game::Read<uint8_t *>(L, OFF_L_TOP);
*reinterpret_cast<int *>(top) = Game::Lua::TYPE_THREAD;
*reinterpret_cast<void **>(top + 8) = L;
Game::Ref<uint8_t *>(L, OFF_L_TOP) = top + 16;
return 1;
}
static void RegisterLuaFunctions() {
g_mainL = Game::Lua::State(); // the state's main thread (see Running)
Game::Lua::RegisterTableFunction("coroutine", "create", &Script_Coroutine_Create);
Game::Lua::RegisterTableFunction("coroutine", "resume", &Script_Coroutine_Resume);
Game::Lua::RegisterTableFunction("coroutine", "yield", &Script_Coroutine_Yield);
Game::Lua::RegisterTableFunction("coroutine", "status", &Script_Coroutine_Status);
Game::Lua::RegisterTableFunction("coroutine", "wrap", &Script_Coroutine_Wrap);
Game::Lua::RegisterTableFunction("coroutine", "running", &Script_Coroutine_Running);
}
static const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
+1 -1
View File
@@ -361,7 +361,7 @@ void RegisterLuaFunctions() {
Game::Lua::RegisterTableFunction("C_CreatureInfo", "GetCreatureTypeIDs",
&Script_GetCreatureTypeIDs);
Cache::QueryLoad::Register(reinterpret_cast<void *>(Offsets::VAR_CREATURE_CACHE),
kCreatureDataLoadResult, Offsets::FUN_CREATURE_GET_RECORD);
&_reserveLoadResult, Offsets::FUN_CREATURE_GET_RECORD);
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
+1 -4
View File
@@ -64,11 +64,8 @@ namespace Debug::Probe {
namespace {
using ResolveUnitToken_t = void *(__fastcall *)(const char *token);
const uint8_t *ResolvePlayer() {
auto fn = reinterpret_cast<ResolveUnitToken_t>(Offsets::FUN_RESOLVE_UNIT_TOKEN);
return static_cast<const uint8_t *>(fn("player"));
return static_cast<const uint8_t *>(Game::ResolveUnitToken("player"));
}
const uint8_t *ResolvePlayerDescriptor() {
+246
View File
@@ -0,0 +1,246 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
// Modern EditBox method backports — cursor, selection, focus and text-state
// accessors that vanilla's 48-method EditBox registry lacks but modern addons
// call unconditionally (the caret bug that motivated this: TwitchEmotes'
// autocomplete calls SetCursorPosition after replacing the text).
//
// SetCursorPosition / GetCursorPosition — caret as a byte offset
// GetUTF8CursorPosition — caret as a character index
// ClearHighlightText — drop the selection, keep the caret
// HasFocus — does this box own keyboard focus
// HasText — is the box non-empty
// Set/GetHighlightColor — the text-selection highlight color
// ClearHistory — empty the up/down input history
//
// The engine already stores and manipulates all of this; we just expose it.
// Every method is registered on the EditBox method registry, so — like any
// per-type frame method — only EditBox frames dispatch to them (a Button or
// Frame still gets "method not found"), so the resolved `this` is always a
// real CSimpleEditBox.
//
// The caret is a BYTE offset into the UTF-8 text buffer (field +0x36c), which
// is exactly what modern WoW's byte-based EditBox cursor uses — for ASCII text
// a byte offset equals a character index, so callers passing `#text` land at
// the end either way.
#include "Game.h"
#include "Offsets.h"
#include <cstdint>
namespace EditBox::Methods {
namespace {
using SetCursorByte_t = void(__thiscall *)(void *editbox, int byteOffset);
using CollapseSelection_t = void(__thiscall *)(void *editbox);
using CountChars_t = int(__thiscall *)(void *editbox, int startByte, int byteCount);
using RegionSetColor_t = void(__thiscall *)(void *region, const uint32_t *colorBGRA);
using RegionGetColor_t = void(__thiscall *)(void *region, uint32_t *outBGRA);
using SetHistoryMax_t = void(__thiscall *)(void *editbox, int maxLines);
// A Lua color arg (0..1) clamped and scaled to a 0..255 byte, matching the
// engine's own float->byte color conversion (truncating, like Texture's
// SetVertexColor). `dflt` is used when the arg is absent.
uint32_t ColorByte(void *L, int idx, double dflt) {
double v = Game::Lua::IsNumber(L, idx) ? Game::Lua::ToNumber(L, idx) : dflt;
if (v < 0.0)
v = 0.0;
else if (v > 1.0)
v = 1.0;
return static_cast<uint32_t>(v * 255.0);
}
int __fastcall Script_SetCursorPosition(void *L) {
void *eb = Game::Lua::ResolveObject(L, 1);
if (eb == nullptr || !Game::Lua::IsNumber(L, 2)) {
Game::Lua::Error(L, "Usage: EditBox:SetCursorPosition(position)");
return 0;
}
const int pos = static_cast<int>(Game::Lua::ToNumber(L, 2));
reinterpret_cast<SetCursorByte_t>(
Offsets::FUN_EDITBOX_SET_CURSOR_BYTE)(eb, pos);
reinterpret_cast<CollapseSelection_t>(
Offsets::FUN_EDITBOX_COLLAPSE_SELECTION)(eb);
// COLLAPSE_SELECTION moves selStart/selEnd but doesn't flag them; set the
// selection-dirty bit (2) so any highlight from before is cleared on the
// next paint. SET_CURSOR_BYTE already set the cursor-dirty bit (4).
Game::Ref<uint32_t>(eb, Offsets::OFF_EDITBOX_FLAGS) |= 2;
return 0;
}
int __fastcall Script_GetCursorPosition(void *L) {
void *eb = Game::Lua::ResolveObject(L, 1);
if (eb == nullptr) {
Game::Lua::Error(L, "Usage: EditBox:GetCursorPosition()");
return 0;
}
Game::Lua::PushNumber(L, static_cast<double>(
Game::Read<int>(eb, Offsets::OFF_EDITBOX_CURSOR_BYTE)));
return 1;
}
// The caret as a 0-based CHARACTER index (UTF-8 codepoints before the caret),
// vs GetCursorPosition's byte offset. Equal for ASCII; differs once the text
// holds multibyte characters.
int __fastcall Script_GetUTF8CursorPosition(void *L) {
void *eb = Game::Lua::ResolveObject(L, 1);
if (eb == nullptr) {
Game::Lua::Error(L, "Usage: EditBox:GetUTF8CursorPosition()");
return 0;
}
const int cursorByte = Game::Read<int>(eb, Offsets::OFF_EDITBOX_CURSOR_BYTE);
const int chars = reinterpret_cast<CountChars_t>(
Offsets::FUN_EDITBOX_COUNT_CHARS)(eb, 0, cursorByte);
Game::Lua::PushNumber(L, static_cast<double>(chars));
return 1;
}
// Drop the current selection without moving the caret. Collapsing selection to
// the caret (selStart = selEnd = cursor) is exactly what the engine's own
// helper does; vanilla can only clear a selection via HighlightText(0, 0),
// which yanks it to offset 0 instead of leaving the caret in place.
int __fastcall Script_ClearHighlightText(void *L) {
void *eb = Game::Lua::ResolveObject(L, 1);
if (eb == nullptr) {
Game::Lua::Error(L, "Usage: EditBox:ClearHighlightText()");
return 0;
}
reinterpret_cast<CollapseSelection_t>(
Offsets::FUN_EDITBOX_COLLAPSE_SELECTION)(eb);
Game::Ref<uint32_t>(eb, Offsets::OFF_EDITBOX_FLAGS) |= 2;
return 0;
}
// True when this edit box currently owns keyboard focus. The engine tracks the
// focused box in one global (written by SetFocus / cleared by ClearFocus), so
// focus is just pointer identity against it.
int __fastcall Script_HasFocus(void *L) {
void *eb = Game::Lua::ResolveObject(L, 1);
if (eb == nullptr) {
Game::Lua::PushBool(L, false);
return 1;
}
Game::Lua::PushBool(L,
eb == Game::Read<void *>(Offsets::VAR_FOCUSED_EDITBOX));
return 1;
}
// True when the box holds any text (its byte length is non-zero).
int __fastcall Script_HasText(void *L) {
void *eb = Game::Lua::ResolveObject(L, 1);
if (eb == nullptr) {
Game::Lua::PushBool(L, false);
return 1;
}
Game::Lua::PushBool(L,
Game::Read<int>(eb, Offsets::OFF_EDITBOX_TEXT_LENGTH) > 0);
return 1;
}
// SetHighlightColor(r, g, b [, a]) — the color of the text-selection highlight.
// The engine paints the selection with three Texture regions (start-line,
// middle-block, end-line); their vertex color is the highlight color, so set
// it on all three via the engine's own region color setter. Colors are 0..1;
// alpha defaults to 1. Takes effect on the next selection paint.
int __fastcall Script_SetHighlightColor(void *L) {
void *eb = Game::Lua::ResolveObject(L, 1);
if (eb == nullptr || !Game::Lua::IsNumber(L, 2) ||
!Game::Lua::IsNumber(L, 3) || !Game::Lua::IsNumber(L, 4)) {
Game::Lua::Error(L, "Usage: EditBox:SetHighlightColor(r, g, b [, a])");
return 0;
}
const uint32_t r = ColorByte(L, 2, 1.0);
const uint32_t g = ColorByte(L, 3, 1.0);
const uint32_t b = ColorByte(L, 4, 1.0);
const uint32_t a = ColorByte(L, 5, 1.0);
const uint32_t packed = b | (g << 8) | (r << 16) | (a << 24); // {b,g,r,a}
auto setColor = reinterpret_cast<RegionSetColor_t>(
Offsets::FUN_FONTSTRING_SET_COLOR);
for (int off = Offsets::OFF_EDITBOX_HIGHLIGHT_REGION;
off <= Offsets::OFF_EDITBOX_HIGHLIGHT_REGION + 8; off += 4) {
void *region = Game::Read<void *>(eb, off);
if (region != nullptr)
setColor(region, &packed);
}
return 0;
}
// GetHighlightColor() -> r, g, b, a (each 0..1). Reads the color off the first
// highlight region; a region with no explicit color reads back white (opaque).
int __fastcall Script_GetHighlightColor(void *L) {
void *eb = Game::Lua::ResolveObject(L, 1);
if (eb == nullptr) {
Game::Lua::Error(L, "Usage: EditBox:GetHighlightColor()");
return 0;
}
uint32_t packed = 0xFFFFFFFF;
void *region = Game::Read<void *>(eb, Offsets::OFF_EDITBOX_HIGHLIGHT_REGION);
if (region != nullptr)
reinterpret_cast<RegionGetColor_t>(Offsets::FUN_REGION_GET_COLOR)(
region, &packed);
const double inv = 1.0 / 255.0;
Game::Lua::PushNumber(L, ((packed >> 16) & 0xff) * inv); // r
Game::Lua::PushNumber(L, ((packed >> 8) & 0xff) * inv); // g
Game::Lua::PushNumber(L, (packed & 0xff) * inv); // b
Game::Lua::PushNumber(L, ((packed >> 24) & 0xff) * inv); // a
return 4;
}
// Empties the up/down input history, keeping the line limit so the box keeps
// recording afterward. The engine's SetHistoryLines internal frees the whole
// ring at max 0, so clear = free (0) then reallocate at the saved max — which
// drops every entry and resets the write position. No-op when history was
// never enabled (max 0).
int __fastcall Script_ClearHistory(void *L) {
void *eb = Game::Lua::ResolveObject(L, 1);
if (eb == nullptr) {
Game::Lua::Error(L, "Usage: EditBox:ClearHistory()");
return 0;
}
const int max = Game::Read<int>(eb, Offsets::OFF_EDITBOX_HISTORY_MAX);
if (max > 0) {
auto setMax = reinterpret_cast<SetHistoryMax_t>(
Offsets::FUN_EDITBOX_SET_HISTORY_MAX);
setMax(eb, 0);
setMax(eb, max);
}
return 0;
}
const Game::Lua::FrameMethodEntry g_methods[] = {
{"SetCursorPosition", &Script_SetCursorPosition},
{"GetCursorPosition", &Script_GetCursorPosition},
{"GetUTF8CursorPosition", &Script_GetUTF8CursorPosition},
{"ClearHighlightText", &Script_ClearHighlightText},
{"HasFocus", &Script_HasFocus},
{"HasText", &Script_HasText},
{"SetHighlightColor", &Script_SetHighlightColor},
{"GetHighlightColor", &Script_GetHighlightColor},
{"ClearHistory", &Script_ClearHistory},
};
void RegisterLuaFunctions() {
Game::Lua::RegisterFrameMethods(
reinterpret_cast<void *>(Offsets::VAR_EDITBOX_METHOD_REGISTRY),
g_methods,
static_cast<int>(sizeof(g_methods) / sizeof(g_methods[0])));
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace
} // namespace EditBox::Methods
+3 -6
View File
@@ -386,7 +386,7 @@ int __fastcall Script_UseEquipmentSet(void *L) {
const uint32_t setID = ArgSetID(L, 1);
const Set *s = Data::FindByID(setID);
if (s == nullptr) {
const int evt = Event::Custom::Lookup(kSwapFinishedEvent);
const int evt = kEvtSwapFinished.Slot();
if (evt >= 0)
Event::Custom::Fire(evt, "%d%d", 0, static_cast<int>(setID));
Game::Lua::PushBoolean(L, 0);
@@ -396,7 +396,7 @@ int __fastcall Script_UseEquipmentSet(void *L) {
// Fire PENDING right after the set-exists check, before any of
// the swap work. Addon UI can use this to gate swap-in-progress
// visuals.
const int pendingEvt = Event::Custom::Lookup(kSwapPendingEvent);
const int pendingEvt = kEvtSwapPending.Slot();
if (pendingEvt >= 0)
Event::Custom::Fire(pendingEvt, "%d", static_cast<int>(setID));
@@ -569,7 +569,7 @@ int __fastcall Script_UseEquipmentSet(void *L) {
virtualPaperdoll[targetSlot - 1] = 0;
}
const int evt = Event::Custom::Lookup(kSwapFinishedEvent);
const int evt = kEvtSwapFinished.Slot();
if (evt >= 0)
Event::Custom::Fire(evt, "%d%d", 1, static_cast<int>(setID));
@@ -620,8 +620,5 @@ static void RegisterLuaFunctions() {
}
static const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
static const Event::Custom::AutoReserve _reserve{kEventName};
static const Event::Custom::AutoReserve _reservePending{kSwapPendingEvent};
static const Event::Custom::AutoReserve _reserveFinished{kSwapFinishedEvent};
} // namespace EquipmentSet::Api
+1 -1
View File
@@ -75,7 +75,7 @@ uint32_t NextAvailableID() {
}
void FireChanged() {
const int slot = Event::Custom::Lookup(kEventName);
const int slot = kEvtSetsChanged.Slot();
if (slot >= 0)
Event::Custom::Fire(slot, "");
}
+8 -5
View File
@@ -13,6 +13,8 @@
#pragma once
#include "event/Custom.h"
#include <cstdint>
#include <string>
@@ -109,8 +111,9 @@ constexpr int LOC_MISSING = -1;
// coincidence, not load-bearing).
constexpr int LOC_IGNORED = 1;
// Custom event names. All three are reserved at module-init time
// via `Event::Custom::AutoReserve` in `Api.cpp`.
// Custom event reservations, shared by the Data and Api TUs (C++17 inline
// variables — one instance each; the AutoReserve ctor dedups by name anyway).
// Fire via `kEvt*.Slot()`.
//
// EQUIPMENT_SETS_CHANGED — no payload; fires on any mutation
// (create / save / modify / delete /
@@ -130,8 +133,8 @@ constexpr int LOC_IGNORED = 1;
// pickup→equip pair actually completed,
// matching modern's "we dispatched"
// definition of success).
constexpr const char *kEventName = "EQUIPMENT_SETS_CHANGED";
constexpr const char *kSwapPendingEvent = "EQUIPMENT_SWAP_PENDING";
constexpr const char *kSwapFinishedEvent = "EQUIPMENT_SWAP_FINISHED";
inline const Event::Custom::AutoReserve kEvtSetsChanged{"EQUIPMENT_SETS_CHANGED"};
inline const Event::Custom::AutoReserve kEvtSwapPending{"EQUIPMENT_SWAP_PENDING"};
inline const Event::Custom::AutoReserve kEvtSwapFinished{"EQUIPMENT_SWAP_FINISHED"};
} // namespace EquipmentSet
+12 -10
View File
@@ -244,25 +244,25 @@ void Grow() {
} // namespace
AutoReserve::AutoReserve(const char *name) {
if (name == nullptr || g_reservedCount >= MAX_RESERVED)
if (name == nullptr)
return;
// Dedup: a name reserved twice resolves both instances to the one entry.
for (int i = 0; i < g_reservedCount; ++i) {
if (std::strcmp(g_reserved[i].name, name) == 0)
if (std::strcmp(g_reserved[i].name, name) == 0) {
index_ = i;
return;
}
}
if (g_reservedCount >= MAX_RESERVED)
return; // overflow — index_ stays -1, Slot() reports unclaimed
g_reserved[g_reservedCount].name = name;
g_reserved[g_reservedCount].slot = -1;
index_ = g_reservedCount;
++g_reservedCount;
}
int Lookup(const char *name) {
if (name == nullptr)
return -1;
for (int i = 0; i < g_reservedCount; ++i) {
if (std::strcmp(g_reserved[i].name, name) == 0)
return g_reserved[i].slot;
}
return -1;
int AutoReserve::Slot() const {
return index_ >= 0 ? g_reserved[index_].slot : -1;
}
int LookupByName(const char *name) {
@@ -320,6 +320,8 @@ void PrepareForReload() {
g_growLatched = false; // re-arm the one-shot grow for the new table
}
static const Game::ReloadAutoRegister _reloadReg{&PrepareForReload};
// Diagnostic Lua functions — registered via the auto-register pattern
// at the bottom of this file. Not on any hot path.
namespace {
+22 -17
View File
@@ -37,31 +37,36 @@ namespace Event::Custom {
// DLL operate on the table independently of the others.
//
// The name pointer must outlive the engine (a string literal does).
// Same name reserved twice is deduped; reserving more than `MAX_RESERVED`
// names total (see Custom.cpp) silently drops the overflow — keep that cap
// above the codebase-wide `AutoReserve` count.
// Same name reserved twice is deduped (both instances resolve to the same
// reservation); reserving more than `MAX_RESERVED` names total (see
// Custom.cpp) silently drops the overflow — keep that cap above the
// codebase-wide `AutoReserve` count.
//
// Fire through the instance: `Fire(_r.Slot(), …)`. `Slot()` is the O(1)
// read of the reservation's current slot id — it resolves through the
// shared reservation table, so reload invalidation (`PrepareForReload`)
// and re-claiming (`RetryClaims`) stay centralized and the value is never
// stale. Returns -1 while unclaimed (before the first Lua-side
// `RegisterEvent` triggers `RetryClaims`), which `Fire` treats as a no-op.
// Do NOT cache the return across frames — read it at fire time (the read
// is two loads; there is nothing to save).
struct AutoReserve {
explicit AutoReserve(const char *name);
};
int Slot() const;
// Returns the slot id currently assigned to `name`, or -1 if not yet
// claimed (e.g. before the first Lua-side `RegisterEvent` has
// triggered `RetryClaims`). Slot indices may change across `/reload`,
// so call this at fire time rather than caching the value.
//
// Only consults our reservation cache — for engine-defined events
// (UNIT_FACTION, BAG_UPDATE, etc.) use `LookupByName`.
int Lookup(const char *name);
private:
int index_ = -1; // into the reservation table; -1 = dropped (overflow)
};
// Walks the live engine event table at `[VAR_EVENT_TABLE_BASE_PTR]`
// looking for an entry whose name strcmps equal to `name`. Returns
// the slot index, or -1 if no entry matches.
//
// Works for both engine-defined events AND our `AutoReserve`-claimed
// custom events (both populate the same table). Slightly slower than
// `Lookup` (O(table size) vs O(reservations)) — prefer `Lookup` for
// names we've reserved. Use this for engine-defined events we want to
// fire ourselves (e.g., polyfilling missing event dispatches).
// custom events (both populate the same table). O(table size) — for
// names we've reserved, prefer the O(1) `AutoReserve::Slot()`. Use this
// for engine-defined events we want to fire ourselves (e.g., polyfilling
// missing event dispatches).
int LookupByName(const char *name);
// True iff at least one frame is currently registered for the event in
@@ -88,7 +93,7 @@ bool HasListeners(int slot);
// Fire(slot, "%s%d", keyName, down); // MODIFIER_STATE_CHANGED(key, down)
//
// No-op for `eventID < 0`, which lets callers cheaply guard on the
// `Lookup()` result without an explicit if.
// `Slot()` result without an explicit if.
template <typename... Args>
inline void Fire(int eventID, const char *format, Args... args) {
if (eventID < 0)
+1 -4
View File
@@ -69,10 +69,7 @@ void PushFlag(void *L, bool value) {
// `[player + 0xE68]`. Returns nullptr if "player" isn't resolvable
// or the sub-struct is uninitialized (pre-login / glue).
const uint8_t *PlayerInfo() {
using ResolveUnitToken_t = void *(__fastcall *)(const char *token);
auto resolve = reinterpret_cast<ResolveUnitToken_t>(
Offsets::FUN_RESOLVE_UNIT_TOKEN);
auto *player = static_cast<const uint8_t *>(resolve("player"));
auto *player = static_cast<const uint8_t *>(Game::ResolveUnitToken("player"));
if (player == nullptr)
return nullptr;
return *reinterpret_cast<const uint8_t *const *>(
+1 -1
View File
@@ -98,7 +98,7 @@ void __fastcall FireNotify_h(int factionID, int delta) {
// any addon relying on the chat event firing first would expect.
FireNotify_o(factionID, delta);
const int slot = Event::Custom::Lookup(kEventName);
const int slot = _reserve.Slot();
if (slot >= 0)
Event::Custom::Fire(slot, "%d%d%d", factionID, newStanding, delta);
+176 -16
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.
@@ -96,6 +103,8 @@
// (`FUN_FRAME_SCRIPT_RESOLVER`) and handing out an external per-frame cell for
// that name — the `Tooltip::SetEvents` analog, applied to every frame.
#include "frame/Attributes.h"
#include "Game.h"
#include "Offsets.h"
#include "baselib/Ascii.h"
@@ -211,7 +220,12 @@ bool CopyAttr(void *L, int frameIdx, const char *lname, char *buf, size_t n) {
const int si = Game::Lua::GetTop(L);
Game::Lua::PushString(L, lname);
Game::Lua::RawGet(L, si); // [.., sub, val]
if (Game::Lua::Type(L, -1) == Game::Lua::TYPE_STRING) {
const int t = Game::Lua::Type(L, -1);
if (t == Game::Lua::TYPE_STRING || t == Game::Lua::TYPE_NUMBER) {
// lua_tostring coerces a number to its digits in place — on our
// temporary stack copy, so the stored attribute is untouched. This
// lets a numeric attribute value (e.g. SetAttribute("spell", 12345)
// or a numeric itemID) read as a string.
const char *s = Game::Lua::ToString(L, -1);
if (s) {
BoundedCopy(buf, s, n);
@@ -514,10 +528,79 @@ bool CallGlobalNum2(void *L, const char *name, double a, double b) {
return called;
}
// _G[name](a) — one number arg, no results. Returns true iff called.
bool CallGlobalNum1(void *L, const char *name, double a) {
const int top = Game::Lua::GetTop(L);
const bool called = Game::Lua::PushGlobalFunction(L, name);
if (called) {
Game::Lua::PushNumber(L, a);
Game::Lua::Call(L, 1, 0);
}
Game::Lua::SetTop(L, top);
return called;
}
// `click` verb: the `clickbutton` attribute holds a FRAME (not a string).
// Resolve it with the same modified-key precedence as ReadModAttr, then forward
// the click via delegate:Click(button). Returns true iff a delegate was called.
bool ClickDelegate(void *L, int fi, const char *prefix, const char *suffix,
const char *button) {
char key[128];
Compose3Lower(key, sizeof key, prefix, "clickbutton", suffix);
bool got = TryPushValue(L, fi, key);
if (!got) {
Compose3Lower(key, sizeof key, "", "clickbutton", suffix);
got = TryPushValue(L, fi, key);
}
if (!got) {
Compose3Lower(key, sizeof key, "", "clickbutton", "");
got = TryPushValue(L, fi, key);
}
if (!got) return false; // no delegate configured
const int di = Game::Lua::GetTop(L); // the delegate value
bool called = false;
if (Game::Lua::Type(L, di) == Game::Lua::TYPE_TABLE) {
Game::Lua::PushString(L, "Click");
Game::Lua::GetTable(L, di); // delegate.Click
if (Game::Lua::Type(L, -1) == Game::Lua::TYPE_FUNCTION) {
Game::Lua::PushValue(L, di); // self = delegate
Game::Lua::PushString(L, button); // button name
Game::Lua::Call(L, 2, 0);
called = true;
}
}
Game::Lua::SetTop(L, di - 1); // drop the delegate (+leftovers)
return called;
}
// Custom verb: a function stored as a raw field on the frame's own table named
// by the verb (retail's escape hatch), invoked as func(self, unit, button).
// Returns true iff such a function existed and was called.
bool CallCustomAction(void *L, int fi, const char *verb, const char *unit,
const char *button) {
if (Game::Lua::Type(L, fi) != Game::Lua::TYPE_TABLE) return false;
Game::Lua::PushString(L, verb);
Game::Lua::RawGet(L, fi); // frame[verb]
bool called = false;
if (Game::Lua::Type(L, -1) == Game::Lua::TYPE_FUNCTION) {
Game::Lua::PushValue(L, fi); // self
Game::Lua::PushString(L, unit); // unit (nil if null)
Game::Lua::PushString(L, button); // button name
Game::Lua::Call(L, 3, 0);
called = true;
} else {
Game::Lua::SetTop(L, Game::Lua::GetTop(L) - 1);
}
return called;
}
// Performs the resolved `verb` on `unit` (a token attribute value, may be null).
// Returns true if it owned the click (so the chained handler is skipped).
// `button` is the raw click-button name ("LeftButton", …), needed by the
// `click` and custom verbs. Returns true if it owned the click (so the chained
// handler is skipped).
bool DispatchVerb(void *L, int fi, const char *prefix, const char *suffix,
const char *verb, const char *unit) {
const char *verb, const char *unit, const char *button) {
if (Ascii::EqualCI(verb, "target")) {
if (!unit) return false;
// `unit="none"` clears the target (retail's SecureActionButton behavior).
@@ -549,14 +632,28 @@ bool DispatchVerb(void *L, int fi, const char *prefix, const char *suffix,
return true;
}
if (Ascii::EqualCI(verb, "spell")) {
// A numeric spell ID (modern addons do SetAttribute("spell", 12345),
// stored as a Lua number; a numeric string is accepted too). Cast the
// EXACT rank by ID through the spellbook-slot resolver — at the unit, or
// with no unit on the current target (targetGuid 0). NOT name
// resolution, which casts the highest known rank regardless of the rank
// the caller asked for.
int spellID;
if (ReadModAttrInt(L, fi, prefix, "spell", suffix, &spellID)) {
if (unit)
Spell::AtUnit::CastByID(spellID, unit);
else
Spell::AtCursor::DispatchSpellCast(spellID);
return true;
}
// Otherwise a spell name. With a `unit`, cast straight at it (GUID →
// dispatcher, no target juggling; a ground-target spell lands at its
// feet). With no `unit`, fall back to the plain global cast on the
// current target — a `type="spell"` button that sets no `unit`
// attribute (issue #18) works like `/cast <spell>`.
char spell[128];
if (!ReadModAttr(L, fi, prefix, "spell", suffix, spell, sizeof spell))
return false;
// With a `unit`, cast straight at it (GUID → dispatcher, no target
// juggling; a ground-target spell lands at its feet). With no `unit`,
// fall back to the plain global cast on the current target — a
// `type="spell"` button that sets no `unit` attribute (issue #18) now
// works like `/cast <spell>` instead of doing nothing.
if (unit)
Spell::AtUnit::CastByName(spell, unit);
else
@@ -638,15 +735,38 @@ bool DispatchVerb(void *L, int fi, const char *prefix, const char *suffix,
Game::Lua::CallGlobal(L, "SpellStopCasting");
return true;
}
if (Ascii::EqualCI(verb, "action")) {
int slot;
if (!ReadModAttrInt(L, fi, prefix, "action", suffix, &slot)) return false;
CallGlobalNum1(L, "UseAction", slot);
return true;
}
if (Ascii::EqualCI(verb, "pet")) {
int index;
if (!ReadModAttrInt(L, fi, prefix, "action", suffix, &index)) return false;
CallGlobalNum1(L, "CastPetAction", index);
return true;
}
if (Ascii::EqualCI(verb, "click")) {
return ClickDelegate(L, fi, prefix, suffix, button);
}
if (Ascii::EqualCI(verb, "menu") || Ascii::EqualCI(verb, "togglemenu")) {
// While a spell is on the cursor, cancel targeting instead of opening
// the menu (retail's SecureUnitButton behavior).
if (CallBoolGlobal(L, "SpellIsTargeting")) {
Game::Lua::CallGlobal(L, "SpellStopTargeting");
return true;
}
if (!unit) return false;
// The unit dropdown is pure FrameXML work (UnitPopup + ToggleDropDown),
// so it lives in the !!!ClassicAPI addon; we just pop it at the cursor.
Game::Lua::CallGlobalString(L, "ClassicAPI_ToggleUnitMenu", unit);
return true;
}
// Unknown verb → not handled here; the chained handler runs.
return false;
// Unknown verb → a custom action: a function stored as a raw field on the
// frame named by the verb (retail's escape hatch). If none exists it isn't
// handled here and the frame's own chained OnClick runs.
return CallCustomAction(L, fi, verb, unit, button);
}
// The chained OnClick handler. Upvalues: 1 = previous handler (or nil), 2 = the
@@ -681,7 +801,7 @@ int __fastcall OnClick_c(void *L) {
const bool haveUnit =
ReadModAttr(L, fi, prefix, "unit", suffix, unit, sizeof unit);
handled = DispatchVerb(L, fi, prefix, suffix, verb,
haveUnit ? unit : nullptr);
haveUnit ? unit : nullptr, btn);
}
}
Game::Lua::SetTop(L, top);
@@ -827,11 +947,30 @@ void FireAttributeChanged(void *L, void *frame, const char *name) {
// ---- the methods -----------------------------------------------------------
// Enables the frame's mouse so it can become the mouse-focus (a bare frame
// otherwise never registers as hovered). Operates on self at index 1.
// otherwise never registers as hovered).
//
// Two properties, both deliberate:
// * STACK-NEUTRAL. The old form did SetTop(L, 1) to set up (self, true) for
// the engine setter, which DESTROYED the (name, value) at stack[2]/[3] that
// FireAttributeChanged reads right after — handing any OnAttributeChanged
// handler a nil `arg2`. We instead push the call at the top and restore the
// caller's stack, so DoSet's (self, name, value) survives intact.
// * PROTECTED. SetAttribute is Frame-scoped (VAR_FRAME_METHOD_REGISTRY), so
// `self` is a real Frame and the setter's type check normally passes;
// invoking through lua_pcall is belt-and-suspenders so a best-effort side
// effect can never surface an error in the addon's SetAttribute. (Calling
// via pcall is also what lets us pass the args at the callee's own stack
// base without clobbering ours.)
void EnableFrameMouse(void *L) {
Game::Lua::SetTop(L, 1); // (self)
Game::Lua::PushBoolean(L, 1);
CallScript(Offsets::FUN_SCRIPT_FRAME_ENABLEMOUSE, L);
const int top = Game::Lua::GetTop(L);
Game::Lua::PushCClosure(
L,
reinterpret_cast<Game::Lua::CFunction>(Offsets::FUN_SCRIPT_FRAME_ENABLEMOUSE),
0);
Game::Lua::PushValue(L, 1); // self (the frame)
Game::Lua::PushBoolean(L, 1); // enable = true
Game::Lua::PCall(L, 2, 0, 0); // best-effort; swallow a transient type error
Game::Lua::SetTop(L, top); // restore the caller's stack
}
int DoSet(void *L, bool fireHandler) { // (self, name, value)
@@ -970,4 +1109,25 @@ const Tick::WorldTick::AutoSubscribe _tick{&MouseoverTick};
} // namespace
// Drop every per-frame map before a /reload (or logout) resets the Lua state.
// Both are keyed by the frame's C object pointer, which the allocator recycles
// across a reload:
// * g_attrHandlers holds OnAttributeChanged handler refs. The refs die with
// the Lua reset, so a stale entry whose address a new frame reuses would
// fire a DEAD ref from FireAttributeChanged — the invoker's protected pcall
// catches it, but the engine error handler still prints it as an error "in
// SetAttribute" (SecureStateDriverManager sets OnAttributeChanged every
// session, so there's always at least one entry to go stale). This was the
// "SetAttribute occasionally errors after /reload" report; same class of
// bug as Tooltip::SetEvents issue #33.
// * g_unitByFrame maps a frame to its `unit` token for the mouse-focus poll;
// a stale entry at a reused address would point the mouseover at the wrong
// unit and mislead the isNew mouse-enable gate.
void PrepareForReload() {
g_attrHandlers.clear();
g_unitByFrame.clear();
}
static const Game::ReloadAutoRegister _reloadReg{&PrepareForReload};
} // namespace Frame::Attributes
+25
View File
@@ -0,0 +1,25 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
#pragma once
namespace Frame::Attributes {
// Drop the per-frame attribute-handler and unit-token maps before a /reload (or
// logout) resets the Lua state and recycles frame object pointers. Without it, a
// stale OnAttributeChanged ref fires from a new frame that reused an old
// address (surfaces as "SetAttribute occasionally errors after /reload"). Wired
// from `FrameScript_Initialize_h` in DllMain.cpp alongside the other cleaners.
void PrepareForReload();
} // namespace Frame::Attributes
+181
View File
@@ -0,0 +1,181 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
// `button:SetScript("PreClick"|"PostClick", fn)` — backport of the modern
// button scripts that fire immediately before and after OnClick, as real frame
// scripts settable via the standard SetScript / GetScript / HookScript. Vanilla
// 1.12 has only OnClick and OnDoubleClick on buttons.
//
// 1.12 already has the script-dispatch machinery, just not these two names.
// Two co-hooks (same shape as Tooltip::SetEvents):
//
// - The Button script resolver FUN_BUTTON_SCRIPT_RESOLVER maps a script name
// to its handler slot (OnClick -> button+0x4CC, …). SetScript/GetScript/
// HookScript all go through it. We co-hook it: for PreClick/PostClick
// (which the engine doesn't know) we hand back a pointer to an external
// per-button cell — a button object has no free slot for two new scripts,
// and the resolver's only contract is "return the address the script lives
// at", so external storage works exactly like an in-object slot.
//
// - The engine fires a button's OnClick through the arg'd script runner
// FUN_FRAME_RUN_SCRIPT_WITH_CONTEXT(frame, frame+0x4CC, "%s", &buttonName).
// We co-hook that runner and, when the fired slot IS the OnClick slot,
// re-invoke the runner for our PreClick cell first, then let OnClick run,
// then re-invoke for PostClick — reusing the SAME (fmt, varargs) so
// PreClick/PostClick receive arg1 = the button name exactly like OnClick.
//
// Why the runner and not the button click vmethod (FUN_00779540): SuperWoW
// inline-hooks that vmethod for click-casting, and a second MinHook there
// corrupts the trampoline (ERROR #132 — see Offsets.h). The runner is
// uncontested, fires at the same top-level dispatch level (so its exec-context
// stamp is valid), and the OnClick slot address is an exact gate.
//
// Limitation: because the runner only runs when a slot is non-empty, PreClick/
// PostClick fire only when the button also has an OnClick handler set. Modern
// fires them regardless; on this client that would require hooking the click
// vmethod, which SuperWoW owns. Bracketing an existing OnClick is the common
// case and the one this backport serves.
#include "frame/ClickEvents.h"
#include "Game.h"
#include "Offsets.h"
#include <cstdint>
#include <unordered_map>
namespace Frame::ClickEvents {
namespace {
enum ScriptKind { SK_PRECLICK = 0, SK_POSTCLICK, SK_COUNT };
// Matched case-insensitively against the resolver's input name.
constexpr const char *kNamesLower[SK_COUNT] = {"preclick", "postclick"};
// Per-button storage: one 8-byte {handler, context} slot per script kind. Only
// buttons that actually had SetScript("PreClick"/"PostClick", …) called get an
// entry, so the map stays small. unordered_map nodes are pointer-stable, so a
// cell address handed to the engine stays valid until PrepareForReload clears
// the map (buttons and their handler refs die on /reload).
struct Cell {
uint32_t slot[SK_COUNT][2]; // [kind] = {handler ref, exec context}
};
std::unordered_map<void *, Cell> g_cells;
uint32_t *SlotFor(void *button, int kind, bool create) {
auto it = g_cells.find(button);
if (it != g_cells.end())
return it->second.slot[kind];
if (!create)
return nullptr;
Cell &c = g_cells[button];
for (auto &s : c.slot) {
s[0] = 0;
s[1] = 0;
}
return c.slot[kind];
}
bool EqualsIgnoreCase(const char *s, const char *literal) {
if (s == nullptr)
return false;
for (;; ++s, ++literal) {
unsigned char a = static_cast<unsigned char>(*s);
const unsigned char b = static_cast<unsigned char>(*literal);
if (a >= 'A' && a <= 'Z')
a = static_cast<unsigned char>(a + 32);
if (a != b)
return false;
if (b == 0)
return true;
}
}
// Recursion guard: a PreClick/OnClick/PostClick handler can programmatically
// click a button again. While bracketing, further OnClick fires pass straight
// through (the nested click still runs — it just doesn't get its own nested
// PreClick/PostClick), which prevents runaway from a self-clicking handler.
int g_firing = 0;
// --- Resolver co-hook (Button script-name -> slot) ---------------------
using Resolver_t = int(__fastcall *)(void *self, void *edx, const char *name);
Resolver_t g_resolverOriginal = nullptr;
int __fastcall Resolver_h(void *self, void *edx, const char *name) {
const int engineSlot = g_resolverOriginal(self, edx, name);
if (engineSlot != 0) // a base-frame or existing button script — leave it
return engineSlot;
for (int k = 0; k < SK_COUNT; ++k)
if (EqualsIgnoreCase(name, kNamesLower[k]))
return reinterpret_cast<int>(SlotFor(self, k, /*create*/ true));
return 0;
}
// --- Runner co-hook (fire PreClick before / PostClick after OnClick) ---
using RunScript_t = void(__cdecl *)(void *frame, uint32_t *slotPtr,
const char *fmt, void *varargs);
RunScript_t g_runOriginal = nullptr;
// Fire the handler (if any) for `kind` on `frame`, reusing the OnClick fire's
// own (fmt, varargs) so arg1 = the button name. We call the original runner
// directly (its exec-context stamp is valid at this dispatch level). The runner
// balances the Lua stack itself; we snapshot/restore the top as cheap insurance
// against any imbalance leaking to the still-running button click code.
void FireCell(void *frame, int kind, const char *fmt, void *varargs) {
uint32_t *slot = SlotFor(frame, kind, /*create*/ false);
if (slot == nullptr || slot[0] == 0)
return;
void *L = Game::Lua::State();
const int savedTop = (L != nullptr) ? Game::Lua::GetTop(L) : 0;
g_runOriginal(frame, slot, fmt, varargs);
if (L != nullptr)
Game::Lua::SetTop(L, savedTop);
}
void __cdecl RunScript_h(void *frame, uint32_t *slotPtr, const char *fmt,
void *varargs) {
const auto onClickSlot = reinterpret_cast<uint32_t *>(
reinterpret_cast<char *>(frame) + Offsets::OFF_BUTTON_ONCLICK_HANDLER);
if (slotPtr != onClickSlot || g_firing > 0) {
g_runOriginal(frame, slotPtr, fmt, varargs);
return;
}
++g_firing;
FireCell(frame, SK_PRECLICK, fmt, varargs);
g_runOriginal(frame, slotPtr, fmt, varargs); // the button's OnClick
FireCell(frame, SK_POSTCLICK, fmt, varargs);
--g_firing;
}
static const Game::HookAutoRegister _resolverHook{
Offsets::FUN_BUTTON_SCRIPT_RESOLVER,
reinterpret_cast<void *>(&Resolver_h),
reinterpret_cast<void **>(&g_resolverOriginal)};
static const Game::HookAutoRegister _runnerHook{
Offsets::FUN_FRAME_RUN_SCRIPT_WITH_CONTEXT,
reinterpret_cast<void *>(&RunScript_h),
reinterpret_cast<void **>(&g_runOriginal)};
} // namespace
void PrepareForReload() {
g_cells.clear();
}
static const Game::ReloadAutoRegister _reloadReg{&PrepareForReload};
} // namespace Frame::ClickEvents
+25
View File
@@ -0,0 +1,25 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
#pragma once
namespace Frame::ClickEvents {
// Drop every per-button PreClick/PostClick handler cell before a `/reload` (or
// logout) tears down the button objects and resets the Lua state. The handler
// refs die with the Lua reset, and a recycled button address must not fire a
// stale ref. Wired from `FrameScript_Initialize_h` in DllMain.cpp, alongside
// the other per-reload cleaners.
void PrepareForReload();
} // namespace Frame::ClickEvents
+99
View File
@@ -0,0 +1,99 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
// `GetClickFrame(name)` — backport of the C global WoW registers for the
// `/click` handler to resolve a frame by its global name. Vanilla 1.12
// never shipped it; 3.3.5's `Script_GetClickFrame` (`0x00564130`) is the
// reference.
//
// It lives in the DLL, not addon Lua, because Blizzard shipped it as an
// engine-registered C global — the backport mirrors where the real
// function lives, not the fact that Lua could fake it.
//
// Behaviour, matching the 3.3.5 resolver `FUN_00562990` (minus its private
// name→frame memoization cache, which is only a perf optimization over the
// global lookup):
// 1. `_G[name]` — a plain global-table lookup.
// 2. The value must be a frame wrapper table backed by a
// `CFrameScriptObject`.
// 3. Its real `GetName()` must equal `name`. This is the load-bearing
// check: it stops a reassigned global (`_G["Foo"] = someOtherFrame`)
// from resolving to a differently-named frame.
// Return the frame on a match, otherwise nil.
//
// `GetName()` is the object's vtable slot +4 (`__thiscall`, returns
// `const char *`) — verified from 1.12's own `Script_GetName`
// (`0x0079FF60` / `0x007A1390`), which resolves self the same way and reads
// the name from that slot. It's a base virtual present on every
// `CFrameScriptObject` subtype, so calling it after `ResolveObject` is
// safe without the RTTI subtype gate `Script_GetName` does first.
#include "Game.h"
#include "Offsets.h"
#include <cstring>
namespace Frame::ClickFrame {
namespace {
// CFrameScriptObject::GetName — vtable slot +4, thiscall, no args.
using GetName_t = const char *(__thiscall *)(void *self);
const char *ObjectName(void *obj) {
auto vtable = *reinterpret_cast<void ***>(obj);
auto getName = reinterpret_cast<GetName_t>(vtable[1]);
return getName(obj);
}
} // namespace
static int __fastcall Script_GetClickFrame(void *L) {
using namespace Game::Lua;
if (GetTop(L) < 1) {
Error(L, "Usage: GetClickFrame(\"name\")");
return 0;
}
const char *name = ToString(L, 1);
if (name == nullptr) {
PushNil(L);
return 1;
}
// getglobal(name): push _G[name].
PushString(L, name);
GetTable(L, GLOBALS_INDEX);
if (Type(L, -1) != TYPE_TABLE) {
PushNil(L);
return 1;
}
void *obj = ResolveObject(L, -1);
if (obj != nullptr) {
const char *objName = ObjectName(obj);
if (objName != nullptr && std::strcmp(objName, name) == 0)
// The wrapper table is still on top — return it as-is.
return 1;
}
PushNil(L);
return 1;
}
static void RegisterLuaFunctions() {
Game::Lua::RegisterGlobalFunction("GetClickFrame", &Script_GetClickFrame);
}
static const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace Frame::ClickFrame
+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
+93 -20
View File
@@ -25,13 +25,28 @@
// So `function(self, delta)` gets nothing today.
//
// We co-hook both runners and REPLACE their pcall tail: keep setting the same
// globals (so vanilla handlers reading `this`/`arg1` keep working — this is
// purely additive; Lua silently drops the extra positional args a zero-param
// handler doesn't declare), but push `self` + `arg1..argN` as real arguments
// and `pcall` with `nargs = 1 + N`. The reimplementation mirrors the engine's
// message-handler errfunc, but saves the clobbered globals on the Lua stack
// (not registry refs like the engine) so an out-of-memory longjmp mid-push
// unwinds them for free — no leaked refs, no per-call ref churn. See RunModern.
// globals (so handlers reading `this`/`arg1` keep working), but push `self` +
// `arg1..argN` as real arguments and `pcall` with `nargs = 1 + N`. The
// reimplementation mirrors the engine's message-handler errfunc, but saves the
// clobbered globals on the Lua stack (not registry refs like the engine) so an
// out-of-memory longjmp mid-push unwinds them for free — no leaked refs, no
// per-call ref churn. See RunModern.
//
// The reimplemented path is taken ONLY for a handler that actually declares a
// parameter or is vararg (checked via its Proto — see HandlerWantsArgs). A
// handler that declares NO parameters can't observe positional args, so pushing
// them is a no-op — those route through the original engine runner unchanged:
// provably identical to vanilla, and OFF the engine's hottest Lua path (the vast
// majority of vanilla handlers, which read `this`/`arg1`, are param-less). So a
// param-less vanilla handler is untouched; only a param-declaring handler sees
// the `(self, [event,] arg1..argN)` shape.
//
// Irreducible caveat: a param-declaring handler and a modern handler are
// indistinguishable to the dispatcher — both are one-param Lua functions. So a
// vanilla handler that declares a parameter expecting it to be nil (e.g. a
// function reused as both a direct call and a handler) WILL now receive a real
// value. There is no signal to tell the two apart; such a handler must opt out
// per-session via `SetModernScriptArgs(false)`.
//
// Convention: `(self, arg1..argN)` for every script, plus `event` inserted as
// the first positional for OnEvent — `(self, event, arg1..argN)` — matching
@@ -41,10 +56,12 @@
// SetScript creates a distinct ref, so a function bound to two scripts still
// differs per slot — so no fragile "are we mid event-dispatch" flag is needed.
//
// Runtime switch, default OFF (opt-in): `SetModernScriptArgs(true)` enables it,
// `GetModernScriptArgs()` reads the state. While disabled both detours are a
// straight passthrough to the original runner (zero reimplementation risk).
// Opt-in because this lands on the hottest Lua path in the engine
// Runtime switch, default ON — modern handler signatures are a core 5.1
// feature, so ports that write `function(self, delta)` work out of the box.
// `SetModernScriptArgs(false)` disables it, `GetModernScriptArgs()` reads the
// state. While disabled both detours are a straight passthrough to the original
// runner (exact vanilla behavior, zero reimplementation risk) — the OFF escape
// valve, since this lands on the hottest Lua path in the engine
// (FUN_FRAME_RUN_SCRIPT_ARGS fires for every OnUpdate frame).
#include "Game.h"
@@ -56,9 +73,10 @@ namespace Frame::ScriptArgs {
namespace {
// Runtime switch. Default OFF (opt-in). When false, both detours tail-call the
// original runner unchanged (exact vanilla behavior, no reimplementation risk).
bool g_enabled = false;
// Runtime switch. Default ON (core 5.1 behavior). When false, both detours
// tail-call the original runner unchanged (exact vanilla behavior, no
// reimplementation risk) — the OFF escape valve for this hot path.
bool g_enabled = true;
// Engine cap: the runner stops setting arg globals at index 19 (arg1..arg19).
constexpr int kMaxArgs = 19;
@@ -263,6 +281,55 @@ bool RunModern(int handlerRef, void *frame, const char *fmt, const void *vaPtr)
return true;
}
// Lua 5.0 internal offsets for reading a handler's arity — verified against the
// engine's own luaD_precall (0x006F6050) / lua_dump (0x006F4370) /
// lua_iscfunction (0x006F34A0):
// lua_State: top @ +0x08 (TValue *, next free slot)
// TValue: tag @ +0x00 (int); GC/closure pointer @ +0x08 (16-byte TValue)
// Closure: isC byte @ +0x06; union member (C func / Proto) @ +0x0C
// Proto: numparams (u8) @ +0x45, is_vararg (u8) @ +0x46
// Single-use here, so kept local (like the lua_State field offsets elsewhere).
constexpr uintptr_t OFF_LUASTATE_TOP = 0x08;
constexpr uintptr_t OFF_TVALUE_GC = 0x08;
constexpr uintptr_t OFF_CLOSURE_ISC = 0x06;
constexpr uintptr_t OFF_LCLOSURE_PROTO = 0x0C;
constexpr uintptr_t OFF_PROTO_NUMPARAMS = 0x45;
constexpr uintptr_t OFF_PROTO_IS_VARARG = 0x46;
constexpr int kLuaTFunction = 6;
constexpr uintptr_t kTValueSize = 0x10;
// Does this handler actually observe positional args? A vanilla handler reads
// this/arg1 globals and declares NO parameters, so pushing positional args to
// it is observably identical to vanilla — those route through the original
// engine runner instead (exact vanilla, and off the hottest Lua path). Only a
// Lua closure that declares a parameter or is vararg — a modern
// `function(self, delta)` / `function(self, ...)` — takes the reimplemented
// positional path. A C-closure handler (HookScript's thunk, say) reads
// globals/upvalues, never positional args, so it counts as "no" too.
//
// Reads the handler's Proto directly (the engine exposes no arity via the C
// API); every read is bounded — a non-function ref, a C closure, or a null
// Proto all fall through to "no".
bool HandlerWantsArgs(void *L, int handlerRef) {
RawGetI(L, Game::Lua::REGISTRY_INDEX, handlerRef); // push registry[handlerRef]
bool wants = false;
const void *tv = reinterpret_cast<const void *>(
Game::Read<uintptr_t>(L, OFF_LUASTATE_TOP) - kTValueSize);
if (Game::Read<int>(tv, 0) == kLuaTFunction) {
const void *cl =
reinterpret_cast<const void *>(Game::Read<uintptr_t>(tv, OFF_TVALUE_GC));
if (cl != nullptr && Game::Read<uint8_t>(cl, OFF_CLOSURE_ISC) == 0) { // Lua closure
const void *proto = reinterpret_cast<const void *>(
Game::Read<uintptr_t>(cl, OFF_LCLOSURE_PROTO));
if (proto != nullptr)
wants = Game::Read<uint8_t>(proto, OFF_PROTO_NUMPARAMS) > 0 ||
Game::Read<uint8_t>(proto, OFF_PROTO_IS_VARARG) != 0;
}
}
Game::Lua::SetTop(L, -2); // pop the handler
return wants;
}
// --- FUN_FRAME_RUN_SCRIPT_ARGS co-hook (scripts with values) ----------------
using RunArgs_t = void(__cdecl *)(int handlerRef, void *frame, const char *fmt,
const void *vaPtr);
@@ -270,9 +337,12 @@ RunArgs_t g_origRunArgs = nullptr;
void __cdecl RunArgs_h(int handlerRef, void *frame, const char *fmt,
const void *vaPtr) {
if (g_enabled && handlerRef != 0 && fmt != nullptr &&
RunModern(handlerRef, frame, fmt, vaPtr))
return;
if (g_enabled && handlerRef != 0 && fmt != nullptr) {
void *L = Game::Lua::State();
if (L != nullptr && HandlerWantsArgs(L, handlerRef) &&
RunModern(handlerRef, frame, fmt, vaPtr))
return;
}
g_origRunArgs(handlerRef, frame, fmt, vaPtr);
}
@@ -283,9 +353,12 @@ using Invoke_t = void(__fastcall *)(int handlerRef, void *frame);
Invoke_t g_origInvoke = nullptr;
void __fastcall Invoke_h(int handlerRef, void *frame) {
if (g_enabled && handlerRef != 0 && frame != nullptr &&
RunModern(handlerRef, frame, /*fmt*/ nullptr, /*vaPtr*/ nullptr))
return;
if (g_enabled && handlerRef != 0 && frame != nullptr) {
void *L = Game::Lua::State();
if (L != nullptr && HandlerWantsArgs(L, handlerRef) &&
RunModern(handlerRef, frame, /*fmt*/ nullptr, /*vaPtr*/ nullptr))
return;
}
g_origInvoke(handlerRef, frame);
}
+1 -1
View File
@@ -107,7 +107,7 @@ void RegisterLuaFunctions() {
Game::Lua::RegisterTableFunction("C_GameObjectInfo", "RequestLoadGameObjectByID",
&Script_RequestLoadGameObjectByID);
Cache::QueryLoad::Register(reinterpret_cast<void *>(Offsets::VAR_GAMEOBJECT_CACHE),
kGameObjectDataLoadResult, Offsets::FUN_GAMEOBJECT_GET_RECORD);
&_reserveLoadResult, Offsets::FUN_GAMEOBJECT_GET_RECORD);
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
+3 -5
View File
@@ -64,8 +64,8 @@ namespace Input::GlobalMouse {
namespace {
constexpr const char *kDownEvent = "GLOBAL_MOUSE_DOWN";
constexpr const char *kUpEvent = "GLOBAL_MOUSE_UP";
const Event::Custom::AutoReserve _reserveDown{"GLOBAL_MOUSE_DOWN"};
const Event::Custom::AutoReserve _reserveUp{"GLOBAL_MOUSE_UP"};
// Held-state bitmap — bit `i` set means the button with 1-based
// `IsMouseButtonDown` ID `i+1` is currently held. Modern API IDs:
@@ -154,7 +154,7 @@ void ProcessMouseMessage(UINT msg, WPARAM wParam) {
else g_mouseButtonMask &= ~bit;
}
const int slot = Event::Custom::Lookup(down ? kDownEvent : kUpEvent);
const int slot = (down ? _reserveDown : _reserveUp).Slot();
if (slot >= 0)
Event::Custom::Fire(slot, "%s", name);
}
@@ -241,8 +241,6 @@ void RegisterLuaFunctions() {
InstallHook();
}
const Event::Custom::AutoReserve _reserveDown{kDownEvent};
const Event::Custom::AutoReserve _reserveUp{kUpEvent};
const Tick::WorldTick::AutoSubscribe _evict{&EvictTick};
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
+3 -5
View File
@@ -102,11 +102,9 @@ void SetBit(int bitIdx, bool down) {
if (down) g_modifierMask |= bit;
else g_modifierMask &= ~bit;
// Slot was assigned when the engine populated its event table; our
// hook on `RebuildEventTable` recorded the index. Lookup at fire
// time rather than caching because `/reload` re-runs the rebuild
// and the index may shift.
const int slot = Event::Custom::Lookup(kModifierStateChanged);
// Read the slot at fire time (never cache it) — `/reload` rebuilds the
// event table and the reservation re-claims, which may shift the index.
const int slot = _reserveModifierStateChanged.Slot();
if (slot >= 0)
Event::Custom::Fire(slot, "%s%d", kKeyName[bitIdx], static_cast<int>(down));
}
+46
View File
@@ -0,0 +1,46 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
// `INTERFACE_VERSION` global — the client's interface (TOC) version number,
// 11200 on this client. Read from the engine's own accessor (the same value
// the addon loader compares each `## Interface:` against) rather than a
// literal, and re-published on every `/reload` via the standard
// `ModuleAutoRegister` flow. It is a true fixed client constant, so it is a
// global number like `CLASSIC_API_VERSION`, not a function.
#include "Game.h"
#include "Offsets.h"
#include <cstdint>
namespace Interface::Version {
namespace {
using ClientIfaceVer_t = uint32_t(__cdecl *)();
void RegisterLuaFunctions() {
void *L = Game::Lua::State();
if (L == nullptr)
return;
const uint32_t version = reinterpret_cast<ClientIfaceVer_t>(
static_cast<uintptr_t>(Offsets::FUN_ADDON_CLIENT_INTERFACE_VERSION))();
Game::Lua::SetGlobalNumber(L, "INTERFACE_VERSION",
static_cast<double>(version));
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace
} // namespace Interface::Version
+73 -16
View File
@@ -58,13 +58,13 @@ static const Event::Custom::AutoReserve _reserveItemDataLoadResult{kItemDataLoad
static const Event::Custom::AutoReserve _reserveGetItemInfoReceived{kGetItemInfoReceived};
static void FireGetItemInfoReceived(int itemID, bool success) {
Event::Custom::FireIdSuccess(Event::Custom::Lookup(kGetItemInfoReceived),
itemID, success);
Event::Custom::FireIdSuccess(_reserveGetItemInfoReceived.Slot(), itemID,
success);
}
static void FireItemDataLoadResult(int itemID, bool success) {
Event::Custom::FireIdSuccess(Event::Custom::Lookup(kItemDataLoadResult),
itemID, success);
Event::Custom::FireIdSuccess(_reserveItemDataLoadResult.Slot(), itemID,
success);
}
// Item-load callbacks are __stdcall, 2 args / 8 bytes — matching the
@@ -176,12 +176,20 @@ struct Pending {
constexpr int kMaxPending = 512;
constexpr int kMaxWaitTicks = 1200;
// World ticks after which we assume the engine's startup
// Wall-clock delay past world-live before we assume the engine's startup
// network/opcode-dispatch state has settled and eager queries are safe.
// Also latched the moment the engine's own response handler fires (a
// response proves dispatch is live) — see `ItemStatsCacheResponse_h`.
// Anchored to VAR_IN_WORLD (set to 1 by the enter-world initializer
// FUN_004908C0, cleared on every transition — the same flag
// bag/UpdateDelayed trusts), NOT a raw frame count: a fixed tick count is
// FPS-dependent, so it could fire before dispatch was ready on a fast
// machine (orphan risk — see the block comment above) yet leave bag icons
// as "?" far longer than intended on a slow login. World-live alone is
// still PLAYER_ENTERING_WORLD's first frame — inside the orphan window —
// so we wait this long past it. Also latched the instant the engine's own
// item-response handler fires (a response proves dispatch is live, so a
// warm WDB settles with no delay) — see `ItemStatsCacheResponse_h`.
// One-way latch; see the relog note in `WarmCache`.
constexpr int kSettleTicks = 60;
constexpr uint32_t kSettleMs = 1000;
// Minimum spacing between outgoing *background* SMSG_ITEM_QUERY_SINGLE
// requests. A single /reload can make addons (pfQuest / Questie / Bagnon)
@@ -198,10 +206,10 @@ constexpr uint32_t kMinRequestSpacingMs = 50;
// login; behind the background floor they showed as "unknown" for a
// second or two while a bulk addon scan drained ahead of them. These
// are issued on a separate, more generous budget so they resolve fast.
// The set is bounded (~120 items) and the engine prefetches inventory in
// parallel, so a small per-tick cap keeps them near-instant without
// re-introducing a burst: worst case is kOwnedPerTick + 1 packets per
// frame (~7), far below the flood that broke connectivity.
// The set is bounded (~120 items), so a small per-tick cap keeps them
// near-instant without re-introducing a burst: worst case is
// kOwnedPerTick + 1 packets per frame (~7), far below the flood that
// broke connectivity.
constexpr int kOwnedPerTick = 6;
constexpr int kMaxOwned = 256;
constexpr uint32_t kOwnedRefreshMs = 1000; // carried set changes slowly
@@ -209,7 +217,10 @@ constexpr uint32_t kOwnedRefreshMs = 1000; // carried set changes slowly
static Pending g_pending[kMaxPending];
static int g_pendingCount = 0;
static bool g_settled = false;
static int g_worldTicks = 0;
// Tick timestamp when we first saw the world go live (VAR_IN_WORLD == 1);
// 0 = not yet. Anchors the kSettleMs delay. Only meaningful until the
// one-way g_settled latch flips, so it is never reset.
static uint32_t g_inWorldSinceMs = 0;
static uint32_t g_lastRequestMs = 0;
// Snapshot of the itemIDs the player currently carries. Rebuilt by a
@@ -219,6 +230,10 @@ static uint32_t g_owned[kMaxOwned];
static int g_ownedCount = 0;
static uint32_t g_ownedBuiltMs = 0;
static bool g_ownedEverBuilt = false;
// One-shot latch: have we proactively seeded the queue with the player's
// carried items this enter-world session? Re-armed per in-game init (login
// or /reload) in RegisterLuaFunctions.
static bool g_ownedPrefetched = false;
static int FindPending(uint32_t itemID) {
for (int i = 0; i < g_pendingCount; ++i) {
@@ -441,11 +456,47 @@ static bool IsOwned(uint32_t itemID) {
// giving up after `kMaxWaitTicks` for items whose query was already
// sent but never resolved (lost query, server silent).
static void OnWorldTick() {
if (!g_settled && ++g_worldTicks >= kSettleTicks)
g_settled = true;
const uint32_t now = Time::Clock::NowMs();
// Settle the startup gate a fixed wall-clock delay past world-live —
// or leave it to ItemStatsCacheResponse_h to latch the instant a real
// item response arrives, whichever comes first. See kSettleMs.
if (!g_settled) {
const bool inWorld = *reinterpret_cast<const volatile uint8_t *>(
static_cast<uintptr_t>(Offsets::VAR_IN_WORLD)) != 0;
if (inWorld && g_inWorldSinceMs == 0)
g_inWorldSinceMs = now;
if (g_inWorldSinceMs != 0 &&
Time::Clock::Elapsed(g_inWorldSinceMs, now) >= kSettleMs)
g_settled = true;
}
// Proactive local-inventory prefetch (once per enter-world session).
// The engine does NOT query bag item stats on enter-world or bag draw:
// GetContainerItemInfo and the icon getter both do a null-callback
// (pure lookup) _GetRecord, and an uncached bag item resolves
// displayInfoID = 0, which the icon path draws as INV_Misc_QuestionMark
// ("?"). (Equipped items are exempt — their icon comes from the
// server-pushed visible-items table.) So on a cold WDB the player's
// carried items stay "?" until something else queries them — a hover, a
// loot, or an addon's GetItemInfo. Seed the queue with every carried
// item so they issue on the owned priority lane ahead of any addon's
// background flood, giving complete carried-item coverage regardless of
// addon behavior. Bounded (<= kMaxOwned) and issued under the same owned
// budget as everything else, so it adds no burst (issue #25). Deferred
// to post-settle like every other query, so it can't orphan an entry
// before the engine's network/opcode dispatch is ready. Gate on a live
// inventory manager so a too-early settle retries next tick instead of
// latching on an empty walk.
if (g_settled && !g_ownedPrefetched &&
Unit::Identity::PlayerInventoryManager() != nullptr) {
RebuildOwned();
g_ownedBuiltMs = now;
for (int i = 0; i < g_ownedCount; ++i)
Track(g_owned[i], /*implicit=*/true);
g_ownedPrefetched = true;
}
// Refresh the carried-item set while there is queued work, at most
// once per kOwnedRefreshMs — the set changes slowly and the walk is
// pointer-chasing, not free.
@@ -567,6 +618,12 @@ static int __fastcall Script_RequestLoadItemData(void *L) {
}
static void RegisterLuaFunctions() {
// Re-arm the proactive carried-item prefetch for this enter-world
// session. Fires on login and /reload (this callback runs per in-game
// Lua init). g_settled is deliberately NOT reset — it is a one-way
// process latch (see WarmCache); on a /reload the cache is warm, so the
// re-run resolves as cache hits and does nothing.
g_ownedPrefetched = false;
Game::Lua::RegisterTableFunction("C_Item", "IsItemDataCachedByID",
&Script_IsItemDataCachedByID);
Game::Lua::RegisterTableFunction("C_Item", "IsItemDataCached", &Script_IsItemDataCached);
+1 -4
View File
@@ -200,11 +200,8 @@ int __fastcall Script_C_Item_EquipItemByName(void *L) {
if (!Item::Cursor::PickupBagItem(L, found.bagID, found.slotIndex))
return 0;
using ResolveUnitToken_t = void *(__fastcall *)(const char *token);
using AutoEquipCursor_t = void (__thiscall *)(void *player, int flag);
auto resolve = reinterpret_cast<ResolveUnitToken_t>(
Offsets::FUN_RESOLVE_UNIT_TOKEN);
if (auto *player = resolve("player")) {
if (auto *player = Game::ResolveUnitToken("player")) {
auto equip = reinterpret_cast<AutoEquipCursor_t>(
Offsets::FUN_AUTO_EQUIP_CURSOR_ITEM);
equip(player, 0);
+1 -3
View File
@@ -24,11 +24,9 @@ namespace {
using GetItemBySlot_t = void *(__thiscall *)(void *invMgr, int slot);
using GetVisibleItem_t = void *(__thiscall *)(void *unit, int slot);
using ResolveUnitToken_t = void *(__fastcall *)(const char *token);
void *ResolveUnit(const char *token) {
auto fn = reinterpret_cast<ResolveUnitToken_t>(Offsets::FUN_RESOLVE_UNIT_TOKEN);
return fn(token);
return Game::ResolveUnitToken(token);
}
// Walks the local player's private inventory manager — the same path
+1 -1
View File
@@ -318,7 +318,7 @@ void AppendNonBagOwned(uint64_t *out, int *count) {
}
void FireChanged() {
Event::Custom::Fire(Event::Custom::Lookup(kEvtNewItems), "");
Event::Custom::Fire(_r.Slot(), "");
}
// Login-baseline handler. Runs every frame but does no work once the
+1 -3
View File
@@ -50,12 +50,10 @@ namespace Item::Usable {
namespace {
using ResolveUnitToken_t = void *(__fastcall *)(const char *token);
using SpellIsUsable_t = int(__fastcall *)(const uint8_t *spellRecord, int *outNoMana);
const uint8_t *PlayerDescriptor() {
auto resolve = reinterpret_cast<ResolveUnitToken_t>(Offsets::FUN_RESOLVE_UNIT_TOKEN);
auto *player = static_cast<const uint8_t *>(resolve("player"));
auto *player = static_cast<const uint8_t *>(Game::ResolveUnitToken("player"));
if (player == nullptr)
return nullptr;
return *reinterpret_cast<const uint8_t *const *>(
+4 -5
View File
@@ -344,7 +344,7 @@ StartFn_t g_origStart = nullptr;
// structurally: a new item appears or the ring evicts an old one (shifting
// every index).
void FireFullUpdate() {
Event::Custom::Fire(Event::Custom::Lookup(kFullUpdate), "");
Event::Custom::Fire(_evFullUpdate.Slot(), "");
}
// START_ROLL: let the original build the store node, then read the item's
@@ -373,8 +373,7 @@ void __fastcall Roll_h(void *msg, void *ctx, int a3) {
if (structural)
FireFullUpdate();
if (itemIdx > 0)
Event::Custom::Fire(Event::Custom::Lookup(kRollChanged), "%d%d", itemIdx,
playerIdx);
Event::Custom::Fire(_evRollChanged.Slot(), "%d%d", itemIdx, playerIdx);
}
void __fastcall Won_h(void *ctx, void *msg, int a3) {
@@ -388,7 +387,7 @@ void __fastcall Won_h(void *ctx, void *msg, int a3) {
if (structural)
FireFullUpdate();
if (itemIdx > 0)
Event::Custom::Fire(Event::Custom::Lookup(kRollComplete), "%d", itemIdx);
Event::Custom::Fire(_evRollComplete.Slot(), "%d", itemIdx);
}
void __fastcall Passed_h(void *ctx, void *msg) {
@@ -402,7 +401,7 @@ void __fastcall Passed_h(void *ctx, void *msg) {
if (structural)
FireFullUpdate();
if (itemIdx > 0)
Event::Custom::Fire(Event::Custom::Lookup(kRollComplete), "%d", itemIdx);
Event::Custom::Fire(_evRollComplete.Slot(), "%d", itemIdx);
}
const Game::HookAutoRegister _hookRoll{
+1 -5
View File
@@ -49,8 +49,6 @@ using ClntObjMgrEnumVisibleObjectsCallback_t = int(__fastcall *)(void *ctx,
using ClntObjMgrEnumVisibleObjects_t =
int(__fastcall *)(ClntObjMgrEnumVisibleObjectsCallback_t cb, void *ctx);
using ResolveUnitToken_t = void *(__fastcall *)(const char *token);
// `__thiscall(self, outBuf) → const C3Vector *` at vtable slot 5
// (byte offset `0x14`). Both CGUnit_C and CGPlayer_C populate the
// caller-provided 12-byte buffer with the unit's world position and
@@ -182,9 +180,7 @@ int __fastcall Script_GetNearbyLootableUnits(void *L) {
// Resolve the local player once — the canonical CGPlayer_C the
// engine's own interact-range checks use, distinct from the
// `VAR_LOCAL_PLAYER_PTR` global. Bail if unavailable.
auto ResolveUnitToken = reinterpret_cast<ResolveUnitToken_t>(
Offsets::FUN_RESOLVE_UNIT_TOKEN);
const void *player = ResolveUnitToken("player");
const void *player = Game::ResolveUnitToken("player");
if (player == nullptr)
return 1;
+2 -5
View File
@@ -105,7 +105,6 @@ using ClntObjMgrEnumVisibleObjectsCallback_t = int(__fastcall *)(void *ctx,
uint64_t guid);
using ClntObjMgrEnumVisibleObjects_t =
int(__fastcall *)(ClntObjMgrEnumVisibleObjectsCallback_t cb, void *ctx);
using ResolveUnitToken_t = void *(__fastcall *)(const char *token);
using LootUnit_t = void(__thiscall *)(void *player, void *target,
char useDistanceCheck);
using CloseLootInner_t = void(__fastcall *)(int sendRelease,
@@ -267,7 +266,7 @@ void Complete() {
s_scan.currentGuid = 0;
s_scan.suppressEvents = false;
s_scan.lootMode = false;
Event::Custom::Fire(Event::Custom::Lookup(kEventCompleted), "");
Event::Custom::Fire(_reserve.Slot(), "");
}
void TryStartNext() {
@@ -443,9 +442,7 @@ bool BeginWalk(bool lootMode, size_t maxCount) {
return false;
if (*reinterpret_cast<void *volatile *>(Offsets::VAR_LOCAL_PLAYER_PTR) == nullptr)
return false;
auto Resolve = reinterpret_cast<ResolveUnitToken_t>(
Offsets::FUN_RESOLVE_UNIT_TOKEN);
void *player = Resolve("player");
void *player = Game::ResolveUnitToken("player");
if (player == nullptr)
return false;
+1 -5
View File
@@ -22,8 +22,6 @@ namespace Loot::Unit {
namespace {
using ResolveUnitToken_t = void *(__fastcall *)(const char *token);
// `FUN_CMSG_LOOT_UNIT` is `__thiscall(player, target, useDistanceCheck)`.
// MSVC `__thiscall` puts `player` in ECX and the rest on the stack —
// matching the engine's own callsite at `FUN_0060FA20`. The trailing
@@ -74,9 +72,7 @@ int __fastcall Script_LootUnit(void *L) {
// `ResolveUnitToken("player")` gives the canonical CGPlayer_C the
// engine uses for `__thiscall` calls — distinct from the global
// at `VAR_LOCAL_PLAYER_PTR` (see the comment on that offset).
auto ResolveUnitToken = reinterpret_cast<ResolveUnitToken_t>(
Offsets::FUN_RESOLVE_UNIT_TOKEN);
void *player = ResolveUnitToken("player");
void *player = Game::ResolveUnitToken("player");
if (player == nullptr)
return 0;
+1 -4
View File
@@ -39,7 +39,6 @@ enum class State {
WaitingOpen,
};
using ResolveUnitToken_t = void *(__fastcall *)(const char *token);
using LootUnit_t = void(__thiscall *)(void *player, void *target,
char useDistanceCheck);
// `FUN_005E1AD0` is `__stdcall(uint8_t)`, not `__fastcall` — the
@@ -164,9 +163,7 @@ int __fastcall Script_LootUnitItem(void *L) {
return 1;
}
auto Resolve = reinterpret_cast<ResolveUnitToken_t>(
Offsets::FUN_RESOLVE_UNIT_TOKEN);
void *player = Resolve("player");
void *player = Game::ResolveUnitToken("player");
if (player == nullptr) {
Game::Lua::PushBool(L, false);
return 1;
+4 -6
View File
@@ -223,8 +223,8 @@ int BuildList(LocEntry *out, int maxOut) {
// lesson). The scan is bounded (16 debuff slots) and early-outs when idle, so
// the per-frame cost is negligible.
constexpr const char *kEventAdded = "LOSS_OF_CONTROL_ADDED";
constexpr const char *kEventUpdate = "LOSS_OF_CONTROL_UPDATE";
const Event::Custom::AutoReserve _reserveAdded{"LOSS_OF_CONTROL_ADDED"};
const Event::Custom::AutoReserve _reserveUpdate{"LOSS_OF_CONTROL_UPDATE"};
// Identity of an active effect for frame-to-frame diffing: a CC is keyed by
// spellID, a school interrupt by its school mask (spellID 0).
@@ -255,7 +255,7 @@ void OnWorldTick() {
for (int i = 0; i < n; ++i) {
if (!ContainsKey(g_prev, g_prevCount, cur[i])) {
// A newly-applied effect -> LOSS_OF_CONTROL_ADDED(eventIndex).
Event::Custom::Fire(Event::Custom::Lookup(kEventAdded), "%d", i + 1);
Event::Custom::Fire(_reserveAdded.Slot(), "%d", i + 1);
changed = true;
}
}
@@ -269,15 +269,13 @@ void OnWorldTick() {
// forward-compatible with per-unit tracking and matches modern WoW, whose
// LOSS_OF_CONTROL_UPDATE carries the unit token.
if (changed)
Event::Custom::Fire(Event::Custom::Lookup(kEventUpdate), "%s", "player");
Event::Custom::Fire(_reserveUpdate.Slot(), "%s", "player");
for (int i = 0; i < n; ++i)
g_prev[i] = cur[i];
g_prevCount = n;
}
static const Event::Custom::AutoReserve _reserveAdded{kEventAdded};
static const Event::Custom::AutoReserve _reserveUpdate{kEventUpdate};
static const Tick::WorldTick::AutoSubscribe _tick{&OnWorldTick};
// `C_LossOfControl.GetActiveLossOfControlDataCount()` -> number.
+90
View File
@@ -0,0 +1,90 @@
// 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/>.
// getfenv / setfenv `__environment` protection — Lua 5.1 backport.
//
// A sandbox hands restricted code an environment table E and puts a metatable
// on it carrying `__environment = <safe value>`. From then on, code inside the
// sandbox that calls `getfenv()` gets the safe value instead of E (so it can't
// reach or mutate the real environment), and `setfenv` refuses to change E.
//
// 1.12's engine ALREADY implements this protection — its `getfenv`
// (0x00702AC0) and `setfenv` (0x00702BF8) share one predicate (0x00702BA0)
// that reads a marker off the environment and, when present, substitutes it
// (getfenv) or errors "cannot change a protected environment" (setfenv). But
// 1.12 reads a RAW `__fenv` field on the env TABLE, whereas Lua 5.1 (verified
// against 3.3.5's `FUN_0084F2F0`) reads a RAW `__environment` field off the
// env's METATABLE. Modern addons written for 5.1 use the metatable form.
//
// We co-hook the ONE shared predicate and REPLACE it (the original is never
// called) so both getfenv and setfenv gain the 5.1 semantics at once. The
// reimplementation preserves the predicate's exact stack contract — push the
// function's environment, then push the marker value (or nil), net +2, and
// return whether the marker is non-nil — which getfenv (`settop -2`) and
// setfenv (`settop -3`, then error) both depend on.
//
// Order: metatable `__environment` (5.1) is checked first; the raw `__fenv`
// field (1.12's original) is kept as a fallback, so anything still relying on
// `__fenv` — e.g. compiled FrameXML we can't see on disk — keeps working. The
// change is additive: nothing on disk sets either marker, so for every
// existing addon the predicate returns nil and getfenv/setfenv behave exactly
// as before. It only activates when code deliberately marks an environment.
#include "Game.h"
#include "Offsets.h"
namespace LuaEnv {
namespace {
// Raw engine primitives not exposed via Game::Lua.
using GetFenv_t = void(__fastcall *)(void *L, int idx);
using GetMetatable_t = int(__fastcall *)(void *L, int idx);
const auto GetFenv = reinterpret_cast<GetFenv_t>(Offsets::LUA_GET_FENV);
const auto GetMetatable = reinterpret_cast<GetMetatable_t>(Offsets::LUA_GET_METATABLE);
// Replacement for the shared getfenv/setfenv protection predicate
// (FUN_LUA_ENV_PROTECT_PREDICATE). Contract mirrored exactly from the engine
// original: the function whose environment to test is at stack top (-1); on
// return the stack has grown by 2 (env, marker) and eax is non-zero iff the
// environment is protected.
int __fastcall EnvProtectPredicate_h(void *L) {
GetFenv(L, -1); // push the function's environment -> [.., env]
bool found = false;
if (GetMetatable(L, -1) != 0) { // -> [.., env, metatable]
Game::Lua::PushLString(L, "__environment", 13);
Game::Lua::RawGet(L, -2); // raw metatable["__environment"] -> [.., env, mt, marker]
if (Game::Lua::Type(L, -1) != Game::Lua::TYPE_NIL) {
Game::Lua::Remove(L, -2); // drop the metatable -> [.., env, marker]
found = true;
} else {
Game::Lua::SetTop(L, -3); // drop the nil + metatable -> [.., env]
}
}
if (!found) {
// Fallback: 1.12's original raw `__fenv` field on the env table.
Game::Lua::PushLString(L, "__fenv", 6);
Game::Lua::RawGet(L, -2); // raw env["__fenv"] -> [.., env, marker|nil]
}
return Game::Lua::Type(L, -1) != Game::Lua::TYPE_NIL;
}
// Full replacement — the original predicate is never invoked (nullptr trampoline).
const Game::HookAutoRegister _hook{Offsets::FUN_LUA_ENV_PROTECT_PREDICATE,
reinterpret_cast<void *>(&EnvProtectPredicate_h),
nullptr};
} // namespace
} // namespace LuaEnv
+31
View File
@@ -0,0 +1,31 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
#pragma once
namespace LuaSyntax {
// Pushes the per-addon shared namespace table for `name` onto the Lua stack,
// creating it on first use. The same table object is returned on every call
// for a given name, so the internal addon-args preamble (which hands a file
// its `(addonName, addonTable)` varargs) and the public gated accessor
// `C_AddOns.GetAddOnLocalTable` share one table per addon. Backed by a map in
// the Lua registry, hidden from addons. Net stack effect: +1. `name` must be
// non-null.
//
// This is the ONE place the namespace map is read/created — do not duplicate
// the registry-key or map access elsewhere, or the two paths would hand out
// different tables for the same addon.
void PushAddonNamespace(void *L, const char *name);
} // namespace LuaSyntax
+207
View File
@@ -0,0 +1,207 @@
// 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/>.
// Lua 5.1 string methods (`("asd"):upper()`, `s:format(...)`) on the 5.0 VM.
//
// Lua 5.0 has no string methods AT ALL — the shared string metatable with
// `__index = string` is a 5.1 addition (`createmetatable` in 5.1's
// lstrlib.c). Nothing was stripped from this build: both halves of the
// would-be mechanism match stock 5.0 source exactly —
// * `lua_setmetatable` (0x006F4020) accepts only tables/userdata, so no
// API call can attach a metatable to the string TYPE; and
// * `luaT_gettmbyobj` (0x006F7BD0) hardcodes `&nilobject` for anything
// that isn't a table or userdata, so the VM would never read one.
// So this is the runtime half of the 5.1 backport (the syntax half is
// Transpile.cpp): we transplant 5.1's mechanism at the read side.
//
// Mechanism: co-hook FUN_LUA_INDEX_NONTABLE (0x006F7EE0) — luaV_gettable's
// non-table continuation, the ONE choke every string index reaches (3 call
// sites: luaV_gettable's else-branch + luaV_execute's OP_GETTABLE/OP_SELF
// fallbacks; see Offsets.h). When the indexed object is a string, answer
// with `luaV_gettable(L, <string library table>, key, loop)` — byte-for-byte
// what the original's own TM-table branch would do had gettmbyobj returned
// that table (it passes `loop` through unchanged too, 0x006F7F23). The
// engine's unmodified lookup then does everything, including exact 5.1 miss
// semantics: `("x").nonexistent` → plain table miss → nil, not an error
// (verified: FUN_006F7D60 returns &nilobject when the table has no
// metatable). Non-strings fall through to the original — userdata __index,
// and the stock "attempt to index" error for everything else. `settable`
// on a string is a different continuation (FUN_006F7F40), untouched: still
// an error, which is also 5.1's behavior.
//
// Hotness: cold. Table `__index` dispatch (all frame/OO method traffic)
// reads the table's OWN metatable via FUN_006F7D60/fasttm and never lands
// here; today's only callers are about-to-error non-table indexes. No other
// Octo DLL hooks the Lua VM internals.
//
// State/GC lifecycle: one slot per Lua state (in-game + glue), keyed by the
// state's global_State (`*(L+0x10)`) so coroutine threads — which share G
// with their parent — resolve too. Each capture anchors the string table in
// that state's registry, so the hook can never serve a GC-collected table:
// even if an addon nils/replaces `_G.string`, resolution keeps the snapshot
// (5.1 parity — its metatable pins the open-time table the same way).
// Captures re-run per /reload (ModuleAutoRegister) and per glue boot
// (GlueModuleAutoRegister), overwriting the anchor so the previous table is
// released. Slots are cleared BEFORE their state can die, so a capture can
// never outlive the table it points at: PrepareForReload (called from
// FrameScript_Initialize_h, which fires on /reload AND /logout before the
// world Lua state is reset) clears the game slot, and the in-game
// registration clears the glue slot (once world scripts are loading, the
// glue state is gone; the next glue boot re-captures before any glue Lua
// runs). A cleared slot fails closed — strings error on index exactly as
// stock 1.12 until the re-capture.
//
// Kill switch: `_classicapi_SetStringMethods(false)` (registered on both
// states) makes the hook forward everything to the original resolver —
// exact stock behavior — for "is this backport breaking this addon?"
// diagnosis, mirroring `_classicapi_SetTranspileOption`.
#include "StringMethods.h"
#include "Game.h"
#include "Offsets.h"
#include <cstdint>
namespace LuaSyntax::StringMethods {
namespace {
// `global_State *` slot in lua_State — the per-universe object shared by
// every coroutine thread of a state. Verified: luaT_gettmbyobj reads the
// tmname array at `*(L+0x10) + 0x80`, lua_setmetatable resolves the
// REGISTRY pseudo-index via `*(L+0x10) + 0x30`. Single-consumer offset,
// local per the Offsets.h rule.
constexpr uintptr_t OFF_L_GLOBALSTATE = 0x10;
// This build's TValue is 16 bytes with `tt` FIRST: {tt @ +0, value @ +8} —
// NOT stock 5.0's {value, tt} order. Verified from lua_setmetatable
// (tag = `*tv`, GC pointer = `tv[2]`, Table->metatable at hvalue+0x08 /
// fast-TM flags byte at hvalue+0x06) and the push helpers' 0x10 stride.
struct TValueMirror {
int tt = 0;
int pad0 = 0;
const void *value = nullptr; // Table* when tt == TYPE_TABLE
int pad1 = 0;
};
static_assert(sizeof(TValueMirror) == 16, "TValue is 16 bytes in this build");
// Captured `string` library table for one Lua state. `G` doubles as the
// valid flag — nullptr means "no capture, pass everything through".
struct StateSlot {
const void *G = nullptr;
TValueMirror tv{};
};
StateSlot g_gameSlot;
StateSlot g_glueSlot;
// Runtime kill switch — `_classicapi_SetStringMethods(false)` reverts to
// exact stock behavior (the hook forwards everything). Process-wide; both
// states share it.
bool g_enabled = true;
// Registry key anchoring the captured table against GC (rawset — the
// registry has no metatable). One key per state's registry; re-capture
// overwrites it, releasing the previously anchored table.
constexpr const char *kAnchorKey = "__classicapi_string_lib";
// FUN_LUA_INDEX_NONTABLE / FUN_LUA_V_GETTABLE shared convention:
// __fastcall(L /*ecx*/, tv /*edx*/, key /*stack*/, loop /*stack*/), RET 8,
// result TValue* in eax (see Offsets.h for the disassembly evidence).
using IndexResolve_t = void *(__fastcall *)(void *L, const void *tv, void *key, int loop);
IndexResolve_t g_indexNonTableOriginal = nullptr;
void *__fastcall IndexNonTable_h(void *L, const void *obj, void *key, int loop) {
// 5.1 backport: a string's `__index` is the `string` library table.
if (g_enabled && obj != nullptr &&
*static_cast<const int *>(obj) == Game::Lua::TYPE_STRING) {
const void *G = Game::Read<const void *>(L, OFF_L_GLOBALSTATE);
const StateSlot *slot = nullptr;
if (g_gameSlot.G == G)
slot = &g_gameSlot;
else if (g_glueSlot.G == G)
slot = &g_glueSlot;
if (slot != nullptr && slot->tv.value != nullptr)
return reinterpret_cast<IndexResolve_t>(Offsets::FUN_LUA_V_GETTABLE)(
L, &slot->tv, key, loop);
}
return g_indexNonTableOriginal(L, obj, key, loop);
}
const Game::HookAutoRegister _hook{Offsets::FUN_LUA_INDEX_NONTABLE,
reinterpret_cast<void *>(&IndexNonTable_h),
reinterpret_cast<void **>(&g_indexNonTableOriginal)};
// Captures `_G.string` for the CURRENT state (whatever VAR_LUA_STATE points
// at — the registration callbacks below run inside the right window for
// each) into `slot`, anchoring it in that state's registry.
void CaptureFor(StateSlot &slot) {
slot.G = nullptr;
slot.tv.value = nullptr;
void *L = Game::Lua::State();
if (L == nullptr)
return;
Game::Lua::PushString(L, "string");
Game::Lua::GetTable(L, Game::Lua::GLOBALS_INDEX); // [stringlib?]
if (Game::Lua::Type(L, -1) == Game::Lua::TYPE_TABLE) {
Game::Lua::PushString(L, kAnchorKey); // [tbl, key]
Game::Lua::PushValue(L, -2); // [tbl, key, tbl]
Game::Lua::RawSet(L, Game::Lua::REGISTRY_INDEX); // registry[key]=tbl. [tbl]
slot.tv.tt = Game::Lua::TYPE_TABLE;
slot.tv.value = Game::Lua::ToPointer(L, -1); // hvalue (Table*)
slot.G = Game::Read<const void *>(L, OFF_L_GLOBALSTATE);
}
Game::Lua::SetTop(L, -2); // pop
}
// `_classicapi_SetStringMethods(enabled)` — diagnostic kill switch. While
// false, string index falls through to the original resolver and errors as
// stock 1.12. Use only to answer "is this backport breaking this addon?".
int __fastcall Script_SetStringMethods(void *L) {
g_enabled = Game::Lua::ToBoolean(L, 1) != 0;
return 0;
}
void RegisterLuaFunctions() {
// World scripts are loading ⇒ the glue state is gone (destroyed on the
// glue→world handoff). Clear its slot so a future glue global_State
// that lands at the same address can never match this stale capture.
g_glueSlot.G = nullptr;
g_glueSlot.tv.value = nullptr;
CaptureFor(g_gameSlot);
Game::Lua::RegisterGlobalFunction("_classicapi_SetStringMethods",
&Script_SetStringMethods);
}
void RegisterGlueFunctions() {
CaptureFor(g_glueSlot);
Game::Lua::RegisterGlueFunction("_classicapi_SetStringMethods",
&Script_SetStringMethods);
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
const Game::GlueModuleAutoRegister _glueAutoreg{&RegisterGlueFunctions};
} // namespace
void PrepareForReload() {
// The world Lua state is about to be reset (/reload) or destroyed
// (/logout); its string table may not survive. Fail closed until the
// next LoadScriptFunctions re-capture — a cleared slot means strings
// error on index exactly as stock, and nothing can read a freed table.
g_gameSlot.G = nullptr;
g_gameSlot.tv.value = nullptr;
}
static const Game::ReloadAutoRegister _reloadReg{&PrepareForReload};
} // namespace LuaSyntax::StringMethods
+25
View File
@@ -0,0 +1,25 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
#pragma once
namespace LuaSyntax::StringMethods {
// Forget the in-game state's captured `string` table. Called from
// FrameScript_Initialize_h BEFORE the engine resets (/reload) or destroys
// (/logout) the world Lua state, so the capture can never outlive the table
// it points at. Fails closed: until the ModuleAutoRegister re-capture at the
// next LoadScriptFunctions, strings error on index exactly as stock 1.12.
void PrepareForReload();
} // namespace LuaSyntax::StringMethods
File diff suppressed because it is too large Load Diff
+1 -4
View File
@@ -377,15 +377,12 @@ void PushTiles(void *L, const ResolvedOverlay *r, const char *basePath,
}
}
using ResolveUnitToken_t = void *(__fastcall *)(const char *token);
// The local player's explored-areas bitfield (`[player+0xE68] + 0xE6C`), or
// null before the player is resident. Bit `AreaBit` set == that AreaTable
// area is explored. Same storage the engine's discovered-overlay rebuild
// (FUN_004a67a0) reads.
const uint8_t *ExplorationBits() {
auto fn = reinterpret_cast<ResolveUnitToken_t>(Offsets::FUN_RESOLVE_UNIT_TOKEN);
auto *player = static_cast<const uint8_t *>(fn("player"));
auto *player = static_cast<const uint8_t *>(Game::ResolveUnitToken("player"));
if (player == nullptr)
return nullptr;
auto *sub = Game::Read<const uint8_t *>(player, Offsets::OFF_CGPLAYER_INFO);
+2
View File
@@ -433,4 +433,6 @@ void PrepareForReload() {
CancelJob(job);
}
static const Game::ReloadAutoRegister _reloadReg{&PrepareForReload};
} // namespace Model::DisplayInfo
+13 -15
View File
@@ -59,13 +59,9 @@ namespace NamePlate::Events {
namespace {
constexpr const char *kEventCreated = "NAME_PLATE_CREATED";
constexpr const char *kEventUnitAdded = "NAME_PLATE_UNIT_ADDED";
constexpr const char *kEventUnitRemoved = "NAME_PLATE_UNIT_REMOVED";
const Event::Custom::AutoReserve _r1{kEventCreated};
const Event::Custom::AutoReserve _r2{kEventUnitAdded};
const Event::Custom::AutoReserve _r3{kEventUnitRemoved};
const Event::Custom::AutoReserve _evtCreated{"NAME_PLATE_CREATED"};
const Event::Custom::AutoReserve _evtUnitAdded{"NAME_PLATE_UNIT_ADDED"};
const Event::Custom::AutoReserve _evtUnitRemoved{"NAME_PLATE_UNIT_REMOVED"};
// Previous tick's snapshot — GUID → nameplate-frame pointer for each
// nameplated unit. Compared against the next tick's walk to compute
@@ -112,14 +108,14 @@ int AssignSlot(uint64_t guid) {
return static_cast<int>(g_slots.size() - 1);
}
// Fire `eventName` with a pre-formatted string as `arg1`. The engine
// Fire `event` with a pre-formatted string as `arg1`. The engine
// dispatcher's `%s` format code pushes the C string into `_G.arg1`
// as a Lua string — no escaping concerns for our own input
// (`"nameplateN"`).
void FireWithString(const char *eventName, const char *value) {
void FireWithString(const Event::Custom::AutoReserve &event, const char *value) {
if (value == nullptr)
return;
const int slot = Event::Custom::Lookup(eventName);
const int slot = event.Slot();
if (slot < 0)
return;
Event::Custom::Fire(slot, "%s", value);
@@ -172,10 +168,10 @@ using LuaRefRef_t = int(__fastcall *)(void *L, int t);
using LuaRefUnref_t = void(__fastcall *)(void *L, int t, int ref);
using LuaRawGetI_t = void(__fastcall *)(void *L, int t, int n);
void FireWithFrame(const char *eventName, void *frame) {
void FireWithFrame(const Event::Custom::AutoReserve &event, void *frame) {
if (frame == nullptr)
return;
const int slot = Event::Custom::Lookup(eventName);
const int slot = event.Slot();
if (slot < 0)
return;
@@ -229,7 +225,7 @@ void OnWorldTick() {
// event handler.
for (const auto &kv : g_currentTickPlates) {
if (g_seenPlates.insert(kv.second).second)
FireWithFrame(kEventCreated, const_cast<void *>(kv.second));
FireWithFrame(_evtCreated, const_cast<void *>(kv.second));
if (g_lastTickPlates.find(kv.first) == g_lastTickPlates.end()) {
const int slot = AssignSlot(kv.first);
// Watch this unit's fields so UNIT_HEALTH/UNIT_AURA/… fire with
@@ -237,7 +233,7 @@ void OnWorldTick() {
// target/party/raid tokens, not nameplates).
Unit::TokenObserver::Register(kv.first, &NamePlateFieldCb);
char tokenBuf[24];
FireWithString(kEventUnitAdded,
FireWithString(_evtUnitAdded,
FormatNamePlateToken(tokenBuf, sizeof tokenBuf, slot + 1));
}
}
@@ -254,7 +250,7 @@ void OnWorldTick() {
continue;
const int oneBased = static_cast<int>(it - g_slots.begin()) + 1;
char tokenBuf[24];
FireWithString(kEventUnitRemoved,
FireWithString(_evtUnitRemoved,
FormatNamePlateToken(tokenBuf, sizeof tokenBuf, oneBased));
Unit::TokenObserver::Unregister(kv.first, &NamePlateFieldCb);
*it = 0; // free the slot (no shift of survivors)
@@ -294,6 +290,8 @@ void PrepareForReload() {
g_slots.clear();
}
static const Game::ReloadAutoRegister _reloadReg{&PrepareForReload};
// Exposed via `nameplate/Walk.h` so the `nameplateN` token resolver
// in `unit/TokenExtensions.cpp` can map an index to a GUID without seeing
// the internal array. Returns 0 for an out-of-range OR currently-free slot.
+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).
+2 -3
View File
@@ -109,8 +109,7 @@ int __fastcall EquipSlotChanged_cb(uint32_t fieldOffset, uint32_t /*size*/,
const int slot0 =
static_cast<int>(fieldOffset - Offsets::OFF_DESC_PLAYER_EQUIP_FIRST) >> 3;
if (slot0 >= 0 && slot0 < Offsets::DESC_PLAYER_EQUIP_SLOTS) {
Event::Custom::FireIdSuccess(Event::Custom::Lookup(kEvtEquipmentChanged),
slot0 + 1,
Event::Custom::FireIdSuccess(_r.Slot(), slot0 + 1,
SlotHasItem(guidLo, guidHi, slot0));
// Gear swap changes item +healing and Spirit/Armor — invalidate the
// GetSpellBonusHealing cache.
@@ -225,7 +224,7 @@ void __fastcall AlertsRecompute_h() {
return;
}
if (changed)
Event::Custom::Fire(Event::Custom::Lookup(kEvtDurability), "");
Event::Custom::Fire(_r2.Slot(), "");
}
static const Game::HookAutoRegister _alertsHook{
+1 -2
View File
@@ -68,8 +68,7 @@ void __fastcall BindPointUpdateHandler_h(void *packetBuffer) {
static_cast<uintptr_t>(Offsets::VAR_IN_WORLD)) == 0)
return;
const int slot = Event::Custom::Lookup(kEventName);
Event::Custom::Fire(slot, "");
Event::Custom::Fire(_reserve.Slot(), "");
}
} // namespace

Some files were not shown because too many files have changed in this diff Show More