Compare commits

...
56 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
129 changed files with 8761 additions and 2219 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>
+29
View File
@@ -84,3 +84,32 @@ if C_AddOns.DoesAddOnExist("Puppeteer") then
CAPI_MouseoverClearedCompat(PTEnemyUpdater)
end)
end
-- Cartographer_Notes gates a WorldMap release-spirit hook behind an
-- `if lua51` probe, treating a 5.1-capable Lua as a modern client that has
-- the WorldMapDeathRelease frame. Our 5.1 syntax backport makes the probe
-- pass, so Cartographer takes the modern branch and hooks a frame 1.12's
-- WorldMap never had (WorldMapDeathRelease:SetScript in OnEnable) -> nil
-- index. Provide an inert, hidden stub so the hook succeeds and does nothing
-- -- the same outcome vanilla had, where the branch never ran. The embedded
-- !!!ClassicAPI addon loads first, so the frame exists before Cartographer's
-- OnEnable.
if C_AddOns.DoesAddOnExist("Cartographer") and not WorldMapDeathRelease then
CreateFrame("Button", "WorldMapDeathRelease", WorldMapButton or UIParent):Hide()
end
-- ModernSpellBook (Spellbook\MSB_Spellbook.lua) re-hooks its "show all ranks"
-- checkbox by calling HookScript as a GLOBAL -- HookScript(frame, "OnClick",
-- handler). That global never existed on any client: HookScript has always
-- been the widget method frame:HookScript, which our DLL provides. MSB even
-- guards the call with `if checkbox.HookScript` (the method IS present), then
-- calls the global (nil) -> "attempt to call global 'HookScript'". Define the
-- global so the call resolves, forwarding to the method. Gated on the addon
-- being present so the global namespace stays clean otherwise.
if C_AddOns.DoesAddOnExist("ModernSpellBook") and not HookScript then
function HookScript(frame, script, handler)
if frame and frame.HookScript then
frame:HookScript(script, handler)
end
end
end
+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"
)
+85 -50
View File
@@ -1,19 +1,49 @@
# ClassicAPI
A small DLL for World of Warcraft 1.12.1 (Vanilla / Turtle WoW) that adds a
collection of Lua API calls Blizzard never exposed in 1.12 but which make
addon authoring noticeably less painful — primarily for backporting addons
written against later API versions (3.3.5+) where these calls do exist.
ClassicAPI is a DLL for World of Warcraft 1.12.1 (Vanilla / Turtle WoW). It
backports a large part of the modern WoW API — 550+ functions and 50+ events,
plus much of Lua 5.1 — into the 1.12 client, so addons written for later
versions (3.3.5+ or Classic Era) run with little or no change.
The DLL hooks the FrameScript engine after WoW boots and registers its
extensions through the same in-engine mechanisms WoW uses for its own Lua
functions. No companion addon is required.
It hooks the engine after WoW boots and registers everything the same way WoW
registers its own Lua functions. Nothing else to install — the DLL is
self-contained.
## What's added
## Highlights
Full per-function reference: **[docs/API.md](docs/API.md)**.
The headline features. Each one takes effect as soon as the DLL loads, so your
existing addons benefit without any code changes. The complete per-function
reference is in **[docs/API.md](docs/API.md)**.
### Calls
### Lua 5.1 compatibility
The flagship feature: run modern Lua 5.1 addon code on 1.12's Lua 5.0 VM.
| Feature | Effect |
|---------|--------|
| [Syntax](docs/API.md#lua-51-syntax) | Compiles the Lua 5.1 length (`#`), modulo (`%`), `...`-expression, `0x` hex-literal, and leveled long bracket (`[=[ ]=]`) syntax that vanilla's Lua 5.0 rejects, by rewriting addon source before it compiles. Each addon file also receives its `(name, table)` through `...` (`local name, tbl = ...`). |
| [String methods](docs/API.md#string-methods-supper-sformat) | Every string value accepts method calls — `("asd"):upper()`, `("%d gold"):format(n)`, `msg:match("^!(%w+)")` — resolving through the `string` table, the way Lua 5.1 works. Works on literals and variables, in-world and on the login screen, and inside coroutines. |
| [Script-handler arguments](docs/API.md#setmodernscriptargsenable--getmodernscriptargs) | Frame-script handlers receive their values as positional arguments — `OnMouseWheel(self, delta)`, `OnClick(self, button)`, `OnEvent(self, event, ...)`, etc. — so modern addon ports work unmodified. The `this` / `arg1` globals stay set. A handler that declares no parameters is unaffected. A handler that declared a parameter and expected nil now receives its real value. On by default. `SetModernScriptArgs(false)` restores exact vanilla dispatch. |
### Modern client behaviors
| Feature | Effect |
|---------|--------|
| Inline textures | Draws inline texture markup (`\|T…\|t`) as icons in FontStrings, chat, and tooltips. This covers item and spell icons, raid-target markers, and the coin icons in money strings. `GetStringWidth` and `GetStringHeight` count the icons, so measured width and text wrapping stay correct. Done in pure C++ by hooking the engine's text pipeline — no addon. |
| Tooltip line cap | Lifts `GameTooltip`'s hard 30-line limit to 60 for every `GameTooltipTemplate` frame (`GameTooltip`, `ShoppingTooltip1/2`, `ItemRefTooltip`, AtlasLoot, …). Stat-heavy tooltips and comparison blocks (e.g. pfUI's eqcompare) no longer have their extra lines silently dropped. Done in pure C++ by growing the engine's FontString pool at tooltip-creation time. |
| [Event-driven nameplates](docs/API.md#nameplate) | The modern `C_NamePlate` API, driven by real events. `NAME_PLATE_UNIT_ADDED` / `NAME_PLATE_UNIT_REMOVED` fire as plates appear and vanish, `nameplate1`..`nameplateN` tokens resolve with every `UnitX` function (and fire `UNIT_HEALTH`, `UNIT_AURA`, … as `arg1 == "nameplateN"`), and `C_NamePlate.GetNamePlateForUnit` / `GetNamePlates` hand back the live frames. |
| [Focus target](docs/API.md#focus) | A sticky focus unit. `FocusUnit("target")` pins it and `ClearFocus()` drops it, with `PLAYER_FOCUS_CHANGED` on every change. The `focus` / `focustarget` tokens resolve with every `UnitX` function and fire unit events (`UNIT_HEALTH`, `UNIT_AURA`, … as `arg1 == "focus"`), and the predefined `FOCUSTARGET` / `TARGETFOCUS` keybinds are ready to set. |
| [Retail-like `/reload`](docs/API.md#reload-picks-up-new-addons-and-new-files) | `/reload` picks up addon changes made while the game runs. A new folder under `Interface\AddOns\` registers and loads as a normal addon, new files added to an existing addon's TOC load, a newly installed addon's first SavedVariables save survives `/reload`, `##` metadata edits (`## SavedVariables:`, `## Dependencies:`, `## Title:`, …) take effect, and a deleted addon folder drops from the addon list. |
| [Multi-flavor & conditional TOC](docs/API.md#conditional-and-multi-flavor-toc-loading) | Loads modern multi-flavor addons that ship one folder. Selects a version-specific TOC (`<Name>_ClassicAPI.toc` or `<Name>_Turtle.toc`) and the matching keybinding file (`Bindings_ClassicAPI.xml` / `Bindings_Turtle.xml`), accepts a comma-separated `## Interface:` version list (compatible when it includes the client version `11200`), and honors per-line `[AllowLoadGameType]` / `[AllowLoadTextLocale]` conditions and `[Family]` / `[Game]` / `[TextLocale]` path variables inside a TOC. |
## Full API reference
Everything ClassicAPI adds, in full. Each section is collapsed — click a
heading to expand it. For signatures and return values, see the per-function
reference in **[docs/API.md](docs/API.md)**.
<details>
<summary><b>In-game Lua calls</b> — 550+ functions across ~60 namespaces</summary>
| Namespace | Calls |
|-----------|-------|
@@ -25,7 +55,7 @@ Full per-function reference: **[docs/API.md](docs/API.md)**.
| [ChatBubbles](docs/API.md#chatbubbles) | `C_ChatBubbles.GetAllChatBubbles` |
| [Class](docs/API.md#class) | `FillLocalizedClassList` |
| [ColorUtil](docs/API.md#colorutil) | `C_ColorUtil.ConvertRGBToHSV`, `C_ColorUtil.ConvertHSVToRGB`, `C_ColorUtil.ConvertHSVToHSL`, `C_ColorUtil.ConvertHSLToHSV`, `C_ColorUtil.ConvertHSLToRGB`, `C_ColorUtil.GenerateTextColorCode`, `C_ColorUtil.WrapTextInColor`, `C_ColorUtil.WrapTextInColorCode` |
| [Combat](docs/API.md#combat) | `InCombatLockdown` |
| [Combat](docs/API.md#combat) | `InCombatLockdown`, `StartAttack`, `StopAttack` |
| [CVar](docs/API.md#cvar) | `C_CVar.GetCVarBool` |
| [Cursor](docs/API.md#cursor) | `GetCursorInfo` |
| [Container](docs/API.md#container) | `C_Container.CalculateTotalNumberOfFreeBagSlots`, `C_Container.GetContainerItemCharges`, `C_Container.GetContainerItemDurability`, `C_Container.GetContainerItemID`, `C_Container.GetContainerItemInfo`, `C_Container.GetContainerItemRepairCost`, `C_Container.GetContainerNumFreeSlots`, `C_Container.GetItemCooldown`, `C_Container.HasContainerItem`, `C_Container.IsContainerItemOpenable`, `C_Container.MoveItem`, `C_Container.PlayerHasHearthstone`, `C_Container.SwapItems`, `C_Container.UseHearthstone`, `GetItemCooldown` |
@@ -37,7 +67,7 @@ Full per-function reference: **[docs/API.md](docs/API.md)**.
| [Expansion](docs/API.md#expansion) | `ClassicExpansionAtLeast`, `ClassicExpansionAtMost`, `GetClassicExpansionLevel` |
| [Faction](docs/API.md#faction) | `C_Reputation.GetFactionDataByIndex`, `C_Reputation.GetFactionStandings`, `C_Reputation.GetLastStandingChange`, `C_Reputation.GetWatchedFactionData`, `C_Reputation.SetWatchedFactionByID`, `GetFactionIDByIndex`, `GetFactionInfoByID`, `GetFactionParentID` |
| [Focus](docs/API.md#focus) | `ClearFocus`, `FocusUnit` |
| [Frame](docs/API.md#frame) | `region:SetPoint("point")` (one-arg form), `region:SetSize`, `region:GetSize`, `region:IsMouseOver`, `region:GetRect`, `region:IsDragging`, `GetMouseFoci`, `frame:SetShown`, `fontstring:GetStringHeight`, `fontstring:GetUnboundedStringWidth`, `fontstring:GetWrappedWidth`, `fontstring:GetNumLines`, `fontstring:GetLineHeight`, `fontstring:IsTruncated`, `fontstring:SetMaxLines`, `fontstring:GetMaxLines`, `fontstring:SetFormattedText`, `texture:SetRotation`, `texture:GetRotation`, `texture:SetVertexOffset`, `texture:GetVertexOffset`, `texture:SetColorTexture`, `fontstring:SetRotation`, `fontstring:GetRotation`, `frame:SetResizeBounds`, `frame:HookScript`, `frame:IsEventRegistered`, `frame:GetEffectiveAlpha`, `frame:SetAttribute`, `frame:SetAttributeNoHandler`, `frame:ClearAttribute`, `frame:GetAttribute`, `OnAttributeChanged` (script), `SetModernScriptArgs`, `GetModernScriptArgs` |
| [Frame](docs/API.md#frame) | `region:SetPoint("point")` (one-arg form), `region:SetSize`, `region:GetSize`, `region:IsMouseOver`, `region:GetRect`, `region:IsDragging`, `GetMouseFoci`, `frame:SetShown`, `fontstring:GetStringHeight`, `fontstring:GetUnboundedStringWidth`, `fontstring:GetWrappedWidth`, `fontstring:GetNumLines`, `fontstring:GetLineHeight`, `fontstring:IsTruncated`, `fontstring:SetMaxLines`, `fontstring:GetMaxLines`, `fontstring:SetFormattedText`, `texture:SetRotation`, `texture:GetRotation`, `texture:SetVertexOffset`, `texture:GetVertexOffset`, `texture:SetColorTexture`, `texture:SetMask`, `frame:CreateMaskTexture`, `texture:AddMaskTexture`, `texture:RemoveMaskTexture`, `texture:GetNumMaskTextures`, `texture:GetMaskTexture`, `fontstring:SetRotation`, `fontstring:GetRotation`, `editBox:SetCursorPosition`, `editBox:GetCursorPosition`, `editBox:GetUTF8CursorPosition`, `editBox:ClearHighlightText`, `editBox:HasFocus`, `editBox:HasText`, `editBox:SetHighlightColor`, `editBox:GetHighlightColor`, `editBox:ClearHistory`, `frame:SetResizeBounds`, `frame:HookScript`, `frame:IsEventRegistered`, `frame:GetEffectiveAlpha`, `frame:SetAttribute`, `frame:SetAttributeNoHandler`, `frame:ClearAttribute`, `frame:GetAttribute`, `OnAttributeChanged` (script), `SetModernScriptArgs`, `GetModernScriptArgs`, `PreClick` (script), `PostClick` (script), `GetClickFrame` |
| [FriendList](docs/API.md#friendlist) | `C_FriendList.GetFriendInfo`, `C_FriendList.GetFriendInfoByIndex`, `C_FriendList.GetNumFriends`, `C_FriendList.GetNumOnlineFriends`, `C_FriendList.GetNumWhoResults`, `C_FriendList.GetWhoInfo`, `C_FriendList.IsFriend`, `C_FriendList.IsIgnored`, `C_FriendList.IsIgnoredByGuid`, `C_FriendList.IsWhoQueryPending`, `C_FriendList.SendWhoQueryByName`, `C_FriendList.SetFriendNotes`, `C_FriendList.SetFriendNotesByIndex` |
| [GameObject](docs/API.md#gameobject) | `C_GameObjectInfo.GetGameObjectInfoByID`, `C_GameObjectInfo.RequestLoadGameObjectByID`, `ClosestGameObjectPosition` |
| [Glue](docs/API.md#glue) | `C_Glue.IsOnGlueScreen` |
@@ -63,28 +93,30 @@ Full per-function reference: **[docs/API.md](docs/API.md)**.
| [Quest](docs/API.md#quest) | `C_QuestLog.GetNumQuestObjectives`, `C_QuestLog.GetQuestDetails`, `C_QuestLog.GetHeaderIndexForQuest`, `C_QuestLog.GetLogIndexForQuestID`, `C_QuestLog.GetQuestIDForLogIndex`, `C_QuestLog.GetTitleForQuestID`, `C_QuestLog.IsOnQuest`, `C_QuestLog.IsQuestDataCachedByID`, `C_QuestLog.IsUnitOnQuest`, `C_QuestLog.RequestLoadQuestByID`, `GetQuestLogLeaderBoardID` |
| [Spell](docs/API.md#spell) | `C_Spell.CancelSpellByID`, `C_Spell.CastAtCursor`, `C_Spell.CastAtUnit`, `C_Spell.CastingInfo`, `C_Spell.ChannelInfo`, `C_Spell.DoesSpellExist`, `C_Spell.GetSchoolString`, `C_Spell.GetSpellCooldown`, `C_Spell.GetSpellDescription`, `C_Spell.GetSpellDispelType`, `C_Spell.GetSpellEffectMechanics`, `C_Spell.GetSpellInfo`, `C_Spell.GetSpellLevelInfo`, `C_Spell.GetSpellLink`, `C_Spell.GetSpellMechanicByID`, `C_Spell.GetSpellName`, `C_Spell.GetSpellPowerCost`, `C_Spell.GetSpellRadius`, `C_Spell.GetSpellReagents`, `C_Spell.GetSpellRequiredTargetLevel`, `C_Spell.GetSpellSubtext`, `C_Spell.GetSpellTexture`, `C_Spell.IsAutoAttackSpell`, `C_Spell.IsCurrentSpell`, `C_Spell.IsNextMeleeSpell`, `C_Spell.IsRangedAutoAttackSpell`, `C_Spell.IsSelfBuff`, `C_Spell.IsSpellHarmful`, `C_Spell.IsSpellHelpful`, `C_Spell.IsSpellInRange`, `C_Spell.IsSpellPassive`, `C_Spell.IsSpellUsable`, `C_Spell.ResetsMeleeSwing`, `C_Spell.SpellHasRange`, `C_Spell.UnitCastingInfo`, `C_Spell.UnitChannelInfo`, `CanDualWield`, `CancelSpellByName`, `CastSpellNoToggle`, `GetCraftSpellID`, `GetSpellBonusDamage`, `GetSpellBonusHealing`, `GetSpellInfo`, `GetSpellLink`, `GetSpellRadius`, `GetSpellRequiredTargetLevel`, `GetSpellSchool`, `IsHarmfulSpell`, `IsHelpfulSpell`, `IsPassiveSpell`, `IsPlayerSpell`, `IsSpellKnown`, `IsUsableSpell`, `SpellHasRange` |
| [SpellBook](docs/API.md#spellbook) | `C_SpellBook.GetCurrentLevelSpells`, `C_SpellBook.GetSkillLineName`, `C_SpellBook.GetSkillLineRank`, `C_SpellBook.GetSpellBookItemInfo`, `C_SpellBook.GetSpellLevelLearned`, `C_SpellBook.GetSpellSkillLine`, `C_SpellBook.IsAutoAttackSpellBookItem`, `C_SpellBook.IsRangedAutoAttackSpellBookItem`, `FindSpellBookSlotByID` |
| [State](docs/API.md#state) | `CancelShapeshiftForm`, `Dismount`, `GetMirrorTimerInfo`, `GetMirrorTimerProgress`, `GetShapeshiftFormID`, `IsAssistingRitual`, `IsFalling`, `IsIndoors`, `IsInGroup`, `IsInRaid`, `IsLoggedIn`, `IsMounted`, `IsOutdoors`, `IsStealthed`, `IsSwimming` |
| [State](docs/API.md#state) | `CancelShapeshiftForm`, `Dismount`, `GetMirrorTimerInfo`, `GetMirrorTimerProgress`, `GetShapeshiftFormID`, `GetSheathState`, `IsAssistingRitual`, `IsFalling`, `IsIndoors`, `IsInGroup`, `IsInRaid`, `IsLoggedIn`, `IsMounted`, `IsOutdoors`, `IsStealthed`, `IsSwimming` |
| [System](docs/API.md#system) | `CopyToClipboard`, `GetPhysicalScreenSize` |
| [Talent](docs/API.md#talent) | `GetTalentIDByIndex`, `GetTalentSpellID` |
| [Targeting](docs/API.md#targeting) | `GetPlayerFacing`, `TargetDirectionEnemy`, `TargetDirectionFriend`, `TargetNearest`, `TargetNearestEnemyPlayer`, `TargetNearestFriendPlayer` |
| [TaxiMap](docs/API.md#taximap) | `C_TaxiMap.GetTaxiNodesForMap`, `C_TaxiMap.GetAllTaxiNodes`, `C_TaxiMap.GetTaxiPaths`, `C_TaxiMap.GetTaxiPathWaypoints`, `C_TaxiMap.GetTaxiRoute` |
| [Time](docs/API.md#time) | `C_DateAndTime.AdjustTimeByDays`, `C_DateAndTime.AdjustTimeByMinutes`, `C_DateAndTime.CompareCalendarTime`, `C_DateAndTime.GetCalendarTimeFromEpoch`, `C_DateAndTime.GetCurrentCalendarTime`, `C_DateAndTime.GetSecondsUntilDailyReset`, `C_DateAndTime.GetServerTimeLocal`, `C_Timer.After`, `C_Timer.NewTicker`, `C_Timer.NewTimer`, `GetServerTime`, `GetTimeCached` |
| [Totem](docs/API.md#totem) | `GetTotemInfo`, `GetTotemTimeLeft`, `GetTotemDuration`, `TargetTotem` |
| [Tracking](docs/API.md#tracking) | `GetNumTrackingTypes`, `GetTrackingInfo`, `SetTracking` |
| [TradeSkillUI](docs/API.md#tradeskillui) | `C_TradeSkillUI.GetTradeSkillListLink`, `C_TradeSkillUI.GetCraftListLink`, `C_TradeSkillUI.GetTradeSkillListRecipes` |
| [UIColor](docs/API.md#uicolor) | `C_UIColor.GetColors` |
| [Unit](docs/API.md#unit) | `ClosestUnitPosition`, `GetUnitSpeed`, `UnitClassBase`, `UnitCreatureFamilyID`, `UnitCreatureID`, `UnitCreatureTypeID`, `UnitDistanceSquared`, `UnitGUID`, `UnitHealthMissing`, `UnitInLineOfSight`, `UnitInRange`, `UnitIsAFK`, `UnitIsDND`, `UnitIsFeignDeath`, `UnitIsInMyGuild`, `UnitIsMinion`, `UnitIsOtherPlayersPet`, `UnitIsPet`, `UnitIsPossessed`, `UnitOwnerGUID`, `UnitPosition`, `UnitPower`, `UnitPowerMax`, `UnitPowerMissing`, `UnitPowerType`, `UnitRaceBase`, `UnitSpellHaste`, `UnitSpellTargetName`, `UnitStandState`, `UnitSubName`, `UnitTokenFromGUID` |
| [UnitAuras](docs/API.md#unitauras) | `C_UnitAuras.GetAuraDataByIndex`, `C_UnitAuras.GetAuraDataBySpellName`, `C_UnitAuras.GetAuraDispelTypeColor`, `C_UnitAuras.GetBuffDataByIndex`, `C_UnitAuras.GetDebuffDataByIndex`, `C_UnitAuras.GetPlayerAuraBySpellID`, `C_UnitAuras.GetUnitAuraBySpellID`, `C_UnitAuras.GetUnitAuras`, `C_UnitAuras.RegisterAuraDurationModifierByTrigger`, `C_UnitAuras.RegisterComboDuration` |
| [UnitAuras](docs/API.md#unitauras) | `C_UnitAuras.GetAuraDataByIndex`, `C_UnitAuras.GetAuraDataBySpellName`, `C_UnitAuras.GetAuraDispelTypeColor`, `C_UnitAuras.GetBuffDataByIndex`, `C_UnitAuras.GetDebuffDataByIndex`, `C_UnitAuras.GetPlayerAuraBySpellID`, `C_UnitAuras.GetUnitAuraBySpellID`, `C_UnitAuras.GetUnitAuras`, `C_UnitAuras.RegisterAuraDurationModifierByTrigger`, `C_UnitAuras.RegisterComboDuration`, `C_UnitAuras.UnitAura`, `C_UnitAuras.UnitBuff`, `C_UnitAuras.UnitDebuff` |
| [VoiceChat](docs/API.md#voicechat) | `C_VoiceChat.GetTtsVoices`, `C_VoiceChat.GetRemoteTtsVoices`, `C_VoiceChat.SpeakText`, `C_VoiceChat.StopSpeakingText`, `C_TTSSettings.GetSpeechRate`, `C_TTSSettings.GetSpeechVolume`, `C_TTSSettings.GetSpeechVoiceID`, `C_TTSSettings.GetVoiceOptionName`, `C_TTSSettings.SetSpeechRate`, `C_TTSSettings.SetSpeechVolume`, `C_TTSSettings.SetVoiceOption`, `C_TTSSettings.SetVoiceOptionByName`, `C_TTSSettings.SetDefaultSettings`, `C_TTSSettings.RefreshVoices` |
| [XMLUtil](docs/API.md#xmlutil) | `C_XMLUtil.DoesTemplateExist`, `C_XMLUtil.GetTemplateInfo`, `C_XMLUtil.GetTemplates` |
### GlueXML calls
</details>
<details>
<summary><b>GlueXML calls</b> — login / realm-select / character-select screens</summary>
Registered on the **glue** Lua state (the engine that runs the login,
realm-select, and character-select screens). The persistence entries
in the first row are *glue-only* — they exist to support GlueXML
patches that need a small persistence surface across sessions, since
vanilla 1.12 glue ships no general-purpose persistence API beyond
`GetSavedAccountName`/`SetSavedAccountName` (saturated by autologin).
patches that need a small persistence surface across sessions.
The rest of the table is in-game calls that we also mirror onto
the glue state because GlueXML had no way to reach them otherwise.
@@ -97,10 +129,13 @@ the glue state because GlueXML had no way to reach them otherwise.
| Script | `RunScript` (compile and run a Lua chunk in the glue state's globals — useful for slash-command-style helpers in GlueXML) |
| State | `IsLoggedIn` |
### Macros
</details>
<details>
<summary><b>Macros</b> — engine-level parsing extensions</summary>
Engine-level extensions to macro parsing and dispatch — no new Lua
functions, just behavior the stock 1.12 engine didn't have. See the
functions. See the
[Macros section in the Lua reference](docs/API.md#macros) for details.
| Form | What it does |
@@ -109,7 +144,10 @@ functions, just behavior the stock 1.12 engine didn't have. See the
| `CastSpellByName("<spellID>")` | Same — numeric strings resolve through the engine's name resolver |
| `CastSpellNoToggle("<name>")` in a macro | Engine's macro parser now recognizes it as a primary-spell line, so the macro slot in an addon like pfUI highlights when its spell is auto-repeating or its self-aura is active |
### Console commands
</details>
<details>
<summary><b>Console commands</b> — developer console (<code>-console</code>)</summary>
Registered on the engine's developer console (the `~` console available
when launching with `-console`), not as Lua functions. See the
@@ -121,7 +159,10 @@ when launching with `-console`), not as Lua functions. See the
| `ExportInterfaceFiles art` | Extracts Blizzard's UI art (`.blp`/`.tga`) from the MPQs to `BlizzardInterfaceArt\` |
| `ExportDBCFiles` | Extracts the client's `.dbc` tables from the MPQs to `DBFilesClient\` |
### Events
</details>
<details>
<summary><b>Events</b> — new events fired to addons</summary>
| Event | Payload |
|-------|---------|
@@ -177,11 +218,14 @@ when launching with `-console`), not as Lua functions. See the
| `VOICE_CHAT_TTS_PLAYBACK_FAILED` | `status, utteranceID, destination` |
| `VOICE_CHAT_TTS_VOICES_UPDATE` | *(none)* |
### Globals
</details>
<details>
<summary><b>Globals & enums</b></summary>
| Group | Constants |
|-------|-----------|
| Version | `CLASSIC_API_VERSION` |
| Version | `CLASSIC_API_VERSION`, `INTERFACE_VERSION` |
| Expansion | `LE_EXPANSION_LEVEL_CURRENT`, `LE_EXPANSION_CLASSIC` … `LE_EXPANSION_MIDNIGHT` |
| Item quality | `LE_ITEM_QUALITY_POOR` … `LE_ITEM_QUALITY_WOWTOKEN` |
| Unit stat | `LE_UNIT_STAT_STRENGTH` … `LE_UNIT_STAT_SPIRIT` |
@@ -193,7 +237,10 @@ when launching with `-console`), not as Lua functions. See the
| Spellbook bank | `Enum.SpellBookSpellBank.{Player,Pet}` (0–1) |
| Spellbook item type | `Enum.SpellBookItemType.{None,Spell,FutureSpell,PetAction,Flyout}` (0–4; 1.12 only yields `Spell`/`PetAction`) |
### Unit tokens
</details>
<details>
<summary><b>Unit tokens</b> — <code>nameplateN</code>, <code>focus</code>, <code>markN</code></summary>
| Token | Resolves to |
|-------|-------------|
@@ -201,9 +248,12 @@ when launching with `-console`), not as Lua functions. See the
| `focus` / `focustarget` | Sticky target set via [`FocusUnit`](docs/API.md#focusunitunit), cleared via [`ClearFocus`](docs/API.md#clearfocus). Same `UnitX` coverage as `nameplateN`. Fires [`PLAYER_FOCUS_CHANGED`](docs/API.md#player_focus_changed-event) on transition. See [Focus](docs/API.md#focus). |
| `mark1`..`mark8` | Unit currently wearing the Nth raid-target marker (`mark1` = star … `mark8` = skull). Same `UnitX` coverage as `nameplateN`, and fires unit events (`UNIT_HEALTH`, `UNIT_AURA`, …) with `arg1 == "markN"`. `UnitExists("markN")` is `false` when the marker is unset, out of range, or on a non-unit. Suffix chains (`mark1target`) compose. See [Unit tokens (`markN`)](docs/API.md#unit-tokens-markn). |
### Bindings
</details>
ClassicAPI backports the later-client direct-action and temporary override
<details>
<summary><b>Bindings</b> — direct-action and override binding families</summary>
ClassicAPI backports the direct-action and temporary override
binding families. Permanent bindings use the standard `SPELL`, `ITEM`,
`MACRO`, and `CLICK` command strings and can be saved normally. Overrides are
session-only, frame-owned, and support the usual priority flag.
@@ -212,11 +262,10 @@ See the [Bindings API reference](docs/API.md#bindings) for signatures,
precedence, execution behavior, macro-text usage, and 1.12 compatibility
notes.
#### Predefined focus bindings
Injected into the engine's **Targeting Functions** group at FrameXML
Bindings.xml load time, so they appear in the keybind UI alongside
native targeting bindings instead of orphaned at the bottom.
**Predefined focus bindings.** Injected into the engine's **Targeting
Functions** group at FrameXML Bindings.xml load time, so they appear in the
keybind UI alongside native targeting bindings instead of orphaned at the
bottom.
| Binding | Action |
|---------|--------|
@@ -226,20 +275,7 @@ native targeting bindings instead of orphaned at the bottom.
See [Predefined focus bindings](docs/API.md#predefined-focus-bindings-focustarget--targetfocus)
for the implementation note.
### Behaviors
Transparent engine tweaks — no API to call, they just fix a vanilla limitation:
| Tweak | Effect |
|-------|--------|
| Tooltip line cap | Lifts `GameTooltip`'s hard 30-line limit to 60 for every `GameTooltipTemplate` frame (`GameTooltip`, `ShoppingTooltip1/2`, `ItemRefTooltip`, AtlasLoot, …). Stat-heavy tooltips and comparison blocks (e.g. pfUI's eqcompare) no longer have their extra lines silently dropped. Done in pure C++ by growing the engine's FontString pool at tooltip-creation time. |
| Inline textures | Draws inline texture markup (`\|T…\|t`) as icons in FontStrings, chat, and tooltips, the way 4.3.4+ clients do. Vanilla 1.12 shows the raw escape as literal text instead. This covers item and spell icons, raid-target markers, and the coin icons in money strings. `GetStringWidth` and `GetStringHeight` count the icons, so measured width and text wrapping stay correct. Done in pure C++ by hooking the engine's text pipeline — no addon. |
| Lua 5.1 syntax | Compiles the Lua 5.1 length (`#`), modulo (`%`), `...`-expression, `0x` hex-literal, and leveled long bracket (`[=[ ]=]`) syntax that vanilla's Lua 5.0 rejects, by rewriting addon source before it compiles. Each addon file also receives its `(name, table)` through `...` (`local name, tbl = ...`). See [Lua 5.1 syntax](docs/API.md#lua-51-syntax). |
| Lua 5.1 string methods | Every string value accepts method calls — `("asd"):upper()`, `("%d gold"):format(n)`, `msg:match("^!(%w+)")` — resolving through the `string` table, the way Lua 5.1 works. Vanilla's Lua 5.0 errors with `attempt to index a string value`. Works on literals and variables, in-world and on the login screen, and inside coroutines. See [String methods](docs/API.md#string-methods-supper-sformat). |
| Lua 5.1 environment protection | `getfenv` / `setfenv` honor a `__environment` metatable field — the Lua 5.1 sandbox form — in addition to vanilla's raw `__fenv`. See [getfenv / setfenv environment protection](docs/API.md#getfenv--setfenv-environment-protection). |
| Multi-flavor & conditional TOC loading | Loads modern multi-flavor addons that ship one folder. Selects a version-specific TOC (`<Name>_ClassicAPI.toc` or `<Name>_Turtle.toc`) and the matching keybinding file (`Bindings_ClassicAPI.xml` / `Bindings_Turtle.xml`), accepts a comma-separated `## Interface:` version list (compatible when it includes the client version `11200`), and honors per-line `[AllowLoadGameType]` / `[AllowLoadTextLocale]` conditions and `[Family]` / `[Game]` / `[TextLocale]` path variables inside a TOC. See [Conditional and multi-flavor TOC loading](docs/API.md#conditional-and-multi-flavor-toc-loading). |
| SavedVariables loaded first | Honors the modern `## LoadSavedVariablesFirst` TOC directive: a flagged addon's SavedVariables load before its Lua runs, so file-scope code sees restored config (instead of vanilla's `nil`). See [SavedVariables loaded first](docs/API.md#savedvariables-loaded-first). |
| Modern script-handler arguments | Frame-script handlers receive their values as positional arguments — `OnMouseWheel(self, delta)`, `OnClick(self, button)`, `OnEvent(self, event, ...)`, etc. — the way 5.1+ clients do, so modern addon ports work unmodified. The `this` / `arg1` globals stay set. A handler that declares no parameters is unaffected. A handler that declared a parameter and expected nil now receives its real value. On by default. `SetModernScriptArgs(false)` restores exact vanilla dispatch. See [SetModernScriptArgs](docs/API.md#setmodernscriptargsenable--getmodernscriptargs). |
</details>
## Installation
@@ -298,7 +334,7 @@ identically in both cases.
## Bundled addon: DebugTools
A 1.12.1 / Lua 5.0 backport of Blizzard's `Blizzard_DebugTools` addon
(originally shipped with the 3.0 client) lives in [`AddOns/DebugTools/`](AddOns/DebugTools/).
lives in [`AddOns/DebugTools/`](AddOns/DebugTools/).
It's an independent addon — it doesn't use anything ClassicAPI adds, and
ClassicAPI works fine without it. It's bundled here because it's the
natural companion for testing and debugging anything written against the
@@ -313,12 +349,11 @@ Slash commands provided:
| `/framestack` (or `/fstack`) | Tooltip showing the frame hierarchy under the mouse cursor. |
| `/luaerrors` (or `/scripterrors`) | Lua error display window. |
Lua globals provided (backports of 3.3.5 helpers that don't exist in
1.12):
Lua globals provided (backports of Blizzard helpers):
| Global | Purpose |
|--------|---------|
| `print(...)` | Backport of the 3.3.5 `print` — concats varargs with `" "` and pushes to `DEFAULT_CHAT_FRAME`. Routes through `setprinthandler`'s handler; falls back via `geterrorhandler` if the handler errors. |
| `print(...)` | Backport of Blizzard's `print` — concats varargs with `" "` and pushes to `DEFAULT_CHAT_FRAME`. Routes through `setprinthandler`'s handler; falls back via `geterrorhandler` if the handler errors. |
| `setprinthandler(func)` / `getprinthandler()` | Install / query a custom print handler. Useful for redirecting print output in tests. |
| `tostringall(...)` | Apply `tostring()` to every vararg, preserving the count. Lua 5.0-compatible (uses `arg.n` since `select` doesn't exist in 5.0). |
+1883 -1409
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
+12 -40
View File
@@ -16,12 +16,10 @@
#include "MinHook.h"
#include "Offsets.h"
#include "event/Custom.h"
#include "luasyntax/StringMethods.h"
#include "model/DisplayInfo.h"
#include "nameplate/Walk.h"
#include "player/NameCache.h"
#include "text/InlineTexture.h"
#include "text/InlineTexturePool.h"
#include "texture/Mask.h"
#include "texture/Transform.h"
static Game::FrameScript_Initialize_t FrameScript_Initialize_o = nullptr;
@@ -38,44 +36,17 @@ static FrameRegisterEvent_t FrameRegisterEvent_o = nullptr;
static void __fastcall InvalidFunctionPtrCheck_h() {}
static bool __fastcall FrameScript_Initialize_h() {
// BEFORE the engine tears down the old event table (at the start of
// FrameScript_Initialize), invalidate our cached slot indices. The
// table is rebuilt at a fresh allocation; the old slots are stale.
Event::Custom::PrepareForReload();
// Fire every module's PrepareForReload BEFORE the engine tears down the old
// event table / Lua state. Each module that keeps reload-fragile state (Lua
// refs, frame-pointer-keyed maps, cached event-slot indices) self-registers
// via `static const Game::ReloadAutoRegister _reload{&PrepareForReload};`,
// so this one call clears them all — a new module can't forget to wire in
// (see the ReloadAutoRegister doc in Game.h). This hook fires on both
// `/reload` and `/logout` (the engine re-inits the Lua state in both).
Game::RunReloadCleanups();
// Clear the nameplate diff state so currently-visible plates
// refire CREATED and UNIT_ADDED on the next tick — re-presents
// them to the freshly reloaded UI, matching modern WoW. The
// wrappers themselves now live in the engine's own registry
// (via `FrameScript_Object::ScriptRegister`), which the engine
// tears down and rebuilds across the Lua reset — no per-module
// cache to clear here.
NamePlate::Events::PrepareForReload();
// The reload teardown destroys every icon pool texture, fontstring,
// and text node — forget all inline-icon pointers now so nothing
// touches them (see InlineTexture.h / InlineTexturePool.h).
Text::InlineTexture::PrepareForReload();
Text::InlineTexturePool::PrepareForReload();
// Drop per-region corner transforms (SetRotation / SetVertexOffset) — the
// reload destroys every addon texture and the region pool reuses their pointers.
Texture::Transform::PrepareForReload();
// Drop pending character-dress jobs and their engine compositors — the
// reload destroys every Model frame the jobs point at.
Model::DisplayInfo::PrepareForReload();
// Forget the captured `string` table before the world Lua state resets
// (this hook also fires on /logout, where the state's tables die) —
// string-method resolution fails closed (stock index error) until
// LoadScriptFunctions re-captures.
LuaSyntax::StringMethods::PrepareForReload();
// Persist the name cache before the engine starts tearing down.
// This hook fires on both `/reload` and `/logout` (the engine
// re-initializes Lua state in both cases), giving us a clean
// deterministic save point for the common lifecycle events.
// Persist the name cache before the engine tears down — a SAVE, not a clear,
// so it stays an explicit call rather than a ReloadAutoRegister.
Player::NameCache::Flush();
FrameScript_Initialize_o();
@@ -103,6 +74,7 @@ static void __stdcall LoadGlueScriptFunctions_h() {
Text::InlineTexture::PrepareForReload();
Text::InlineTexturePool::PrepareForReload();
Texture::Transform::PrepareForReload();
Texture::Mask::PrepareForReload();
}
// Every Lua-side `frame:RegisterEvent(...)` is a chance to claim a slot
+17
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
+536 -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
@@ -4750,6 +4876,11 @@ enum Offsets {
// Verified in docs/LuaCAPI.md and by the getfenv/getmetatable base-lib
// functions that call it. Used by the `__environment` env-protection hook.
LUA_GET_METATABLE = 0x6F3CF0,
// `lua_setmetatable(L, objindex)` — pops the table at the top of the
// stack and installs it as the metatable of the value at objindex;
// returns 1. Catalogued (★-verified) in docs/LuaCAPI.md. Used by
// Table::Length to build its weak-keyed mark table from C.
LUA_SET_METATABLE = 0x6F4020,
// `lua_getfenv(L, idx)` — pushes the environment table of the function
// (or userdata/thread) at `idx`. The getfenv/setfenv base-lib code calls
// it to read a function's environment before the protection check.
@@ -4829,6 +4960,16 @@ enum Offsets {
// (named there since the table.insert trace). Used by the 5.1
// `unpack(list, i, j)` upgrade for the default range end.
LUAL_GETN = 0x6F5050,
// `table.insert` (5.0 `luaB_tinsert`), `int __fastcall(L)`. Found two
// independent ways: the table-lib luaL_reg pair in .data (entry
// {name 0x008821B4 "insert", func} at 0x00822D70) and the LUAL_SETN
// caller trace above. Decompile matches 5.0.2 tinsert exactly:
// lua_gettop → luaL_checktype(1, TABLE) → n = luaL_getn(1)+1 →
// optional-pos shift-up loop (rawgeti/rawseti) → luaL_setn(1, n) →
// lua_pushvalue(value) → lua_rawseti(1, pos). Both `table.insert` and
// the FrameXML `tinsert` alias resolve to this one C function. Hooked
// by Table::Length to mark deliberate trailing-nil appends (#36/#39).
FUN_LUA_TABLE_INSERT = 0x7FB6B0,
// `luaV_gettable(L, t, key, loop)` — the VM's generic index resolver.
// __fastcall(L /*ecx*/, t /*edx*/, key /*stack*/, loop /*stack*/),
// RET 8. Returns the result TValue* in eax (Ghidra types it void; the
@@ -5333,8 +5474,12 @@ enum Offsets {
//
// `VAR_ADDON_LIST_CTRL` is just the address of the control struct
// (i.e., `0x00BE1B64`). Pass `&VAR_ADDON_LIST_CTRL` as the `this`
// arg to `FUN_INTRUSIVE_LIST_INSERT`.
// arg to `FUN_INTRUSIVE_LIST_INSERT`. `VAR_ADDON_LIST_HEAD` is the
// control's +0x08 head slot; walk entries via
// `next = *(entry + [VAR_ADDON_LIST_CTRL] + 4)` (= `entry+0x10`),
// stopping on a low-bit-1 sentinel or NULL.
VAR_ADDON_LIST_CTRL = 0x00BE1B64,
VAR_ADDON_LIST_HEAD = 0x00BE1B6C,
// `SStrDup(const char *src, const char *file, int line)`. `__stdcall`.
// Storm's string-copy wrapper around `SMemAlloc` — used by the engine
@@ -5606,6 +5751,178 @@ enum Offsets {
VAR_ADDON_ARRAY = 0x00BE1B94,
VAR_ADDON_COUNT = 0x00BE1B90,
// AddOnEntry fields shared by `Addons::Embedded` and `Addons::Rescan`
// (the full per-field derivation lives in CLAUDE.md's AddOn registry
// section):
// +0x14 — canonical name pointer (what the load pass passes to the
// by-name loader `FUN_0051F240`).
// +0x29 — filter-out byte; the flat display-array builder skips
// entries where it's non-zero (how `!!!ClassicAPI` hides).
// LoadWith desc@+0x58 (count@+0x5C, data@+0x60) — `## LoadWith:`
// names, parsed by `FUN_TOC_PARSER` (append site writes
// rec[0x16..0x19]).
// reverse-LoadWith desc@+0x88 (count@+0x8C, data@+0x90,
// quantum@+0x94) — `AddOnEntry *`s of every addon that
// declared `## LoadWith: <this>`; built by the scan
// `FUN_0051C760`'s tail loop (rec[0x22..0x25]), consumed by
// the loader's post-ADDON_LOADED loop.
OFF_ADDON_ENTRY_NAME_PTR = 0x14,
OFF_ADDON_ENTRY_FILTER_OUT = 0x29,
OFF_ADDON_LOADWITH_COUNT = 0x5C,
OFF_ADDON_LOADWITH_ARRAY = 0x60,
OFF_ADDON_REVLOADWITH_DESC = 0x88,
// `## Secure:` flag byte. Parser write site verified in
// `FUN_TOC_PARSER`'s decompile (`*(bool *)(entry_dwords + 10)` =
// `+0x28`); also set by the SMSG_ADDON_INFO secure path at login.
// Entries with this set are SMSG-managed (Blizzard addons on stock
// clients) — never evict/re-register them: the parser cannot restore
// packet-delivered state (and their MPQ TOCs never change anyway).
OFF_ADDON_ENTRY_SECURE = 0x28,
// Complete single-`AddOnEntry` destructor — vtable slot 0 of the
// registry's node factory (`[0x00BE1B60]` = `&PTR_FUN_00808c7c`,
// installed by the boot init `FUN_0051C360`; the login teardown
// `FUN_0051FA40` dispatches per entry through it).
// `__stdcall(AddOnEntry *)`, RET 4 (arg read from `[EBP+8]`,
// verified by disassembly). Body (`FUN_005200A0` → field cleanup
// `FUN_005200D0`, both decompiled):
// - frees EVERY owned allocation: name (+0x14), the +0x30 string,
// all five dep/SV string arrays AND their buffers
// (+0x38/+0x48/+0x58/+0x68/+0x78), the reverse-LoadWith buffer
// (+0x88), and the per-entry `##` metadata hash table (+0x98 —
// the Title/Notes/Author/X-* nodes `GetAddOnMetadata` reads);
// - SELF-UNLINKS from both registry structures: the intrusive
// list (link node +0xC via `FUN_00520A60`, which neighbor-
// patches from the node alone and no-ops when already unlinked)
// and the hash bucket chain (+0x4);
// - frees the entry struct itself (`__AUUIADDON__` tag).
// What it does NOT clean: THIS entry's pointer inside OTHER
// entries' reverse-LoadWith lists — the caller must scrub those
// first (`Addons::Rescan`), or the loader's post-ADDON_LOADED loop
// (`FUN_0051F240` tail: reads +0x8C/+0x90, derefs each entry's
// +0x14) reads freed memory.
FUN_ADDON_ENTRY_DESTROY = 0x005200A0,
// `u8` set to 1 by `FUN_ADDON_INIT` after the login scan completes,
// cleared by the registry teardown `FUN_0051FA40`. Gates anything
// that touches the registry outside the login path.
VAR_ADDON_INITIALIZED = 0x00BE1C08,
// ── The frozen loose-file index (why new files need a restart) ────
//
// Every relative-path file open resolves through `FUN_00647E60`,
// which consults a HASH TABLE OF LOOSE FILES built ONCE per process:
// `FUN_00646EA0` (lazy, latched by `VAR_VFS_INDEX_READY`) walks the
// whole game directory tree via `FUN_VFS_INDEX_SUBTREE`, keying each
// file by its base-relative path and storing the joined on-disk path.
// Index hits open the mapped path LIVE from disk (edits to existing
// files take effect on /reload); index misses fall through to the
// MPQ search ONLY — the archive layer's live disk probe
// (`FUN_00654DD0` → GetFileAttributesA) is dead code at runtime
// because its enable global `DAT_00865B44` (ships as 1 in the image)
// is unconditionally cleared by early boot (`FUN_00402210` →
// `FUN_00648C20`). File WRITES (`FUN_0042A460` = raw CreateFileW)
// never register in the index either. Net effect on a stock client:
// any file created after boot — a new addon's TOC, a new .lua in an
// existing addon, even a freshly written SavedVariables file — is
// INVISIBLE to every relative-path read until the client restarts.
//
// `FUN_VFS_INDEX_SUBTREE` — the recursive indexer itself:
// void __fastcall IndexSubtree(
// const char *basePath, // ecx — index keys are relative to
// // this (the game dir)
// const char *relSubdir, // edx — subtree below basePath; ""
// // for the boot-time full walk
// void *findHandle);// stack — from FUN_VFS_FIND_OPEN on
// // join(basePath, relSubdir)
// Dedup-safe: every insert is preceded by a hash lookup, so
// re-running it on a subtree registers only genuinely new files —
// which is exactly what `Addons::Rescan` does per /reload for
// `Interface\AddOns` and `WTF\Account`.
//
// `FUN_VFS_FIND_OPEN` — `__stdcall(const char *dirPath) -> void*`
// (RET 4). Allocates a find block, strips trailing backslashes,
// requires the path to be an EXISTING DIRECTORY
// (GetFileAttributes & 0x10) — returns NULL otherwise — then arms
// it with "\*". Release with `FUN_VFS_FIND_CLOSE`
// (`__stdcall(void*)`, RET 4; NULL-safe).
//
// `VAR_VFS_BASE_PATH` — the game base dir recorded at boot
// (`FUN_006488B0`); `FUN_00646EA0` uses it as the walk base when
// `FUN_VFS_FIND_OPEN` accepts it, else falls back to ".".
// `VAR_VFS_INDEX_READY` — `u8` latch; nonzero once the boot walk ran.
FUN_VFS_INDEX_SUBTREE = 0x00646910,
FUN_VFS_FIND_OPEN = 0x006674A0,
FUN_VFS_FIND_CLOSE = 0x006676A0,
VAR_VFS_BASE_PATH = 0x00C52418,
VAR_VFS_INDEX_READY = 0x00C5251C,
// ── Login scan walk #2 (loose-addon discovery) — replayed per /reload
//
// `FUN_ADDON_SCAN_DISK_DIRS` — generic disk directory walker
// (resolves to kernel32 FindFirstFileW; the DISK sibling of
// `FUN_MPQ_ENUM_FILES`):
// int __fastcall ScanDiskDirs(
// const char *basePath, // ecx — e.g. "Interface\AddOns\"
// const char *pattern, // edx — appended to basePath; the
// // scan passes the "*" at
// // VAR_ADDON_SCAN_PATTERN
// Callback cb, // stack — see below
// void *userParam, // stack — passed to cb in edx
// int includeHidden); // stack — 0 skips hidden files
// RET 0xC. Callback: `int __fastcall(FindInfo * /*ecx*/,
// void *userParam /*edx*/)` with FindInfo `{+0x4 attrs (0x10 =
// directory), +0x8 char name[]}`; returning nonzero STOPS the walk
// (note: OPPOSITE of `FUN_MPQ_ENUM_FILES`' convention).
//
// `FUN_ADDON_DISK_DIR_CB` — the engine's own per-entry callback for
// this walk (defined + verified in Ghidra): for every directory not
// starting with '.', calls `FUN_TOC_PARSER` on the name; returns 0.
// `Addons::Rescan` replays the scan's exact call —
// `ScanDiskDirs(VAR_ADDON_PATH_PREFIX, VAR_ADDON_SCAN_PATTERN,
// FUN_ADDON_DISK_DIR_CB, 0, 0)` — so new-folder discovery is 100%
// engine code; the parser's dedup guard makes it a no-op per
// already-registered addon.
FUN_ADDON_SCAN_DISK_DIRS = 0x0042AD10,
FUN_ADDON_DISK_DIR_CB = 0x0051D7B0,
VAR_ADDON_SCAN_PATTERN = 0x00833180, // "*" (walker config block)
VAR_ADDON_PATH_PREFIX = 0x00853724, // "Interface\AddOns\"
// ── Flat display array + descriptor grow helpers ──────────────────
//
// The `GetNumAddOns`/`GetAddOnInfo(i)` array (`VAR_ADDON_ARRAY` /
// `VAR_ADDON_COUNT` above) is a full `{cap, count, data, quantum}`
// descriptor at `VAR_ADDON_ARRAY_CAP` (cap@0x00BE1B8C,
// count@0x00BE1B90, data@0x00BE1B94, quantum@0x00BE1B98) holding
// NAME POINTERS (entry+0x14), rebuilt from scratch and qsorted by
// `FUN_0051DA70` phase 2 whenever SMSG_ADDON_INFO lands.
// `Addons::Rescan` mirrors that phase (never calls `FUN_0051DA70`
// itself — its phase 1 consumes the packet) so newly appended
// registry entries show up in the by-index Lua surface.
//
// Grow helpers (one template instantiation per desc — use each at
// its verified site): `__thiscall(desc /*ecx*/, uint newCap)`,
// RET 4 — Storm-reallocs `desc->data` to newCap*4 and writes
// `desc->cap = newCap`.
// FUN_ADDON_ARRAY_GROW — flat display array (used by
// `FUN_0051DA70` phase 2)
// FUN_ADDON_REVLOADWITH_GROW — reverse-LoadWith descs (used by the
// scan tail loop)
// `FUN_DESC_QUANTUM_CALC` — `__thiscall(desc /*ecx*/, uint needed)`,
// RET 4: returns the append-rounding quantum (highest power of two
// ≤ needed, min 1; ≥ 0x40 caps at 0x40 and stores desc->quantum).
// `FUN_CRT_QSORT` — the CRT `qsort` (`__cdecl(base, num, width,
// cmp)`); `FUN_ADDON_NAME_COMPARE` is the engine's `__cdecl`
// name-pointer comparator `FUN_0051DA70` sorts the array with.
VAR_ADDON_ARRAY_CAP = 0x00BE1B8C,
VAR_ADDON_ARRAY_QUANTUM = 0x00BE1B98,
FUN_ADDON_ARRAY_GROW = 0x004755F0,
FUN_ADDON_REVLOADWITH_GROW = 0x005213C0,
FUN_DESC_QUANTUM_CALC = 0x00521C50,
FUN_CRT_QSORT = 0x0073F727,
FUN_ADDON_NAME_COMPARE = 0x0051DEB0,
// NOTE: an earlier revision defined OFF_PLAYER_FIELD_VIS_BYTES /
// PLAYER_VIS_BIT_STEALTH here at 0x17C, believing bit 0x02 was a
// "stealth bit". That was wrong: 0x17C is UNIT_FIELD_AURALEVELS
@@ -6307,6 +6624,23 @@ enum Offsets {
FUN_GET_OBJECT_BY_GUID = 0x00468460,
OBJECT_TYPE_ITEM = 2,
// Melee auto-attack primitives (back StartAttack / StopAttack). 1.12
// exposes only the toggling AttackTarget (0x00489B50 -> FUN_006131A0);
// these are the non-toggling start/stop it calls internally.
// START: __thiscall(player, const uint64_t *targetGuid). Validates
// CanAttack on the target, sends CMSG_ATTACKSWING (opcode 0x141), and
// stamps the victim GUID at [player+0xC48]. Idempotent / switch-aware
// on a valid target (never stops an in-progress attack); an
// unattackable target while attacking routes to STOP.
// STOP: __fastcall(player). Clears the attack state; self-guards on the
// attack flag, so it is a no-op (safe) when not attacking.
// RESOLVE_TARGET: __thiscall(player, uint64_t *outGuid) -> int. Validates
// the player's CURRENT target (charm/fear/confuse redirect + CanAttack)
// and writes its GUID; returns 0 (and posts the UI error) if invalid.
FUN_ATTACK_START = 0x005ECB70,
FUN_ATTACK_STOP = 0x005ECAC0,
FUN_ATTACK_RESOLVE_TARGET = 0x00612DF0,
// Inbox — array of mail-entry pointers behind a slot indirection.
// The address itself holds a heap pointer (`MOV ECX, [imm32]` in
// the engine), so reading the array requires one extra deref
@@ -6459,6 +6793,12 @@ enum Offsets {
// SPELL_ATTR_EX3_IGNORE_CASTER_MODIFIERS (verified via disassembly; the
// server enum agrees). NOT AttributesEx2's bit 29 (that's CANT_CRIT).
SPELL_ATTR_EX3_IGNORE_CASTER_MODIFIERS = 0x20000000,
// AttributesEx3 bit 18 — the paladin judgement-debuff marker. The server's
// melee-swing judgement refresh selects auras by SPELLFAMILY_PALADIN +
// this bit (tortoise-wow SpellDefines.h SPELL_ATTR_EX3_ALWAYS_HIT,
// Unit::DealMeleeDamage). Verified in the client Spell.dbc: all 14
// Judgement of Light/Wisdom/Justice/Crusader debuff ranks carry it.
SPELL_ATTR_EX3_ALWAYS_HIT = 0x00040000,
// AttributesEx channel bits (CHANNELED_1 0x4 | CHANNELED_2 0x40) and the
// AttributesEx2 autorepeat flag (bit 5, Auto Shot / Shoot) — each shared
// by more than one module, so kept here rather than redefined locally.
@@ -6860,6 +7200,31 @@ enum Offsets {
// (case-insensitive, hashed). See FUN_006ee6f0 in Templates.cpp notes.
FUN_XML_TEMPLATE_LOOKUP = 0x006EE6F0,
// `CreateFrame(type [, name, parent, template])` — the global FrameScript
// function at table 0x00872E74 slot 3. Standard `int __fastcall(void *L)`
// shape. Vanilla accepts only a SINGLE template name; the hook in
// `Frame::CreateFrame` splits comma-separated inherits strings so modern
// multi-template calls work (e.g. "UIPanelButtonTemplate,
// SecureActionButtonTemplate"). It builds the frame with the first
// resolved template through the engine, then applies each remaining
// template onto the created object with the engine's OWN inherit
// primitive: `frameObj->vtable[+8](templateDefNode, status)` for content
// (FUN_00769820) and `(frameObj+0x24)->vtable[+8](...)` for the template's
// `<Frames>` children (FUN_0076a060) — exactly the two `__thiscall`
// recursion calls the builder FUN_006ee280 emits when resolving an
// `inherits=`. Finalize/OnLoad (`vtable[+0x24]`) runs once during the
// engine build and is NOT re-run.
FUN_SCRIPT_CREATEFRAME = 0x007060B0,
// XML-build "status" object — a printf-style error/warning accumulator the
// appliers log through (`status->vtable[+0xc](status, level, fmt, ...)`,
// __cdecl varargs). Script_CreateFrame builds one on its stack as the
// 5-dword `{PTR_TEXLOAD_DESC_VTBL, 8, &self+8, (&self+8)|1, 0}` (same shape
// the texture loader reuses; see PTR_TEXLOAD_DESC_VTBL). Reset/emptied by
// `FUN_FRAMESCRIPT_STATUS_RESET` (`__thiscall(this)`), which frees any
// accumulated message nodes and re-stamps the vtable.
FUN_FRAMESCRIPT_STATUS_RESET = 0x00419E30,
// --- Inline texture escape (`|Tpath:h:w:...|t`) backport -------------------
// See src/text/InlineTexture.cpp and docs/InlineTextureEscapes.md. The
// 1.12 text pipeline is a shared `|`-tokenizer feeding per-purpose loops.
@@ -6881,6 +7246,126 @@ enum Offsets {
// Script_Texture_SetTexture's own call site does.
VAR_TEXTURE_BLEND_DEFAULT = 0x00878CF0,
// --- Texture masking (`Texture:SetMask` backport) ---------------------------
// Mirrors the engine's own canonical masked-quad draw: the minimap BLIP mask
// FUN_004eae10 (a sibling of the disc FUN_004ec440; both fed by the mask that
// Minimap:SetMaskTexture at 0x004EE4A0 loads into DAT_00bc7968). FUN_004eae10
// is the exact template — bind base on unit 0, mask on unit 1, force each
// unit's combiner to MODULATE, submit with SEPARATE uv0/uv1, then unbind
// unit 1:
// GxRs(0x17, base); GxRs(0x1F, 1); // unit 0: bind + MODULATE preset
// GxRs(0x18, mask); GxRs(0x20, 1); // unit 1: bind + MODULATE preset
// FUN_0058a2a0(4, corners, …, uv0, uv1); // explicit second UV stream
// GxRs(0x18, 0); // unbind unit 1
// Unit 1's MODULATE combiner multiplies the mask's alpha into the base's
// alpha, so a white-with-alpha mask clips the base to the mask's shape.
//
// The recipe was also pinned by an in-game probe (git history's
// MaskProbe.cpp) on the live backend — findings, load-bearing:
// * The COMBINER PRESET selectors are 0x1F+unit (GXRS_COMBINER0), value 1 =
// MODULATE/MODULATE (the .rdata preset tables at 0x0080a25c/0x0080a274,
// consumed by the D3D9 combiner apply FUN_005a2dd0: COLOROP table
// {4,4,13,7,5,12}, ALPHAOP {3,4,3,7,5,3} — value 1 is the only preset
// that MODULATEs the alpha channel too, which is what masking rides).
// Binding a texture on unit 1 alone happens to give MODULATE by default,
// but FUN_004eae10 sets it EXPLICITLY — we mirror that.
// * The SECOND vertex UV stream (uv1) DOES feed unit 1 (FUN_004eae10
// passes a distinct uv1 = this+0x50), so uv1 = uv0 makes unit 1 sample
// the mask at the base's own texcoords. SetMask originally rode this
// recipe but was moved to texgen (below): texcoord-relative sampling
// breaks for SetTexCoord'd sprites (the mask gets sampled at the same
// sub-rect OF THE MASK IMAGE), and uv1 is the LAST explicit UV slot
// that exists — the vertex-format component table at 0x008097A8
// (13 formats × 13 components) has no format with a third UV set, so
// masks past the first cannot ride an explicit stream anyway.
GXRS_TEXTURE0 = 0x17, // texture bind, +unit 0..7 (via FUN_GX_RS_SET_PTR)
GXRS_COMBINER0 = 0x1F, // combiner preset, +unit 0..7 (via FUN_GX_RS_SET)
GXRS_COMBINE_MODULATE = 1, // preset value: COLOROP=MODULATE, ALPHAOP=MODULATE
// GxRs setters — `__fastcall(selector, value)`. The int form (0x00589E60)
// writes combiner presets and the like; the pointer form (0x00589E80) writes
// texture binds.
FUN_GX_RS_SET = 0x00589E60,
FUN_GX_RS_SET_PTR = 0x00589E80,
// --- Texgen + per-unit texture matrices (multi-mask / rotated masks) -------
// Decoded from the D3D9 backend's draw-time state flush FUN_005a2f00 (the
// selector → SetTextureStageState/SetTransform Rosetta stone) and the
// minimap DISC draw FUN_004ec440, which is the engine's own texgen-mask
// template (it submits positions+colors ONLY and feeds BOTH units via
// texgen + a per-unit texture matrix). Selector namespace is five per-unit
// families, each 8 units wide (device supports 8 fixed-function stages;
// live cap at [VAR_GX_DEVICE]+OFF_GXDEV_STAGE_CAP):
// 0x17+u bind 0x1F+u combiner 0x27+u mip-bias 0x2F+u texgen
// 0x37+u coord-transform mode
// Texgen values (FUN_005a2b40): 0 = passthrough (stage reads vertex UV set
// #u — the default; why stage 1 sees the explicit uv1 stream), 1 = OBJECT-
// space position (D3D TCI_CAMERASPACEPOSITION + an auto inv(view)·inv(model)
// basis matrix — UV becomes a pure function of the RAW vertex position,
// immune to whatever model/view the pass set), 2 = world-space position,
// 3 = camera-space position (identity basis; what the disc draw uses),
// 5/4/6 = normal/reflection.
// Coord-transform values (FUN_005a14e0): 0 = off, 1 = apply the unit's
// texture matrix (basis × user matrix, D3DTTFF_COUNT3), 2 = projected.
// Per-unit texture-matrix STACKS (4 levels, base ctx+0xFD0+unit*0x118;
// slot 8 = model, 10 = view) — the disc draw's push/translate/scale/pop:
GXRS_TEXGEN0 = 0x2F, // texgen mode, +unit (int form)
GXRS_TEXGEN_OBJECT_POS = 1,
GXRS_TEXXFORM0 = 0x37, // coord-transform mode, +unit (int form)
GXRS_TEXXFORM_MATRIX = 1,
// Texture-matrix stack ops — `__fastcall(int unit, ...)`. PUSH duplicates
// the top (max depth 4); PUSH_LOAD pushes then loads an arbitrary 4×4
// (row-vector D3D layout: out = pos·M, translation in row 3). POP restores.
// (PUSH/POP were previously mislabeled FUN_GX_TEXUNIT_ENABLE/_DISABLE —
// they never enabled anything; binding a texture is what enables a stage,
// binding null disables it, per FUN_005a29d0.)
FUN_GX_TEXMTX_PUSH = 0x0058B200, // (unit)
FUN_GX_TEXMTX_PUSH_LOAD = 0x0058B210, // (unit, const float m[16])
FUN_GX_TEXMTX_POP = 0x0058B220, // (unit)
// Gx render-state journal push/pop — brackets a state scope; pop replays
// every selector written since the push back to its prior value. The engine
// wraps each region-layer draw (FUN_0076fb00) and the disc draw in a pair.
FUN_GX_STATE_PUSH = 0x00589F40,
FUN_GX_STATE_POP = 0x00589F50,
// The indexed submit that actually queues the draw for the verts staged by
// FUN_GX_PRIM_STREAMS — `__fastcall(int primType, int indexCount, const
// void *indices)`. Every FUN_GX_PRIM_STREAMS caller invokes it immediately
// after (region layer: FUN_0076fb00; disc: FUN_004ec440). State/matrix
// RESTORES must run AFTER this call, not after the stream call — the disc
// draw pops its matrices/state only past this point, and restoring earlier
// reverts the state the queued draw will be flushed with.
FUN_GX_PRIM_INDEXED = 0x0058A2E0,
// The device/context singleton the Gx wrappers route through (set at
// backend creation, FUN_00589ac0). +0x23C holds the live texture-stage cap
// the per-stage applies clamp against (FUN_005a29d0/FUN_005a2b40).
VAR_GX_DEVICE = 0x00C0ED38,
OFF_GXDEV_STAGE_CAP = 0x23C,
// The vertex-stream primitive every textured region draws through
// (FUN_0076fb00 calls it per region). Decompiler signature (13 params):
// (count, positions, posStride, s3, s3Stride, colors, colorStride, drop8,
// drop9, uv0, uv0Stride, uv1, uv1Stride); RET 0x2C. The inner FUN_0058a3d0
// builds an interleaved vertex from whichever of {s3, colors, uv0, uv1} are
// non-null — so uv1 is a real, buildable second UV slot no engine caller
// populates. `positions` for a region draw is the region's corner array
// (region + OFF_SIMPLETEXTURE_CORNERS), which is how the mask co-hook
// recovers the owning region: region = positions - OFF_SIMPLETEXTURE_CORNERS
// (verified: FUN_00772fd0 appends the batch entry with positions =
// region+0xD4, and FUN_0076fb00 forwards that pointer here).
FUN_GX_PRIM_STREAMS = 0x0058A2A0,
// Path → HTEXTURE loader trio (re-adds of the quad-era offsets removed in
// 56c2670, with the same derivations — see docs/InlineTextureEscapes.md):
// FUN_TEXTURE_LOAD_BY_PATH(path, &desc, flags, 0, 1) __fastcall, desc =
// the 5-dword {PTR_TEXLOAD_DESC_VTBL, 8, &self8, (&self8)|1, 0} on-stack
// node FUN_00770200 builds; flags via FUN_GX_TEXFLAGS_INIT(&flags, blend,
// 0,0, 0,0,0, 1, 0) __thiscall. Never returns null (engine substitutes a
// fallback texture). FUN_TEXTURE_GET_RENDERABLE(hTex, 1, 0) __fastcall
// returns the bindable CGxTex at [hTex+0x140] and drives the streaming
// load — calling it each frame IS the residency reference (what the
// minimap's masked draw does for its mask at 0x004ec65c).
FUN_TEXTURE_LOAD_BY_PATH = 0x00449D90,
PTR_TEXLOAD_DESC_VTBL = 0x007FFA10,
FUN_GX_TEXFLAGS_INIT = 0x0058A980,
FUN_TEXTURE_GET_RENDERABLE = 0x0044ACF0,
// --- Inline-texture positioning (slice 1: measure/emit integration) --------
// The 1.12 text pipeline builds a layout as a list of render "nodes" (one
// per wrapped line); each node owns per-font-page glyph vertex batches and a
@@ -7099,6 +7584,55 @@ enum Offsets {
// *(this+0xF0) via SStrDup.
OFF_EDITBOX_TEXT_FONTSTRING = 0x328, // CSimpleFontString* — the display text object
// CSimpleEditBox cursor / selection internals (backs EditBox::Methods'
// SetCursorPosition / GetCursorPosition). Verified from the engine's own
// HighlightText (FUN_00798870 → FUN_0077cca0) and Insert (FUN_00798400 →
// FUN_0077bee0) paths:
// +0x31c dirty-flag dword — bit 2 = selection/highlight changed, bit 4 =
// cursor changed (both trigger the next-paint relayout).
// +0x338 text length in BYTES (the clamp bound below).
// +0x35c / +0x360 selection start / end (byte offsets).
// +0x36c cursor insertion point, a BYTE offset — matches modern WoW's
// byte-based EditBox cursor (GetCursorPosition returns bytes; for
// ASCII text byte == character).
OFF_EDITBOX_FLAGS = 0x31c,
OFF_EDITBOX_TEXT_LENGTH = 0x338, // current text length in bytes
OFF_EDITBOX_CURSOR_BYTE = 0x36c,
// `void __thiscall(editbox, byteOffset)` — sets the cursor to a byte
// offset, clamping to [0, textByteLen] and OR-ing the +0x31c cursor-dirty
// bit. The engine's own "restore cursor" primitive (called from the insert
// path FUN_0077bee0 to put the caret back after splicing text).
FUN_EDITBOX_SET_CURSOR_BYTE = 0x0077e360,
// `void __thiscall(editbox)` — collapses the selection onto the cursor
// (selStart = selEnd = +0x36c). The engine's own "clear selection to the
// caret" helper (FUN_0077ccf0), used before extending a shift-selection.
FUN_EDITBOX_COLLAPSE_SELECTION = 0x0077ccf0,
// `int __thiscall(editbox, startByteIndex, byteCount)` — counts CHARACTERS
// (UTF-8 codepoints) in a byte range, walking the per-byte char-class table
// at +0x330 (a byte begins a codepoint when its class is 2/3/6). So
// `COUNT_CHARS(eb, 0, cursorByteOffset)` is the cursor's character index —
// backs GetUTF8CursorPosition. Verified from the arrow-key mover
// FUN_0077cb20's own `FUN_0077bc80(this, byteOff, cursorByte - byteOff)`.
FUN_EDITBOX_COUNT_CHARS = 0x0077bc80,
// Selection-highlight regions — three consecutive CSimpleTexture pointers
// (start-line +0x350, middle-block +0x354, end-line +0x358) the editbox
// shows/positions to paint the selection (FUN_0077d950). Their vertex
// color IS the highlight color — Set/GetHighlightColor read/write it via
// the generic region color accessors. The blinking caret is a SEPARATE
// region at +0x3d4, deliberately left alone.
OFF_EDITBOX_HIGHLIGHT_REGION = 0x350,
// Input-history ring buffer: max lines (ring capacity) at +0x388 (what
// GetHistoryLines reads), write position at +0x38c, container
// {size@+0x390, alloc@+0x394, data@+0x398} of {char* text, int exec}
// entries. AddHistoryLine writes at +0x38c and advances modulo +0x388.
OFF_EDITBOX_HISTORY_MAX = 0x388,
// `void __thiscall(editbox, int maxLines)` — the SetHistoryLines internal
// (FUN_0077cd90). Reallocates the history ring to maxLines; maxLines == 0
// frees the whole buffer and zeroes the container. ClearHistory calls it
// with 0 then the saved max — empties every entry and resets the write
// position while keeping the limit (so recording still works after).
FUN_EDITBOX_SET_HISTORY_MAX = 0x0077cd90,
// --- FontString measure internals (GetStringWidth icon fix + GetStringHeight) ---
// CSimpleFontString::GetStringWidthInternal — `float(__fastcall)(fs /*ecx*/)`,
// returns in x87 ST0. Lazily computes into the fs+0xFC cache (ANCHOR units,
+35 -41
View File
@@ -50,6 +50,7 @@
#include "addons/EngineIO.h"
#include "addons/FlavorBindings.h"
#include "addons/FlavorToc.h"
#include "addons/Registry.h"
#include "addons/Toc.h"
#include "addons/TocRewrite.h"
#include "bindings/Inject.h"
@@ -377,26 +378,22 @@ using ListInsert_t = void(__thiscall *)(void *listCtrl, void *entry,
// engine's own insert helper handles remove-then-insert atomically,
// so it's safe to call on an entry that's already in the list.
//
// Entries are pointed to by `[VAR_ADDON_LIST_HEAD]` (`0x00BE1B6C`),
// with the next-pointer field at `entry + 0x10` (computed as
// `*(value-at-VAR_ADDON_LIST_CTRL) + entry + 4` = `0xC + entry + 4`
// = `entry + 0x10`). Low-bit-1 means "sentinel" (end of list).
// Name pointer lives at `entry + 0x14`.
constexpr uintptr_t VAR_ADDON_LIST_HEAD = 0x00BE1B6C;
constexpr int OFF_ADDON_ENTRY_NAME_PTR = 0x14;
// `entry+0x29` — the "filter-out" byte. The flat display-array builder
// `FUN_0051da70` walks the linked list and copies an entry into
// `GetNumAddOns`/`GetAddOnInfo`'s array ONLY when this byte is 0, so
// setting it hides `!!!ClassicAPI` from the character-select AddOns
// list — there's no checkbox, so it can't be toggled off. The builder
// only ever *writes* this byte on the secure/SMSG path (`entry+0x28 !=
// 0`); our entry is non-secure, so a manual `1` here is stable across
// rebuilds. Hiding does NOT stop loading (the load pass `FUN_0051f600`
// never reads `+0x29`) and does NOT break dependency resolution or
// by-name queries (those use the addon hash table, which keeps the
// entry regardless).
constexpr int OFF_ADDON_ENTRY_FILTER_OUT = 0x29;
// The list is walked via the shared `Addons::ForEachEntry`
// (`addons/Registry.h`); the callback stops the walk once it finds and
// re-links our entry, since the re-link invalidates the current
// next-pointer.
//
// `entry + OFF_ADDON_ENTRY_FILTER_OUT` is the "filter-out" byte. The
// flat display-array builder `FUN_0051da70` walks the linked list and
// copies an entry into `GetNumAddOns`/`GetAddOnInfo`'s array ONLY when
// this byte is 0, so setting it hides `!!!ClassicAPI` from the
// character-select AddOns list — there's no checkbox, so it can't be
// toggled off. The builder only ever *writes* this byte on the
// secure/SMSG path (`entry+0x28 != 0`); our entry is non-secure, so a
// manual `1` here is stable across rebuilds. Hiding does NOT stop
// loading (the load pass `FUN_0051f600` never reads `+0x29`) and does
// NOT break dependency resolution or by-name queries (those use the
// addon hash table, which keeps the entry regardless).
// `entry+0x2b` — DefaultState (`## DefaultState: enabled/disabled`).
// The enable resolver `FUN_ADDON_ENABLE_RESOLVE` falls back to this
@@ -411,29 +408,26 @@ constexpr int OFF_ADDON_ENTRY_DEFAULT_STATE = 0x2b;
// with the `FUN_ADDON_ENABLE_RESOLVE` co-hook, `!!!ClassicAPI` always
// loads and never appears as a toggleable entry.
void FinalizeEmbeddedEntry() {
uintptr_t entry = *reinterpret_cast<uintptr_t *>(VAR_ADDON_LIST_HEAD);
const int linkOffset = *reinterpret_cast<const int *>(
static_cast<uintptr_t>(Offsets::VAR_ADDON_LIST_CTRL));
while ((entry & 1) == 0 && entry != 0) {
Addons::ForEachEntry([](uintptr_t entry) -> bool {
const char *name = *reinterpret_cast<const char *const *>(
entry + OFF_ADDON_ENTRY_NAME_PTR);
if (name != nullptr && std::strcmp(name, kAddonName) == 0) {
// Hide from the character-select AddOns list and default it
// to enabled.
*reinterpret_cast<uint8_t *>(entry + OFF_ADDON_ENTRY_FILTER_OUT) = 1;
*reinterpret_cast<uint8_t *>(entry + OFF_ADDON_ENTRY_DEFAULT_STATE) = 1;
entry + Offsets::OFF_ADDON_ENTRY_NAME_PTR);
if (name == nullptr || std::strcmp(name, kAddonName) != 0)
return true; // keep walking
auto fn = reinterpret_cast<ListInsert_t>(
Offsets::FUN_INTRUSIVE_LIST_INSERT);
fn(reinterpret_cast<void *>(
static_cast<uintptr_t>(Offsets::VAR_ADDON_LIST_CTRL)),
reinterpret_cast<void *>(entry),
/*position=*/ 1, /*anchor=*/ 0);
return;
}
entry = *reinterpret_cast<const uintptr_t *>(
entry + linkOffset + 4);
}
// Hide from the character-select AddOns list and default it to
// enabled, then move it to the head of the load order.
*reinterpret_cast<uint8_t *>(
entry + Offsets::OFF_ADDON_ENTRY_FILTER_OUT) = 1;
*reinterpret_cast<uint8_t *>(entry + OFF_ADDON_ENTRY_DEFAULT_STATE) = 1;
auto fn = reinterpret_cast<ListInsert_t>(
Offsets::FUN_INTRUSIVE_LIST_INSERT);
fn(reinterpret_cast<void *>(
static_cast<uintptr_t>(Offsets::VAR_ADDON_LIST_CTRL)),
reinterpret_cast<void *>(entry),
/*position=*/ 1, /*anchor=*/ 0);
return false; // stop — the re-link invalidated our next-pointer
});
}
void __fastcall AddonInit_h(const char *accountName) {
+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
+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).
+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);
+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
+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
+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
+4 -4
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
+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.
+2
View File
@@ -202,4 +202,6 @@ void PrepareForReload() {
g_gameSlot.tv.value = nullptr;
}
static const Game::ReloadAutoRegister _reloadReg{&PrepareForReload};
} // namespace LuaSyntax::StringMethods
+70 -22
View File
@@ -79,11 +79,12 @@
// `...` yields the name + a per-addon shared table. `__addonns` returns the
// same table for every file of an addon (registry-backed). The preamble is
// newline-free (line numbers preserved).
// * Non-addon vararg chunks (RunScript `/run`, XML <OnLoad>, loadstring) get a
// `local arg = {n=0}` fallback: their main chunk is not vararg, so a
// top-level `...` would otherwise be `unpack(nil)` and throw where 5.1
// yields nothing. A chunk called with args (`loadstring(...)(a,b)`) can't
// recover them — this build doesn't populate `arg` for called chunks.
// * Non-addon vararg chunks (RunScript `/run`, XML handler bodies,
// `loadstring`) compile as a REAL 5.0 vararg closure: the body is wrapped
// `return function(...) … end` and the main is called once at load, so
// the caller receives the inner function whose own `arg` carries real
// call args — `loadstring("return ...")(a, b)` yields a, b, matching 5.1
// called-chunk semantics (the old `local arg={n=0}` fallback lost them).
// The `__addonns` global is CONTEXT-GATED (see `Script_AddonNS`): it only
// answers for the addon mid-load, for its own name — so it cannot be used
// at runtime to read another addon's private table. Cross-addon access is
@@ -252,6 +253,16 @@ size_t SkipNumber(const char *src, size_t len, size_t pos) {
}
void Tokenize(const char *src, size_t len, std::vector<Token> &out) {
// Clear first — RunPasses re-tokenizes into the SAME vector after a pass
// rewrites the buffer. Without this, the re-lex APPENDED the new tokens
// after the stale ones, and the next pass walked a mixed stream: stale
// positions from the old buffer, then new tokens starting back at 0,
// whose `t.start` sits BEHIND the splice cursor — the `t.start - p`
// size_t underflow then threw std::length_error("string too long")
// through the loadbuffer hook into the engine = fatal ERROR #132.
// Field-reproduced with WeakAuras' Chomp Internal.lua (hex pass fires,
// then the vararg pass walks the doubled stream).
out.clear();
size_t i = 0;
while (i < len) {
unsigned char c = static_cast<unsigned char>(src[i]);
@@ -1250,9 +1261,22 @@ int __fastcall LoadBuffer_h(void *L, const char *buff, unsigned size, const char
// Syntax transpile: hex literals first, then vararg, then # / % — one shared
// tokenization (see RunPasses). `didVararg` / `didOps` drive the preamble.
//
// GUARDED: a C++ exception from the rewrite machinery (std::length_error
// from an underflowed splice length, bad_alloc, anything) must NEVER cross
// into the engine — the engine has no handler, so it surfaces as a fatal
// ERROR #132 at RaiseException (seen in the field: a WeakAuras Chomp
// chunk killed the client from inside this hook). On any exception the
// chunk falls through to the ORIGINAL buffer untouched: worst case it
// fails to compile exactly as stock 1.12 would — never a crash.
std::string transpiled;
bool didVararg = false, didOps = false;
const bool changed = RunPasses(buff, size, transpiled, &didVararg, &didOps);
bool changed = false;
try {
changed = RunPasses(buff, size, transpiled, &didVararg, &didOps);
} catch (...) {
return g_origLoadBuffer(L, buff, size, name);
}
const char *body = changed ? transpiled.data() : buff;
size_t bodyLen = changed ? transpiled.size() : size;
@@ -1277,6 +1301,7 @@ int __fastcall LoadBuffer_h(void *L, const char *buff, unsigned size, const char
pre += "local unpack=unpack;";
bool armed = false;
bool wrapped = false;
char addon[128];
if (didVararg) {
if (fromFileFunnel && AddonNameFromChunk(name, addon, sizeof addon)) {
@@ -1288,20 +1313,26 @@ int __fastcall LoadBuffer_h(void *L, const char *buff, unsigned size, const char
armed = true;
} else {
// Top-level `...` OUTSIDE the addon file funnel — RunScript (`/run`),
// XML <OnLoad>, or loadstring. Vanilla's main / RunScript chunk is
// NOT vararg, so `arg` is nil there and the `unpack(arg)` we emit
// would throw where 5.1's `...` yields nothing. Bind a chunk-local
// empty vararg table so those chunks match 5.1 (top-level `...`
// yields nothing). A vararg function's own `arg` local shadows this
// at its own scope, so nested `function(...)` bodies are unaffected.
//
// Limitation (verified in-game): a loadstring'd chunk CALLED with
// args — `loadstring("return ...")(a,b)` — can't recover them. This
// build does not populate `arg` for a called chunk, and `...` is not
// a real 5.0 expression, so there is no source for the call args.
// Not a regression: without the rewrite that chunk fails to compile
// outright. This case is vanishingly rare in addons.
pre += "local arg={n=0};";
// XML handler bodies, or loadstring. Vanilla's main chunk is NOT
// vararg (verified in-game): `arg` is nil there, and no rewrite can
// read call args the VM never captured. So compile the chunk as a
// REAL 5.0 vararg closure instead: wrap the body in
// `return function(...) … end` and, after a successful compile,
// call the main once so the caller receives the inner function.
// The inner's own 5.0 `arg` then carries whatever the caller
// passes — `loadstring("return ...")(a, b)` yields a, b — matching
// 5.1 called-chunk semantics. Chunks the engine calls with zero
// args (RunScript, OnLoad) see an empty `arg` exactly as the old
// `local arg={n=0}` fallback gave them; XML handlers invoked
// through Frame::ScriptArgs' positional push now receive those
// args through `...` as well — the modern handler shape. The
// helper captures stay in the OUTER main, which runs once at load
// in the default environment, so the sandbox rationale above is
// preserved (the inner binds them as upvalues). The prefix is
// newline-free and the synthetic `end` rides on its own appended
// final line, so existing line numbers are preserved.
pre += "return function(...) ";
wrapped = true;
}
}
@@ -1318,10 +1349,15 @@ int __fastcall LoadBuffer_h(void *L, const char *buff, unsigned size, const char
? 3
: 0;
std::string full;
full.reserve(bodyLen + pre.size() + 4);
full.reserve(bodyLen + pre.size() + 8);
full.append(body, bom); // keep a leading BOM ahead of the preamble
full += pre;
full.append(body + bom, bodyLen - bom);
// Close the vararg wrap on its OWN line: a body ending in a line comment
// (`-- foo` with no trailing newline) would otherwise swallow the `end`.
// Appending a final line shifts no existing line numbers.
if (wrapped)
full += "\nend";
// Grant this chunk (and only this chunk) the right to fetch its own
// namespace via `__addonns`; the preamble consumes the grant as the chunk's
@@ -1334,6 +1370,12 @@ int __fastcall LoadBuffer_h(void *L, const char *buff, unsigned size, const char
g_origLoadBuffer(L, full.data(), static_cast<unsigned>(full.size()), name);
if (armed && rc != 0)
g_loadingAddon[0] = '\0';
// Materialize the wrapped chunk's inner function. The main is helper-local
// binds plus `return function(...) … end` — closure creation only, it
// cannot raise — so a plain lua_call is safe, and it leaves the inner
// function exactly where the engine expects the compiled chunk.
if (wrapped && rc == 0)
Game::Lua::Call(L, 0, 1);
return rc;
}
@@ -1349,7 +1391,13 @@ int __fastcall Script_Transpile(void *L) {
}
unsigned n = Game::Lua::StrLen(L, 1);
std::string out;
if (RewriteChunk(s, n, out))
bool rewrote = false;
try {
rewrote = RewriteChunk(s, n, out);
} catch (...) {
rewrote = false; // same guard as LoadBuffer_h — never throw into the VM
}
if (rewrote)
Game::Lua::PushLString(L, out.data(), static_cast<unsigned>(out.size()));
else
Game::Lua::PushLString(L, s, n);
+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
+13 -22
View File
@@ -43,12 +43,12 @@ namespace Player::InputEvents {
namespace {
constexpr const char *kStartedMovingEvent = "PLAYER_STARTED_MOVING";
constexpr const char *kStoppedMovingEvent = "PLAYER_STOPPED_MOVING";
constexpr const char *kStartedLookingEvent = "PLAYER_STARTED_LOOKING";
constexpr const char *kStoppedLookingEvent = "PLAYER_STOPPED_LOOKING";
constexpr const char *kStartedTurningEvent = "PLAYER_STARTED_TURNING";
constexpr const char *kStoppedTurningEvent = "PLAYER_STOPPED_TURNING";
const Event::Custom::AutoReserve _reserveStartedMoving{"PLAYER_STARTED_MOVING"};
const Event::Custom::AutoReserve _reserveStoppedMoving{"PLAYER_STOPPED_MOVING"};
const Event::Custom::AutoReserve _reserveStartedLooking{"PLAYER_STARTED_LOOKING"};
const Event::Custom::AutoReserve _reserveStoppedLooking{"PLAYER_STOPPED_LOOKING"};
const Event::Custom::AutoReserve _reserveStartedTurning{"PLAYER_STARTED_TURNING"};
const Event::Custom::AutoReserve _reserveStoppedTurning{"PLAYER_STOPPED_TURNING"};
// Below this per-frame delta, treat the yaw as stable. Picks up
// real rotation (smallest user-perceptible RMB-drag is ~0.5°/frame
@@ -65,15 +65,12 @@ bool g_havePrevBodyYaw = false;
float g_prevCameraYaw = 0.0f;
bool g_havePrevCameraYaw = false;
using ResolveUnitToken_t = void *(__fastcall *)(const char *token);
const uint8_t *Controller() {
return Game::Read<const uint8_t *>(Offsets::VAR_UI_INPUT_CONTROLLER);
}
const uint8_t *Player() {
auto fn = reinterpret_cast<ResolveUnitToken_t>(Offsets::FUN_RESOLVE_UNIT_TOKEN);
return static_cast<const uint8_t *>(fn("player"));
return static_cast<const uint8_t *>(Game::ResolveUnitToken("player"));
}
const uint8_t *Camera() {
@@ -85,12 +82,12 @@ const uint8_t *Camera() {
gameState, Offsets::OFF_GAME_STATE_CAMERA_PTR);
}
void FireTransition(bool now, bool &prev, const char *startedName,
const char *stoppedName) {
void FireTransition(bool now, bool &prev, const Event::Custom::AutoReserve &started,
const Event::Custom::AutoReserve &stopped) {
if (now == prev)
return;
prev = now;
const int slot = Event::Custom::Lookup(now ? startedName : stoppedName);
const int slot = (now ? started : stopped).Slot();
if (slot >= 0)
Event::Custom::Fire(slot, "");
}
@@ -172,19 +169,13 @@ void OnWorldTick() {
g_havePrevCameraYaw = true;
}
FireTransition(moving, g_wasMoving, kStartedMovingEvent, kStoppedMovingEvent);
FireTransition(turning, g_wasTurning, kStartedTurningEvent, kStoppedTurningEvent);
FireTransition(looking, g_wasLooking, kStartedLookingEvent, kStoppedLookingEvent);
FireTransition(moving, g_wasMoving, _reserveStartedMoving, _reserveStoppedMoving);
FireTransition(turning, g_wasTurning, _reserveStartedTurning, _reserveStoppedTurning);
FireTransition(looking, g_wasLooking, _reserveStartedLooking, _reserveStoppedLooking);
}
} // namespace
static const Event::Custom::AutoReserve _reserveStartedMoving{kStartedMovingEvent};
static const Event::Custom::AutoReserve _reserveStoppedMoving{kStoppedMovingEvent};
static const Event::Custom::AutoReserve _reserveStartedLooking{kStartedLookingEvent};
static const Event::Custom::AutoReserve _reserveStoppedLooking{kStoppedLookingEvent};
static const Event::Custom::AutoReserve _reserveStartedTurning{kStartedTurningEvent};
static const Event::Custom::AutoReserve _reserveStoppedTurning{kStoppedTurningEvent};
static const Tick::WorldTick::AutoSubscribe _tickSub{&OnWorldTick};
} // namespace Player::InputEvents
+3 -3
View File
@@ -33,8 +33,8 @@ static const Event::Custom::AutoReserve _reserveQuestDataLoadResult{kQuestDataLo
static void __stdcall QuestLoadCallback(void *userData, int success) {
const auto questID =
static_cast<int>(reinterpret_cast<uintptr_t>(userData));
Event::Custom::FireIdSuccess(Event::Custom::Lookup(kQuestDataLoadResult),
questID, success != 0);
Event::Custom::FireIdSuccess(_reserveQuestDataLoadResult.Slot(), questID,
success != 0);
}
static void RequestAndMaybeNotify(uint32_t questID) {
@@ -45,7 +45,7 @@ static void RequestAndMaybeNotify(uint32_t questID) {
// invoking our callback. Fire the event ourselves so addons get the
// notification regardless of cache state, matching modern semantics.
if (wasCached) {
Event::Custom::FireIdSuccess(Event::Custom::Lookup(kQuestDataLoadResult),
Event::Custom::FireIdSuccess(_reserveQuestDataLoadResult.Slot(),
static_cast<int>(questID), true);
}
}
+1 -5
View File
@@ -21,8 +21,6 @@ namespace Quest::Log {
namespace {
using ResolveUnitToken_t = void *(__fastcall *)(const char *token);
// Walks the unit's `+0xE68` sub-struct quest list (20 slots, stride
// 0xC) and returns true on first match. Mirrors the engine's loop in
// `Script_IsUnitOnQuest` (`0x004DFE10`).
@@ -225,9 +223,7 @@ static int __fastcall Script_IsUnitOnQuest(void *L) {
return 1;
}
auto resolve = reinterpret_cast<ResolveUnitToken_t>(
static_cast<uintptr_t>(Offsets::FUN_RESOLVE_UNIT_TOKEN));
auto *unit = static_cast<const uint8_t *>(resolve(token));
auto *unit = static_cast<const uint8_t *>(Game::ResolveUnitToken(token));
Game::Lua::PushBool(L, UnitHasQuest(unit, target));
return 1;
}
+2 -2
View File
@@ -157,7 +157,7 @@ void __fastcall QuestLogRebuild_h(int param_1) {
// (login or post-reload). Modern WoW's QUEST_ACCEPTED doesn't
// fire on initial sync either; we match that.
if (newCount == 1) {
const int slot = Event::Custom::Lookup(kAcceptedEventName);
const int slot = _reserveAccepted.Slot();
if (slot >= 0)
Event::Custom::Fire(slot, "%d%d", firstNewIdx, firstNewID);
}
@@ -168,7 +168,7 @@ void __fastcall QuestLogRebuild_h(int param_1) {
// all but one of the previous character's quests (1 removal, N
// additions in the same rebuild).
if (removedCount == 1 && newCount <= 1) {
const int slot = Event::Custom::Lookup(kRemovedEventName);
const int slot = _reserveRemoved.Slot();
if (slot >= 0)
Event::Custom::Fire(slot, "%d", firstRemovedID);
}
+1 -2
View File
@@ -77,8 +77,7 @@ int __fastcall Handler_h(void *thisPlayer, void *edx, void *packetStream) {
const int result = Handler_o(thisPlayer, edx, packetStream);
if (questID > 0) {
const int slot = Event::Custom::Lookup(kEventName);
Event::Custom::Fire(slot, "%u%u%u",
Event::Custom::Fire(_reserve.Slot(), "%u%u%u",
questID, xpReward, moneyReward);
}
+1 -4
View File
@@ -58,10 +58,7 @@ bool InWorld() {
// Player resolves only when in-world. Same gate other modules
// (e.g. `Item::Location::ResolveBag`) use to avoid touching
// engine state during loading screens or glue.
using ResolveUnitToken_t = void *(__fastcall *)(const char *);
auto fn = reinterpret_cast<ResolveUnitToken_t>(
Offsets::FUN_RESOLVE_UNIT_TOKEN);
return fn("player") != nullptr;
return Game::ResolveUnitToken("player") != nullptr;
}
void CancelPlacement() {
+22 -12
View File
@@ -70,6 +70,7 @@
#include "Game.h"
#include "Offsets.h"
#include "aura/Source.h"
#include "dbc/Lookup.h"
#include "net/PacketDispatch.h"
#include "net/PacketReader.h"
@@ -86,7 +87,6 @@ namespace Spell::Cast {
namespace {
using ResolveUnitToken_t = void *(__fastcall *)(const char *token);
using GetCastTime_t = uint32_t(__fastcall *)(int spellID, int unit, int flag);
using GetDuration_t = int(__fastcall *)(const uint8_t *spellRecord, int unit, int skipMod);
@@ -219,7 +219,7 @@ void PushMs(void *L, int ms) {
}
void *Resolve(const char *token) {
return reinterpret_cast<ResolveUnitToken_t>(Offsets::FUN_RESOLVE_UNIT_TOKEN)(token);
return Game::ResolveUnitToken(token);
}
// Effective cast time (ms) for the local player, 0 if instant. Uses the
@@ -514,14 +514,18 @@ void HandleSpellStart(uint64_t caster, int spellID, uint32_t castTime,
now - g_cast.startMs < kCastStartDedupMs) {
// Confirming packet. Keep startMs (no visual restart), but SNAP the
// end time to the server's authoritative castTime. The client stamp
// came from FUN_006e3340, which folds in the caster's cast-speed /
// ranged-haste multiplier (descriptor +0x22c) — right for spells the
// server also hastes (Aimed/Multi-Shot), WRONG for ones it doesn't.
// Volley is the case that surfaced this: not channeled, not sped by
// Quick Shots server-side, so the hasted prediction ended the bar
// early and the player was "still casting after the bar finished."
// The server's castTime is reality; reconcile to it (preserving any
// pushback already accumulated). A no-op when client and server agree.
// came from FUN_006e3340, whose only caster factor is the cast-speed
// multiplier at descriptor +0x22c (UNIT_MOD_CAST_SPEED — spell
// haste); the server additionally scales ranged ABILITIES by ranged
// attack speed (SpellEntry::GetCastTime's IsRangedSpell branch,
// m_modAttackSpeedPct[RANGED_ATTACK] — quiver/Quick Shots), which
// the prediction can't see. Two real divergences so far: Volley
// (client +0x22c hastes it, server doesn't → bar ended early) and
// Steady/Aimed Shot (server ranged-hastes it, client doesn't → bar
// ran long, pfUI#43). The server's castTime is reality; reconcile
// to it (preserving accumulated pushback). A no-op when they agree;
// when they differ, PollPlayer sees the end move and fires
// UNIT_SPELLCAST_DELAYED so cast bars re-read the corrected times.
g_cast.endMs =
g_cast.startMs + static_cast<int>(castTime) + g_cast.delayMs;
g_castTargetGuid = targetGuid; // confirming packet carries the target
@@ -612,7 +616,11 @@ void ParseChannelStart(Net::CDataStore *packet) {
// stores the new end nowhere, so re-anchor g_channel.endMs to the server's
// remaining time so UnitChannelInfo (and the OnWorldTick endMs self-expiry)
// track pushback; on remaining == 0 clear the channel so CHANNEL_STOP fires
// promptly (ahead of the ~1 s-lagged +0x228 field).
// promptly (ahead of the ~1 s-lagged +0x228 field). The pushback also
// shortened the channel's aura on each hit target server-side
// (Spell::DelayedChannel → DelaySpellAuraHolder), so re-anchor the cached
// target-side expirations too — else C_UnitAuras reads a pushed-back channel
// debuff (Dark Harvest, the drains) late by the accumulated pushback.
void ParseChannelUpdate(Net::CDataStore *packet) {
if (g_channel.spellID == 0)
return;
@@ -622,6 +630,8 @@ void ParseChannelUpdate(Net::CDataStore *packet) {
g_channel.spellID = 0; // authoritative channel end
} else {
g_channel.endMs = NowMs() + static_cast<int>(remaining);
Aura::Source::RestampPlayerChannel(static_cast<uint32_t>(spellID),
remaining);
Spell::CastEvents::OnPlayerChannelUpdate(spellID);
}
}
@@ -837,7 +847,7 @@ void OnWorldTick() {
// based so it needs no changes to the many stamp/clear sites; ~1 frame
// latency is imperceptible for a cast bar, and every fire is
// listener-gated (near-free when no addon uses these).
Spell::CastEvents::PollPlayer(g_cast.spellID, g_cast.startMs, g_cast.delayMs,
Spell::CastEvents::PollPlayer(g_cast.spellID, g_cast.startMs, g_cast.endMs,
g_channel.spellID, g_channel.startMs);
Spell::CastEvents::PollRemote();
Spell::CastEvents::PollReticle();
+35 -41
View File
@@ -31,33 +31,20 @@ namespace Spell::CastEvents {
namespace {
constexpr const char *kStart = "UNIT_SPELLCAST_START";
constexpr const char *kStop = "UNIT_SPELLCAST_STOP";
constexpr const char *kDelayed = "UNIT_SPELLCAST_DELAYED";
constexpr const char *kChannelStart = "UNIT_SPELLCAST_CHANNEL_START";
constexpr const char *kChannelStop = "UNIT_SPELLCAST_CHANNEL_STOP";
constexpr const char *kChannelUpdate = "UNIT_SPELLCAST_CHANNEL_UPDATE";
constexpr const char *kSucceeded = "UNIT_SPELLCAST_SUCCEEDED";
constexpr const char *kInterrupted = "UNIT_SPELLCAST_INTERRUPTED";
constexpr const char *kFailed = "UNIT_SPELLCAST_FAILED";
constexpr const char *kFailedQuiet = "UNIT_SPELLCAST_FAILED_QUIET";
constexpr const char *kSent = "UNIT_SPELLCAST_SENT";
constexpr const char *kReticleTarget = "UNIT_SPELLCAST_RETICLE_TARGET";
constexpr const char *kReticleClear = "UNIT_SPELLCAST_RETICLE_CLEAR";
const Event::Custom::AutoReserve _rStart{kStart};
const Event::Custom::AutoReserve _rStop{kStop};
const Event::Custom::AutoReserve _rDelayed{kDelayed};
const Event::Custom::AutoReserve _rChannelStart{kChannelStart};
const Event::Custom::AutoReserve _rChannelStop{kChannelStop};
const Event::Custom::AutoReserve _rChannelUpdate{kChannelUpdate};
const Event::Custom::AutoReserve _rSucceeded{kSucceeded};
const Event::Custom::AutoReserve _rInterrupted{kInterrupted};
const Event::Custom::AutoReserve _rFailed{kFailed};
const Event::Custom::AutoReserve _rFailedQuiet{kFailedQuiet};
const Event::Custom::AutoReserve _rSent{kSent};
const Event::Custom::AutoReserve _rReticleTarget{kReticleTarget};
const Event::Custom::AutoReserve _rReticleClear{kReticleClear};
// The reservations double as the fire handles (`k*.Slot()` — O(1)).
const Event::Custom::AutoReserve kStart{"UNIT_SPELLCAST_START"};
const Event::Custom::AutoReserve kStop{"UNIT_SPELLCAST_STOP"};
const Event::Custom::AutoReserve kDelayed{"UNIT_SPELLCAST_DELAYED"};
const Event::Custom::AutoReserve kChannelStart{"UNIT_SPELLCAST_CHANNEL_START"};
const Event::Custom::AutoReserve kChannelStop{"UNIT_SPELLCAST_CHANNEL_STOP"};
const Event::Custom::AutoReserve kChannelUpdate{"UNIT_SPELLCAST_CHANNEL_UPDATE"};
const Event::Custom::AutoReserve kSucceeded{"UNIT_SPELLCAST_SUCCEEDED"};
const Event::Custom::AutoReserve kInterrupted{"UNIT_SPELLCAST_INTERRUPTED"};
const Event::Custom::AutoReserve kFailed{"UNIT_SPELLCAST_FAILED"};
const Event::Custom::AutoReserve kFailedQuiet{"UNIT_SPELLCAST_FAILED_QUIET"};
const Event::Custom::AutoReserve kSent{"UNIT_SPELLCAST_SENT"};
const Event::Custom::AutoReserve kReticleTarget{"UNIT_SPELLCAST_RETICLE_TARGET"};
const Event::Custom::AutoReserve kReticleClear{"UNIT_SPELLCAST_RETICLE_CLEAR"};
// Autoshot spams client-side failures while ramping — nampower filters it
// out of its failure event, so do we.
@@ -108,8 +95,9 @@ void BuildCastGuid(char *out, size_t n, int type, int spellID, int castUID) {
// rank)`. Gated on a listener so an unwatched event does no DBC lookups or
// string formatting. A null name/rank pushes `nil` through the dispatcher's
// `lua_pushstring(NULL) → pushnil` tail-jump, which is fine.
void Fire(const char *eventName, int spellID, int guidNum, int type = 3) {
const int slot = Event::Custom::Lookup(eventName);
void Fire(const Event::Custom::AutoReserve &event, int spellID, int guidNum,
int type = 3) {
const int slot = event.Slot();
if (!Event::Custom::HasListeners(slot))
return;
const uint8_t *rec = Spell::Lookup::RecordForID(spellID);
@@ -127,7 +115,7 @@ void Fire(const char *eventName, int spellID, int guidNum, int type = 3) {
// inserted at arg2. `target` is a unit token (e.g. "target") resolved from
// the cast packet, or "" for self / no-target casts.
void FireSent(int spellID, int guidNum, const char *target) {
const int slot = Event::Custom::Lookup(kSent);
const int slot = kSent.Slot();
if (!Event::Custom::HasListeners(slot))
return;
const uint8_t *rec = Spell::Lookup::RecordForID(spellID);
@@ -148,8 +136,8 @@ void FireSent(int spellID, int guidNum, const char *target) {
// the load-bearing fields — are exact; only arg2 differs (`""` vs `nil`),
// which is inconsequential for a reticle (no cast to identify). spellName /
// rank are our usual extension tail.
void FireReticle(const char *eventName, int spellID) {
const int slot = Event::Custom::Lookup(eventName);
void FireReticle(const Event::Custom::AutoReserve &event, int spellID) {
const int slot = event.Slot();
if (!Event::Custom::HasListeners(slot))
return;
const uint8_t *rec = Spell::Lookup::RecordForID(spellID);
@@ -176,7 +164,7 @@ int g_guidCounter = 0;
int g_castSpell = 0; // 0 = not casting
int g_castStart = 0; // startMs of the tracked cast (detects same-spell recast)
int g_castGuid = 0; // castGUID counter value for the tracked cast
int g_castDelay = 0; // last-seen accumulated pushback (ms)
int g_castEnd = 0; // last-seen endMs (any movement fires DELAYED)
bool g_castSucceeded = false; // SPELL_GO seen for the tracked cast this cast
// UNIT_SPELLCAST_INTERRUPTED de-dup. SpellFailed_h and PollPlayer can both
@@ -382,9 +370,9 @@ const Net::SendObserver::AutoSubscribe _sendSub{&OnSend};
// the payload is fanned out per token (`target` / `focus` / `nameplateN` /
// `party` / `raid` / `mouseover`). Same `(unit, castGUID, spellID, spellName,
// rank)` shape as the player events, just with a non-"player" unit.
void FireRemote(const char *eventName, uint64_t casterGuid, int spellID,
int guidNum) {
const int slot = Event::Custom::Lookup(eventName);
void FireRemote(const Event::Custom::AutoReserve &event, uint64_t casterGuid,
int spellID, int guidNum) {
const int slot = event.Slot();
if (!Event::Custom::HasListeners(slot))
return;
const uint8_t *rec = Spell::Lookup::RecordForID(spellID);
@@ -441,7 +429,7 @@ RemoteEvt *AllocRemoteEvt(uint64_t guid) {
bool PlayerCastSucceeded() { return g_castSucceeded; }
void PollPlayer(int castSpellID, int castStartMs, int castDelayMs,
void PollPlayer(int castSpellID, int castStartMs, int castEndMs,
int channelSpellID, int channelStartMs) {
// ---- Regular cast -------------------------------------------------
// A "new cast" is a different spell OR the same spell re-stamped at a
@@ -474,12 +462,18 @@ void PollPlayer(int castSpellID, int castStartMs, int castDelayMs,
g_castGuid = NextCastGuid(castSpellID);
g_castSpell = castSpellID;
g_castStart = castStartMs;
g_castDelay = castDelayMs;
g_castEnd = castEndMs;
g_castSucceeded = false;
Fire(kStart, castSpellID, g_castGuid);
} else if (g_castSpell != 0 && castDelayMs > g_castDelay) {
// Pushback grew (SMSG_SPELL_DELAYED extended the cast).
g_castDelay = castDelayMs;
} else if (g_castSpell != 0 && castEndMs != g_castEnd) {
// The cast's end moved: pushback (SMSG_SPELL_DELAYED extends it) or
// the confirming SMSG_SPELL_START snapping the client-predicted cast
// time to the server's authoritative one — the server scales ranged
// abilities by ranged attack speed (Steady/Aimed Shot), which the
// client's prediction helper doesn't model, so the confirming packet
// shortens the bar ~RTT after START (pfUI#43). Either direction, the
// addon must re-read the times; DELAYED is the re-read trigger.
g_castEnd = castEndMs;
Fire(kDelayed, g_castSpell, g_castGuid);
}
+6 -2
View File
@@ -45,11 +45,15 @@ namespace Spell::CastEvents {
// after its own upkeep, passing the live `g_cast` / `g_channel` fields.
// Detects, against the previous snapshot:
// - regular cast start / stop / same-spell recast → UNIT_SPELLCAST_START / _STOP
// - accumulated pushback growth → UNIT_SPELLCAST_DELAYED
// - end-time movement (either direction) → UNIT_SPELLCAST_DELAYED
// - channel start / stop → UNIT_SPELLCAST_CHANNEL_START / _STOP
// End-time movement covers both pushback (SMSG_SPELL_DELAYED) and the
// confirming SMSG_SPELL_START reconciling the client-predicted cast time to
// the server's (ranged-haste-scaled Steady/Aimed Shot, pfUI#43) — DELAYED is
// the "re-read the times" trigger, whichever way the end moved.
// Channels never fire INTERRUPTED — retail emits only CHANNEL_STOP for a
// channel whether it ends naturally or is cut short.
void PollPlayer(int castSpellID, int castStartMs, int castDelayMs,
void PollPlayer(int castSpellID, int castStartMs, int castEndMs,
int channelSpellID, int channelStartMs);
// Derive the ground-target reticle events from the engine's targeting flags.
+37 -7
View File
@@ -19,6 +19,7 @@
#include <cstdint>
#include <cstdio>
#include <cstring>
namespace Spell::Info {
@@ -179,15 +180,44 @@ static bool BookTypeIsPet(const char *s) {
return lc(s[0]) == 'p' && lc(s[1]) == 'e' && lc(s[2]) == 't' && s[3] == '\0';
}
// Resolves the Lua args to a spellID, supporting:
// GetSpellInfo(spellID) -- arg2 absent or non-string
// GetSpellInfo(slot, "spell"|"pet") -- arg2 string → spellbook lookup
// Returns 0 for invalid/empty inputs (Lua side surfaces 0 as nil since
// spellID 0 is never valid). Calls `lua_error` if arg1 isn't a number,
// matching the engine's own `lua_error`-on-misuse style.
// Parses a spellID out of a spell hyperlink ("…|Hspell:<id>[:…]…").
// Returns 0 when `s` isn't a spell link, so the caller can fall through
// to a plain-name lookup.
static int SpellIDFromLink(const char *s) {
if (s == nullptr)
return 0;
const char *p = std::strstr(s, "Hspell:");
if (p == nullptr)
return 0;
p += 7; // past "Hspell:"
if (*p < '0' || *p > '9')
return 0;
int id = 0;
while (*p >= '0' && *p <= '9')
id = id * 10 + (*p++ - '0');
return id;
}
// Resolves the Lua args to a spellID, supporting the full retail
// GetSpellInfo argument set:
// GetSpellInfo(spellID) -- arg1 number, arg2 non-string
// GetSpellInfo(slot, "spell"|"pet") -- arg1 number, arg2 string → book slot
// GetSpellInfo("name") -- arg1 string → spellbook name lookup
// GetSpellInfo("…|Hspell:ID|h…") -- arg1 string → link's embedded spellID
// A name resolves against the player's/pet's spellbook (retail's scope),
// returning the highest known rank; an unknown name yields 0. Returns 0
// for invalid/empty inputs (Lua side surfaces 0 as nil since spellID 0 is
// never valid). Only a non-string, non-number arg1 raises `lua_error` —
// a name retail doesn't recognize returns nil, it does not error.
static int ResolveLuaArgsToSpellID(void *L) {
if (Game::Lua::Type(L, 1) == Game::Lua::TYPE_STRING) {
const char *s = Game::Lua::ToString(L, 1);
const int fromLink = SpellIDFromLink(s);
return fromLink > 0 ? fromLink : Spell::Lookup::SpellNameToID(s);
}
if (!Game::Lua::IsNumber(L, 1)) {
Game::Lua::Error(L, "Usage: GetSpellInfo(spellID) or GetSpellInfo(slot, bookType)");
Game::Lua::Error(L, "Usage: GetSpellInfo(spellID | \"name\" | link) "
"or GetSpellInfo(slot, bookType)");
return 0;
}
const int arg1 = static_cast<int>(Game::Lua::ToNumber(L, 1));
+1 -4
View File
@@ -34,15 +34,12 @@ namespace Spell::IsCurrent {
namespace {
using ResolveUnitToken_t = void *(__fastcall *)(const char *token);
int ReadGlobalSpellID(uintptr_t addr) {
return *reinterpret_cast<const int *>(addr);
}
int ReadPlayerChannelSpell() {
auto resolve = reinterpret_cast<ResolveUnitToken_t>(Offsets::FUN_RESOLVE_UNIT_TOKEN);
auto *player = static_cast<const uint8_t *>(resolve("player"));
auto *player = static_cast<const uint8_t *>(Game::ResolveUnitToken("player"));
if (player == nullptr)
return 0;
auto *desc = *reinterpret_cast<const uint8_t *const *>(
+1 -4
View File
@@ -22,15 +22,12 @@ namespace Spell::Level {
namespace {
using ResolveUnitToken_t = void *(__fastcall *)(const char *token);
// Reads the local player's descriptor — same pattern as
// Unit::Combat::Script_InCombatLockdown. Returns nullptr at login
// screen / character select / loading transition where the
// CGPlayer doesn't exist yet.
const uint8_t *PlayerDescriptor() {
auto fn = reinterpret_cast<ResolveUnitToken_t>(Offsets::FUN_RESOLVE_UNIT_TOKEN);
auto *player = static_cast<const uint8_t *>(fn("player"));
auto *player = static_cast<const uint8_t *>(Game::ResolveUnitToken("player"));
if (player == nullptr)
return nullptr;
return Game::Read<const uint8_t *>(
+75
View File
@@ -16,6 +16,8 @@
#include "Offsets.h"
#include "dbc/Lookup.h"
#include <cstring>
namespace Spell::Lookup {
const uint8_t *RecordForID(int spellID) {
@@ -109,4 +111,77 @@ int FindSpellbookSlot(int spellID, int *outBookType) {
return 0;
}
int SpellNameToID(const char *name) {
if (name == nullptr || *name == '\0')
return 0;
const int locale = *reinterpret_cast<const int *>(
static_cast<uintptr_t>(Offsets::VAR_LOCALE_INDEX));
// A trailing "(subtext)" pins a specific rank — the vanilla
// "SpellName(Rank N)" addressing that CastSpellByName and friends use
// (a space before the paren is tolerated: "Mind Blast (Rank 8)"). The
// subtext is matched against the record's localized Rank field. With no
// subtext, we return the highest known rank of the base name.
char base[256];
char wantRank[64];
bool haveRank = false;
{
const char *open = std::strrchr(name, '(');
const char *close = (open != nullptr) ? std::strrchr(name, ')') : nullptr;
size_t baseLen;
if (open != nullptr && close != nullptr && close > open + 1) {
baseLen = static_cast<size_t>(open - name);
size_t rankLen = static_cast<size_t>(close - open - 1);
if (rankLen >= sizeof(wantRank))
rankLen = sizeof(wantRank) - 1;
std::memcpy(wantRank, open + 1, rankLen);
wantRank[rankLen] = '\0';
haveRank = true;
} else {
baseLen = std::strlen(name);
}
while (baseLen > 0 &&
(name[baseLen - 1] == ' ' || name[baseLen - 1] == '\t'))
--baseLen; // trim any space between the name and the paren
if (baseLen == 0 || baseLen >= sizeof(base))
return 0;
std::memcpy(base, name, baseLen);
base[baseLen] = '\0';
}
// Player book first, then pet — retail's precedence. For a plain name we
// keep the LAST matching slot: a spell's ranks are stored in ascending
// order, so the final match is the highest rank the player knows. For a
// rank subtext we return the exact name+rank match.
const uintptr_t books[2] = {Offsets::VAR_PLAYER_SPELLBOOK,
Offsets::VAR_PET_SPELLBOOK};
for (uintptr_t bookBase : books) {
auto *array = reinterpret_cast<const int *>(bookBase);
int best = 0;
for (int i = 0; i < Offsets::SPELLBOOK_MAX_SLOTS; ++i) {
const int spellID = array[i];
if (spellID <= 0)
continue; // BSS-zeroed empty slot
const uint8_t *record = RecordForID(spellID);
if (record == nullptr)
continue;
const char *sname = *reinterpret_cast<const char *const *>(
record + Offsets::OFF_SPELL_NAMES + locale * 4);
if (sname == nullptr || std::strcmp(sname, base) != 0)
continue;
if (!haveRank) {
best = spellID; // highest rank (last match)
continue;
}
const char *srank = *reinterpret_cast<const char *const *>(
record + Offsets::OFF_SPELL_RECORD_RANK + locale * 4);
if (srank != nullptr && std::strcmp(srank, wantRank) == 0)
return spellID; // exact name + rank
}
if (!haveRank && best != 0)
return best;
}
return 0;
}
} // namespace Spell::Lookup
+9
View File
@@ -51,6 +51,15 @@ int SpellbookSlotToID(int slot1Based, int bookType);
// in either book.
int FindSpellbookSlot(int spellID, int *outBookType);
// Resolves a spell NAME to a spellID against the player's (then pet's)
// spellbook — the same scope retail's `GetSpellInfo(name)` uses. The
// match is exact and case-sensitive against the current locale's name
// field (matching retail and ShaguTweaks' `libspell`). When a spell has
// several ranks in the book, the highest rank is returned (the spellbook
// arrays list a spell's ranks in ascending order, so the last match
// wins). Returns 0 for a null/empty name or one the player doesn't know.
int SpellNameToID(const char *name);
// Resolves a 0-based UI slot into the recipe's spellID. Used by both
// tradeskill and craft scrapers — they share the same storage shape:
// `[entriesVar]` is a pointer to a heap-allocated array of recipe-
+1 -4
View File
@@ -21,15 +21,12 @@ namespace Spell::Mod {
namespace {
using ResolveUnitToken_t = void *(__fastcall *)(const char *token);
// The local player's class SpellFamilyName, or 0 if it can't be resolved
// (e.g. at the login screen). Gates the SpellMod family match — only
// spells of the player's own class family receive the player's mods, so
// a same-flag-bit spell of another family isn't falsely modified.
uint32_t PlayerSpellFamily() {
auto resolve = reinterpret_cast<ResolveUnitToken_t>(Offsets::FUN_RESOLVE_UNIT_TOKEN);
auto *unit = static_cast<const uint8_t *>(resolve("player"));
auto *unit = static_cast<const uint8_t *>(Game::ResolveUnitToken("player"));
if (unit == nullptr)
return 0;
auto *desc = Game::Read<const uint8_t *>(unit, Offsets::OFF_UNIT_DESCRIPTOR);
+2 -5
View File
@@ -21,7 +21,6 @@ namespace Spell::SkillLine {
namespace {
using ResolveUnitToken_t = void *(__fastcall *)(const char *token);
using SkillLineToSlot_t = int(__thiscall *)(void *player, uint32_t skillLineID);
// Reads UNIT_FIELD_BYTES_0 from the local player's descriptor. Same
@@ -31,8 +30,7 @@ using SkillLineToSlot_t = int(__thiscall *)(void *player, uint32_t skillLineID);
void ReadPlayerClassRace(uint8_t *outClassByte, uint8_t *outRaceByte) {
*outClassByte = 0;
*outRaceByte = 0;
auto fn = reinterpret_cast<ResolveUnitToken_t>(Offsets::FUN_RESOLVE_UNIT_TOKEN);
auto *player = static_cast<const uint8_t *>(fn("player"));
auto *player = static_cast<const uint8_t *>(Game::ResolveUnitToken("player"));
if (player == nullptr)
return;
auto *desc = Game::Read<const uint8_t *>(
@@ -232,8 +230,7 @@ int __fastcall Script_GetSkillLineRank(void *L) {
return 1;
}
auto resolve = reinterpret_cast<ResolveUnitToken_t>(Offsets::FUN_RESOLVE_UNIT_TOKEN);
auto *player = static_cast<uint8_t *>(resolve("player"));
auto *player = static_cast<uint8_t *>(Game::ResolveUnitToken("player"));
if (player == nullptr) {
Game::Lua::PushNil(L);
return 1;
+187
View File
@@ -0,0 +1,187 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
#include "Game.h"
#include "Offsets.h"
#include "dbc/Lookup.h"
#include "spell/Lookup.h"
#include <cstdint>
// `GetNumTrackingTypes()` / `GetTrackingInfo(index)` / `SetTracking(index)` —
// backport of the minimap-tracking enumeration the modern client exposes.
//
// Vanilla 1.12 never enumerates the tracking abilities the player KNOWS — it
// only stores the single currently-active tracker as a plain spellID in
// `VAR_ACTIVE_TRACKING_SPELL`, surfaced through `GetTrackingTexture()` /
// `CancelTrackingBuff()` / `GameTooltip:SetTrackingSpell()`. 3.3.5's
// `GetTrackingInfo` iterates a spellbook-derived list of tracking spells and
// reports name/texture/active/category per entry; we rebuild that list here.
//
// A tracking spell is identified exactly the way the engine's own active-
// tracker updater `FUN_004E4170` identifies one: a Spell.dbc record with an
// `EffectApplyAuraName` of Track Creatures (44), Track Resources (45), or
// Track Stealthed (151). That effect-based test reproduces pfUI's hardcoded
// spellID/icon table (Find Herbs/Minerals/Treasure/Trees, every Hunter
// Track*, Sense Undead/Demons, Track Humanoids) with no table to maintain,
// and auto-covers server-custom trackers such as Turtle's Find Trees (52917).
namespace Spell::Tracking {
// Tracking aura effect types — SPELL_AURA_TRACK_* ApplyAura names, the same
// values `FUN_004E4170` scans a spell's three effects for.
static constexpr int32_t AURA_TRACK_CREATURES = 44;
static constexpr int32_t AURA_TRACK_RESOURCES = 45;
static constexpr int32_t AURA_TRACK_STEALTHED = 151;
// True iff `spellID`'s record has any of the three Track* effects.
static bool IsTrackingSpell(int spellID) {
const uint8_t *record = Spell::Lookup::RecordForID(spellID);
if (record == nullptr)
return false;
auto *aura = Game::Ptr<const int32_t>(
record, Offsets::OFF_SPELL_RECORD_EFFECT_APPLY_AURA_NAME);
for (int i = 0; i < Offsets::SPELL_RECORD_EFFECT_COUNT; ++i) {
if (aura[i] == AURA_TRACK_CREATURES || aura[i] == AURA_TRACK_RESOURCES ||
aura[i] == AURA_TRACK_STEALTHED)
return true;
}
return false;
}
// Walks the player spellbook in slot order counting tracking spells. If
// `wantNth >= 1` and an Nth tracking spell exists, writes its spellID and
// 1-based spellbook slot to `*outSpellID` / `*outSlot`. Returns the total
// number of tracking spells found (so callers detect out-of-range by
// comparing the return against `wantNth`).
//
// The spellbook array is zero-padded past the populated count (see
// `Spell::Lookup::FindSpellbookSlot`), so a 0 entry is just an empty slot —
// skip it and keep scanning the full range rather than break, matching the
// existing spellbook walkers.
static int EnumerateTracking(int wantNth, int *outSpellID, int *outSlot) {
auto *book = reinterpret_cast<const int *>(
static_cast<uintptr_t>(Offsets::VAR_PLAYER_SPELLBOOK));
int found = 0;
for (int i = 0; i < Offsets::SPELLBOOK_MAX_SLOTS; ++i) {
const int spellID = book[i];
if (spellID <= 0 || !IsTrackingSpell(spellID))
continue;
++found;
if (found == wantNth) {
if (outSpellID != nullptr)
*outSpellID = spellID;
if (outSlot != nullptr)
*outSlot = i + 1;
}
}
return found;
}
// `GetNumTrackingTypes()` -> number of tracking spells in the player's book.
static int __fastcall Script_GetNumTrackingTypes(void *L) {
Game::Lua::PushNumber(L,
static_cast<double>(EnumerateTracking(0, nullptr, nullptr)));
return 1;
}
// `GetTrackingInfo(index)` -> name, texture, active, category, spellID.
// - index is 1-based into the tracking list (spellbook order).
// - texture is the SpellIcon.dbc path (nil if the icon is missing).
// - active is a boolean: the tracker currently in effect.
// - category is always "spell" — vanilla has no non-spell ("other")
// trackers, unlike 3.3.5's class-masked table.
// - spellID is a ClassicAPI extension (the modern 5th return is `nested`,
// which has no meaning here); handy since selecting/among trackers is
// spellID-driven.
// Returns nil for an out-of-range index.
static int __fastcall Script_GetTrackingInfo(void *L) {
if (!Game::Lua::IsNumber(L, 1)) {
Game::Lua::Error(L, "Usage: GetTrackingInfo(index)");
return 0;
}
const int index = static_cast<int>(Game::Lua::ToNumber(L, 1));
if (index < 1)
return 0;
int spellID = 0;
int slot = 0;
if (EnumerateTracking(index, &spellID, &slot) < index)
return 0; // index past the number of tracking types -> nil
const uint8_t *record = Spell::Lookup::RecordForID(spellID);
const int locale = Game::Read<int>(static_cast<uintptr_t>(Offsets::VAR_LOCALE_INDEX));
const char *name =
Game::Read<const char *>(record, Offsets::OFF_SPELL_NAMES + locale * 4);
const char *texture = nullptr;
const int iconID = Game::Read<int>(record, Offsets::OFF_SPELL_RECORD_ICON_ID);
if (auto *iconRec = DBC::Record(Offsets::VAR_SPELL_ICON_RECORDS,
Offsets::VAR_SPELL_ICON_COUNT,
static_cast<uint32_t>(iconID))) {
texture = Game::Read<const char *>(iconRec, Offsets::OFF_SPELLICON_PATH);
}
const bool active =
Game::Read<int>(static_cast<uintptr_t>(Offsets::VAR_ACTIVE_TRACKING_SPELL)) ==
spellID;
Game::Lua::PushString(L, name); // 1. name (PushString handles NULL -> nil)
Game::Lua::PushString(L, texture); // 2. texture (icon path) or nil
Game::Lua::PushBool(L, active); // 3. active
Game::Lua::PushString(L, "spell"); // 4. category
Game::Lua::PushNumber(L, static_cast<double>(spellID)); // 5. spellID (extension)
return 5;
}
// `SetTracking(index)` — selects (activates) the tracking spell at `index`.
// Vanilla has no "set tracking" primitive: choosing a tracker IS casting its
// spell (the tracking dropdown does exactly this). So we resolve the entry's
// spellbook slot and tail-call the engine's own `Script_CastSpell(slot,
// "spell")` — reusing its slot bounds-check, target resolution, and error
// handling rather than reconstructing the cast path. Returns nil for an
// out-of-range index.
static int __fastcall Script_SetTracking(void *L) {
if (!Game::Lua::IsNumber(L, 1)) {
Game::Lua::Error(L, "Usage: SetTracking(index)");
return 0;
}
const int index = static_cast<int>(Game::Lua::ToNumber(L, 1));
if (index < 1)
return 0;
int spellID = 0;
int slot = 0;
if (EnumerateTracking(index, &spellID, &slot) < index)
return 0;
// Rebuild the stack as CastSpell(slot, "spell") and hand off to the
// engine handler. `slot` is 1-based, matching what a Lua `CastSpell`
// caller passes.
Game::Lua::SetTop(L, 0);
Game::Lua::PushNumber(L, static_cast<double>(slot));
Game::Lua::PushString(L, "spell");
using CastSpell_t = int(__fastcall *)(void *);
auto castSpell = reinterpret_cast<CastSpell_t>(Offsets::FUN_SCRIPT_CAST_SPELL);
return castSpell(L);
}
static void RegisterLuaFunctions() {
Game::Lua::RegisterGlobalFunction("GetNumTrackingTypes",
&Script_GetNumTrackingTypes);
Game::Lua::RegisterGlobalFunction("GetTrackingInfo", &Script_GetTrackingInfo);
Game::Lua::RegisterGlobalFunction("SetTracking", &Script_SetTracking);
}
static const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace Spell::Tracking
+1 -3
View File
@@ -25,7 +25,6 @@ namespace Spell::Usable {
namespace {
using ResolveUnitToken_t = void *(__fastcall *)(const char *token);
using GetSpellCost_t = uint32_t(__fastcall *)(int spellID, int unit);
// Spell.dbc record field offsets we read for usability. Fully documented
@@ -52,8 +51,7 @@ bool PlayerKnowsSpell(int spellID) {
}
const uint8_t *PlayerDescriptor() {
auto resolve = reinterpret_cast<ResolveUnitToken_t>(Offsets::FUN_RESOLVE_UNIT_TOKEN);
auto *player = static_cast<const uint8_t *>(resolve("player"));
auto *player = static_cast<const uint8_t *>(Game::ResolveUnitToken("player"));
if (player == nullptr)
return nullptr;
return Game::Read<const uint8_t *>(
+230
View File
@@ -0,0 +1,230 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
// Stale table-length healing — Lua 5.1 border semantics for 5.0's
// out-of-band lengths.
//
// Lua 5.0 stores a table's `table.insert`/`getn` length OUT of band (a
// `t.n` field, else the registry's weak LUA_SIZES table). Clearing a
// table's keys (`for k in pairs(t) do t[k] = nil end`) does NOT reset it —
// 5.0 code must call `table.setn(t, 0)`. Lua 5.1 removed all of that:
// lengths are computed borders, and a cleared table is length 0
// automatically.
//
// That difference breaks the standard Ace2-era dual-compatibility idiom
// the moment our 5.1 syntax backport makes their probe pass:
//
// local lua51 = loadstring("return function(...) return ... end")
// and true or false
// local table_setn = lua51 and function() end or table.setn
//
// With `lua51` true (the `...`-expression now compiles via
// LuaSyntax::Transpile), `table_setn` is a NO-OP — correct on real 5.1,
// wrong on this VM, whose table functions still read the out-of-band
// length. Cleared-and-reused tables keep stale lengths: `table.insert`
// appends past the nil'd slots and `table.getn` counts entries that no
// longer exist. Verified live: Dewdrop-2.0's menus (the SuperAPI /
// NampowerSettings minimap buttons) break exactly this way, and work
// again when the probe is forced back to 5.0.
//
// Since we made the client PASS 5.1 probes, 5.1 length semantics must
// hold. The engine funnels every length read through ONE function —
// `luaL_getn` (LUAL_GETN, consulted by table.insert / getn / remove /
// concat / sort / foreachi, plus our `unpack`) — so a single co-hook
// closes the gap for every consumer at once:
//
// 1. Reported slot populated (`t[n] ~= nil`) → return n unchanged. The
// healthy path costs one rawgeti.
// 2. `t[n]` nil but `t.n` is a number → return n unchanged. An explicit
// `n` field is the 5.0 vararg-table contract (`arg` = {n=3, holes}),
// where trailing nils are intentional — healing it would corrupt
// vararg counts.
// 3. `t[n]` nil, no `t.n`: scan down for the true border `b` and heal to
// it (the 5.1 answer) — UNLESS the nil at slot `n` is a deliberate
// `table.insert(t, nil)` append slot, detected by the writer-side mark
// below, in which case keep the stored length. Read-only — no
// write-back, so a length read never mutates state; the engine's own
// `table.insert` calls `luaL_setn(n+1)` on the next append, which
// re-syncs the stored length by itself.
//
// Why a writer-side mark (the tinsert co-hook) and not a state heuristic: a
// deliberate 5.0 nil append (`table.insert(t, nil)` — writes `t[n]=nil` +
// `setn(n)`; issue #36: Waterfall's `{key,val,…}` builders append a nil for
// every option without a passValue, and the next insert must land AFTER the
// reserved slot) and a cleared-and-reused table that happens to be stale by
// exactly one (a one-element table pairs-cleared, or a recycled table whose
// previous life was one slot longer — Tablet-2.0's `del()`/`copy()` pool
// recycles per tooltip refresh, so this is everyday traffic) leave the table
// in BYTE-IDENTICAL states: stored `n`, dense below, `t[n]` nil, no `t.n`.
// No inspection of the table can split them, and caller-gating can't either
// (both reach here through `table.insert`). v1.12.8 tried a staleness-gap
// heuristic (keep when `n - b == 1`) and it corrupted the second group —
// issue #39, FuBar/Tablet/Dewdrop menus and tooltips shifting by one with a
// nil hole at slot 1. Only the WRITER knows which dialect the table speaks,
// and the write is observable: a co-hook on `table.insert`
// (FUN_LUA_TABLE_INSERT) records `t → storedN` in a weak-keyed registry
// table whenever the two-arg form appends a literal nil. Rule 3 keeps the
// stored length only for a marked table whose mark still equals `n`; every
// unmarked off-by-one table heals. A stale mark dies on its own: any later
// append/remove moves the stored length away from the recorded value, and
// the weak key lets a dead table's entry be collected.
//
// Note real Lua 5.1 gives the HEAL answer even for the deliberate append
// (`#`-based tinsert drops a trailing nil and the next insert overwrites
// it), so keeping the slot is deliberately 5.0-native behavior for
// 5.0-dialect writers — Waterfall's builder runs on the 5.0 `arg` vararg
// table and predates 5.1. 5.1-dialect code never relies on insert(t, nil)
// reserving a slot, because on real 5.1 it doesn't.
//
// Positional nil insertion (`table.insert(t, pos, nil)`) is not marked and
// heals; no consumer is known, and 5.0 itself makes it a shifted no-op.
//
// `table.setn` itself still works for code that calls it — the heal only
// changes the answer when the stored length points past the border and no
// mark vouches for it, which is the same answer real 5.1 would give.
#include "Game.h"
#include "Offsets.h"
namespace Table::Length {
namespace {
using LuaLGetN_t = int(__fastcall *)(void *L, int idx);
using RawGetI_t = void(__fastcall *)(void *L, int idx, int n);
LuaLGetN_t g_getnOriginal = nullptr;
Game::Lua::CFunction g_tinsertOriginal = nullptr;
// Registry key of the weak-keyed mark table: `marks[t] = storedN` recorded
// at the moment `table.insert(t, nil)` reserved slot `storedN`.
constexpr char kMarkKey[] = "ClassicAPI_TrailingNilMark";
// True if t[k] (rawgeti) is nil. Balances the stack.
bool SlotIsNil(void *L, int absIdx, int k) {
reinterpret_cast<RawGetI_t>(Offsets::LUA_RAWGETI)(L, absIdx, k);
const bool isNil = Game::Lua::Type(L, -1) == Game::Lua::TYPE_NIL;
Game::Lua::SetTop(L, -2);
return isNil;
}
// Pushes the mark table, creating `registry[kMarkKey] = setmetatable({},
// {__mode = "k"})` on first use (per Lua state — the registry survives
// `/reload` because the state is reused, and the marked tables survive with
// it). Leaves exactly one value on the stack.
void PushMarkTable(void *L) {
Game::Lua::PushString(L, kMarkKey);
Game::Lua::RawGet(L, Game::Lua::REGISTRY_INDEX);
if (Game::Lua::Type(L, -1) == Game::Lua::TYPE_TABLE)
return;
Game::Lua::SetTop(L, -2); // pop the nil. []
Game::Lua::NewTable(L); // [marks]
Game::Lua::NewTable(L); // [marks, meta]
Game::Lua::PushString(L, "__mode");
Game::Lua::PushString(L, "k");
Game::Lua::RawSet(L, -3); // meta.__mode = "k". [marks, meta]
Game::Lua::SetMetatable(L, -2);// pops meta. [marks]
Game::Lua::PushString(L, kMarkKey);
Game::Lua::PushValue(L, -2); // [marks, key, marks]
Game::Lua::RawSet(L, Game::Lua::REGISTRY_INDEX); // [marks]
}
// True when `marks[t] == n` — the nil at slot `n` was written by a
// deliberate `table.insert(t, nil)` and nothing has moved the stored
// length since. Balances the stack.
bool HasTrailingNilMark(void *L, int absIdx, int n) {
PushMarkTable(L); // [marks]
Game::Lua::PushValue(L, absIdx); // [marks, t]
Game::Lua::RawGet(L, -2); // [marks, marks[t]]
const bool match =
Game::Lua::Type(L, -1) == Game::Lua::TYPE_NUMBER &&
static_cast<int>(Game::Lua::ToNumber(L, -1)) == n;
Game::Lua::SetTop(L, -3); // pop both
return match;
}
// Co-hook on the engine's `table.insert` (both `table.insert` and the
// `tinsert` alias are this one C function). The two-arg form appending a
// literal nil is the 5.0 idiom that must keep its reserved slot; record it
// so rule 3 can tell it apart from an identically-shaped stale table. The
// detection runs before the original (the arg stack is caller-owned, so
// index 1 still holds the table afterwards); the mark reads the raw stored
// length the original's `luaL_setn` just wrote.
int __fastcall TableInsert_h(void *L) {
const bool nilAppend = Game::Lua::GetTop(L) == 2 &&
Game::Lua::Type(L, 1) == Game::Lua::TYPE_TABLE &&
Game::Lua::Type(L, 2) == Game::Lua::TYPE_NIL;
const int ret = g_tinsertOriginal(L);
if (nilAppend) {
const int storedN = g_getnOriginal(L, 1);
PushMarkTable(L); // [marks]
Game::Lua::PushValue(L, 1); // [marks, t]
Game::Lua::PushNumber(L, storedN);
Game::Lua::RawSet(L, -3); // marks[t] = storedN. [marks]
Game::Lua::SetTop(L, -2); // pop marks
}
return ret;
}
int __fastcall LuaLGetN_h(void *L, int idx) {
const int n = g_getnOriginal(L, idx);
if (n <= 0)
return n;
// Normalize a relative index once — the checks below push/pop around
// the access, and the `t.n` probe has a key on the stack at its
// access point, which would shift a negative index. Pseudo-indices
// (registry and below, <= -10000) pass through unchanged.
int absIdx = idx;
if (idx < 0 && idx > -10000)
absIdx = Game::Lua::GetTop(L) + idx + 1;
if (!SlotIsNil(L, absIdx, n))
return n; // healthy — the reported last slot is populated
// Explicit `t.n` count (vararg-table contract): trailing nils are
// intentional, keep the stored length.
Game::Lua::PushString(L, "n");
Game::Lua::RawGet(L, absIdx);
const bool hasExplicitN = Game::Lua::Type(L, -1) == Game::Lua::TYPE_NUMBER;
Game::Lua::SetTop(L, -2);
if (hasExplicitN)
return n;
// Scan down for the true border.
int b = n - 1;
while (b > 0 && SlotIsNil(L, absIdx, b))
--b;
// Stored length exactly one past the border: either a deliberate
// `table.insert(t, nil)` reserved slot (keep — issue #36) or a cleared /
// recycled table stale by one (heal — issue #39). The states are
// identical; only the writer-side mark recorded by `TableInsert_h` can
// tell them apart.
if (n - b == 1 && HasTrailingNilMark(L, absIdx, n))
return n;
// Stale — heal to the border, the 5.1 answer.
return b;
}
const Game::HookAutoRegister _hook{Offsets::LUAL_GETN,
reinterpret_cast<void *>(&LuaLGetN_h),
reinterpret_cast<void **>(&g_getnOriginal)};
const Game::HookAutoRegister _hookInsert{
Offsets::FUN_LUA_TABLE_INSERT, reinterpret_cast<void *>(&TableInsert_h),
reinterpret_cast<void **>(&g_tinsertOriginal)};
} // namespace
} // namespace Table::Length
+213 -92
View File
@@ -1629,6 +1629,78 @@ static const Game::HookAutoRegister _emitterHook{Offsets::FUN_TEXT_EMITTER,
using Paint_t = void(__fastcall *)(void *layout);
Paint_t g_paintOriginal = nullptr;
// Builds the region placements for `node`'s recorded icons, expressed RELATIVE
// to the owning fontstring `fs`'s rect min corner (the pool anchors them there,
// so the engine moves them with the line). Fills `out` and returns true ONLY
// when everything the placement needs is ready this instant — K live (raster
// globals written) and the fs rect resolved. Returns false otherwise, and the
// caller must NOT queue an empty set (that would hide the icons and the dedup
// would freeze them hidden — the scroll-landing bug's second head). Shared by
// the paint flush and the SMF-refresh early apply so both produce
// bit-identical placements (the pool's dedup depends on that). The per-icon
// geometry mirrors exactly what the emitter reserves — see IconAdvancePen.
bool ComputePlacementsForNode(void *node, void *fs,
std::vector<Text::InlineTexturePool::Placement> &out) {
if (!LooksReadable(node) || !LooksReadable(fs))
return false;
auto itIcons = g_nodeIcons.find(node);
if (itIcons == g_nodeIcons.end() || itIcons->second.empty())
return false;
const PenScale K = PenPerAnchor();
if (!(K.x > 1.0f) || !(K.y > 1.0f))
return false;
auto *n = reinterpret_cast<uint8_t *>(node);
const float ox = Game::Read<float>(n, Offsets::OFF_TEXT_NODE_ORIGIN_X);
const float oy = Game::Read<float>(n, Offsets::OFF_TEXT_NODE_ORIGIN_Y);
// Same pixel-snap mode the emitter honours (bit-7 clear) — gates the
// drawn-rect snap in the placement loop below.
const bool snapNode = (Game::Read<uint32_t>(n, Offsets::OFF_TEXT_NODE_FLAGS) & 0x80u) == 0;
const float *rc = Game::Ptr<const float>(fs, Offsets::OFF_REGION_RECT);
const float fsBottom = (rc[0] < rc[2]) ? rc[0] : rc[2];
const float fsLeft = (rc[1] < rc[3]) ? rc[1] : rc[3];
if (rc[1] == rc[3])
return false; // unresolved rect reads 0-width
for (const IconRecord &r : itIcons->second) {
// Screen left = pen + the FULL lead pad (the emitter reserves
// w + 1.5×pad in the advance: lead 1×, trail 0.5×). Pad is
// fontH-relative — must match IconAdvancePen exactly.
const float cx = r.x + ox + r.offsetX + r.fontH * g_iconPadFrac + r.outlineInk * 0.5f;
// Centre on the line: penY sits near the text top. offsetY shifts up
// (WoW convention), so subtract it. Tall icons centre in the grown line
// (the string-height co-hook added the overflow).
const float cy = r.y + r.fontH * g_centerFrac + oy + g_vBias - r.offsetY;
float rx = cx + g_regionCalX;
float y0 = cy + g_regionCalY - r.h * 0.5f;
float w = r.w, h = r.h;
if (snapNode) {
// Land the drawn rect on WHOLE render-target pixels, like the
// engine's own glyphs (crisp texel sampling). Size snaps
// independently of position so a :16 icon is EXACTLY 16px.
rx = std::floor(rx + 0.5f);
y0 = std::floor(y0 + 0.5f);
w = std::floor(w + 0.5f);
h = std::floor(h + 0.5f);
if (w < 1.0f)
w = 1.0f;
if (h < 1.0f)
h = 1.0f;
}
Text::InlineTexturePool::Placement p;
p.path = r.path;
p.x0 = rx / K.x - fsLeft;
p.y0 = y0 / K.y - fsBottom;
p.x1 = (rx + w) / K.x - fsLeft;
p.y1 = (y0 + h) / K.y - fsBottom;
p.color = r.color;
p.u0 = r.u0;
p.v0 = r.v0;
p.u1 = r.u1;
p.v1 = r.v1;
out.push_back(std::move(p));
}
return true;
}
// Walks the layout's live render nodes and, for each recorded icon, computes
// its screen geometry (node-local pen coords translated by the node's screen
// origin +0x70/+0x74 — the SAME transform the paint pass applies to glyph
@@ -1768,99 +1840,17 @@ void FlushLayout(void *layout) {
}
}
}
const float ox = Game::Read<float>(n, Offsets::OFF_TEXT_NODE_ORIGIN_X);
const float oy = Game::Read<float>(n, Offsets::OFF_TEXT_NODE_ORIGIN_Y);
// Same pixel-snap mode the emitter honours (bit-7 clear) — gates
// the drawn-rect snap in the placement loop below.
const bool snapNode =
(Game::Read<uint32_t>(n, Offsets::OFF_TEXT_NODE_FLAGS) & 0x80u) == 0;
// The fs rect, read HERE in the same flush as the icon coords — a
// coherent snapshot. Placements are stored FS-RELATIVE: an
// apply-time rect read raced the chat relayout (SetText invalidates
// the rect briefly), and a placement applied against a mid-relayout
// rect parked the icon off the line — then the dedup (unchanged
// absolute want) froze it there. Relative offsets are also
// position-invariant, so scrolling no longer re-places anything.
const PenScale K = PenPerAnchor();
float fsLeft = 0.0f, fsBottom = 0.0f;
bool fsRectValid = false;
if (fs != nullptr) {
const float *rc = Game::Ptr<const float>(fs, Offsets::OFF_REGION_RECT);
fsBottom = (rc[0] < rc[2]) ? rc[0] : rc[2];
fsLeft = (rc[1] < rc[3]) ? rc[1] : rc[3];
fsRectValid = rc[1] != rc[3]; // unresolved rect reads 0-width
}
// Geometry + readiness gate factored into ComputePlacementsForNode
// (shared with the SMF-refresh early apply so both produce
// bit-identical placements — the pool dedup depends on it). It
// returns false when K or the fs rect isn't ready; SKIP the queue
// then and retry next paint. An empty queue here would HIDE the
// line's icons and the dedup would freeze it hidden forever
// (identical empty re-queues never dirty) — the scroll-landing
// bug's second head, where lines painted before K was available
// queued {} and stayed iconless until their text changed.
std::vector<Text::InlineTexturePool::Placement> places;
for (const IconRecord &r : it->second) {
// Screen left = pen + the FULL lead pad. The emitter reserves
// w + 1.5×pad in the advance (lead 1×, trail 0.5×) — drawing at
// the raw pen put all of that gap AFTER the icon, which is why
// a string-final icon (money-string copper) looked jammed
// against its digits and grew per-coin offset hacks. The pad is
// fontH-relative (NOT r.w) — must match IconAdvancePen exactly.
const float cx =
r.x + ox + r.offsetX + r.fontH * g_iconPadFrac + r.outlineInk * 0.5f;
// Centre on the line: penY sits near the text top, so add a
// fraction of the font height (plus the fine-tune bias). offsetY
// shifts up (WoW convention), so subtract it.
//
// TALL icons (h > fontH) centre too: the string-height co-hook
// grew the line by the overflow, and the text centres itself
// inside the grown rect (verified in-game — a bottom-aligned
// icon clipped the neighbouring line while retail shows tall
// emotes centred with no clip), so the extra space appears half
// above and half below the text. A centred icon fills exactly
// that — the retail look.
const float cy = r.y + r.fontH * g_centerFrac + oy + g_vBias - r.offsetY;
if (K.x > 1.0f && K.y > 1.0f && fs != nullptr && fsRectValid) {
float rx = cx + g_regionCalX;
float y0 = cy + g_regionCalY - r.h * 0.5f;
float w = r.w, h = r.h;
if (snapNode) {
// Land the drawn rect on WHOLE render-target pixels,
// like the engine's own glyphs (rounded origin +
// truncated advances). Pen px = render px, the fsLeft/
// fsBottom terms cancel exactly through the anchor
// round-trip (the engine adds the same rect corner
// back at resolve), and the pool's width convergence
// preserves the linear factor — so an integral pen
// rect lands integral on screen: 1:1-sized icons
// sample texel centres (crisp) instead of blending
// four neighbours (soft). Size snaps independently of
// position so a :16 icon is EXACTLY 16px, never 15/17
// from the two edges rounding apart.
rx = std::floor(rx + 0.5f);
y0 = std::floor(y0 + 0.5f);
w = std::floor(w + 0.5f);
h = std::floor(h + 0.5f);
if (w < 1.0f)
w = 1.0f;
if (h < 1.0f)
h = 1.0f;
}
Text::InlineTexturePool::Placement p;
p.path = r.path;
p.x0 = rx / K.x - fsLeft;
p.y0 = y0 / K.y - fsBottom;
p.x1 = (rx + w) / K.x - fsLeft;
p.y1 = (y0 + h) / K.y - fsBottom;
p.color = r.color;
p.u0 = r.u0;
p.v0 = r.v0;
p.u1 = r.u1;
p.v1 = r.v1;
places.push_back(std::move(p));
}
}
// The queue gate must match the per-icon build gate EXACTLY —
// including K. If any input wasn't ready this paint (unresolved fs
// rect, raster globals not yet written), SKIP the queue and retry
// next paint: an empty queue here would HIDE the line's icons and
// the dedup would freeze it hidden forever (identical empty
// re-queues never dirty). That was the scroll-landing bug's second
// head: lines painted before K was available queued {} and stayed
// iconless until their text changed.
if (fs != nullptr && fsRectValid && K.x > 1.0f && K.y > 1.0f)
if (ComputePlacementsForNode(node, fs, places))
Text::InlineTexturePool::QueuePlacements(fs, std::move(places));
} else if (fs != nullptr) {
Text::InlineTexturePool::QueuePlacements(fs, {});
@@ -1890,6 +1880,121 @@ static const Game::HookAutoRegister _paintHook{Offsets::FUN_TEXT_PAINT,
reinterpret_cast<void *>(&Paint_h),
reinterpret_cast<void **>(&g_paintOriginal)};
// --- SMF-refresh early apply (kills the one-frame chat-icon lag) -------------
//
// The lag: a ScrollingMessageFrame re-SetTexts every visible line slot on each
// new message (FUN_00788750), but the engine builds each line lazily at PAINT.
// So a line's icons are recorded — and its regions placed — one frame AFTER its
// glyphs draw, because a frame's textures (our icon regions) draw before its
// fontstring glyphs and the placement isn't computed until the glyph build runs
// mid-paint. This co-hook closes it: after the refresh sets up the lines (still
// PRE-RENDER — the refresh runs from the SMF's Lua/event-driven methods, never
// mid-render), force each icon-bearing line to resolve+build and apply its icon
// placement synchronously, so the icons draw with their glyphs the same frame.
//
// Additive and best-effort: any line we can't resolve/build in time (rect still
// pending) falls back to the existing paint-flush + FrameTick pipeline (correct,
// one-frame lagged). Gated by its own toggle (default on) so it can be A/B'd and
// disabled independently of the whole feature; the SEH latch trips it on a fault.
bool g_earlyApply = true;
using SmfRefresh_t = void(__fastcall *)(void *smf, void *edx, int newestIdx);
SmfRefresh_t g_smfRefreshOriginal = nullptr;
using EnsureBuilt_t = int(__fastcall *)(void *node);
using RealizeAnchor_t = void(__thiscall *)(void *anchor, int flag);
using RebuildStringFs_t = void(__fastcall *)(void *fs);
// Force `fs` to resolve its rect and lay out its text node NOW (pre-render),
// mirroring what the engine's pending-layout pass + paint would do later.
// Returns the live text node, or nullptr if it couldn't be built yet (rect
// still unresolved — caller falls back to the lagged pipeline). Uses only the
// engine's own supported force paths (the same Realize/RebuildString/ensure-
// built the Show and paint paths call).
void *ForceBuildFontString(void *fs) {
// 1. Synchronous rect resolve (Realize flag 0 — the call Show itself uses;
// it unlinks the anchor from the pending-layout list and resolves now).
// The caller walks lines bottom-up, so this line's relativeTo (the line
// below it / the frame) is already resolved when we get here.
reinterpret_cast<RealizeAnchor_t>(Offsets::FUN_REGION_LAYOUT_REALIZE)(
reinterpret_cast<uint8_t *>(fs) + Offsets::OFF_REGION_ANCHOR, 0);
// 2. Rebuild the text block — creates the fresh node (but only if the rect
// is now resolved). Internally gated on the fs dirty bit, so a clean
// line is a no-op and we avoid depending on reading that bit ourselves.
reinterpret_cast<RebuildStringFs_t>(Offsets::FUN_FONTSTRING_REBUILD_STRING)(fs);
void *block = Game::Read<void *>(fs, Offsets::OFF_FONTSTRING_TEXT_BLOCK);
if (!LooksReadable(block))
return nullptr; // rect still unresolved — no node created
void *node = Game::Read<void *>(block, Offsets::OFF_TEXTBLOCK_NODE);
if (!LooksReadable(node))
return nullptr;
// 3. Lay the node out now: runs the glyph emitter (our Emitter_h records the
// icons) and finalizes the node origin. Idempotent — paint's own ensure-
// built re-runs later and the flush dedups the identical placement.
reinterpret_cast<EnsureBuilt_t>(Offsets::FUN_TEXT_ENSURE_BUILT)(node);
return node;
}
// Walk the SMF's visible line array and early-apply each icon-bearing line.
// The C++-object-holding body is kept out of the SEH frame (see
// SafeSmfEarlyApply) — MSVC won't allow __try where objects need unwinding.
void SmfEarlyApplyImpl(void *smf) {
if (!LooksReadable(smf))
return;
const int count = Game::Read<int>(smf, Offsets::OFF_SMF_LINE_COUNT);
if (count <= 0 || count > 1024)
return;
auto *arrayBase = Game::Read<uint8_t *>(smf, Offsets::OFF_SMF_LINE_ARRAY);
if (!LooksReadable(arrayBase))
return;
for (int i = 0; i < count; ++i) {
uint8_t *entry = arrayBase + i * Offsets::SMF_LINE_STRIDE;
if (!LooksReadable(entry))
break;
void *fs = Game::Read<void *>(entry, Offsets::OFF_SMF_LINE_FONTSTRING);
if (!LooksReadable(fs))
continue;
// Only shown lines (the desired-shown latch the SMF just set) that carry
// inline markup — a plain chat line costs nothing but the text scan.
if (Game::Read<uint32_t>(fs, Offsets::OFF_REGION_DESIRED_SHOWN) == 0)
continue;
const uint8_t *text = Game::Read<const uint8_t *>(fs, Offsets::OFF_FONTSTRING_TEXT);
if (!LooksReadable(text))
continue;
int len = 0;
while (len < 0x4000 && text[len] != '\0')
++len;
if (!HasInlineTexture(text, len))
continue;
void *node = ForceBuildFontString(fs);
if (node == nullptr || NodeEditable(node))
continue; // couldn't build in time, or editable (raw markup)
std::vector<Text::InlineTexturePool::Placement> places;
if (ComputePlacementsForNode(node, fs, places))
Text::InlineTexturePool::ApplyNow(fs, std::move(places));
}
}
// SEH latch mirroring SafeFlush — a fault disables the early path for the
// session (the baseline paint+tick pipeline still runs) instead of crashing.
void SafeSmfEarlyApply(void *smf) {
__try {
SmfEarlyApplyImpl(smf);
} __except (EXCEPTION_EXECUTE_HANDLER) {
g_earlyApply = false;
}
}
void __fastcall SmfRefresh_h(void *smf, void *edx, int newestIdx) {
g_smfRefreshOriginal(smf, edx, newestIdx);
if (g_inlineEnabled && g_earlyApply)
SafeSmfEarlyApply(smf);
}
static const Game::HookAutoRegister _smfRefreshHook{
Offsets::FUN_SMF_DISPLAY_REFRESH, reinterpret_cast<void *>(&SmfRefresh_h),
reinterpret_cast<void **>(&g_smfRefreshOriginal)};
// --- Lua control surface ---------------------------------------------------
// _classicapi_InlineTexEnable([on]) -> enabled. The feature kill switch (also
@@ -1903,8 +2008,22 @@ int __fastcall Script_InlineTexEnable(void *L) {
return 1;
}
// _classicapi_InlineTexEarly([on]) -> enabled. Toggles the SMF-refresh
// pre-render early apply (the chat-icon-lag fix) independently of the whole
// feature — for A/B'ing the lag against the baseline paint+tick pipeline, which
// keeps running either way. Also what the SMF SEH latch trips on a fault.
int __fastcall Script_InlineTexEarly(void *L) {
if (Game::Lua::GetTop(L) == 0)
g_earlyApply = true;
else
g_earlyApply = Game::Lua::ToBoolean(L, 1) != 0;
Game::Lua::PushBool(L, g_earlyApply);
return 1;
}
void RegisterLuaFunctions() {
Game::Lua::RegisterGlobalFunction("_classicapi_InlineTexEnable", &Script_InlineTexEnable);
Game::Lua::RegisterGlobalFunction("_classicapi_InlineTexEarly", &Script_InlineTexEarly);
}
static const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
@@ -1920,4 +2039,6 @@ void PrepareForReload() {
g_nodeBakedColor.clear();
}
static const Game::ReloadAutoRegister _reloadReg{&PrepareForReload};
} // namespace Text::InlineTexture

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