Compare commits

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Two client-side triggers mirror the server exactly:

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

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

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

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

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

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

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

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

All fire sites converted to the instance handle:
- CastEvents: the k* name constants become the AutoReserve instances,
  so all 19 fire sites stay textually identical; the Fire/FireReticle/
  FireRemote helpers take the reservation by reference.
- equipmentset: reservations move into Set.h as C++17 inline consts so
  the Data and Api TUs share one handle.
- Cache::QueryLoad::Register takes the AutoReserve pointer instead of
  an event-name string (creature/gameobject callers updated).
- Tts keeps its lazy VanillaTTS-stand-down reservations via
  null-guarded pointers set at registration.
- GlobalMouse/InputEvents/lossofcontrol instances moved above their
  fire sites (static-init list order is irrelevant).
2026-08-24 10:11:10 -05:00
Brues a2d25cef8b fix(object): gate ByGuid on the registry root to survive out-of-world calls
The engine resolver FUN_OBJECT_RESOLVE_BY_GUID funnels unconditionally
into FUN_00464890, whose first action loads the registry root from
[VAR_LOCAL_PLAYER_PTR] and derefs its +0x24 hash mask with no null
check. That root is NULL out of world (login, loading screens, logout
teardown), so any resolve reaching it mid-transition dies with ERROR
#132 (MOV EAX,[EDX+0x24], EDX=0) - hit in the wild by Aura::Source's
SMSG_SPELL_GO path, whose Store/Claim eviction consults
DescriptorListsAura while packets still dispatch during transitions.

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

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

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

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

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

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

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

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

- Include SecureTemplateTest debug addon for verification.

* Include AuraUtil.lua in ClassicAPI.toc

mistake from merge

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

CI release runs now bundle the pdb + map into one crash-symbols zip
attached to the release, replacing the loose renamed map. The zip name is
kept unappealing to normal players and carries the tag so a dev can match
symbols to the exact build.
2026-08-23 10:40:03 -05:00
Brues 7960266969 feat(addon): add [known] macro conditional to SecureCmdOptionParse
ClassicAPI extension (not a 3.3.5 conditional). [known:spellID] uses
IsPlayerSpell (broad knowledge: spellbook, talents, recipes, racials);
[known:name] falls back to a spellbook name scan via GetSpellName, since
1.12 has no name-to-id resolver -- so a name matches castable spellbook
entries only.

Verified in-game: [known:133] false on a non-Mage, [known:45651] and
[known:Aspect of the Viper] both true by id and by name.
2026-08-23 02:42:48 -05:00
Brues 85ed4eb378 feat(addon): backport SecureStateDriver + SecureCmdOptionParse
RegisterStateDriver / RegisterUnitWatch / attribute drivers, driven by a
Lua SecureCmdOptionParse whose macro-conditional grammar is reverse-
engineered from the 3.3.5 client. Builds entirely on the shipped frame
attribute system -- no DLL change.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Adds FUN_LUAL_LOADBUFFER to Offsets.h with its derivation.

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

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

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

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

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

* Use Lua length operator for class count

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Review cleanups, behavior-preserving:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* feat(addons): honor ## LoadSavedVariablesFirst

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Review findings against the Lua 5.1 syntax transpiler:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* revert MAX_CLASSES

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

The trailing-icon trim, outline-ink threading, and exact-K measure work have
since tightened that drift under half a glyph, so the existing 0.5-glyph
feasibility tolerance now carries the auto-width case on its own. Verified
in-game: pfUI/TwitchEmotes labels no longer spuriously wrap, and near-full
real-frame lines wrap correctly instead of overflowing.
2026-08-19 22:21:24 -05:00
Brues 653a2c6e3a Update README.md 2026-08-19 19:09:37 -05:00
Brues 85c5c924a9 fix(loader): install hooks off the loader lock to survive VanillaFixes' 10s injection deadline
DllMain enabled ~90 hooks individually, and every MH_EnableHook freezes all
process threads (CreateToolhelp32Snapshot + SuspendThread each) under the
Windows loader lock. On machines whose security stack intercepts thread
suspension, 90 serialized freezes ran past VanillaFixes' 10-second
remote-LoadLibrary deadline, so VF read the still-running injection thread as
STILL_ACTIVE (259) and showed its generic "compatible client" error — a false
diagnosis of a slow load that blocked launch entirely for affected users.

Move the whole install off the loader lock. DllMain now only spawns a worker
thread and returns immediately. Hooks install via a latched EnsureInitialized()
reached from two triggers: VanillaFixes' `Load` export (called on the game's
main thread after injection — VF's sanctioned extension point) or the fallback
worker (for any non-VF injector). Whichever arrives first installs; the other
observes the cached result. All hooks are queued and activated in a single
MH_ApplyQueued, so ~90 thread-freezes collapse to one.

- src/DllMain.cpp: minimal DllMain + InstallHooks/EnsureInitialized/Load export
- src/Game.cpp: RunHookRegistrations queues instead of enabling
- src/ClassicAPI.def + CMake /DEF: export `Load` undecorated
- src/Common.h: drop the now-dead HOOK_FUNCTION/UNHOOK_FUNCTION macros
2026-08-19 17:46:59 -05:00
Brues f2af2734f0 docs: trim internal implementation detail from FontString entries
User-facing API docs should describe the contract (behavior, inputs,
outputs, errors, observable caveats), not how a method is built or which
engine path it uses. Drop the "engine already computes this internally /
only adds the binding / comes from the engine's own wrap math / reads
what it last drew and compares" narration from the FontString entries,
keeping every observable behavior and caveat (the setup requirements,
the must-have-rendered-once note, the icon-inclusive widths, the <=1px
rounding note).
2026-08-19 17:04:02 -05:00
Brues dc13d7bbfc fix(fontstring): SetFormattedText uses engine str_format, not _G
SetFormattedText resolved _G.string.format and called it, so an addon
that replaced string.format could hijack or break it. Push the engine's
own str_format C function instead (strlib "format" entry 0x007FD3D0,
verified by decode) and pcall it -- immune to global replacement, and
matching retail, whose SetFormattedText formats in C. Also simpler: one
PushCClosure replaces the _G table walk and its two failure branches.

Verified in-game: with both string.format and the format global replaced
by a stub, SetFormattedText("%d apples", 5) still renders "5 apples".
2026-08-19 16:58:56 -05:00
Brues 99c0ab3ba3 refactor(tradeskill): use SetFormattedText
The trade-skill link frame's rank and summary FontStrings built their
text with SetText(string.format(...)). Use the backported
FontString:SetFormattedText instead -- one call, and it routes through
the engine's own SetText path.
2026-08-19 16:48:38 -05:00
Brues 87acb70c5b feat(fontstring): add IsTruncated, SetMaxLines/GetMaxLines
Backport three FontString methods, each working WITH the engine's own
text-layout machinery instead of reconstructing it:

- IsTruncated(): did the engine cut the text off with an ellipsis?
  Reads render truth -- the built text node keeps a copy of the string
  it actually laid out (node+0x48), the ellipsized form on truncation
  -- and compares it to the source. Same "displayed vs full" check
  retail does (5.4.8 inner fn 0x0045E001). Re-calling the display-text
  resolver from Lua cannot work: it reads the laid-out rect (fs+0x64),
  which is zero between frames, so it never sees the bound.

- SetMaxLines(n)/GetMaxLines(): cap the fontstring to n wrapped lines,
  ellipsizing the rest. Writes the engine's own per-fontstring maxLines
  field (fs+0x128, already honored by the truncation resolver) and
  re-invalidates the layout the way SetText does (drop caches, release
  node, notify region). Vanilla has no SetWordWrap -- the fs->layout
  flag translator exposes no no-wrap bit -- so SetMaxLines(1) is how you
  make a single-line, ellipsized label.

Also correct a mislabeled bit in the inline-texture notes: fs+0x120 bit
0x1000 (gxu/node bit 0x40) is NON-SPACE-WRAP (what SetNonSpaceWrap
toggles), not a dedicated "editable" flag. The inline-texture code uses
it as an editbox proxy, which works because multi-line editors enable
non-space-wrap while display text does not; the comments now say so,
with the rare-edge-case caveat. Documentation only, no logic change.

Verified in-game: IsTruncated true/false across bounded/unbounded boxes;
SetMaxLines caps of 1/2/9/0 with matching GetNumLines and IsTruncated.
2026-08-19 16:46:55 -05:00
Brues 92a959c381 feat(fontstring): backport GetWrappedWidth
The rendered width: the widest wrapped line -- the method that
completes the width trio (GetStringWidth and GetUnboundedStringWidth
both measure the string on ONE line; verified in-game they agree to
float noise). Uses the tooltip auto-size's own recipe: break positions
from FUN_FONTSTRING_BREAK_ARRAY, widest per-segment measure through
FUN_FONTSTRING_MEASURE_SUBSTRING -- both icon-aware through the
existing co-hooks. Segments are trimmed of the whitespace the wrap
consumed (the render never draws it, and a trailing space flips the
last real letter from ink to full-advance treatment, +~1.5px measured).

The wrap budget (shared with GetNumLines' unbuilt fallback via
WrapBudget) is rectWidth / fs+0x7C, calibrated EMPIRICALLY against the
render with a three-point probe on a 100px box at ~0.91 scale, using
text whose candidate lines measure 78.1 / 94.6 / 101.6 px:

  raw rectW    -> effective budget ~91px  -> broke one word EARLY
  rectW/scale^2 -> effective budget ~110px -> broke one word LATE
  rectW/scale  -> effective budget 100px  -> breaks exactly where the
                  render does (94.6 <= 100 < 101.6)

All three observations fit budget_px = pxOf(param) x scale. A paper
derivation from the rebuild's node-budget chain predicted /scale^2 --
one hop of that chain models live behaviour wrong; the probe data is
recorded in the code comment so the broken paper chain doesn't get
re-derived. (FUN_00772B60 multiplying its width arg by fs+0x7C before
gxu, and FUN_0044DB50 normalizing through FUN_0041AE40, are verified
decompiles.)

Verified in-game: 100px box, wrapped 93.8 / one-line 165.7 / 2 lines,
break at the render's own position. Residual <=1px vs a direct measure
of the line text -- the engine's own per-glyph measure conventions
(ink-vs-advance, kerning-variant flag), the same class GetStringWidth
carries; documented in API.md.
2026-08-19 15:04:21 -05:00
Brues efc6b29b6b docs(fontstring): correct GetUnboundedStringWidth vs GetStringWidth
The entry claimed GetStringWidth measures inside the wrap box -- it
does not, on any client: both methods measure the string on ONE line
(verified in-game: a fontstring wrapping in a 100px box reports ~165px
from both). They differ only on modern clients and only for
ellipsis-truncated text, where GetStringWidth reports the displayed
width; 1.12's getter never applies truncation, so the two always agree
here. The as-rendered width (widest wrapped line) is GetWrappedWidth,
which 1.12 lacks.
2026-08-19 14:30:22 -05:00
Brues b09db4d5a5 feat(fontstring): backport GetUnboundedStringWidth, GetNumLines, GetLineHeight, SetFormattedText
Four modern FontString bindings 1.12 never shipped, all thin wrappers
over engine machinery already verified in this codebase
(fontstring/Metrics.cpp grows from the GetStringHeight backport into
the general metrics module):

- GetUnboundedStringWidth(): the substring measure
  (FUN_FONTSTRING_MEASURE_SUBSTRING) over the whole string (len 0).
  Icon-aware for free -- the call routes through the InlineTexture
  co-hook. Verified in-game it agrees with GetStringWidth to float
  noise, which is retail parity: both measure unwrapped (the classic
  gotcha; the rendered width is GetWrappedWidth, a future backport).

- GetNumLines(): render truth from the node's built-line count
  (OFF_TEXT_NODE_BUILT_LINES = 0x9C, the draw builder's own counter /
  early-out gate) once painted; for an unbuilt node, the engine's
  break-array computer (FUN_FONTSTRING_BREAK_ARRAY = 0x00772B60, the
  tooltip auto-size's own segment source -- icon-aware via the
  wrap-stepper hook) against the fs's rect width. Empty text 0; no
  wrap constraint 1. Verified: 165px text in a 100px box reports 2.

- GetLineHeight(): the fs font-height getter (FUN_007727b0) through
  the standard push conversion; excludes spacing, like retail.

- SetFormattedText(fmt, ...): resolves the LIVE string.format (addon
  replacements honoured), pcalls it over the varargs, propagates
  format errors as real Lua errors, and sets through the engine's
  FUN_FONTSTRING_SET_TEXT so sanitizer/escape handling is identical
  to SetText.

New Offsets entries carry the derivations; docs/API.md documents all
four under the FontString section.
2026-08-19 14:26:02 -05:00
Brues 0ce9d159e9 fix(text): wrap-shrink axis error; substring measure; trailing-icon trim
Three inline-|T measure fixes, the first one load-bearing for chat.

1) Wrap shrink was overscaled by the render aspect. The wrap stepper's
inputs are gxu-normalized PER AXIS -- fontH normalized-Y (x rasterY =
pen px), wrapWidth/outWidth normalized-X (x rasterX = pen px); both
caller flavors land there (the draw builder passes node+0x1C/+0x3C,
pre-normalized at block creation via FUN_0041ae50/ae40, and the
fs-level callers x-normalize inside FUN_0044d670 before gxu, verified
by decompile). The hook converted the pen-x icon sum with fontH/fontHPx
-- a Y-AXIS factor -- overshrinking the wrap budget by rasterX/rasterY:
x1.33 at the 4:3 setups it was calibrated on (subtle, and the
glyph-relative tolerances were inflated by the same factor, masking
it), x2.37 at 2560x1080 -- icon-bearing chat lines wrapped a word or
two early per icon. The correct pen-x -> width-units factor is
1/rasterX; the auto-width bail, feasibility band, escalation step and
bisect threshold now derive from fontHPx/rasterX so a "half glyph"
tolerance is an actual glyph width.

2) The substring measure (FUN_00772AE0, new Offsets entry) is now
icon-aware. An earlier note claimed nothing consumed it; its xrefs
refute that: the GameTooltip auto-size measures each WRAPPED SEGMENT of
a wrap-enabled line through it (breaks from FUN_00772B60, already
icon-aware via the stepper hook) and takes the max as the tooltip width
-- so icon-bearing wrapped tooltip lines undersized their tooltip. Same
pen-exact adjustment as the width hook; the editbox caret/selection
callers (10 of its 11 xrefs) keep measuring raw via the established
editable-bit / focused-buffer gates.

3) A string-final icon now reports its INK edge instead of its full
advance (TrailingIconTrimPen: subtract the trail half-pad + half the
outline ink when the text ends in a well-formed span), matching the
engine's own last-glyph convention -- the measure loop ends on ink, not
advance. Render/justify/wrap keep the full advance (the pen genuinely
moves); only the reported width changes. The visible beneficiary is the
money-string shape ("...|TCoin:0|t"): a right-anchored auto-width money
fs sizes to the coin's drawn edge instead of ~2px past it. Remaining
width residual is sub-pixel (region placement rounding + the base
loop's ~0 payload-0 token contribution).
2026-08-19 14:07:01 -05:00
Brues 23b622acff fix(text): snap inline-icon drawn rects to whole render-target pixels
Icon regions placed at fractional pen positions through the anchor
math, so 1:1-sized art (a 16px emote at :16) sampled between texels and
rendered soft. Snap the drawn rect in the flush, gated on the node's
pixel-snap mode (bit-7 clear -- the same condition the engine's glyphs
snap under): position rounds to whole pen px, and SIZE rounds
independently of position so a :16 icon is exactly 16px, never 15/17
from the two edges rounding apart. Floors at 1px.

An integral pen rect survives to the screen because 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 multiplier is linear.
2026-08-19 13:48:43 -05:00
Brues f806f17f40 fix(text): snap icon advances to pen pixels; make fs measure hooks render-exact
Two changes to the inline-|T pipeline, both anchored in the engine's
own conventions.

1) Post-icon text rendered on half-pixel positions. Native glyphs
always sit on INTEGER pen pixels -- the origin finalize rounds
(FUN_005cdf70) and every glyph advance is truncated (the emitter's
__ftol when node bit-7 is clear) -- but IconAdvancePen added a
fractional advance (w + 1.5 x fontH x 0.18 + ...), shifting every glyph
AFTER an icon onto half-pixel positions: bilinear-filtered, fuzzy or
ghosted post-icon text, with visibility depending on the machine's
filtering config (the "artifacting only on the text after the icon"
report). IconAdvancePen now truncates when the node snaps, the
centre/right justify pre-shift snaps the same way, and the wrap
stepper's shrink probes carry the real snap flag so the budget matches
the render advance exactly.

2) The fs-level GetStringWidth / GetStringHeight hooks now compute the
icon contribution in TRUE PEN PX -- the exact numbers the emitter
reserves, outline ink and snap truncation included -- and bridge it
into the getter's return space via the font height known in both
spaces (sumPen x fontHInt / fontHPen). ResolveFsPenFont replicates the
render chain hop for hop: nodeFontSize = internal x fs+0x7C /
[0x832A48] (the rebuild feeds internal x 0x7C to FUN_0044d420, which
normalizes it through FUN_0041ae50 -- a hop whose omission on first
flight undersized fontHPen ~40%, growing icon-bearing chat lines with
phantom tall-icon overflow and pushing money-tooltip coins off their
right anchor), floored at [0x801628]/rasterY = 2 pen px (the node
ctor), realized by the emitter's own height helper. The outline ink
resolves through face = [[fs+0xE0]+0x20] -- verified stored verbatim at
node+0x44 by FUN_005c1c30 -> FUN_005cd6d0. fs-built nodes are always in
snap mode (no fs-flag maps to node bit 7 in FUN_0044d420), so the
snapped sums are exact for every fontstring. Replaces the old
SetPoint-push-factor bridge, whose units cancelled for :0 icons but
could not express the truncation or the ink.

New Offsets: OFF_FONTSTRING_FONT_HANDLE (0xE0), OFF_FONT_HANDLE_FACE
(0x20), FUN_TEXT_NODE_INVALIDATE derivation notes extended; the 2.0
outline constant's double duty as the minimum font size is documented.
Remaining measure residual: only the engine's own trailing-icon
ink-vs-advance <=1px.
2026-08-19 13:31:46 -05:00
Brues e9407f6564 fix(text): follow text-colour changes on icon-bearing fontstrings
The glue AddonList greys a disabled addon's title through SetTextColor
RGB alone (gold <-> gray, alpha stays 1.0). An icon-bearing title
followed neither channel: the icons only ever mirrored the colour's
ALPHA byte, and the TEXT was frozen at its bake-time colour -- a
segmented (bit-3) node carries baked per-glyph colours, and the
engine's colour setter (FUN_005CCB40) skips the rebuild-invalidate for
bit-3 nodes because UNIFORM bit-3 text recolours live at paint. Plain
titles swapped fine; icon-bearing ones didn't.

Two mechanisms, matching what each colour channel means:

- Text: re-bake on base-RGB change, everywhere. The emitter stamps the
  node's base colour (node+0x2c) at bake; the flush compares it against
  the live value and, on an RGB divergence, calls the engine's own node
  invalidate (FUN_TEXT_NODE_INVALIDATE = 0x005CDEF0 -- exactly what the
  colour setter does for accumulation nodes: clears the built state and
  frees the page buffers, so the next paint re-emits). The re-bake
  recolours base-coloured glyphs while |c runs keep their own colours
  -- baked per-glyph RGB cannot be patched in place. Alpha-only changes
  still take the cheap in-place alpha mirror (fades animate per frame;
  every baked glyph carries the node alpha, so the byte-patch is safe).

- Icons: brightness-follow on the GLUE screen only. The pool's fade
  signal widens from the colour's alpha byte to the whole folded BGRA
  colors[0] dword (FsColorKey), so a pure-RGB SetTextColor now trips
  the change detector at all; FadedColor then scales the icon by the
  text colour's VALUE (max of R/G/B) when no local player exists.
  Value-follow dims but can never tint -- gold and red titles have a
  255-max channel, factor 1.0 -- and in-world icons keep following
  alpha only (retail semantics: guild-green chat must not tint emotes).

Verified on the glue addon list: toggling now swaps title text AND
icons together (gold + bright <-> gray + dimmed), |c-coloured segments
keep their colours through the re-bake.
2026-08-19 13:10:28 -05:00
Brues f42f7459e2 docs(text): update InlineTextureEscapes.md for the exact placement pipeline
Current design gains the verified coordinate-space story (pen units are
render-target pixels; per-axis K = raster / anchorExtent from four live
globals, with the failed empirical and SetPoint-factor forms recorded),
the glyph baked-alpha mirror, and the outline-ink pads. The bit-3
section documents the per-glyph colour consequence, the positioning
steps reference the per-axis conversion, and the width-hook section
explains why its own ~1468 factor is a different, self-cancelling
conversion from the placement K.
2026-08-19 12:32:15 -05:00
Brues 968335debe fix(text): place inline icons with the engine's exact pen conversion
Inline |T icon regions were positioned right only at the resolution the
placement math was calibrated on. Root cause: the pen->anchor scale (K)
was derived EMPIRICALLY (node origin / fs rect justify edge) -- chat
could never derive its own (its rect edge sits near screen x = 0 and
failed the divide-guard), so it rode a global cache seeded by whichever
fontstring derived first and clamped to +/-10% forever. Wrong seed =
icon size and position drifting together, amplified by screen distance
(the skull rendering lines away from its chat message).

The engine's own conversion chain, decompiled end to end this session:

  FUN_0041ade0: block/node positions are stored NORMALIZED -- fs-local
    anchor x / [0x832A44] (screen width in anchor units), y / [0x832A48]
    (its previously unmapped y sibling).
  FUN_005cdf70: node origin (+0x70/+0x74) = round(normalized x
    [0xC2B9A4 / 0xC2B9A0]) -- integer render-target pixel dimensions,
    justify shift and vertical align folded in.
  FUN_005c2b50: writes those raster ints from the live render-target
    rect; every font re-rasterizes when they change.

So pen units are render-target pixels and K = raster / anchorExtent,
per axis, from four live globals -- no calibration, no cache, no fs
scale term. Verified against a live probe: rasterX/anchorW matched the
measured origin/rect-edge quotient to four digits, and icons now land
correctly at 2560x1080 and 1024x768 (previously 1080p-only).

Also fixes the coin-into-digits clip the exact K exposed: outlined
faces (pfUI money bars) draw INK past the glyph advance, which the old
miscalibrated K had been masking with accidental slack. The engine
quantifies that ink itself (emitter prologue: face+0x180 bit 3 -> 4.0
pen px, bit 0 -> 2.0, both .rdata constants); OutlineInkPen reads the
same flags and constants, half leading and half trailing every icon,
threaded through the shared advance helper (emitter advance, justify
pre-shift, wrap-stepper shrink probes). The fs-level GetStringWidth
adjustment passes ink 0 (it cannot cheaply resolve the gxu face) --
outlined icon text measures 2-4px short per icon there; render-side is
exact.

New Offsets: VAR_TEXT_RASTER_X/Y, VAR_UI_ANCHOR_SCREEN_H,
OFF_FONTFACE_FLAGS, FLOAT_OUTLINE_EXTRA_THICK/THIN, each with the
derivation chain.
2026-08-19 12:28:30 -05:00
Brues 04d4bf3326 fix(text): inset rotated glyph UVs half a texel against font-atlas bleed
An axis-aligned glyph quad samples its atlas cell on exact texel
boundaries, so bilinear filtering never crosses into a neighbouring
glyph. A quad rotated by FontString:SetRotation samples along
diagonals, and at the cell border the filter pulls in texels from
ADJACENT cells -- rendering as dotted fragments of other glyphs strung
along the rotated text's edges. Machine-dependent (renderer, driver
filtering, resolution/UI-scale phase decide whether samples land inside
the cell), which is why it reproduced on a user's 1440p setup but not
locally.

The engine has the same hazard with its bit-7 font nodes and ships the
fix: FUN_TEXT_EMITTER's init-once block (0x005CCBE0) sets four globals
to +/-0.001953125 (half a texel of the 256px font page) and shrinks
each glyph's UV rect by that per edge. Mirror exactly that inset for
quads we rotate: InsetGlyphUVs walks the node's page buffers in
four-vert quads and nudges each vert's u/v toward the cell centre.

The rotation baseline now captures/restores UVs alongside positions so
the inset never compounds across restore->rotate cycles, and
SetRotation(0) puts the untouched cell edges back. Unrotated text is
unaffected.
2026-08-19 11:28:56 -05:00
Brues c54556f6a1 refactor: centralize engine field/global access via Game::Read/Ref/Ptr
Replace the raw `*reinterpret_cast<const T *>(base + Offsets::OFF_X)`
idiom (and its double-cast variant when base wasn't already a byte
pointer) with three typed accessors in Game.h:

  Game::Read<T>(base, offset)  field value read
  Game::Ref<T>(base, offset)   writable field lvalue
  Game::Ptr<T>(base, offset)   pointer to a field (arrays / out-params)
  Game::Read<T>(address) / Ref<T>(address)  fixed-VA globals

Pure mechanical refactor, zero behavior change: ~330 cast sites across
84 files collapse to 651 helper calls. Offsets stay in Offsets.h per the
single-source-of-truth rule — the helpers only remove the cast noise and
fold away the inner `reinterpret_cast<uint8_t*>(obj)` base casts.

Left as raw casts by design: FUN_* function-pointer casts, void*/char*
VA handles passed as arguments (caches, method registries), subobject-
anchor pointers fed to __thiscall helpers, `void *volatile` player-ptr
readiness gates (converting would strip volatile), and VA-as-array-base
captures that are subscripted later.
2026-08-19 11:01:19 -05:00
Brues bd14a93a11 fix(text): keep baked glyph alphas in sync with the fontstring's live opacity
Text with an inline |T...|t icon stopped respecting its parent frame's
alpha: the icon faded, the glyphs stayed fully opaque. Iconless text was
unaffected.

Root cause: a fontstring's live opacity (SetTextColor alpha x the frame
chain's effective alpha, re-folded by FUN_0077fac0 on every alpha
change) lands in the node's uniform colour at node+0x2c, which the
paint consults PER FRAME for uniform text. But a segmented icon line
carries baked PER-GLYPH colours (the emitter hook clears node-flag bit
3), which the paint copy-out FUN_005c8710 uses verbatim with no later
modulation -- and FUN_005ccb40 skips the colour-change rebuild for
bit-3 nodes, so those baked alphas never refreshed. On top of that the
emitter hook force-baked alpha 0xFF: a misdiagnosis of the pfUI-bubble
"black text" bug, where the observed 0x07 was the bubble's live folded
alpha at build time (it built while faded out), not a stale default.

Fix, systemic for both bugs:
- Drop the 0xFF force -- colorState's build-time alpha (a node+0x2c
  copy) is already correct.
- Mirror the node's live alpha byte into every baked glyph colour from
  the flush walk, change-detected per page (a byte compare per frame
  until an actual fade). Same signal and cadence as the icon fade
  mirror; bit-3-clear (chat) nodes excluded -- the engine re-bakes
  those on colour change itself.

Adds OFF_TEXT_PAGE_COLOR_COUNT (0x1C), OFF_TEXT_PAGE_COLORS (0x20) and
OFF_TEXT_NODE_COLOR (0x2C) to Offsets.h with the derivation chain.
2026-08-19 10:17:25 -05:00
fuffcandBrues 5978d7dd62 fix(texture): measure vertex offsets against the untouched layout rect (#27)
* fix(texture): measure vertex offsets against the untouched layout rect

* fix(texture): honor SetTexCoordModifiesRect instead of dropping the crop

The flag is not inert in 1.12. Script_SetTexCoordModifiesRect
(0x0079C080) writes the Lua boolean to region+0x124 and
Script_GetTexCoordModifiesRect (0x0079C120) reads it back (1/nil), and
all three FUN_REGION_TEXCOORD_CROP call sites gate on that field: the
4-arg and 8-arg SetTexCoord writers (FUN_00770410 / FUN_007704C0)
crop+store only when it is set, and the layout-resolve store path
(FUN_00770670) crops when set / stores the raw anchor rect when clear.
The earlier "toggling it changes nothing" probe was a timing artifact:
the crop applies at the next SetTexCoord or layout resolve, never
retroactively.

So instead of removing the crop call, ApplyFromRect now applies it
under the engine's own gate. Default state (flag clear) behaves exactly
as this PR intends -- untouched layout rect, wedges fixed -- while a
region that DID opt in keeps its offsets measured against the same
cropped rect the engine stores, so the immediate apply and the
corner-store co-hook can never disagree about the base rect in either
flag state.

Adds OFF_REGION_TEXCOORD_MODIFIES_RECT (0x124) to Offsets.h with the
derivation, and corrects the FUN_REGION_TEXCOORD_CROP comment that
implied a partial SetTexCoord always shrinks the drawn quad.

---------

Co-authored-by: Brues <5278969+brues-code@users.noreply.github.com>
2026-08-19 09:44:11 -05:00
Brues 67172c95f7 feat(model): add Model:SetCreature; dress character displays natively
SetCreature(creatureID) resolves through the client creature cache to a
display ID, then loads like SetDisplayInfo (uncached entry = no-op,
documented with a RequestLoadCreatureByID recipe).

Character-based displays (extendedDisplayInfoID != 0) are now dressed by
the engine's own CharComponent compositor, mirroring
CGUnit_C::UpdateCharacterCustomization's NPC branch field for field:
baked body texture, hair/facial-hair geosets (CharHairGeosets,
CharacterFacialHairStyles), equipment geosets (sleeves, robe->trousers,
tabard), and helm/shoulder attachment models with HelmetGeosetVisData
hiding. Dress jobs run on WorldTick behind the engine's own
model-loaded gate, one compositor per Model frame, kept until the frame
loads another display or the reload teardown (PrepareForReload).

Attached helm/shoulder children additionally get the frame's light/fog
pre-render callback: the frame only registers it on its own instance,
so the children rendered pure black (verified in-game -- a light tint
colored the body but not the equipment). Weapons cannot show: NPC
weapons are server-pushed per spawned unit, not in any client table.

The manual baked-skin/hair-texture appliers are superseded by the
compositor and removed, along with the geoset debug probes
(_classicapi_ModelInstance/Peek/Poke).

Creature::Info gains a public DisplayID(creatureID) for the cache
resolve.
2026-08-19 09:28:30 -05:00
Brues 59c3bf9e0f feat(text): add FontString:GetGlyphVerts() introspection
The fontstring analog of Texture:GetCorners — returns the first glyph's
four baked vertex positions plus the bounding box of all glyph verts (12
numbers). Because the text paint (FUN_005c8710) is translate-only, these
node-local coords track the on-screen positions directly, so a 0-vs-90
dump reads the rotation direction arithmetically.

Also documents, on RotateVerts, why that dump can NOT settle rotation
CHIRALITY on its own: node-local glyph-vert Y handedness is not
determinable from the verts alone, so the visual comparison against the
retail-verified Texture:SetRotation is the arbiter. The RotateVerts sine
is unchanged (matches Texture:SetRotation / retail CCW-for-positive).
2026-08-18 19:27:11 -05:00
Brues 54782df95e feat(model): add Model:SetDisplayInfo(creatureDisplayID)
Backport the retail Model widget method (present since 5.4.8, string
verified in the 5.4.8 and 1.15.8 binaries) that points a Model frame at
a creature by its display ID rather than a raw .mdx path.

Resolve creatureDisplayID -> CreatureDisplayInfo[+0x04] ModelID ->
CreatureModelData[+0x08] ModelPath, load it through SetModel's own worker
(vtable+0x94), then apply the display's TextureVariation columns as
replaceable-texture skins (MONSTER_1/2/3 = slots 11/12/13). The DBC
column offsets are verified two ways: parsing the extracted 1.12 DBCs and
the engine's own unit-display resolver FUN_0060afb0.

Verified in-game: displays 13/330 (human NPCs) load correctly textured
and lit; missing assets fall back to the engine's error cube.

Only the single-model form is implemented; the optional mountDisplayID
(rider-on-mount) second arg is a separate follow-up.
2026-08-18 19:26:58 -05:00
fuffc 3ab690ac53 fix(texture): correct vertex-offset units and the corner-store contract (#26)
* fix(texture): correct vertex-offset units and the corner-store contract

The SetVertexOffset/SetRotation corner writes were wrong in four coupled
ways, found by driving pixel-ruler and wedge fixtures against a raw
corner-array readback:

- Offsets converted px->anchor with the global 1024-normalized factor
  alone, so a region scaled 0.9 got every offset 1/0.9 too large (a 24px
  request measured at 26.67px). Offsets are now stored in the caller's
  LOCAL pixels and folded through the region's live effective scale
  (+0x7C) inside WriteCorners, which also makes them track later scale
  changes. Verified at two scale points (0.9 and 0.45): a 24px offset
  lands at 24.00px on both.

- The anchor space is Y-DOWN (a rect reads top=0.222 < bottom=0.277),
  so +offsetY -- up, the retail contract -- is numerically negative
  there. It was added positive, sending every vertical offset the wrong
  way.

- The draw consumes the +0xD4 array with y INVERTED about the rect's
  horizontal midline, and CULLS a quad whose slot y-parity flips: quads
  stored at their target positions rendered as exact y-mirrors of the
  stored corners, and mirroring the values in place culled every
  transformed quad, including a plain rotated square. Each corner's
  mirrored data is therefore written into its VERTICAL PARTNER's slot
  (BL<->TL, BR<->TR): positions land on target after the draw's flip,
  and the slot parity keeps the winding the draw accepts. For an
  axis-aligned rect this degenerates to exactly what the engine itself
  writes -- which is why none of it was visible on plain rects or
  centered squares, both mirror-invariant as point sets.

- With that store contract, the y-down-transposed rotation matrix is
  the one that renders a positive angle counter-clockwise (settled with
  a rotated-bar fixture; squares and near-equilateral arrows are
  direction-blind).

Only regions carrying an active transform are affected: the corner-store
co-hook passes untracked regions through unchanged.

* feat(texture): GetCorners debug binding

texture:GetCorners() returns the raw drawn-quad corner array (+0xD4)
and the resolved layout rect -- 12 numbers, no unit conversion.
Diagnostic tooling: comparing stored corners against rendered pixels is
what made the corner-store contract measurable from Lua. Kept for
future transform triage; note that with the mirrored-partner store the
raw values deliberately differ from the on-screen positions.
2026-08-18 17:34:08 -05:00
Brues 6b13368e65 feat(fontstring): backport FontString:SetRotation / GetRotation
Generalizes the region-transform module (formerly texture-only) to serve
the FontString method registry too, backporting the modern
`fontstring:SetRotation(angle [, cx, cy])` and `GetRotation()`.

Rotation is visual only, matching retail: GetStringWidth/GetStringHeight/
GetRect/SetPoint stay axis-aligned. A positive angle is CCW, same
convention as Texture:SetRotation; the optional cx,cy pivot is a superset
of retail (centre-only).

How it works: the text paint copy-out (FUN_005c8710) is translate-only,
so a rotation baked into the node-local glyph verts survives to screen.
SetRotation rotates the live verts immediately (like the texture path)
around the vert bounding-box centre, idempotently via an axis-aligned
baseline snapshot (restore -> rotate). The baseline is re-captured on
every fresh vertex bake, wired through inline-texture's DrawBuilder
co-hook (gated on g_buildEmitSeq > 0 so clean paints never double-rotate,
since inline-texture owns every text-render hook). Node page-buffer
vertex layout added to Offsets.h (cross-verified across the emitter,
FUN_005c8710, and FUN_005ce0c0).

Known limitation: inline |T icons in a rotated fontstring don't turn with
the text (separate anchored regions). Plain text rotates correctly.
2026-08-18 16:54:42 -05:00
Brues 5456295e2e fix(item): throttle item-cache queries; prioritize carried items
Post-settle `WarmCache` fired one CMSG_ITEM_QUERY_SINGLE per item
immediately, and `OnWorldTick` escalation issued every pending item in a
single tick. A `/reload` that makes addons (pfQuest/Questie/Bagnon) look
up hundreds of items in one frame therefore emitted an unbounded packet
burst — a reported 1,325 requests in ~14 ms exhausted a consumer
router's NAT table and dropped the machine's internet connectivity.

Make `OnWorldTick` the single point that issues queries and rate-limit
it. All warmups now route through the pending queue (`WarmCache` ->
`Track`); background misses go out at most one per 50 ms (20/sec). The
timeout now counts only after a query is actually issued, so a throttled
backlog no longer fires spurious failures for items never queried.

To keep the throttle from delaying the player's own inventory on a cold
cache, add a priority tier: the carried items (equipment + backpack +
equipped bags) issue first on a separate budget (up to 6/tick, no
spacing floor). The owned set is a bounded snapshot built by a pure-C
inventory walk (no Lua stack — safe at world-tick time), refreshed at
most once per second and only while there is queued work. Peak issue
rate stays ~7 packets/frame, far below the flood.

Fixes #25
2026-08-18 15:31:33 -05:00
Brues 9b34ceff9e feat(unit): add UnitCreatureID(unit)
The unit-token twin of C_CreatureInfo.GetCreatureID — resolves the token
to a GUID and reads the creature-template entry vanilla packs into bits
24-47. nil for players, empty tokens, and non-creature/pet GUIDs.

Factor the entry extraction + creature/pet gate out of GetCreatureID into
a shared Guid::CreatureEntry so both callers use one source of truth for
the GUID bit layout.
2026-08-18 14:44:06 -05:00
Brues a373a775cf Clarify UnitSpellTargetName docs 2026-08-18 14:34:32 -05:00
Brues e9014801b6 docs: move UnitSpellTargetName to the Unit section
It's a Unit* function taking a unit token, so it belongs with the other
Unit calls, not under Spell. Relocates the API.md section + ToC entry and
the README calls-table row.
2026-08-18 14:28:20 -05:00
Brues dc8a510611 feat(spell): add UnitSpellTargetName(unit)
Returns the name of the unit a caster is casting/channeling a spell at,
or nil. The cast's target rides in SMSG_SPELL_START (SpellCastTargets:
u16 mask + packed GUID when TARGET_FLAG_UNIT) — we already parse that
packet for cast timing and just stopped before the target block. Extend
the parse to capture the target GUID for the player (g_castTargetGuid)
and remote units (RemoteCast.targetGuid), gated on an active cast so a
stale target is never returned.

nil when the unit isn't casting, the spell has no unit target
(self/ground/item), or the target's name can't be resolved.

Factor the GUID->name chain (object manager -> friends -> NameCache) out
of UnitNameFromGUID into a shared Player::Info::NameFromGuid so both
callers use one source of truth.
2026-08-18 14:23:59 -05:00
Brues d514dac465 docs(readme): mention inline-texture rendering in the Behaviors table
Adds a Behaviors row for the inline `|T…|t` texture backport (icons in
FontStrings, chat, and tooltips; icon-inclusive GetStringWidth/Height).
Raid marks were already covered by the `mark1`..`mark8` Unit tokens row.
2026-08-18 13:39:06 -05:00
Brues 621d1a9db9 feat(attributes): cast spell without a unit via CastSpellByName
A `type="spell"` click-cast button with no `unit` attribute did nothing:
the `spell` verb bailed the moment no unit resolved. Now it reads the
`spell` attribute first, then branches — with a `unit` it casts at that
unit via C_Spell.CastAtUnit (unchanged), and with no `unit` it falls back
to the plain global CastSpellByName on the current target, like `/cast`.

Fixes #18.
2026-08-18 13:28:54 -05:00
Brues 7d566f3787 fix(spell): stop false cast interrupts on grenades / movement-immune casts
Throwing a grenade and moving mid-cast fired a false UNIT_SPELLCAST_INTERRUPTED
and cut the bar short, even though the throw completed (issue #23). A cast is
interrupted by movement only if its Spell.dbc InterruptFlags carry
SPELL_INTERRUPT_FLAG_MOVEMENT (verified against the server's HandleMovement-
Opcodes and the client's own CheckCast); thrown items (e.g. spell 4068) lack it,
so the server never interrupts them on movement, but the engine still drops the
client cast-state, which our poll surfaced as an interrupt.

Fix: when the movement path clears a movement-immune cast WHILE THE PLAYER IS
MOVING (PlayerMoving mirrors the engine's 0x200f cast-drop mask), keep it tracked
instead of ending it; it then finishes as SUCCEEDED (no INTERRUPTED) or, if its
SPELL_GO never comes, ends a grace past its computed end. A real cancel/interrupt
happens with the player stationary, so it clears normally. Gated on a per-cast
latch, so normal (movement-interruptible) casts are unchanged.

Also de-dup UNIT_SPELLCAST_INTERRUPTED: the client fires Spell_C_SpellFailed AND
drops the tracked cast for the same interrupt (e.g. an Esc cancel), which fired
the event twice; PollPlayer now skips its own when SpellFailed_h already fired
one for the same spell. SuperWoW-style kicks (no Spell_C_SpellFailed) stay owned
by the poll.

New Offsets.h constants: SPELL_INTERRUPT_FLAG_MOVEMENT (InterruptFlags bit 0),
MOVEFLAG_MASK_CAST_DROP (0x200f). Wrap-safe deadline/window checks use
Time::Clock::Reached / Elapsed.

Fixes #23.
2026-08-18 13:10:11 -05:00
Brues acc9a85bc6 feat(spell): CastSpellNoToggle accepts an optional unit token
CastSpellNoToggle(name | spellID [, unit]) now takes a unit token as an
optional second argument, so the spell fires at that unit without
changing the current target:

    CastSpellNoToggle("Auto Shot", "focus")
    CastSpellNoToggle("Shoot", "targettarget")

The spam-safe toggle gates run first and are unchanged: if the requested
spell is already auto-repeating or its self-aura is already up, the call
stays a no-op and the unit is ignored. When the spell does cast and a
unit is supplied, it dispatches through the existing Spell::AtUnit core
(resolve token -> GUID -> dispatch) instead of the plain CastSpellByName
-- a unit-target/auto-repeat spell fires straight at the unit, a ground
spell lands at its feet, and an unknown token raises the engine's
standard "Unknown unit" error. No new offsets or hooks.

Fixes #22.
2026-08-18 12:02:04 -05:00
Petr KomogortsevandBrues 207d47c8b8 feat(bindings): backport direct-action and override binding APIs (#19)
* feat(bindings): backport direct-action and override binding APIs

Add SetBindingSpell/Item/Macro/Click plus the owner-scoped
SetOverrideBinding family. Store permanent commands through vanilla
SetBinding, execute SPELL/ITEM/MACRO/CLICK in the resolved-command hook,
and layer priority overrides ahead of the native binding table.

Factor raw and saved macro dispatch into Macro::Execute so click
attributes and binding commands share the same parser fallback. Preserve
key-down/key-up pairing when overrides change during an action.

Document return values, persistence, precedence, macro-text usage, and
the deliberate 1.12 differences in README and docs/API.md.

* fix(bindings): normalize override key order and preserve command exec-context

Two fidelity gaps in the override binding layer, both verified against the
1.12 binary:

- Override key matching now normalizes modifier order to the engine's own
  keypress form (ALT-CTRL-SHIFT- before the base key, per FUN_004b6630), so a
  caller passing "CTRL-ALT-F" matches the "ALT-CTRL-F" the engine builds at
  keypress. Prefix-stripping keeps the minus base key intact ("SHIFT--").

- Override-dispatched commands now run inside the same execution context the
  engine wraps around a native keypress (save/zero/restore DAT_00ceeac0, its
  nesting depth DAT_00ceeac4, and the manager +0xD8 mid-command flag, per
  FUN_004b7990). Without it, an ordinary Bindings.xml command routed through
  SetOverrideBinding ran without the context its handler expects. The native
  binding-table path is untouched (the engine already establishes context
  before the co-hooked FUN_004b7b50).

Adds the three verified offsets to Offsets.h and documents the any-order,
case-insensitive key handling in docs/API.md.

---------

Co-authored-by: Brues <5278969+brues-code@users.noreply.github.com>
2026-08-18 11:32:30 -05:00
brues-code 76f48de67f Inline |T…|t texture escapes (with coin strings, texture methods, and TextureUtil) (#24)
* feat(text): inline |T...|t texture escapes in chat and display text

Backport modern inline-texture markup to the 1.12 text engine: |Tpath:h|t
renders an icon inline in FontStrings / chat, for BLP and uncompressed TGA.

- Rendering primitive: resolve a path to a resident CGxTexture via FUN_0044acf0
  and draw it as a textured quad through the UI text VB path.
- Positioning: co-hook the per-line glyph emitter (FUN_005ccbe0); render plain
  runs by delegating to the original per segment (threading the pen through
  linkState[4], read as a float), record icon quads at the pen, and flush them
  in the paint pass (FUN_005c8fe0) translated by each node origin. Handles the
  FontString pipe-doubling (|T -> ||T), the bit-3 per-call batch-clear mode,
  multi-icon and multi-line accumulation, and font-relative vertical centering.
- Measure/wrap: co-hook the shared tokenizer (FUN_005c2810) so an inline-texture
  span is one near-zero-width token, so icons no longer force early line wrap.
- Editbox exclusion: read the focused-editbox global (DAT_00cf4dc8) and suppress
  icon recording while an input field is focused, so editboxes show raw,
  editable markup while cached display text keeps its icons.

Diagnostic scaffolding (stats/capture Lua helpers) is still present. Follow-ups:
exact icon width in measure (hover-box accuracy), texcoords for sprite sheets,
vertex color, and the already-built editbox case. See docs/InlineTextureEscapes.md.

* fix(text): exclude editboxes from inline textures via node flag bit 6

Editable text (node flags [+0x5c] bit 6 / 0x40 -- verified in-game: the macro
editbox is 0x4D vs chat display 0x20D and FontStrings 0x0D) now shows raw,
editable |T...|t markup instead of rendered icons, checked per node in the
tokenizer, emitter, and flush. This is the 1.12 analog of 4.3.4's per-render
texture-disable flag (tokenizer bit 0x1000).

Replaces the earlier focus-global-only exclusion, which only caught single-line
inputs that rebuild on each keystroke; bit 6 also covers multi-line editors
(the macro editor) that build once while un-focused. The keyboard-focus global
(DAT_00cf4dc8) is kept as a fallback. Removes the failed monochrome heuristic.

Diagnostic scaffolding remains; a docs update and cleanup pass follow.

* feat(text): inline-texture texcoords + fix vertical flip

- Add the width / offset / texcoord fields to the escape
  (:width:offsetX:offsetY:texW:texH:left:right:top:bottom), so a sprite sheet
  such as UI-RaidTargetingIcons can be cropped to a single cell.
- Fix a vertical flip: the UI device backend is OpenGL (bottom-left texture
  origin), so top screen corners map to v1 and bottom corners to v0. The flip
  was invisible on symmetric icons but obvious on directional ones (raid-target
  markers, swords).

* docs(text): document the completed inline-texture RE map

Fold this session's findings into InlineTextureEscapes.md now that the feature
ships: the frame -> renderNode -> group -> node -> line render hierarchy;
segmented-delegation positioning (the float linkState[4] pen read, the bit-3
per-call batch-clear handling, first-line multi-line accumulation); font-relative
vertical centring; the FontString pipe-doubling (|T arrives as ||T); the shared
tokenizer measure/wrap fix; editbox exclusion via node flag bit 6 (0x40), with
the failed focus-global and monochrome attempts recorded so they aren't retried;
and the texcoord fields plus the OpenGL bottom-left vertical flip.

* chore(text): enable inline textures by default on this branch

* feat(text): inline-texture vertex colour tint (:r:g:b)

Parse the trailing r:g:b (0-255) positional fields of the inline-texture
escape and apply them as the quad's vertex colour, so |T...:r:g:b|t tints
the icon (the font stage's default MODULATE COLOROP does texture.rgb x
tint; white = untinted). The bytes pack to 0xFFrrggbb -- the same
0xAARRGGBB order the tokenizer builds for |cAARRGGBB text, verified from
FUN_005c2810's colour branch -- so the value flows straight into the
existing vertex colour slot. Threaded parse -> IconDesc -> IconRecord ->
DrawTexturedQuad. Verified in-game (red/green/blue/white diamonds).

Also record the hover-accuracy (measure-width) verdict in the docs: the
tokenizer reports an icon as ~zero width, so GetStringWidth/wrap/hit-test
undercount it. Scoped by disassembly (FUN_005c6940 width backend,
FUN_005c6b70 per-glyph advance, FUN_005cdc20/FUN_005c7260 wrap): the token
contract has no width field, there is no single cold choke, and the only
fixes are hot per-glyph hooks (against the MinHook-collision guidance) or
~6 parallel measure-loop hooks -- for a mostly-cosmetic gain. Deferred.

* docs(text): record editbox/measure findings; strip inline-texture debug scaffolding

No behavior change — functionally identical to the shipped inline-texture
feature. This captures what a long investigation established and removes the
temporary debug instrumentation added during it.

Comments (src/text/InlineTexture.cpp):
- The tokenizer's `flags & 0x40` gate is load-bearing for RENDER, not just
  measure: a suppressed editbox delegates to the ORIGINAL emitter, which
  re-enters the tokenizer to lay out glyphs, so bit 0x40 (EDITABLE) must make
  the tokenizer stand down or the macro editor draws BLANK instead of raw.
- bit 6 (NodeEditable) and the focus global (InputFocused) are COMPLEMENTARY,
  not redundant: bit 6 catches the un-focused macro editor; the focus global is
  the only signal for single-line focused inputs (chat box, pfChatCopyBox),
  whose nodes lack bit 6 (0x0D/0x20D, indistinguishable from display).
- Removed the tokenizer call/pipe/span counters and the ZZWIDTH TokCap capture
  (debug-only), and the now-unused g_lastFocused/g_lastMono stats fields.

Docs (docs/InlineTextureEscapes.md):
- Editbox exclusion: two complementary signals, and VERIFIED against retail —
  typing `|T` into a chat box or macro editor shows raw there too; only
  addon/system text renders. Our editbox-raw + display-renders split matches.
- Measure gap: added the focus-time caveat (icon ~0 width unfocused; counts the
  literal path text while an editbox is focused — the editbox-raw tradeoff).
- Vertex-colour tint documented in the descriptor-fields section.

* feat(chat): strip player-injected |T icon spoofs from chat + bubbles

Vanilla neutralizes player-TYPED escapes in chat (a typed |c/|H/|T shows
raw); only trusted text (addon AddMessage, shift-clicked links) renders
them. Our Text::InlineTexture backport broke that parity for |T alone: it
detects the neutralized ||T form and draws the icon anyway, so a player
could /say |Tpath|t and put a real icon in everyone's chat AND the speech
bubble over their head. This restores vanilla behavior for |T while leaving
|c/|r/|H/|h exactly as vanilla.

It can only be done at the chat source, not the text emitter: addon |T and
player |T reach the emitter as byte-identical ||T (vanilla doubles the
unrecognized |T for both), so the only place they differ is provenance --
server chat flows through the chat dispatcher FUN_0049A870, addon print does
not. That one function copies the message into a single buffer fed to BOTH
the chat frame and the say/yell bubble spawn (FUN_00608AC0), so sanitizing
on entry covers both. (ECX is the MESSAGE, not the "sender name" the old
note claimed -- verified from the call site in FUN_0049D560.)

Structure mirrors Net::PacketDispatch (one hook, fanned out), since MinHook
allows only one hook per target and this choke point already had the
GUID-capture concern on it:
  - Chat::Dispatch      -- single owner of the FUN_0049A870 hook; orchestrates
  - Chat::IconFilter    -- pure Sanitize(); no hook, no engine state
  - Chat::CurrentGUID   -- GUID globals + getter + DispatchScope RAII (no
                           longer owns a hook)

Defang detail (the subtle bit): the message arrives doubled (||T). Blanking
the PIPE would leave a lone `|` + space -- an INVALID escape the chat frame
tolerates but the bubble text setter chokes on, producing an empty bubble
that fades instantly. So we keep the `||` (a valid literal-pipe escape) and
blank the `T`; for a lone |T we blank both chars. No dangling `|`, no |T
opener survives. Verified in-game: typed |T shows raw in chat and bubble,
addon icons still render, colours and links untouched.

* feat(text): per-editbox inline-texture suppression

Inline-texture rendering was globally suppressed whenever any editbox held
focus (VAR_FOCUSED_EDITBOX != 0), so opening the chat box flipped every
rendered icon/emote in chat history to raw |T...|t.

Replace the global check with per-editbox buffer correlation: suppress inline
rendering only for text that belongs to the FOCUSED editbox, matched by
pointer range against its own text buffers. Chat history keeps rendering while
you type.

The editbox keeps its text in two buffers (both verified via Ghidra), and both
must be matched or the field desyncs (raw width but a rendered icon):
- INPUT buffer ([fe+0x32C]/[fe+0x334], selected by [fe+0x318]&8) — the
  caret/width MEASURE path reads it in place (FUN_0077da80 -> FUN_00772ae0 ->
  FUN_005c6940, which loops the tokenizer).
- DISPLAY buffer (*([fe+0x328]+0xF0)) — a SStrDup'd copy the editbox actually
  RENDERS from (FUN_00771d80); the emitter node text and the re-entrant render
  tokenizer see this one.

TextInFocusedEditbox() bounds each buffer by a tight strlen (distinct heap
allocations -> no false-match cap) and runs only on pipe-leading tokens after a
null-focus short-circuit, so it stays off the hot path. A display FontString
measured while an editbox is focused matches neither buffer, so its icons now
measure correctly -- an improvement over the old global.

Editbox struct offsets centralized in Offsets.h (OFF_EDITBOX_BUFFER_SELECT /
BUFFER / BUFFER_MASKED / TEXT_FONTSTRING).

* feat(chat): raid-target marker substitutions ({rt1}/{skull}/…)

Expand the modern raid-target marker tokens in chat into inline icons, backported
onto the inline-texture renderer. {rt1}..{rt8} plus the named aliases ({star},
{circle}/{coin}, {diamond}, {triangle}, {moon}, {square}, {cross}/{x}, {skull};
case-insensitive) each become a |T escape cropping Interface\TargetingFrame\
UI-RaidTargetingIcons to the matching 64x64 cell (mapping verified against the
client's UnitPopup.lua: col=(N-1)%4, row=(N-1)/4).

Chat::RaidMarkers::Substitute runs in Chat::Dispatch AFTER the |T anti-spoof
sanitize, so players get the fixed, safe marker icons without a path to inject an
arbitrary texture. It emits the pipe-DOUBLED ||T…||t form: the inline emitter
renders both, but this is what survives into speech bubbles — the vanilla bubble
text builder (FUN_004b1600) truncates a lone |T while preserving ||, and pfUI's
bubble reskin copies the bubble's FontString text into its own FontString, which
then renders the icon through the normal paint path. (This is the form bubbles
rendered before the anti-spoof filter defanged the ||T opener.)

InlineTexture emitter fixes for the standalone-FontString (bit-3, batch-clear)
render path that bubbles/tooltips use — chat lines are unaffected:
- Colour: clearing bit 3 switches the engine to per-glyph colour from
  colorState=[node+0x2c], whose alpha byte is a stale ~0x07; force the glyph
  alpha opaque (keeping RGB) so a FontString coloured via SetTextColor with plain
  text doesn't render near-transparent (which showed only pfUI's dark outline =
  "black text"). |c-coloured text already has full alpha, so it's a no-op for chat.
- Horizontal justify: the engine positions centre/right-justified text using the
  MEASURED width, which counts inline icons as ~zero (the measure gap), so the
  rendered line overflows its box. Pre-sum the line's icon widths and shift the pen
  (half for centre, full for right) so text+icons justify as a unit.
- Default centerFrac 0.5 -> 0.6 (better vertical centring across chat + bubbles).

* fix(text): suppress inline textures in editboxes by content match

The per-editbox suppression missed the chat input box: a pasted `|T…|t` rendered
as an icon instead of raw markup. An in-game probe showed why — the editbox
renders its text through a TRANSIENT copy: same bytes as the input buffer, but a
distinct heap allocation with no pointer link to the editbox in either direction
(every offset/back-pointer scan of fe / its FontString / the render node came back
-1). So the prior pointer/node-identity checks (input buffer, SetText copy,
FontString +0x48, node == *(fe+0x328)) could never match the render.

Match on CONTENT instead:
- Emitter: EmitLineIsFocusedEditbox compares the render line's bytes against the
  focused editbox's input buffer (the emitter is handed the whole line, so a
  bounded strcmp identifies it). Chat-history lines differ, so their icons keep
  rendering while an editbox is focused.
- Measure (caret/GetStringWidth): unchanged pointer path — the tokenizer reads the
  input buffer in place, so TextInFocusedEditbox still matches by range.
- Re-entrant render: the suppressed emitter brackets its delegated raw layout with
  a text range (g_reentryLo/Hi); the tokenizer stands down across the whole span
  via TextInReentry, so `|T` isn't eaten as a zero-width token (which drew BLANK).

Drops the dead node-identity/second-and-third-buffer heuristics. Covers any focused
input (chat box, pfChatCopyBox, macro editor).

* fix(text): drop stale icon records for reused chat line FontStrings

Inline-texture icons could paint as a ghost strip at the bottom of the screen
after chat had been busy for a while. The chat frame reuses its line
FontStrings: when a line is cleared (text set empty) or reused for markup-free
text, the draw builder early-returns on it, so the emitter never runs — and so
never erases that node's old icon record. The paint flush still walked the node
and drew its now-stale record at the FontString's parked position.

Gate the flush draw on the node's LIVE text still containing `|T`: if a node has
an icon record but its current text has no markup, the record is stale — drop it
and skip drawing. Kills the bottom-of-screen ghost.

(The separate scroll-freeze — icons only refreshed on the chat's dirty repaints —
is a different, deeper issue tracked next.)

* fix(text): stop chat icons vanishing — suppress only input boxes, not editable chat display

pfUI renders the chat scrollback through EDITABLE nodes (flags 0x45: bit 6
editable, no bit 3). The inline-texture emitter's blanket NodeEditable gate
suppressed inline rendering for ALL editable nodes, so those chat icons blanked
whenever that display was live — during scroll and as messages arrived, which
read as icons "disappearing/reappearing."

Gate suppression on a true INPUT box instead: editable (bit 6) AND bit 3 (0x08).
Macro editor 0x4D and name/chat input 0xC8 all carry 0x08; the chat scrollback
0x45 does not — so real input boxes still show raw |T markup while the chat
display keeps its icons. Also drop the flush's NodeEditable draw-gate: a
suppressed input box never records, so any record reaching the flush is a real
display line.

Pointer/content match alone can't cover a focused input box because its render
form is pipe-doubled (||T) while the input buffer is single-pipe, so the compare
misses.

* feat(text): inline |T icons as fontstring-anchored engine regions

Port 4.3.4's CSimpleEmbeddedTexture model. Each icon the emitter records
becomes a pooled CSimpleTexture ANCHORED TO ITS OWNING FONTSTRING,
configured off-render on the WorldTick, and drawn by the engine's own
region pipeline. 1.12 chat lines are real CSimpleFontStrings (the
ScrollingMessageFrame display refresh FUN_00788750 drives one per visible
line), so a co-hook on CSimpleFontString::RebuildString (0x7724A0) maps
every text node to its owner and one mechanism covers chat, tooltips, and
standalone FontStrings.

This replaces the raw-GxU-quad renderer and every overlay attempt:
- engine-drawn regions keep their textures resident: no flicker, verified
  across three concurrent clients
- the anchor system moves icons with their lines: scroll costs nothing
- icons mirror the owner's shown state: faded lines hide their icons
- all engine mutation happens outside the render pass: no pending-layout
  corruption, survives /reload (pointers dropped in PrepareForReload)
- placement verified pixel-exact against owner rects

RE landmarks recorded in Offsets.h:
- fs+0xF8 is an HTEXTBLOCK handle; the text node is at handle+8
- Show (0x77FCB0) no-ops unless desired-shown (+0xC4) is set, and
  SetParentAndLayer(show=0) clears it on reparent
- the renderer draws the corner array (+0xD4), stored from the rect by
  SetTexCoord - realize (0x768060) must run between SetPoint and
  SetTexCoord or the corners go stale (the historical invisible-icon and
  frozen-top-left-icon bugs)
- pen coords are y-down from the owner fontstring's BOTTOM edge; anchor
  offsets use Script_SetPoint's pixel scale (LinePool::PixelToInternal)

* refactor(text): inline |T icons = pixel-perfect quads + residency holders

Replace the fontstring-anchored region approach with a hybrid that
decouples position from residency, because the two live in different
coordinate spaces that no single scale could reconcile:

- POSITION: draw each icon as a raw GxU quad in the text engine's own
  render space (pen + node origin), the same coordinate system and
  dynamic VB the glyphs use. Pixel-exact by construction — no anchor/
  layout-space conversion. This is the original quad math; the region
  detour proved pen-space and anchor-space differ by the text paint's
  projection, not a scalar, so region anchoring could never be exact.

- RESIDENCY: a raw quad binds an unowned texture the engine evicts under
  VRAM pressure (the flicker on scroll / blank on a 2nd client). So for
  each distinct texture path, Text::InlineTexturePool creates ONE managed
  CSimpleTexture, draws it every frame (a ~2px, alpha 1/255 corner box),
  and never touches it again — its engine-managed draw is the live
  reference that keeps the texture resident for the quads to bind.

Verified in-game: icons pixel-perfect across chat / bubbles / wrapped
lines / sizes / tint, no flicker on scroll, and two concurrent clients
both render — the residency-holder premise (untested before: the earlier
"disproof" had non-drawing holders) finally validated with a holder that
actually draws. Non-zero holder alpha is load-bearing: a fully
transparent region gets culled and residency is lost.

Diagnostics (probe, perf counters, scale/bias tune knobs) stripped; the
SEH fault guard on the flush stays.

* feat(text): render inline |T icons as engine regions anchored to their fontstring

Replace the raw-quad + residency-holder hybrid as the default renderer with
the 4.3.4 CSimpleEmbeddedTexture model: one pooled CSimpleTexture per visible
icon, anchored RELATIVE TO THE OWNING FONTSTRING. Engine-drawn, so texture
residency (the chat scroll flicker / blank 2nd client) is impossible by
construction; engine-anchored, so icons track their line for free.

The pen<->anchor bridge that unblocked this: RebuildString (0x7724A0)
multiplies every text-unit quantity by fs+0x7C when creating the text node,
and the node origin is the fontstring's justification anchor point mapped by
a single measurable scale K (verified with two-position probes: origin =
rectJustifyRef x K, K ~ 1470 = physical px per anchor unit, both axes, no
drift). Placement pipeline:

- RebuildString co-hook records node -> owning fontstring (a node only
  speaks for its fs while it IS fs+0xF8->block+8, killing the stale-node
  clear race around rebuilds).
- The paint hook converts each icon's pen-space quad through K and queues
  fs-relative placements; regions are keyed BY FONTSTRING (nodes die on
  every SetText - node keying orphaned regions into an accumulating wall).
- Placements apply on WorldTick, never mid-render. SetPoint offsets are
  divided by the region's effective scale (+0x7C; anchor resolution
  multiplies stored offsets by the scale chain - measured 0.0319 stored ->
  0.0290 resolved = x0.91), then converged exactly by a per-region rect
  readback correction each tick (deterministic, self-healing; a same-call
  readback read stale pooled rects and shipped 7.4px crests).
- Engine SetTexCoord (0x770410) takes the 4-float rect Y-FIRST INTERLEAVED
  {v0,u0,v1,u1} (diagnosed in-game: {u,v,u,v} rendered the raid-mark moon
  as the circle cell); it is absolute, and the +0x124 shrink-by-UV-span
  flag stays clear so crops stretch to the rect like modern clients.
- Expired/hidden lines are swept every tick (regions parent to the chat
  frame, not the line, so they cannot inherit the line's own hide).

The quad renderer and residency holders remain behind
_classicapi_InlineTexMode (0 = quads, 1 = regions [default], 2 = overlay
A/B) as the bring-up oracle, plus diagnostics (_classicapi_InlineTexProbeFS
/ RegionDump / RegionCal / HolderCfg). Calibrated residual: RegionCal
(3, -1) pen units.

* fix(text): apply the inline-icon LEAD pad at draw time

The emitter reserves w + 1.5x pad in every inline icon's advance (lead 1x,
trail 0.5x), but FlushLayout drew the icon at the raw pen - all reserved gap
landed AFTER the icon, none before. Mid-text icons looked balanced only
because their surplus trailing gap padded the following text; a string-FINAL
icon (the money-string copper coin) sat jammed against its digits, and the
locale format strings had grown per-coin offsetX hacks to compensate.

Draw at pen + w x iconPadFrac (quads and regions share the cx), matching the
emitter's reservation. The coin formats drop to zero offsets - verified
against the texture itself: the three coin arts in UI-MoneyIcons are
byte-identical in alpha (same bounds, same center of mass, measured from the
exported BLP's DXT3 data), so no coin ever needed a positional nudge.

RegionCal's baked x constant drops 3 -> 0: the +3 calibrated pre-fix was
exactly this missing lead pad (user re-calibrated (0,-1) post-fix). The 1px
y residual remains the pen->anchor map's one true constant.

* fix(text): make inline-icon state lifetime-exact; fix every scroll-landing loss

Chat icons randomly landed shown-or-hidden when lines scrolled, and stale
icons ghosted at parked positions. Root causes, each found by in-game
triangulation (FsDump/Broken/Stats diagnostics, included), all fixed:

- Placements are FS-RELATIVE, computed at PAINT time in one coherent
  snapshot (icon coords + fs rect in the same flush). The old apply-time fs
  rect read raced the chat relayout after SetText and parked icons off
  their line, where the dedup froze them.
- The queue tail gate now matches the per-icon build gate EXACTLY (incl.
  K); any not-ready input skips-and-retries instead of queueing {} - an
  empty queue froze lines iconless via the dedup (early-session K window).
- K (pen->anchor scale) is derived PER NODE from its own fontstring - pen
  space scales with each fs's frame-scale chain, so a global chat-derived K
  mis-sized icons on differently-scaled fontstrings. Global cache kept as a
  +/-10%-clamped fallback.
- Stuck-blockless fontstrings (RebuildString releases the block on a
  SetText that lands on an unresolved rect and never rebuilds; the zombie
  node keeps painting) are re-dirtied so the engine rebuilds - the icon
  pipeline then resumes naturally.
- First-line-of-build detection comes from a draw-builder co-hook
  (FUN_005CDC20) build stamp - exact - with the old text-pointer heuristic
  only as fallback for emitter paths outside the builder.
- THE LIFETIME FIX: co-hook the gxu text-node free (FUN_005CD950, the
  single site where every node dies and its address becomes reusable) and
  erase ALL per-node state there. Anything in the maps now refers to a
  live node by construction: inherited ghost records on recycled nameplate
  fontstrings, orphaned owners (dropNoFs 61k -> 0), and reload leftovers
  lose their mechanism of existence.
- Ghost guard rescans the FONTSTRING's text (fs+0xF0, proven trustworthy),
  not the node text pointer (which lied on pfUI chat lines and made the old
  scan erase LIVE records every frame - the persistent iconless LFG lines).
  False matches can only false-keep, never false-erase.
- Freshness TTL: regions stay only while the paint keeps re-queueing their
  fontstring (deduped touches count); parked/orphaned fs regions expire in
  ~1s. Do NOT build-version records against the builder counter - the
  builder runs every paint but only emits when dirty, so a version check
  erases good records on every clean paint (the all-icons-gone regression).

Diagnostics added (Lua): _classicapi_InlineTexFsDump (pipeline triangulation
with chain classification), _classicapi_InlineTexBroken (names the broken
lines), _classicapi_InlineTexStats gains queued/drop/nudge counters and the
SEH fault address.

Also removes the FrameTick glue-screen attempt (b164d68): hooking the UI
render root regressed in-world chat; glue-screen icon support will be
re-approached without touching the in-world render path. The
FUN_UI_RENDER_ROOT offset stays documented for that retry.

* fix(text): pointer probes must allow Large-Address-Aware heap (>2GB)

LooksReadable capped plausible pointers at 0x7FFF0000. This client is LAA
(VanillaFixes), so heap allocations above 0x80000000 are valid and routine
once the heap grows - and the flush's layout walk BREAKS on an "unreadable"
node, so a single high-heap node silently truncated the walk and every node
AFTER it in the layout list lost its icons. That was the irreducible random
per-ROW missing-chat-icon bug that survived every renderer architecture
since the guard shipped with the first inline-texture commit (aa94ea5): it
fired only when the allocator placed a node/fontstring above 2GB, varying
per session with addon load and uptime. The same check also rejected
high-heap fontstrings (dropNoFs storms, "unreadable" owner chains).

Bound raised to 0xFFFF0000 in both copies.

Diagnosed with the per-node build/emit instrumentation added here: the
smoking-gun Broken() trio was builtKnown=1 (engine built the node),
emitOutcome=8 (emitter recorded its icons), nodeSeenAge=-1 (our walk never
once saw it). _classicapi_InlineTexBroken now reports both new fields.

* feat(text): render inline |T icons on the glue (login) screen

Inline icons never appeared on the login / character-select screens: the
text-pipeline hooks are engine-generic and fire on glue (the |T spans are
consumed there), but InlineTexturePool::Maintain - the step that creates and
shows the icon regions - was driven by Tick::WorldTick, and FUN_WORLD_TICK
(0x0066FD50) is the world-simulation tick, silent on glue. Queued placements
were never applied.

Add Tick::FrameTick, hooked at FUN_UI_RENDER_ROOT (0x00764330) - the
CSimpleTop singleton's per-frame render callback, registered by both the
world-init (FUN_0048FBF0) and glue-boot (FUN_0046A7B0) paths and drained
every frame by the master scene-render pump. Subscribers run at the tail of
the callback (strata walk complete, no frame mid-draw). The pool's Maintain
moves onto FrameTick as its ONLY tick: one driver, fires wherever the UI
renders.

An earlier version of this change was blamed for an in-world chat regression
and reverted. That regression was the Large-Address-Aware pointer-bound bug
(dc61f77) rolling its allocation dice at the moment the tick moved - the
symptom persisted after the revert, which already exonerated the hook.

Glue boot (LoadGlueScriptFunctions_h) now also clears the inline-icon maps:
the world->glue teardown bulk-frees every world fontstring and text node they
reference (glue->world was already covered by FrameScript_Initialize_h, and
the node-free hook covers individual deaths in both states).

WorldTick keeps its world-only subscribers (aura eviction, cast state, raid
targets) - they read world state that is null on glue; the two ticks stay
semantically distinct: WorldTick = "world advanced", FrameTick = "a UI frame
is rendering".

* feat(text): icon-inclusive GetStringWidth + FontString:GetStringHeight backport

GetStringWidth now counts inline |T...|t icons. The old "deliberate
measure gap" analysis was right that the gxu engine has no cold choke
(parallel measure/wrap/hit-test loops, no width field in the token
contract, hot per-glyph helpers) - but the choke sits one level up:
FUN_00772890 = CSimpleFontString::GetStringWidthInternal, a cold,
MinHook-safe funnel for exactly 4 callers (Script_GetStringWidth,
GameTooltip auto-size, editbox caret positioner, effective-width
vmethod). The co-hook calls the original and re-adds the icon advances
the tokenizer hook hid, so measured width matches rendered width - and
tooltips/auto-width layout now size correctly for icon lines.

Shared-source refactor so measure and render can never disagree:
- InlineInterceptActive: the tokenizer's stand-down set factored into
  ONE predicate (tokenizer passes gxu flag 0x40; the width hook passes
  fs+0x120 & 0x1000, the fs-level bit FUN_0044D670 translates into it,
  so editbox carets keep measuring raw markup).
- IconAdvancePen/SumIconAdvances: the emitter's reserved advance
  (w + 1.5*pad + positive offsetX) factored out and shared by the
  emitter loop, the justify pre-shift, and the width hook. Side fix:
  the justify pre-shift previously omitted the positive-offsetX term.

Units (bit us on first flight): escape sizes are UI pixels; convert
px->anchor by the push factor K = [0x832A4C]*1024/[0x832A44] (~1468),
NEVER by fs+0x7C - that's the layout UI scale (~0.68), and dividing by
it measured a 16px icon as +44k px. The hook never writes the fs+0xFC
cache (original may serve cached; the sum is re-added per call).
Verified in-game: delta +19.5 for a 16px icon = 20.32 exact advance
minus the known 0.8 trailing-icon ink-width residual. Wrap-break,
substring measure, and hit-test stay icon-blind (documented).

FontString:GetStringHeight() (2.3.0+ binding) backported in new
src/fontstring/Metrics.cpp: 1.12 already ships the internal wrap-aware
getter FUN_007729B0 (cache fs+0x100, lines*fontH + (lines-1)*spacing,
same wrap engine the render uses, empty -> 0) - it just was never
bound. Registered on the FontString method registry; mirrors
Script_GetStringWidth's resolve + anchor->px push conversion.
Verified in-game: one-line 11.7, wrapped 46.9 (4 lines), empty 0.

* fix(text): wrap lines account for inline icon advances

Wrap decisions measured inline |T icons as ~0 (the tokenizer hook), so
icon-bearing chat lines broke as if the icons weren't there and the
emitter's real advances ran past the frame's right edge.

Choke: FUN_TEXT_WRAP_STEPPER (0x005C7260), the wrap-stepper dispatcher
that lays out one wrapped line per call. Its exactly 4 callers are
every wrap consumer - the draw builder (render breaks), the height
measure (GetStringHeight), the chars-that-fit counter (ellipsis), and
the break-array computer - so one co-hook keeps render, height,
truncation, and break arrays mutually consistent. outBreak and
outNext-text are byte counts (==len means "all fit"); outWidth is the
measured icons-at-zero line width in the caller's units.

The hook shrinks the wrapWidth argument by a minimal feasible shrink,
found by probing the stepper sandboxed (local out-params + a copy of
the p10 first-line state byte): feasible(s) iff the line's icon
advances fit in the shrink plus the spare the break left
(iconSum <= s + probeW - outWidth). s=0 feasible -> no shrink (the
common fits-anyway case); otherwise escalate by the measured deficit,
then bisect the [infeasible, feasible] bracket to a quarter-glyph
(<=8 probes). Never overflows; packs each line to its physical
capacity. Gated on the shared InlineInterceptActive predicate + the
editbox flag bit 0x40 the dispatcher already carries.

Unit trap #2 (cost one flight): each caller passes fontH/wrapWidth in
its OWN space - node text units from the builder, the small
anchor-converted space from the fs-level callers chat wraps through.
px -> caller units = fontH / FUN_TEXT_FONT_HEIGHT(flag, fontH), the
engine's own pixel realization of the fontH param (FUN_005C6940's
final scale proves the convention for every gxu caller).

Rejected en route (documented in docs/InlineTextureEscapes.md): raw-px
subtraction (shredded lines to 2-glyph fragments), whole-remaining-
text sums (8-icon line -> one word per line), icon-count fixed-point
iteration (oscillates - 55-byte zero-width escapes make the
break<->count coupling discontinuous), max/average tiebreaks.

Cross-checked against 4.3.4 (renders |T natively): Blizzard extended
the token contract instead - texture token type 7 + a shared parser
(FUN_00617ba0) called inline by all five layout loops, fontH passed in
for :0 defaults, plain width, no padding. No single-choke trick exists
at source level; the probe search is the 1.12-shaped equivalent.

Diagnostics: _classicapi_InlineTexWrap(n) dumps the last 8
icon-bearing calls (widths, per-probe sums/breaks/raw out-params,
final shrink, floor hits).

* fix(text): wrap-shrink search refinements from in-game shakeout

Three fixes to the minimal-feasible-shrink search (2450522), each from a
live regression the _classicapi_InlineTexWrap diag pinned down:

- Full bracket bisection (<=8 probes, quarter-glyph epsilon). The first
  cut abandoned the [infeasible, feasible] bracket after one stale
  escalation candidate, costing the Marks harness line a whole
  mark+word pair per line (3+5 instead of the optimal 4+4).

- Half-glyph feasibility tolerance. The width hook and the wrap hook
  derive the icon sum through different unit chains (FUN_007727B0xK vs
  FUN_005C6FA0 ratio, OUTLINE extras, the trailing-glyph ink/advance
  quirk); the drift can read "fits exactly" as "1px over" and shrink a
  zero-slack line. Genuine icon overflow is >= a full icon, so half a
  glyph separates drift from real overflow.

- Auto-width bail (all text fits at s=0 AND slack <= 1.5 glyphs). A
  single-anchor, no-width fontstring (pfUI's addon-list labels) wraps
  at its own text-only measure plus a ~1-glyph engine margin (measured
  1.07xfontH), so icons "never fit" its budget - but the box is
  synthetic, icons draw past it harmlessly, and any shrink FABRICATES a
  wrap ("Twitch Emotes" split across two lines). Real frames stay
  covered: overflowing lines break, fitting lines carry frame-sized
  slack (Marks measures ~3.5xfontH). The narrow give-up band - text
  within 1.5 glyphs of exactly filling a real frame - reverts to the
  pre-hook overflow, bounded by the line's icon sum.

Diag reshaped for /dump's ~30-value print cap: 8 numeric headers plus
ONE pass-summary string (shrink candidate, icon sum, line length, raw
outBreak/outNext/outWidth per probe), so no record truncates.

* feat(texture): add Texture:SetRotation via corner-vertex rotation

Backports the modern texture:SetRotation(angle [, cx, cy]) method, which
vanilla 1.12 lacks entirely.

4.3.4's SetRotation (Script FUN_0052cb50) rotates the four texture
COORDINATES around a centre and feeds the 8-arg SetTexCoord, which clips
the corners of an off-axis square. 1.12 can't mirror that — its
CSimpleTexture::SetTexCoord takes only a min/max UV rect.

1.12 has a cleaner seam: a CSimpleTexture stores its drawn quad as four
independent corner positions at +0xD4, and the draw enqueue (FUN_00772fd0)
hands region+0xD4 straight to the vertex batch as four free vertices — no
axis-aligned rect is re-derived. So rotating those four corner positions
draws a truly rotated quad with no corner clipping.

The engine fills the corners axis-aligned from the layout rect via
FUN_REGION_STORE_CORNERS; a co-hook there re-applies rotation right after
each store, so a static rotation is written once and costs nothing per
frame while moves/resizes re-rotate automatically. Angle is radians
(positive = counter-clockwise); optional (cx, cy) is a normalized pivot
in [0,1], default centre; an angle of 0 clears.

Per-region state keys on the raw region pointer and is cleared on /reload
and world->glue (Texture::Rotation::PrepareForReload). A texture freed and
instantly re-pooled mid-session could briefly carry a stale rotation —
accepted; a destructor-evict hook is the later hardening.

New in Offsets.h (all verified against WoW.exe): OFF_SIMPLETEXTURE_CORNERS,
FUN_REGION_TEXCOORD_CROP, FUN_REGION_GET_RECT.

* feat(texture): add Texture:SetVertexOffset, generalize corner transforms

Backports Texture:SetVertexOffset / GetVertexOffset (plus GetRotation),
generalizing the SetRotation module (texture/Rotation.* -> texture/Transform.*)
into one corner-transform primitive that backs both methods.

Both write the four drawn corner POSITIONS at region+0xD4 — the vertices the
draw enqueue FUN_00772fd0 hands straight to the batch; no stock 1.12 function
exposes them. Per-region state widened from an angle to {rotation, four vertex
offsets}, applied through the same FUN_REGION_STORE_CORNERS co-hook: after the
engine writes the axis-aligned corners, rotate about the pivot, then add the
offsets. The two transforms compose (matching retail) and persist across layout
resolve / move / resize / SetTexture with no per-frame cost.

SetVertexOffset offsets are UI coordinate units (like SetPoint x/y), converted
to the engine's internal layout space with the same factor Tooltip::LinePool
uses; +y is up. vertexIndex maps to a corner via VertexSlot; the
UPPER_LEFT_VERTEX / ... constants are defined FrameXML-style in the embedded
addon's Util/Constants.lua.

Non-inverting warps (perspective, skew, trapezoid, wave) are free; a warp that
mirrors the quad past edge-on inverts its winding and the region draw culls it
(documented).

* feat(texture): add Texture:SetColorTexture

Backports the 7.0 SetColorTexture(colorR, colorG, colorB [, a]) as a second
name on the engine's own Script_Texture_SetTexture handler (0x0079BB40): its
numeric branch already clamps r/g/b/a to [0,1] and fills a solid colour via
FUN_00770360 (the string branch loads a path). So SetColorTexture is exactly
vanilla's numeric SetTexture form under the modern name — zero reimplementation,
with the clamp and opaque-alpha default coming straight from the engine.

The alias also accepts a path string (a harmless superset of the retail
contract). SetDesaturated / IsDesaturated are already native (real Desaturate.bls
pixel shader); SetDesaturation is not addable — vanilla desaturation is a fixed
effect handle with no strength constant in the setter, draw command, or effect
table.

* fix(text): correct the emitter's lazy pen read-back; drop quad render mode

The inline-icon emitter hook threaded the pen between text runs by
reading the engine's linkState[4] after each delegated run. That value
is LAZY (verified in FUN_005ccbe0): the engine places each glyph at
pen + pairAdvance(prev, cur) and its final write re-adds the LAST PAIR
as a stand-in for the last glyph's own advance. For a one-glyph run the
stand-in is 0 (no pair exists), so the run read back as zero-width and
the icon drew on top of the digit — the money-string "coin covers the
lone 0/1" bug. For "] 1" the stand-in was the space's advance, ~3px
short: the single-digit clip. "11" matched by luck (same-glyph pair).

Fix (the 4.3.4 model — FUN_0061ea10 case 7 computes the pending last-
glyph advance explicitly instead of trusting a stored pen): drawRun now
token-walks each run with the engine's own tokenizer and, when it ends
in a glyph, replaces the stand-in with the truth:

  pen = linkState[4] - pairAdvance(prev, last)*scale
                     + baseAdvance(last)*scale

using the engine's own advance functions (FUN_005ca240 base,
FUN_005ca2d0/FUN_005ca4b0 pair, per the node's kerning flag) and its
per-placement __ftol snap. Runs ending in a non-glyph token (trailing
|c/|r) need no correction — the trailing iteration already folded the
last glyph's base advance into the write.

Also:
- The per-icon breathing pad is now FONT-relative (fontH * padFrac),
  not icon-width-relative: the gap no longer scales with icon size, so
  a small icon doesn't jam its neighbour while a big one floats.
- Quad render mode is removed entirely (DrawTexturedQuad, residency
  holders, _classicapi_InlineTexMode/_InlineTexHolders/_InlineTexHolderCfg).
  Engine regions (the 4.3.4 CSimpleEmbeddedTexture model) are the only
  render path.

* feat(currency): GetCoinTextureString / C_CurrencyInfo.GetCoinTextureString

The retail coin-icon money string, backported as a C function
(src/currency/CoinText.cpp) the way retail ships it (behaviour from
3.3.5's Script_GetCoinTextureString FUN_00510d00): split copper into
gold/silver/copper, emit only non-zero denominations in that order,
zero shows "0<copper>". Each denomination formats through the
GOLD/SILVER/COPPER_AMOUNT_TEXTURE GlobalStrings, backported in the
embedded addon's locale layer (stock 1.12 doesn't have them), so
locales can override.

1.12 has no per-coin icon textures — the |T markup crops vanilla's
UI-MoneyIcons 4-cell sheet (gold 0:1, silver 1:2, copper 2:3; the
three coin arts are byte-identical in alpha, verified by decoding the
exported BLP's DXT3 data). height/width 0 = size to the display
font's height (the retail default, honoured by the inline-texture
renderer). No offsetX nudges and no separator spaces: correct spacing
comes from the renderer's font-relative pad plus the lazy-pen fix
(550cf5e) — per-coin offsets were compensations for that bug and must
not return.

FormattingUtil.lua comment updates only (GetMoneyString stays the
text-symbol form; the coin form now exists). docs/API.md: new
Currency section.

* docs(readme): list GetCoinTextureString in the API table

* refactor(text): purge the quad-mode leftovers

Follow-through on 550cf5e (quad render mode removed): delete everything
that existed only for the raw-GxU-quad path and no longer has a single
reference.

- Offsets.h: drop the GxU device primitives (dynamic-VB lock/data/
  submit/unlock, texture bind, vertex stride/selector), the standalone
  path->CGxTexture loader trio (FUN_TEXTURE_LOAD_BY_PATH,
  PTR_TEXLOAD_DESC_VTBL, FUN_GX_TEXFLAGS_INIT), the colorop render-state
  and direct-D3D state setters, FUN_TEXTURE_FORCE_DECODE, and
  FUN_TEXTURE_GET_RENDERABLE. All were quad/residency-era scaffolding;
  the VAs stay recorded in docs/InlineTextureEscapes.md and git history.
  FUN_TEXT_PAINT (the flush co-hook site) and VAR_TEXTURE_BLEND_DEFAULT
  (the pool's SetTexture blend arg) remain, with comments rewritten for
  the region path.
- InlineTexture.cpp: drop IconRecord.z / penZ (only the quad draw read
  them); rewrite the paint-flush section comments to describe what the
  flush actually does now (queue fontstring-relative region placements).

* feat(addon): backport TextureUtil.lua (Blizzard_SharedXMLBase)

The non-atlas slice of the Classic Era reference, ported to Lua 5.0
(string.format instead of string-method syntax):

- SetClampedTextureRotation / ClearClampedTextureRotation — the retail
  90-degree-step texcoord shuffle with width/height swap. Works
  unchanged because 1.12's Texture:GetTexCoord() natively returns the
  8-value corner form and SetTexCoord accepts it (both operate on the
  same engine corner array, verified at Script_Texture_GetTexCoord
  0x0079BDF0). One deviation, commented in-file: Clear also nils
  rotationDegrees — the reference leaves 0 behind, which is truthy in
  Lua and breaks the next Set's origTexCoords re-capture.
- GetTexCoordsByGrid — pure math.
- CreateTextureMarkup / CreateSimpleTextureMarkup — |T markup builders;
  output renders via the inline-texture backport.
- TextureUtil = {} for namespace parity.

Omitted: everything atlas-based (CreateAtlasMarkup*, the TextureKit
family, CheckSetAtlas, GetAtlasSize, SetTextureWithAddressModeOptions —
1.12 has no atlas system), the LFG role-icon tables, and
GetTextureInfo (reads the atlas/fileID getters).

* refactor(text): remove the inline-texture bring-up diagnostics

The pool's _classicapi_InlineTexRegionDump / _InlineTexFsDump /
_InlineTexBroken were bring-up triage for the scroll-landing /
ghost-icon bug class, which the node-free lifetime hook ended
(b5df20d) — they have no remaining job. Removing them also kills their
entire support tree, none of which had another consumer:

- the five Debug* chain-state exports on Text::InlineTexture
  (DebugChainState / NodeFlags / NodeSeenAge / NodeBuiltKnown /
  NodeEmitOutcome),
- g_nodeSeen + g_flushSeq (flush visit stamps),
- g_nodeEmit (per-node emitter-outcome tracking — map churn on every
  emitted line, purely diagnostic),
- g_nodeBuilt + g_buildCounter + NodeIcons.builtSeq (write-only: the
  build-version check they were made for was deliberately REJECTED —
  the builder runs every paint but only emits when dirty, so version
  mismatches on clean paints erased live records). g_buildNode /
  g_buildEmitSeq stay — they are the live firstLine detection.

NodeIcons collapses to a plain std::vector<IconRecord> map value, and
the pool drops its Game.h / InlineTexture.h includes (nothing left
uses them). The stale "versioned by build" struct comment is rewritten
to describe the actual invariant (node-free hook + firstLine erase).

Kept: _classicapi_InlineTexEnable / Suppress / Tune / Stats / ProbeFS /
RegionCal / Wrap — the live control surface and field telemetry.

* refactor(text): reduce the inline-texture Lua surface to the kill switch

Only _classicapi_InlineTexEnable remains — the feature toggle the SEH
latch pairs with. Removed with their entire backing state, none of
which had a non-diagnostic consumer:

- _InlineTexSuppress + g_suppressInline (the manual global override —
  editbox exclusion has been per-node/per-buffer for a while) and the
  now-unreferenced InputFocused() helper
- _InlineTexTune: g_vBias / g_sizeScale / g_centerFrac / g_iconPadFrac
  become constexpr (calibrated during bring-up, fixed now)
- _InlineTexRegionCal: g_regionCalX/Y become constexpr (0 / -1)
- _InlineTexStats + the six flush-gate counters (the queue gate
  if/else collapses to the plain condition; the nudge drops its
  counter-guard and just sets the dirty bit — idempotent)
- _InlineTexWrap + the WrapDiag ring and every capture site in the
  wrap-stepper hook (the minimal-feasible-shrink algorithm is
  untouched — it always ran on locals)
- _InlineTexProbeFS (the pen<->anchor bridge verification probe)
- the SEH filter no longer records fault code/address (nothing read
  them); the latch still disables the feature on a flush fault
- the Arg() Lua helper and <cstdio> (last users removed)

* feat(text): inline icons fade with their chat line

The one visible seam left in the region renderer: icon regions parent
to the CHAT FRAME (regions cannot parent regions), so they inherited
the frame's alpha but not the LINE's — a fading chat line's text
dimmed while its inline icons stayed opaque until the end-of-fade
hide.

RE (verified in the 1.12 binary): chat fading is engine-side in the
ScrollingMessageFrame's fader FUN_00788460 — gated on the SetFading
flag (smf+0x360; the 40-method SMF registry's SetFading 0x007939A0
writes it), it counts down each visible line's timeVisible then
fadeDuration ({rgba@+4, shown@+8, timeLeft@+0xC, fadeLeft@+0x10} in
the stride-0x10 line array at smf+0x3A4) and animates the LINE
FONTSTRING's color alpha byte through the fs SetColor (0x0077F750)
every frame, hiding the fs at zero. SetColor stores the dword at
(*(u32**)(fs+0xB8))[0] (count fs+0xB4; alpha = byte 3), and the
per-line setter FUN_00788af0 resets alpha to 0xFF on message reset —
so that byte IS the line's live fade state, whoever wrote it.

The mirror (InlineTexturePool): Maintain reads FsColorAlpha(fs) each
tick and, on change only, rewrites the shown regions' colors with the
markup tint's alpha modulated by the line's alpha (RGB preserved).
ApplyPlacement folds the live alpha in at placement time, so a line
placed mid-fade starts at the right opacity. Composition is exact by
construction: the fs's on-screen alpha is frameAlpha(+0xC8) ×
fsColorAlpha, and the regions share the same parent frame, so
mirroring the fs byte alone makes icon and text alpha identical.
Steady state costs one byte compare per icon-bearing fontstring per
tick; scroll-back/un-fade restores (the SMF resets the line color and
the mirror follows). Verified in-game: fade lockstep, reappear on
scroll.

* feat(text): line-height growth for tall inline icons

An inline icon taller than the font (a 32px TwitchEmotes emote in 15px
chat) used to overlap its neighbouring lines — the line box stayed
fontH-sized. Retail grows the line: 4.3.4's measure and wrap loops
export a max-texture-height out-param (FUN_00618b90 / FUN_00618e40
case 7) and the layout takes it.

1.12 equivalent, one cold co-hook: FUN_FONTSTRING_STRING_HEIGHT
(GetStringHeightInternal, cache fs+0x100) gains the tallest icon's
overflow past the font height (MaxIconOverflowPx — explicit |T heights
only; the :0 default IS the font height). Its three binary consumers
(verified via xrefs) split exactly right:
- FUN_00772A60, the anchor effective-height vmethod — chat line
  fontstrings get only a SetWidth from the ScrollingMessageFrame
  refresh, so their rect height resolves here -> the rect grows;
- FUN_00788750, the SMF display refresh — budgets vertical space and
  anchors each line to the previous line's rect -> the stack spaces by
  the grown height;
- FUN_0077D4D0, the multi-line editbox self-size — excluded by the
  same editbox gate as the width hook (editboxes render raw markup).
Same rules as the width hook: shared InlineInterceptActive predicate,
px->anchor via the push factor (never fs+0x7C), the fs+0x100 cache is
never written (delta re-derived per call — idempotent). The backported
fontstring:GetStringHeight routes through the hooked VA and becomes
icon-inclusive automatically.

Draw side: tall icons CENTRE on the line like any other icon. The text
centres itself inside the grown rect, so the extra space appears half
above and half below — a centred icon fills exactly that, matching
retail. (A bottom-aligned first cut clipped the neighbouring line;
verified in-game against retail's centred, no-clip rendering.)

v1 limit (documented in-code): the delta is the whole text's max
overflow — exact when a message's tall icons share one wrapped line
(the emote case); tall icons on two different wrapped lines of one
message under-grow.

Verified in-game: 32px emotes between normal chat lines each take
their own vertical room, centred, no neighbour overlap.

* fix(text): full-height hyperlink hover for tall inline icons

With line-height growth (49934b0), a tall emote inside a |H...|h link
was only hoverable across its text-high middle band: the hyperlink hit
rect is registered at layout time (FUN_TEXT_LINK_RECT_ADD 0x005CD310,
the GXUFONTHYPERLINKINFO append — record {y,x,y,x} normalized + link
ptr/len pairs) with the TEXT band's y extent, fontH tall.

Consumer geometry (FUN_00788c00 -> FUN_007a3350 decompile + two in-game
experiments): the ScrollingMessageFrame rebuilds a hit-region frame per
record, anchored to the LINE FONTSTRING's top-left with the record's
band as offset/height. The band's y values are Y-UP (numeric max =
screen top — extending the max visibly grew the region into the line
above), and the band is TEXT-relative — but the grown rect's top sits
half the icon overflow above the text (the text centres in the grown
rect), pre-shifting the whole hit band up. Net: the unmodified band
already covered from the icon's top down to textBottom − overflow/2,
and the single correct fix is pushing the screen bottom DOWN by the
full overflow — decreasing the numeric MIN edge.

Co-hook on the append: when the |H...|h escape span contains a |T icon
taller than the line's font (the same MaxIconOverflowPx the
string-height hook grows the line by), decrease the band's min edge by
the overflow before the original stores it. All consumers of the
record (hover, click) inherit the expansion. Gated on the shared
InlineInterceptActive predicate so raw-rendered contexts stay literal.

Known v1 rough edge (commented in-code): a tall icon outside the link
but on the same line grows the line without this hook firing for that
link, leaving its band half-overflow high.

Verified in-game: a 32px emote link hits across its full height, no
bleed into neighbouring lines.

* docs(text): truth-pass on the inline-texture RE docs

InlineTextureEscapes.md and InlineTextureResidency.md described the
removed raw-quad renderer and a bring-up plan as if current. Bring them
in line with the shipped engine-region design.

InlineTextureEscapes.md:
- New "Current design (shipped)" section up front: region renderer, the
  three fs-level measure hooks (width/height/wrap), the lazy-pen fix,
  chat fade, tall-icon line growth + full-height hover, editbox
  exclusion, the payload, and anti-spoof — each with its commit.
- Banner the quad "rendering primitive" section as REMOVED (bring-up
  record; the address log for code deleted in 56c2670).
- Fix the DrawTexturedQuad / quad-flush references to region-queue
  language; vertical centring is g_centerFrac (0.6) with the tall-icon
  grown-rect note; the flip note becomes the region SetTexCoord
  Y-first order.
- "Remaining/optional": diagnostics removed (only InlineTexEnable
  left), measure/height/wrap DONE, animation is addon-side, the TGA
  convert command is -compress none only.
- "Reality check": correct the prediction that we would reimplement the
  emitter — the shipped design segment-delegates and co-hooks instead.

InlineTextureResidency.md: rewritten from a "not-yet-implemented plan"
into an accurate reference. States the region design, records the
CSimpleTexture entry points / layout the pool uses (VAs verified
against Offsets.h), and corrects the root cause honestly — the flicker
was the LAA pointer-bound truncation (dc61f77), NOT texture residency;
regions are kept for 4.3.4 parity and are resident by construction as a
bonus. Keeps the D3D "ruled out" notes as a do-not-re-investigate
record.

* fix(text): stop inline-icon Maintain from dereferencing stale fontstrings

A destroyed chat-line fontstring left a dangling key in the icon pool's
per-fontstring map. Its heap memory was reused as a string buffer, and
Maintain's weak LooksReadable() range check waved the reused pointer
through; reading the "parent" field (fs+0x9C) then yielded string bytes
(ASCII "-Rac"), which the engine's region attach (FUN_0076a750)
dereferenced at *(parent+0x1B4) -> ACCESS_VIOLATION (ERROR #132 at
0x0076A7CE).

Two compounding defects, both in Maintain:
  - the stale-entry expiry was gated on shown > 0, so a scrolled-off line
    (shown == 0) never expired and its dead key was re-dereferenced every
    frame, waiting for its memory to be reused;
  - LooksReadable is a bare VA-range test that a reused buffer passes.

Fix: verify a key is a LIVE fontstring by its object+0x00 vtable before
using it. The vtable is learned at paint time from fontstrings the engine
is actively drawing (QueuePlacements), so every fontstring class -- chat's
internal ScrollingMessageFrame lines, tooltip/CreateFontString lines,
addon fontstrings -- is recognized without hardcoding (one hardcoded value
filtered live chat icons out entirely). Maintain now iterates with erase:
a key whose memory no longer carries a learned fontstring vtable has its
regions hidden (our tex pointers stay valid, parented to the chat frame)
and the entry erased, so dead keys can neither accumulate nor be
dereferenced.

* fix(text): guard inline-icon pool hide loops against short region vector

CreateRegion can fail when the region pool is exhausted, breaking the
apply loop with fewer regions than `want`. The code still set
`np.shown = want`, so a later tick's freshness-expiry and fs-hidden hide
loops indexed np.regions[i] past regions.size() -- an out-of-bounds
std::vector access.

Clamp np.shown to min(want, regions.size()) so it never exceeds the
vector, and add the same i < regions.size() bound the eviction loop
already carries to the other two hide loops so all three self-guard.

* refactor(text): centralize inline-texture offsets and pointer probe

Enforce the single-source-of-truth offset rule on the inline-texture
files:

- Read the fontstring text pointer via Offsets::OFF_FONTSTRING_TEXT
  instead of a raw `fs + 0xF0` in FlushLayout's ghost guard; the symbol
  already existed and is used elsewhere in the same file.
- Add OFF_TEXT_NODE_JUSTIFY (0x54) to Offsets.h with a verification note
  and use it for the two node-justify reads (DeriveK and the emitter
  pre-shift) that previously hardcoded 0x54 in two places.
- Move the LAA-aware LooksReadable probe into a shared text/PtrProbe.h
  (namespace Text) so InlineTexture.cpp and InlineTexturePool.cpp share
  one definition instead of two copies plus a third raw-bounds copy in
  LearnFontStringVtable. The 0xFFFF0000 bound now lives in exactly one
  place -- this is the constant whose earlier 0x7FFF0000 form caused the
  missing-chat-icon bug, so keeping the copies from drifting matters.

No behavior change; full build compiles and links.

* fix(currency): clamp GetCoinTextureString to the uint32 copper ceiling

An amount above INT_MAX gold copper (e.g. 1e15) made `gold =
(int)(amount / 10000)` overflow: the long long division was truncated to
its low 32 bits, so GetCoinTextureString(1e15) printed a garbage gold
count (1215752192). silver/copper were already bounded by the % math.

Vanilla money is a uint32 copper field, so clamp amount to 0xFFFFFFFF
copper (~429,496 gold) -- the most the game can represent -- which keeps
gold within int range for the "%d" GlobalString format. Can't widen the
type instead: the count is formatted through the retail-compatible "%d"
coin GlobalString, and passing a long long to %d is undefined.

* fix(text): SEH-guard the inline-icon Maintain tick; document region leak

#6 (SEH backstop): Maintain's liveness probe dereferences a fontstring
pointer that may have been freed (the documented IsLiveFontString
residual). The paint path already latches off on a fault via SafeFlush,
but Maintain ran bare under the render-root tick, so a fault there was an
uncaught client crash. Split the body into MaintainImpl (holds the C++
objects) and a thin Maintain() SEH wrapper that latches g_maintainDisabled
on fault -- mirroring the paint latch. Re-armed on /reload.

#7 (region leak): document why the stale-key eviction intentionally drops
np.regions without reclaiming the pooled CSimpleTextures. Any reclaim (a
free list or explicit destroy) would touch a region whose parent frame may
already be gone -- a use-after-free on the very path this liveness check
was added to stop crashing. The leak is bounded and near-zero in practice
(chat/tooltip fontstrings are reused, never evicted).

* fix(chat): stop raid-marker expansion from dropping the message tail

A token inflates ~28x ({rtN} -> ~85-byte |T markup), so a marker-dense
message could fill the 0x800 buffer and silently truncate everything after
it -- a DLL user would see a shorter chat line than a non-DLL user (a mild
content-hiding vector).

Reserve room for the literal tail before expanding a token: only iconify a
marker when the expansion plus the whole remaining input (worst case copied
1:1) still fits. Past that point later tokens stay literal {rtN}, so the
full message text is always preserved -- just with fewer icons.

* fix(text): gate eviction Hide on region liveness to avoid a UAF

The stale-key eviction Hid its regions unconditionally, assuming they
outlive the fontstring. They usually do (regions are parented to the
fontstring's parent FRAME, which persists as lines churn) -- but if the
fontstring became non-live because that parent frame was destroyed, the
frame freed the fontstring AND its child regions together. Hiding a region
pointer there wrote to freed/recycled memory: a use-after-free that could
crash.

Learn the pooled CSimpleTexture vtable in CreateRegion and gate each
eviction Hide on IsLiveRegion (the same vtable-liveness technique
IsLiveFontString already uses). A recycled region is skipped (it needs no
hiding -- it's already gone); a live one is still hidden so it can't ghost.
Factored the shared WoW-image-range check into InWowImage so the two Learn*
functions don't duplicate the constant.
2026-08-18 04:15:54 -05:00
fuffcandBrues 72c4185026 fix(aura): identify a cached aura by its caster and descriptor slot (#21)
* fix(aura): identify a cached aura by its caster and descriptor slot

Two casters of one spell on one target are two auras in two descriptor
slots, but `Aura::Source` held a single entry per `(target, spellId)`.
The second cast overwrote the first caster's `casterGuid` and timing, and
`OnAuraRemoved` for either copy evicted the entry backing the survivor.

Observable from Lua, with a same-class groupmate debuffing your target:
`sourceUnit` names the wrong caster, so a `PLAYER`-filtered query drops
your own debuff; and the base-duration estimate stored for the other
caster elapses while your talent-extended aura is still up, which
`PushEnriched` reports as `expirationTime = 0` — an aura on the unit with
no timer at all.

Entries are now keyed `(target, spellId, caster)`, the identity the server
uses. Recovering which instance a descriptor slot holds needs the two
halves joined: SpellGo knows the caster but no slot, `OnAuraAdded` knows
the slot but no caster, and they arrive in that order, so the application
hook seats its slot on the newest cast capture still awaiting one.
`Get` takes that slot and resolves by it first, falling back to the unit's
sole entry for the spell — every single-caster case, and the paths with no
descriptor to read a slot from. Several entries and no binding resolves to
a miss: the caller's unknown-caster defaults beat a coin flip between two
casters.

`OnAuraRemoved` and `EvictAbsent` retire the instance in the vacated slot
rather than every entry sharing its spell ID.

* fix(aura): compare unbound-entry freshness wrap-safely

FindFreshestUnbound picked the newest unbound capture with a raw
`stampMs >` on two absolute engine ticks. That compare inverts across the
2^32 ms tick wrap (~49.7 days uptime, or the rdtsc backend already past
2^31 after a warm reboot), so the OLDER capture would win and OnAuraAdded
would seat the descriptor slot onto the wrong cast — mis-attributing that
aura's caster and duration.

Pick by smallest Time::Clock::Elapsed(stamp, now) instead, the wrap-safe
signed difference the rest of the codebase uses for tick math.

* fix(aura): keep the observed caster on the aura fallback paths

The cache-fallback push paths (the descriptor-drop fallback and the
out-of-range group-member fallback) already hold each aura instance's
caster and timing — Enumerate copies them straight out of the cache entry.
But PushEnriched then re-resolved them through Get(guid, spell, slot),
which returns a miss for an unbound entry that shares its spell with
another caster's entry. So an aura Enumerate had fully attributed came
back out with no sourceUnit/sourceGUID and no expirationTime.

Hand the entry's own attribution to PushEnriched as `known` so the
fallback reports what the cache already knew instead of re-deriving it.

* refactor(aura): route Get and Evict through one FindInstance resolver

Get and Evict each hand-rolled the same FindBySlot-then-FindSole ladder —
the read-side rule for which instance a slot-bearing query names. Naming it
once keeps the two from drifting into resolving the same query to different
instances (reporting one caster while evicting another). No behavior change.

* perf(aura): bound cache scans to the active high-water prefix

Every Aura::Source lookup linear-scanned all 2048 cache slots. Get runs
per-aura per-query — hundreds of times a frame when addons poll UnitAura in a
raid — so the full scan dominated the read path.

Track a high-water mark that Claim (the sole allocator) extends and FlushAll
resets, and scan only [0, g_usedHigh). Every used entry lives below the mark,
so no lookup can miss one. Outside a raid the working set is a handful of
auras, cutting a ~2048-entry scan to ~30; in a raid it drops to the real
working set. Claim still scans the full array — as the allocator it must hand
out slots beyond the current prefix.

---------

Co-authored-by: Brues <5278969+brues-code@users.noreply.github.com>
2026-08-16 14:41:49 -05:00
Brues d366fec43e fix(attributes): restore the previous target after a macro click-cast
The `macro` verb selected the clicked unit and left it targeted (Clique's
click-heal minus the restore). Snapshot the current selection GUID, retarget
the clicked unit, run the macro, then restore the prior target via
FUN_TARGET_BY_GUID (which validates the GUID and drops a stale one), or
ClearTarget when there was no prior target. The macro's actions dispatch
synchronously while the clicked unit is selected, so restoring afterward
doesn't affect them.
2026-08-13 00:03:42 -05:00
Brues c6d99cf843 feat(attributes): target the unit for macro click-casts
The `macro` click verb ran the macro text with no unit context, so a
plain `/cast Flash Heal` bound to a raid frame hit the current target
instead of the clicked unit -- a regression from Clique/Healbot-style
click healing. Vanilla macros have no `@unit` conditional, and unlike
the `spell` verb (which feeds a GUID straight to the cast dispatcher)
we can't inject a target into arbitrary macro text.

Emulate pfUIevan's classic click-heal: TargetUnit the clicked unit
before running the macro. Like pfUIevan, the previous target is not
restored -- the click doubles as a target swap. Only the `macro` verb
is affected; `spell`/`item` already act on the unit directly.
2026-08-12 03:48:35 -05:00
Brues 39d22ac124 fix(time): centralize engine ms-tick reads in Time::Clock
The engine's OS millisecond counter (FUN_OS_TICKCOUNT_MS, same epoch
GetTime() scales by 0.001) is unsigned and wraps at 2^32 ms. Nine files
each re-derived the reader; several stored the tick into a signed int,
so past 2^31 ms (~24.9 days uptime) every timestamp went negative and
`startTime + duration` landed in the past -- callers saw every cooldown,
aura, and lockout as already elapsed. The same bug shipped three times
(the cooldown by-ID getters and C_LossOfControl).

Add one source of truth -- Time::Clock (src/time/Clock.h) -- with a
uint32 NowMs() and wrap-safe Reached/Remaining/Elapsed helpers, and route
every tick reader through it. No module re-derives the reader or hand-
rolls a `now < deadline` compare (which inverts across the wrap) anymore.

Also fixes the reported cooldown/LoC getters (unsigned out-params,
wrap-safe compares) and three latent instances of the same `now < end`
inversion found while sweeping: totem/Tracker's two timer getters and
aura/Source's cache-eviction pass.

Named Time::Clock, not Time::Tick, because a Time::Tick inner name
shadows the global Tick namespace (the WorldTick hook) from inside any
Time::* file.

Discovered by fuffc in #17.
2026-08-12 01:58:54 -05:00
Brues b497bb7fb0 fix(attributes): run macrotext as text, not through name-based RunMacro
The secure "macro" action read both `macrotext` (raw text) and the
deprecated `macro` (saved-macro name) into one buffer and ran whichever
through the global RunMacro. RunMacro is name-based -- stock resolves an
index, and SuperCleveRoidMacros' RunMacro calls GetMacroIndexByName --
so a macrotext value like "/cast [hp:<90] Holy Light" resolved to no
macro and silently did nothing. Because the global existed, the native
fallback never ran.

Split the two by their real semantics: macrotext runs through the chat
parser (RunMacroTextC), so each line dispatches via SlashCmdList and
picks up addon slash hooks like SuperCleveRoidMacros' conditional /cast;
the name form keeps preferring RunMacro. Fixes click-cast macros with
conditionals.
2026-08-12 00:32:28 -05:00
Brues 4d5fe8d41b feat(spellbook): add C_SpellBook.GetSpellBookItemInfo
Backport C_SpellBook.GetSpellBookItemInfo(slotIndex, spellBank) -> table,
reusing Spell::Info's DBC reader. slotIndex is 1-based across the book;
spellBank is Enum.SpellBookSpellBank (0 = Player, 1 = Pet), resolved via
Spell::Lookup::SpellbookSlotToID. Returns nil for an empty/out-of-range
slot, matching retail.

Fields map from Spell.dbc: itemType is Spell (player) or PetAction (pet)
- vanilla's book holds only real spells, so None/FutureSpell/Flyout never
occur; actionID == spellID (no override system); name/subName from the
localized Name/Rank; isPassive from Attributes; isOffSpec always false.
iconID is the icon PATH string (vanilla has no fileID), the same
deviation as C_Spell.GetSpellInfo.iconID; skillLineIndex is omitted.

Also register the two Enum tables the return contract references
(non-destructively, like the other Enum.* additions) so
info.itemType == Enum.SpellBookItemType.Spell comparisons work:
Enum.SpellBookSpellBank and Enum.SpellBookItemType.

Document in docs/API.md (SpellBook section + both Enum entries) and list
in README.
2026-08-11 01:28:31 -05:00
Brues 46cae9d465 fix(unitmenu): stop stacking Focus/Cancel when a submenu opens
ClassicAPI_ToggleUnitMenu's initialize closure ran the same level-1
customization at every level. When the mouse crossed a nested entry
(Loot Method / Loot Threshold / Raid Target Icon) the menu builds its
level-2 submenu and re-invokes initialize; our AddFocusButton /
AddCancelButton call UIDropDownMenu_AddButton with no level, which
defaults to level 1 (not the current level), appending onto the still-
open level-1 list. Level 1 is not reset while a submenu rebuilds, so the
list grew every hover until "Too many buttons in UIDropDownMenu:
ClassicAPIUnitMenuDropDown", pushing the real entries out of reach.

Make initialize level-aware: at level >= 2 hand off to UnitPopup_ShowMenu
(it resolves the submenu from UIDROPDOWNMENU_MENU_VALUE and adds its own
Cancel); do the scratch-copy + Focus/Cancel append only at level 1 -
mirroring how Blizzard's own unit dropdowns init.
2026-08-10 13:08:58 -05:00
Brues 93360ce482 fix(cast): don't start target-selection casts at the reticle step
Spell_C_CastSpell (0x006E4B60) calls the cast-start writer
FUN_CAST_START_SET before Spell_C_TargetSpell raises the reticle, so a
target-selection spell (Disenchant, ground-target AoE) stamps g_cast at
the green-cursor step - seconds before the item/ground is clicked and the
cast actually begins. The later click re-enters Spell_C_CastSpell only to
hit its "already this spell" short-circuit, so the writer never re-fires
for the real start. Result: UNIT_SPELLCAST_START (and a UnitCastingInfo
cast bar) fired far too early, and a cancel fired a phantom
START/INTERRUPTED/STOP for a cast that never happened.

Drop the client stamp in OnWorldTick while the reticle is up for that
spell. On selection the server's SMSG_SPELL_START re-stamps g_cast via
the fresh-stamp path (it is 0), so START fires with the correct time and
the SENT castGUID; on cancel it stays cleared. Gated on !g_castFromServer
(never touches the server re-stamp) and on g_cast.spellID ==
VAR_PENDING_CAST_SPELL (only the spell whose reticle is actually up).
Normal casts have no targeting phase and are unaffected.
2026-08-10 00:11:21 -05:00
Brues b879730dfb ci: attach ClassicAPI.map to GitHub releases
Add build/Release/ClassicAPI.map to the release step's asset list so
every vX.Y.Z release ships the linker map beside the DLL. It resolves
the `0001:offset` logical addresses in a shipped build's ERROR #132
crash dump back to function names for that exact build. Dev-build
artifacts stay DLL-only.
2026-08-09 23:27:53 -05:00
Brues 9cbf185b3c fix(frame): make ScriptArgs global save/restore longjmp-safe
RunModern reimplements the frame-script runner tail, saving the
this/arg1..argN globals it clobbers so it can restore them after the
handler pcall. The old save used luaL_ref into the registry, mirroring
the engine's own FUN_00704f10 - but any push before the protected call
can longjmp (out-of-memory being the real case), which unwinds past the
C frame and skips the restore: the refs leak and the globals stay
clobbered for later handlers.

Save the old values on the Lua stack instead. An unwinding error resets
L->top past them for free, so there is nothing to leak and no clobbered
state to strand. This also drops the per-call luaL_ref/luaL_unref churn
and the per-arg re-read from globals on the OnUpdate hot path; the call
args are pushed directly from a pure-C parse of the vararg buffer.

The stack save is deeper than the registry save (~2N slots live at
once), so CheckStack up front; if it cannot grow (or there is no Lua
state) RunModern returns false before any side effect and the thunk
falls back to the original engine runner, never double-running.

Behavior unchanged: default-off passthrough, and identical
(self, [event,] arg1..argN) semantics when enabled.
2026-08-09 23:26:47 -05:00
Brues 68f6a687cc build: emit linker .map for crash-dump triage
Add /MAP to the shared-linker flags so the Release build produces
ClassicAPI.map, which resolves the `0001:offset` logical addresses in
WoW's ERROR #132 crash dumps back to function names. Linker-only output;
no effect on code layout.
2026-08-09 23:26:47 -05:00
Brues 2d5ecdc493 refactor(spell): identify Ferocious Bite structurally, share IsFitToFamily
Carnage's Ferocious Bite gate was a hardcoded list of five rank IDs. Replace
it with the server's own structural test (IsDruidFerociousBite): druid family
+ the Rip/Bite family flag + a direct-damage effect (the damage effect is what
separates Bite from Rip, its DoT sibling that shares the flag). Rank-proof and
covers any Turtle-added rank with no ID list. Verified against the client
Spell.dbc: all five FB ranks are {family 7, flag 0x800000, Effect[0]
SCHOOL_DAMAGE}; Rip shares the flag but is APPLY_AURA, and Rake carries a
different flag.

The family + flag-bit test is the client-side form of the server's
SpellEntry::IsFitToFamily, and Aura::Source::AffectedMatchesRaw read the same
two fields inline, so extract Spell::Lookup::IsFitToFamily and route both
through it. Adds SPELL_EFFECT_SCHOOL_DAMAGE (= 2, verified vs the server's
SpellDefines.h).
2026-08-09 02:52:34 -05:00
Brues 783ffd05bf feat(aura): Carnage Rip/Rake refresh + signal duration-mod changes
Turtle's Carnage refreshes the caster's Rip and Rake off Ferocious Bite,
behind a per-combo-point roll whose outcome the client only sees as the
combo point the proc returns. A src/turtle/Carnage watcher arms on a
Ferocious Bite send and confirms on the first combo-point rise, then
refreshes the cached auras. Verified against tortoise-wow
spell_druid_carnage: icons 108 (Rip) / 494 (Rake), family bits 0x800000 /
0x1000, caster scoping, and RefreshHolder (restore the aura's own applied
duration).

A cache duration edit has no engine packet behind it, so nothing re-fired
UNIT_AURA and event-driven aura bars never saw the change. The trigger mods
(ApplyMod) and the Carnage refresh now enqueue the affected unit, and the
WorldTick flush re-broadcasts UNIT_AURA via the engine's own per-token
signal (FUN_UNIT_EVENT_BROADCAST), deferred off the SMSG_SPELL_GO subscriber.

Also move the two static Turtle duration mods (Conflagrate -> Immolate,
Molten Blast -> Flame Shock) from Lua into src/turtle/DurationMods via a new
C++ entry Aura::Source::AddDurationMod, and drop the now-unused exact-spellID
RegisterAuraDurationModifier Lua export (RegisterAuraDurationModifierByTrigger
stays for Shadow Weaving).

Based on the approach from @fuffc in #15.

Closes #15
2026-08-09 02:40:47 -05:00
Brues f559eb4d07 docs(api): move UNIT_SPELLCAST_* events into the Events section, fix TOC link
The TOC entry pointed at #unit_spellcast_-events-player, but the heading
generates #unit_spellcast_-events (no -player suffix) — broken link. Move
both the TOC entry and the content block out of the Spell section and into
the Events section where the other event docs live, and correct the anchor.
2026-08-08 17:10:11 -05:00
fuffc 2de7176b94 fix(aura): key the combo-point capture by spellID so a proc can't eat it (#14)
The capture was a single slot, and ConsumeCapturedCP cleared it before
testing whether the spellID even matched. Every player-cast aura spell
reaches TryComboScaledMs, so any aura landing between a finisher's
CMSG_CAST_SPELL and its own SMSG_SPELL_GO consumed the snapshot and left the
finisher with no combo points — falling back to the 0-CP base duration.

Not a rare race. Turtle's rogue Taste for Blood casts spell 52529 on the
player off the same Rupture, and its SPELL_GO arrives first, so Rupture
reported a static 12s (its 6s 0-CP base plus the talent) at every combo
count. Untalented, with no proc to clobber the slot, the same cast scaled
correctly — which is what made this look like a modifier bug rather than a
capture one.

Key the captures by spellID in a small ring and consume only the matching
entry. The comment claiming one cast is in flight at a time was reasoning
about finishers alone; the funnel sees every cast.
2026-08-08 16:27:57 -05:00
Brues 13008ab8aa refactor(item): centralize passive item-cache peek in Item::PeekRecord
Add item/Record.{h,cpp} with Item::PeekRecord(itemID) -- the passive
ItemStats_C cache peek (null callback, no network query). Route the three
duplicate implementations through it:
  - the per-module GetItemRecord_t typedef + fn(cache,id,&zero,nullptr,
    nullptr,0) call in ~25 read-only item accessors
  - Item::StatAccum::FetchRecord (deleted; 7 callers repointed)
  - item/Tooltip.cpp's local PeekItemRecord (deleted)

Behavior-identical: every converted call was already a passive peek with
identical args (dedup flag is a no-op when the callback is null).

item/Data.cpp is deliberately untouched -- it owns the ACTIVE fetch
(CacheFetch passes a real callback to fire SMSG_ITEM_QUERY_SINGLE, the
load-bearing cache-race path in CLAUDE.md) and keeps its own typedef.
2026-08-08 15:35:07 -05:00
Brues 0df8885045 refactor(offsets): collapse duplicate object type-mask enum (OBJ_TYPE_* -> TYPEMASK_*)
OBJ_TYPE_* was a partial second naming of the same object-type bits as
the complete TYPEMASK_* set (OBJ_TYPE_ITEM=2=TYPEMASK_ITEM, ... GAMEOBJECT
=0x20 both) -- a single-source-of-truth violation. Repoint all ~18
references to TYPEMASK_* (identical values, behavior-preserving) and
delete the OBJ_TYPE_* definitions, folding their derivation notes
(bitmask semantics, FUN_004F8DB0 for PLAYER, FUN_0052AA20 for GAMEOBJECT,
FUN_00529FE0 SetUnit) into the surviving TYPEMASK_* comment.
2026-08-08 15:12:55 -05:00
Brues 8fa344a084 refactor(item): name CGItem deref idioms via Item::InstanceBlock / ObjectFields
Add item/CGItem.{h,cpp} with two null-safe accessors for a CGItem's two
descriptor-like pointers:
  InstanceBlock(cgItem) -> CGItem + 0x08 (item GUID @ +0, itemID @ +0x0C)
  ObjectFields(cgItem)  -> CGItem + 0x114 (stack/flags/charges/durability/...)

Replace the 30 hand-written `*reinterpret_cast<const uint8_t *const *>(x +
OFF_ITEM_INSTANCE_BLOCK/OFF_ITEM_DESCRIPTOR)` derefs across 24 files with
these calls. Behavior-preserving: same deref, all reads were already const,
and each site keeps its own null-check and field reads. Helpers add a
cgItem null guard (strictly safer).
2026-08-08 15:08:34 -05:00
Brues 2612118ff6 refactor(object): centralize GUID->object resolution in Object::ByGuid
Every caller of the engine object resolver (0x00468460) re-declared the
same __fastcall typedef and split the GUID into lo/hi by hand -- 30 call
sites across 28 files. Add object/Resolve.{h,cpp} with

  void *Object::ByGuid(int typeMask, uint64_t guid, const char *debugName, int priority)

and route every site through it (behavior-identical: same typeMask,
debugName, priority per site; single-uint64 callers pass guid, split-lo/hi
callers reconstruct it).

Also collapse the duplicate offset constant: FUN_CLNT_OBJ_MGR_OBJECT_PTR
was a second name for the same 0x00468460 address as
FUN_OBJECT_RESOLVE_BY_GUID (a single-source-of-truth violation). Remove
it; the ABI-equivalent (u32,void*,u64,int) view it documented is folded
into the surviving constant's comment.
2026-08-08 04:13:30 -05:00
Brues 76e40fec96 refactor: share ASCII case-insensitive compare + DBC reverse token lookup
Add baselib/Ascii.h (header-only Ascii::ToLower / Ascii::EqualCI) and
replace the two hand-rolled case-insensitive compares (frame/Attributes'
EqI, taxi/Map's IEqual + AsciiLower).

Add DBC::ClassIdForToken / RaceIdForToken (token -> ChrClasses/ChrRaces
id, the reverse of ClassToken/RaceToken) backed by a shared
IdForStringField walk using Ascii::EqualCI, and replace player/NameCache's
two near-identical ResolveClassToken / ResolveRaceToken loops.
2026-08-08 02:09:08 -05:00
Brues b72c52d9f7 refactor(identity): share NameForGuid, drop friendlist copies
Add Unit::Identity::NameForGuid(guid) -- a passive NameCache peek that
returns the cached character name -- and replace the two identical local
copies in friendlist/IsFriend and friendlist/IsIgnored. Both files no
longer touch Offsets directly, so drop the now-dead include.
2026-08-08 02:08:59 -05:00
Brues 1447317cd3 refactor(dbc): route class/race/area name lookups through DBC::Names
Add DBC::RaceToken (race client-filename token) and make AreaName skip
the parent Record lookup when resolveToParent is false, then replace the
inline ChrClasses/ChrRaces/AreaTable name+token reads across the codebase
with the shared helpers:

  player/Info, creature/Info, player/LocationInfo, unit/ClassBase,
  unit/RaceBase, map/Areas, taxi/Map, loot/History (drops its verbatim
  ClassTokenForId), classes/Info (drops local Records/LocaleIndex).

All behavior-preserving -- the helpers wrap the same DBC::Record /
LocalizedField / StringField calls with identical id==0 / out-of-range /
empty-string -> nullptr semantics (verified against Lookup.cpp).

Left alone: NameCache's reverse token->id resolvers (no forward helper
fits) and the Record() reads of non-name fields in spell/Mod,
map/Overlays, creature/Info.
2026-08-08 01:53:02 -05:00
Brues f293653837 docs: clarify GetNumWhoResults second return is server-dependent
The second value's meaning varies by server core. Mangos/cmangos cores
send the query match count; Turtle (tortoise-wow) sends the whole
realm's online population whenever more than 49 players are online,
ignoring the filter (verified against tortoise-wow MiscHandler.cpp:
data.put(4, count > 49 ? count : clientcount), count = online players).
Earlier wording claimed it was always the match total, which is wrong
off Turtle -- confirmed by a Twinstar /who priest returning 15/15.
2026-08-08 01:09:27 -05:00
Brues ba1e12fca9 feat(friendlist): C_FriendList.GetWhoInfo / GetNumWhoResults
Add the modern table form of /who results. Vanilla's global
GetWhoInfo returns positional values and no class token; GetWhoInfo
here returns a table with filename (the "MAGE"/"WARRIOR" token, keyed
for RAID_CLASS_COLORS) plus fullName, fullGuildName, level, raceStr,
classStr, and area. GetNumWhoResults returns the shown + total counts.
Reads the engine's /who buffer directly (WHO_RESULT_* globals added to
Offsets.h, verified by decompiling Script_GetWhoInfo / GetNumWhoResults).
No gender field -- vanilla's /who entry has no sex.

Share the class/race/zone display-name resolvers: extract ClassName,
ClassToken, AreaName out of GetFriendInfo into dbc/Names.{h,cpp} (adding
RaceName and an AreaName resolveToParent flag -- friends resolve a
sub-area to its zone, /who uses the area's own name). GetFriendInfo and
GetWhoInfo both use them now.

Document both functions in docs/API.md and README.md.
2026-08-08 01:01:39 -05:00
Brues 867478f410 feat(friendlist): add classFilename to GetFriendInfo
Return the locale-independent class token ("WARRIOR", "MAGE", ...)
alongside the localized className, read from the same ChrClasses.dbc
filename column GetPlayerInfoByGUID's englishClass uses. It is the key
for RAID_CLASS_COLORS and other class tables, so addons can color a
friend's name without matching the localized class name. nil when
unknown, so className and classFilename appear together. Document in
docs/API.md.
2026-08-08 00:40:41 -05:00
Brues caeb598b78 feat(player): resolve UnitNameFromGUID from the friends list
Add a friends-list fallback to UnitNameFromGUID, between the object
manager and the persistent name cache. The friend list keeps name +
GUID for online AND offline friends (SMSG_FRIEND_LIST), so a friend's
GUID now resolves to a name even when the unit is not synced and was
never seen in chat -- the case that previously returned nil.

Only UnitNameFromGUID uses this: it returns name + realm, and the
friend entry has the name. GetPlayerInfoByGUID is left unchanged because
it also returns race and sex, which the friend entry does not carry.

Add FriendList::NameForGuid helper. Document the lookup order in
docs/API.md.
2026-08-08 00:28:34 -05:00
Brues 37ca209210 feat(friendlist): C_FriendList.GetNumOnlineFriends
Add the online-friend count. Vanilla stores no online total, so count
friend entries whose connected byte is set -- the same flag
GetFriendInfo reports. Document in docs/API.md and README.md.
2026-08-08 00:02:15 -05:00
Brues 94c5eaafd2 feat(locale): backport SET_FRIENDNOTE_LABEL / SET_NOTE globals
Add the two friend-note GlobalStrings vanilla 1.12 lacks, per locale
(enUS/deDE/esES/frFR/ruRU/zhCN/zhTW), from Blizzard's Classic
GlobalStrings. Lets note-aware UI (e.g. a pfUI "Set Note" entry driven
by C_FriendList.SetFriendNotes) label itself in the client language.
2026-08-07 23:22:41 -05:00
Brues 3f09b3f883 feat(friendlist): C_FriendList.IsIgnored / IsIgnoredByGuid
Add ignore-list membership checks. Vanilla's ignore list is a flat u64
GUID array in the social singleton (social + 0x650, stride 8, max 25,
terminated by the first zero) -- verified by decompiling GetIgnoreName
(FUN_005AE570 reads social + 0x650 + index*8) and GetNumIgnores
(FUN_005AE550 stops at the first zero, caps at 25).

IsIgnoredByGuid parses the GUID string and walks the array.
IsIgnored accepts a name or GUID string (mirroring IsFriend); for a name
it resolves each ignored GUID to its cached name through the NameCache
and compares -- the same GUID->name path GetIgnoreName uses -- so it
matches any ignored player the client has synced this session.

Share the ignore layout + IgnoreGuidByIndex / IsGuidIgnored helpers in
FriendList.h alongside the friend layout. Document both functions in
docs/API.md and README.md.
2026-08-07 22:50:15 -05:00
Brues 9f640de74b fix(addon): use plain info tables in the unit menu (stock 1.12 has no UIDropDownMenu_CreateInfo)
UIDropDownMenu_CreateInfo does not exist in stock 1.12.1 FrameXML, so
building the Focus and Cancel buttons through it errored with "attempt
to call global UIDropDownMenu_CreateInfo (a nil value)". Build the info
tables directly ({}) -- the vanilla idiom UIDropDownMenu_AddButton
expects.
2026-08-07 22:27:44 -05:00
Brues 67b968c36c fix(addon): NPC unit menu + working Cancel on the Focus dropdown
Unify ClassicAPI_ToggleUnitMenu so every menu (players and NPCs) shows
the base menu minus its trailing Cancel, then appends Focus + Cancel.
An NPC resolves to the RAID_TARGET_ICON menu, whose entries
UnitPopup_HideButtons hides entirely unless you are grouped and can
place markers -- so solo the menu was empty and never opened. The
appended Focus + Cancel now always open it, and the NPC's own name is
used as the title.

Give the Cancel button a no-op func: UIDropDownMenuButton_OnClick
early-returns when a button has no func and never hides the list, so the
funcless Cancel did nothing on click.
2026-08-07 21:12:37 -05:00
Brues f833aad769 feat(addon): Set/Clear Focus on the unit right-click menu
Add a contextual Focus / Clear Focus entry to the ClassicAPI unit
dropdown (ClassicAPI_ToggleUnitMenu), shown for real non-self units
(party / raid / target). pfUI unit frames route their right-click
through this dropdown, so it reaches every frame in use.

Built entirely inside our own dropdown: no hooksecurefunc and no
mutation of Blizzard's shared UnitPopupMenus tables, so default frames
and other addons are unaffected. Cancel stays last by showing the base
menu minus its trailing Cancel, then appending Focus and Cancel with our
own click funcs. "Clear Focus" shows when the unit is the current focus,
"Set Focus" otherwise.

Labels use localized SET_FOCUS / CLEAR_FOCUS globals, added per locale
(enUS/deDE/esES/frFR/ruRU/zhCN/zhTW) from Blizzard's Classic
GlobalStrings.
2026-08-07 21:05:14 -05:00
Brues b38673f51f docs: document the friend-notes API
Update the GetFriendInfo entry so notes is a live field (with the
per-character persistence note), add SetFriendNotes and
SetFriendNotesByIndex sections plus TOC and README-matrix rows. Example
values are from a verified in-game /dump.
2026-08-07 20:14:07 -05:00
Brues f2161265be Add a native friend-notes system (C_FriendList SetFriendNotes / notes)
Vanilla has no friend notes, so persist them client-side like the equipment manager. New friendlist/Notes.{h,cpp}
stores guid -> note in
WTF\Account\<acct>\<realm>\<char>\ClassicAPI_FriendNotes.txt --
per-character (matching the friends list's own scope), GUID-keyed (stable
and realm-unique), atomic tmp+rename write.

Expose C_FriendList.SetFriendNotes(name, notes) and
SetFriendNotesByIndex(index, notes) -- empty/nil clears, fires
FRIENDLIST_UPDATE -- and fill the notes field in GetFriendInfo /
GetFriendInfoByIndex from the store.

Character isolation (the equipment-manager trap): EnsureLoaded re-resolves
the path every call and reloads on change; Load clears the map before the
file-open check, so a note-less character still wipes the previous one's
map; and an empty-globals transition drops the cache so a stale path is
never read or written.

FriendInfo's optional strings (notes, className, area) are left nil when
absent instead of "" -- SetFieldString coerces null to "", which is
truthy in Lua and would fool an `if info.notes then` check.

Verified in-game: set / get / clear by name and by index.
2026-08-07 20:14:04 -05:00
Brues eefcbb17f2 docs: document C_FriendList.GetFriendInfo family
Add API.md sections and the README matrix row for GetFriendInfo,
GetFriendInfoByIndex, and GetNumFriends, with the FriendInfo field list
and example values captured from a live /dump (verified, not guessed).
2026-08-07 19:42:16 -05:00
Brues 599a65d80c Add C_FriendList.GetFriendInfo / GetFriendInfoByIndex / GetNumFriends
Return the modern FriendInfo table (name, connected, level, className,
area, guid, afk, dnd, plus vanilla-truthful mobile / referAFriend /
rafLinkType defaults). Fields are read straight from the friend entry,
mirroring the engine's GetFriendInfo (FUN_005AD060): class name via
ChrClasses.dbc, zone via AreaTable.dbc resolved to the parent area, both
locale-applied. Verified in-game against a live friends list -- online,
offline, level-60 and level-1, with AFK and DND toggled.

Factor the friend-entry layout and list accessors into a shared
friendlist/FriendList.h and move IsFriend onto it, so the one struct has
one definition. Add OFF_AREATABLE_PARENT_ID (0x08) for the zone resolve.
2026-08-07 19:41:11 -05:00
Brues 228c5daa0f docs: document IsFriend, GetSpellDispelType, IsNextMeleeSpell, ResetsMeleeSwing
Add API.md sections and README matrix rows for the four functions, with
example returns verified against Spell.dbc. Also correct two stale docs:
- IsRangedAutoAttackSpell now describes the AttributesEx2 autorepeat flag
  (bit 0x20), matching the fixed behavior -- not the old Attributes
  RANGED-bit test that wrongly matched Aimed Shot / Multi-Shot.
- ResetsMeleeSwing mirrors the server's IsMeleeAttackResetSpell: true for
  cast-time spells whose casting resets the swing (Fireball, Frostbolt),
  false for instant specials (Sinister Strike) and for Slam (NOT_RESET
  flag). The first draft had the examples and framing backwards.
2026-08-07 19:22:09 -05:00
Brues cc6953a530 Add C_FriendList.IsFriend(guid)
Backport the friend-list membership check. Friends live inline in the
social singleton (0x00C28168): up to 50 entries, stride 0x20, each with
a GUID at +0x08 and a name pointer at +0x04 (verified from GetNumFriends
/ GetFriendInfo and the count walker FUN_005AE490). IsFriend matches the
input GUID against +0x08 -- the GUID the server sends for online and
offline friends -- with a name-compare fallback (GUID -> NameCache name),
and also accepts a bare character name.

Renamed VAR_WHO_SYSTEM -> VAR_SOCIAL_SYSTEM: 0x00C28168 is the one social
singleton shared by the friend list, ignore list, and /who system, so it
gets one accurate name instead of a who-specific alias plus a duplicate.
2026-08-07 19:21:59 -05:00
Brues 84da9c23df Consolidate the duplicated Spell.dbc attribute flag bits
The remaining flag-bit duplicates -- the same drift risk that caused the
AttributesEx2/Ex3 mix-up -- now live once in Offsets.h:
- SPELL_ATTR_PASSIVE (0x40): was already in Offsets.h yet copied in
  Info.cpp and Level.cpp; drop both locals.
- SPELL_ATTR_EX_CHANNELED (0x4|0x40): identical local in Cast.cpp and
  CastEvents.cpp; add to Offsets.h and repoint.
- SPELL_ATTR_EX2_AUTOREPEAT_FLAG (0x20): the same bit under two names
  (Cast's _AUTOREPEAT, AutoAttack's _FLAG); unify in Offsets.h.

Single-use flag bits (TRADESPELL, RANGED, HEALTH_FUNNEL,
HIDDEN_CLIENTSIDE, ON_NEXT_SWING, NOT_RESET_AUTO_ACTIONS) and per-module
effect/target enum codes stay local -- they are not duplicated and read
clearer at the use site.
2026-08-07 17:52:55 -05:00
Brues 6e58cb81d1 Consolidate SpellIcon path and remaining Spell.dbc offset stragglers
Fold the last duplicated DBC offsets into Offsets.h:
- SpellIcon.dbc path (+0x04): 4 copies across 3 files -> OFF_SPELLICON_PATH.
  aura/Data and spell/Cast had local consts; spell/Info used two raw
  `iconRec + 4` literals.
- totem/Tracker's OFF_MISC_VALUE (0x1A8) duplicated the existing
  OFF_SPELL_RECORD_EFFECT_MISC_VALUE -- repointed. OFF_TOTEM_TOOL (0xA0) is
  a Spell.dbc field, added as OFF_SPELL_RECORD_TOTEM.

Audit result: unit-descriptor offsets were already fully centralized in
Offsets.h, and item ItemStats_C fields are read via a named struct rather
than offset consts, so neither needed work. Spell.dbc and its sub-DBC
record offsets are now a single source of truth.
2026-08-07 17:49:05 -05:00
Brues 8a065481b4 Consolidate all Spell.dbc record offsets into Offsets.h
Extend the single-source-of-truth cleanup to the whole Spell.dbc record
layout. 40 local constexpr offset copies across 16 files (spell/, totem/,
lossofcontrol/, macro/, aura/) redefined field offsets that belong in
Offsets.h: name (0x1E0), icon (0x1D4), effect (0xF4), rank (0x204),
effect implicit targets (0x148/0x154), powerType (0x7C), manaCost (0x80),
rangeIndex (0x90), castingTimeIndex (0x48), maxLevel (0x6C), spellLevel
(0x74), manaCostPercent (0x270).

Add the ones Offsets.h lacked as OFF_SPELL_RECORD_* and repoint every
local use to the Offsets:: constant, deleting the duplicates. Purely
mechanical and behavior-preserving -- every local value already matched
the canonical (verified before repointing). SpellIcon.dbc / SpellRange.dbc
sub-record offsets (0x04/0x08) stay local; they are different structs.
2026-08-07 17:44:01 -05:00
Brues 2a7cbf75ca Remove the invalid auto-learn hide gate in GetCurrentLevelSpells
SpellHiddenFromAutoLearn tested AttributesEx3 bit 0x02000000, a gate
ported from a MoP function (FUN_00911c60) whose +0x24 field is not
1.12's AttributesEx3. Verified against Spell.dbc: in 1.12 the bit is set
on only 44 of 27925 records -- a grab-bag of passive procs, odd DoT
ranks, the Night Elf priest Starshards line, and Turtle customs -- so it
is not an auto-learn flag and could only wrongly hide legitimate
learnable spells (Starshards carries it).

Drop the AttributesEx3 test and keep the verified HIDDEN_CLIENTSIDE
(Attributes bit 0x80) filter, renamed SpellHiddenFromSpellbook. The
authoritative auto-learn signal is SkillLineAbility.learnOnGetSkill
(col 9), not a Spell.dbc bit; this feature reports the broader
"trainable at level" set and applies no learnOnGetSkill filter.
2026-08-07 16:58:14 -05:00
Brues ff5ce5cdcb Fix IsRangedAutoAttackSpell to test the autorepeat flag
The check tested Attributes bit 1 (RANGED), which every ranged ability
sets -- so Aimed Shot and Multi-Shot wrongly counted as ranged
auto-attacks. The two auto-repeat spells (Auto Shot, Shoot) are the ones
with SPELL_ATTR_EX2_AUTOREPEAT_FLAG (AttributesEx2 bit 5, 0x20). Verified
from Spell.dbc: Auto Shot (75) and Shoot (5019) carry Ex2 0x20; Aimed
Shot (19434) and Multi-Shot (2643) set only RANGED, not the flag.
2026-08-07 16:52:25 -05:00
Brues 47b6a66b96 Fix GetSpellInfo isFunnel to read the health-funnel flag
isFunnel read AttributesEx bit 6, which is CHANNELED_2 -- false for
Health Funnel itself and true for unrelated channels, so it never
actually detected funnel spells. The real flag is
SPELL_ATTR_EX2_HEALTH_FUNNEL (AttributesEx2 bit 11, 0x800). Verified
from Spell.dbc: Health Funnel (755, Ex2=0x808) and Hellfire (1949,
Ex2=0x800) carry it; the channeled non-funnels Drain Life, Mind Flay,
Arcane Missiles, and Rain of Fire do not.
2026-08-07 16:51:38 -05:00
Brues e78c36bb70 Fix the spell-mod gate to read AttributesEx3, not AttributesEx2
The prior consolidation moved the spell-mod disable read from record
+0x24 to +0x20 on the handoff's claim that all three "0x24 readers"
intend AttributesEx2. A tortoise-wow sweep plus client disassembly
disproved that for the mod gate: the client's spell-mod core
(FUN_006e6b30) reads [record + 0x24] & 0x20000000 -- AttributesEx3 bit
29, SPELL_ATTR_EX3_IGNORE_CASTER_MODIFIERS, which is the real "ignore
caster modifiers" flag. AttributesEx2 bit 29 is CANT_CRIT and has
nothing to do with spell mods. So +0x20 was a regression.

Add OFF_SPELL_RECORD_ATTRIBUTES_EX3 (0x24) and the correctly named
SPELL_ATTR_EX3_IGNORE_CASTER_MODIFIERS to Offsets.h, and point Mod.cpp
at them. Cast.cpp's autorepeat read stays at AttributesEx2 (bit 5,
AUTOREPEAT_FLAG) -- that one was a genuine EX2 flag and the move to
+0x20 was correct.

Level.cpp's auto-learn gate was ported from a Cata function
(FUN_00911c60, outside 1.12's .text) and its 1.12 meaning is
unverified, so restore it to the original +0x24 read (no behavior
change from before the consolidation) and mark the constant unverified
pending a real 1.12-client check.
2026-08-07 16:43:35 -05:00
Brues 41fb17502d Add C_Spell spell-record field readers
Add C_Spell.GetSpellDispelType, IsNextMeleeSpell, and ResetsMeleeSwing.
Each reads a Spell.dbc field straight from the record: dispel type
(0x10), the on-next-swing attribute bits (0x18 & 0x404), and the melee
swing-reset rule (InterruptFlags 0x54 has AUTOATTACK and AttributesEx2
0x20 lacks NOT_RESET_AUTO_ACTIONS). All offsets come from the shared
Offsets.h constants. This lets pfUI drop nampower's GetSpellRecField.
2026-08-07 16:35:04 -05:00
Brues a989499cae Consolidate Spell.dbc attribute offsets into Offsets.h
The Attributes (0x18), AttributesEx (0x1C), and AttributesEx2 (0x20)
record offsets were each copied as local constants across several spell
modules. AttributesEx2 was 0x24 in three of those copies, which is
actually AttributesEx3 -- an off-by-one-field bug. Spell.dbc stores each
record as a flat uint32 array by column, so AttributesEx2 (column 8) is
at 0x20. Verified against the 1.12.1 client, the server column order,
and nampower.

Move the record offsets to Offsets.h as the single source of truth (add
AttributesEx at 0x1C and InterruptFlags at 0x54; correct AttributesEx2
to 0x20) and remove the local copies in AutoAttack, Cast, CastEvents,
Info, and Level. This corrects the autorepeat aim-time skip, the
auto-learn hide check, and the spell-mod family gate -- all of which read
AttributesEx2 -- and removes the duplication that let one wrong offset
become three bugs.
2026-08-07 16:34:46 -05:00
Brues 722c257acb fix(aura): size source cache to the raid working set; never evict a present aura
The Aura::Source cache holds one entry per (target, spellId) for every
aura-applying SMSG_SPELL_GO observed — every buff on every unit in view, not
just the player's. At 256 entries the table was permanently full in a group or
raid, so nearly every new cast fell through Store's overflow branch, which
overwrote a blind round-robin slot with no presence or expiry check. That slot
was almost always a live buff, dropping its cached caster — the "buff kept its
sourceGUID for a few minutes then lost it" report (e.g. Blessing of Wisdom).

Size kCacheSize to the realistic worst case (2048 — a fully raid-buffed 40-man
plus debuffs, ~80 KB static) so the table stops living at capacity, and replace
the round-robin overflow with a presence-aware victim: prefer the
least-recently-updated ORPHAN (no live descriptor backing — out of range /
despawned), and only touch a still-present entry under true saturation (every
one of 2048 slots a live aura, practically unreachable). This extends the
descriptor-presence invariant the tick sweep and slot-reuse already honor to
the last unguarded eviction path, so an aura still on a resolvable unit keeps
its source for its whole life. Verified in-game: 30+ minutes in a full
Stormwind with no source loss.
2026-08-07 16:16:57 -05:00
Brues a6c7098106 fix(aura): match self-buffs in the PLAYER aura filter
The self-buff sourceGUID inference from 7caee62 ran only at display, so a
self-buff we never saw cast (e.g. login-persisted Inner Fire) showed
sourceUnit="player" yet was excluded from HELPFUL|PLAYER — IsPlayerCast
still keyed off the Aura::Source cache alone, which has no entry for it.

Centralize the resolution in EffectiveCaster (observed caster, else the
self-buff inference: a self-only-target aura is cast by the unit carrying
it) and route display plus both filter predicates (IsPlayerCast,
GroupIsPlayerCast) through it, so the two paths can't disagree. The
predicate stays unit-scoped: a self-buff on the player matches PLAYER; the
same self-buff on another unit resolves to that unit and correctly does not.
2026-08-07 01:51:28 -05:00
Brues 7caee620e8 fix(aura): make C_UnitAuras sourceGUID persist and infer self-buff casters
Two changes so an aura's source stops vanishing:

Pin observed casters. The WorldTick expiry sweep (and Store's slot
reuse) dropped a cache entry once its computed expirationMs elapsed —
but that time is only a base-duration estimate for non-player casters
(we lack their mod tables), so a talent-extended debuff expired in the
cache while still live on the target. The entry took the caster with it,
and the descriptor path then rendered the aura with no sourceGUID. Now
an entry whose aura the owning unit's live descriptor still lists is
never timer-reclaimed (DescriptorListsAura resolves the GUID through the
object manager); only genuinely orphaned entries — unit out of range /
stealthed / despawned — expire. The fallback path already skips expired
entries, so no phantom can resurface. PushEnriched also reports an
already-elapsed cached expiration as unknown rather than a negative
remaining time.

Infer self-buff casters. On a caster miss (no cache entry, or one with
no caster), a self-only-target aura can only have been self-cast, so the
unit carrying it is its own source. Factor the record scan out of the
C_Spell.IsSelfBuff Lua wrapper into Spell::IsSelfBuff::IsSelfBuff and
call it from PushEnriched to recover sourceUnit/sourceGUID for
pre-existing self-buffs we never saw applied. Caster is exact; timing
stays unknown.
2026-08-07 01:45:09 -05:00
Brues 954533a170 feat(aura): add CROWD_CONTROL / !CROWD_CONTROL aura filter
Reuses the C_LossOfControl classifier so the two never drift. Extract
ClassifyControlLoss out of the LoC module into a shared
Spell::CrowdControl: Classify(rec) returns the hard control-loss type
string (stun/fear/silence/root/charm/confuse/pacify/possess/disarm) and
still backs C_LossOfControl; IsCrowdControl(spellID) backs the aura
filter.

Crowd control is broader than loss of control: IsCrowdControl also
counts movement slows / snares (SPELL_AURA_MOD_DECREASE_SPEED = 33,
added to Offsets.h), which are crowd control but not a loss of control —
so a Frostbolt/Hamstring slow is CROWD_CONTROL while C_LossOfControl
still ignores it. Classify (LoC) is unchanged.

Wire it as a third CcMode dimension on the Aura::Data::Match struct,
checked in MatchesAura; tokenizer honors CROWD_CONTROL / !CROWD_CONTROL.
Docs updated. Verified in-game: Psychic Scream (fear) and a Frostbolt
slow both classify as CROWD_CONTROL; DoTs and buffs do not.
2026-08-07 01:21:17 -05:00
Brues dbe5ab40ee feat(aura): add DISPELLABLE / !DISPELLABLE aura filter
Modern DISPELLABLE means "can be dispelled/purged/stolen by SOME
mechanism, regardless of whether the player can". Confirmed against the
Turtle/Tortoise server: an aura is dispellable iff its Spell.dbc Dispel
type is Magic(1), Curse(2), Disease(3), or Poison(4) — the server's
DISPEL_ALL_MASK — and vanilla applies no per-aura "undispellable"
attribute, so the dispel type is the whole story.

Data::IsDispellable(spellID) reads the Dispel field (OFF_SPELL_DISPEL_TYPE)
via the existing SpellRecord and checks 1..4 (the type ID directly, not
dispelName != nil, in case SpellDispelType.dbc names Stealth/Invisibility).

Fold the caster and dispel predicates into one Aura::Data::Match struct
that replaces the standalone CasterMode parameter across the Find*/Push*/
Append* surface, applied by a shared MatchesAura(match, isPlayerCast,
spellID). Orthogonal filter dimensions now add a struct field instead of
another parameter. Existing caster behavior is unchanged.

Tokenizer honors DISPELLABLE / !DISPELLABLE; docs updated. Verified
in-game: HARMFUL|DISPELLABLE returns Magic debuffs, HARMFUL|!DISPELLABLE
returns physical debuffs (Rend).
2026-08-07 01:06:39 -05:00
Brues f3b328247c ci: run build steps in PowerShell, not bash
shell: bash on the self-hosted Windows runner resolved to the WSL
launcher at C:\Windows\System32\bash.exe (Git Bash isn't on the
NETWORK SERVICE account's PATH), so Resolve version died with 'no
installed distributions'. Default the build job's run steps to Windows
PowerShell 5.1 (always present) and rewrite Resolve version accordingly,
dropping the bash/WSL dependency from the native-Windows build.
2026-08-07 00:44:42 -05:00
Brues 470361fbce ci: fall back to windows-latest when self-hosted runner is offline
Probe repo runners in a pick-runner job and hand the resolved label
(self-hosted if one is online, else windows-latest) down to build via
needs, so an offline self-hosted runner no longer queues the build
indefinitely.
2026-08-07 00:30:57 -05:00
Brues 2d266a9b76 feat(aura): tokenize C_UnitAuras filters; add PLAYER/!PLAYER negation
The aura filter string was matched with raw strstr, which the modern
AuraFilters token set breaks: strstr(f, "PLAYER") also matches
RAID_PLAYER_DISPELLABLE (false PLAYER-only), and "!PLAYER" contains
"PLAYER" so it was read as PLAYER-only — the opposite of intent.

Replace it with a real tokenizer (Aura::Api::ParseFilters): tokens
separated by "|" or whitespace, each optionally negated with a leading
"!", whole-token matching. Honored tokens: HELPFUL / HARMFUL
(descriptor slot ranges) and PLAYER / !PLAYER (caster == / != the local
player). Every other AuraFilters token (RAID, CANCELABLE, DISPELLABLE,
INCLUDE_NAME_PLATE_ONLY, MAW, …) is accepted and ignored — vanilla has
no data for them.

!PLAYER makes the caster restriction ternary, so the Aura::Data layer's
`bool playerOnly` becomes `CasterMode { Any, PlayerOnly, NotPlayer }`
threaded through every Find*/Push*/Append* function (plus the
lossofcontrol call site). Existing Any/PlayerOnly behavior is preserved
exactly; NotPlayer is additive. A cache miss counts as not-player, so
!PLAYER includes auras whose cast we did not observe (matching
IsPlayerCast's miss semantics).

Also fixes: "PLAYER" alone on the by-id / by-name getters now searches
both ranges instead of wrongly restricting to helpful.

Docs: API.md gains an STE "Filter tokens" note under GetAuraDataByIndex.
2026-08-06 23:50:15 -05:00
Brues 8e3e207f83 fix(frame): show mouseover tooltip for offline party/raid members
pfUI-style unit frames have no OnEnter; their tooltip is produced by
Frame::Attributes driving the engine mouseover setter (FUN_00492890).
That setter builds the unit tooltip only when the GUID resolves to a
live object, so an offline / far out-of-range party or raid member
(roster GUID, no object) got no tooltip.

The unit-tooltip builder FUN_00529FE0 itself works offline — with no
object it fills name/level/class + the "Offline" line from the party/
raid roster (same path GameTooltip:SetUnit("party1") uses). So when the
hovered unit-frame's GUID has no live object, build the tooltip
ourselves from the roster.

Fire OnTooltipSetDefaultAnchor before building (via the self-contained
FUN_FRAME_INVOKE_SCRIPT), exactly as the engine setter does — that
FrameXML handler sets the tooltip owner/anchor and is what shows it.
Without it the first hover worked by luck but a re-hover after the
leave-clear had no owner and stayed invisible (only a live-unit hover,
which takes the engine's own anchor path, would recover it).

New offsets: VAR_GAMETOOLTIP_OBJECT_PTR (0x00B4B3C4, the mouseover
tooltip object) and OFF_TOOLTIP_SET_DEFAULT_ANCHOR_HANDLER (0x444).
2026-08-06 23:26:52 -05:00
Brues 1fd1295679 debugtools: use DEBUG_FRAMESTACK from locale if available 2026-08-06 19:27:14 -05:00
Brues e4a75e68ca feat(frame): add GetMouseFoci — mouse-focus stack under the cursor
Modern WoW exposes GetMouseFoci() (the stack of frames under the
cursor) alongside GetMouseFocus(), with GetMouseFocus() ==
GetMouseFoci()[1]. Vanilla stores only the single topmost focus
(Script_GetMouseFocus reads *(ctx + 0x7C)); there is no stored stack.

Reimplement natively (no Lua polyfill, no hooks): walk the engine frame
list that Script_EnumerateFrames iterates (head *(ctx + 0xCC4), next
+0x308, low-bit/null sentinel), keep frames that are visible +
mouse-enabled and whose rect contains the cursor (Region getters +
cursor/effective-scale, mirroring Frame::Modern's IsMouseOver), order
top-first by strata rank then frame level (level read directly from
+0xC4), and force index 1 to the engine's stored focus so the invariant
holds by Lua table identity. Frames are pushed via the shared
UI::FrameObject::Push so == compares the same wrapper GetMouseFocus
returns.

Filters to the mouse-enabled (interactive) stack, matching 1.15.x
(verified: GetMouseFocus skips a higher non-mouse-enabled cooldown to
land on the button, and 1.15.x GetMouseFoci returns the same set).
/framestack lists more because it is a pure-geometry debug view, not the
mouse-focus set.

Docs: API.md entry (Simplified Technical English).
2026-08-06 18:42:35 -05:00
Brues 28bf41c647 locale: Backport debuff symbols and debug label
Add DEBUFF_SYMBOL_{CURSE,DISEASE,MAGIC,POISON} and DEBUG_FRAMESTACK translations to locale files (deDE, enUS, esES, frFR, ruRU, zhCN, zhTW).
2026-08-06 17:59:16 -05:00
Brues ac97f9b960 ci: add workflow_dispatch manual trigger to Build
Push-triggered runs can be silently dropped when GitHub throttles
webhooks during an Actions incident, leaving no way to kick a build.
Add a workflow_dispatch trigger as a manual escape hatch, runnable
from the Actions UI.

A single Resolve step computes is_release/tag from the pushed ref, the
dispatched ref, or an optional `tag` input, and every downstream step
keys off it — so push behavior is unchanged. Dispatch modes: run from a
vX.Y.Z tag (release build of that commit), run from a branch with a
`tag` input (release build attached to that tag), or run from a branch
with a blank input (dev build artifact). The Release step now passes
tag_name explicitly so it also works when the ref is a branch.
2026-08-06 17:17:46 -05:00
Brues 7f6c6e2dd4 consolidate UPDATE_MOUSEOVER_UNIT compat logic 2026-08-06 16:45:19 -05:00
Brues 9d09266fc6 build(embed): flag-driven embedded toc version + marker-gated dev override
Two related fixes to the embedded-addon precedence so users stop
running a stale/mismatched on-disk !!!ClassicAPI without realizing it,
while keeping the developer's disk-wins workflow intact.

Version is now a pure build-flag artifact. The embedded copy's
`## Version:` is stamped from CLASSICAPI_TAG at embed time
(cmake/embed_addon.cmake rewrites the line in-memory before hex
encoding, portable via a temp-file round-trip). The committed source
toc stays `## Version: DEV` and is never edited, so a release build is
`-DCLASSICAPI_TAG=vX.Y.Z` and nothing else. Dropped the CI sed step
that previously mutated the source toc. Moved the version-derivation
block above the embed step so CLASSICAPI_TOC_VERSION is available to it.

Dev override moved from the toc version to an explicit marker. A
`.classicapi-dev` file in the on-disk addon folder now forces the disk
copy to win regardless of version (checked via the original
FUN_FILE_READ, so it reflects a real on-disk file). Consequently `DEV`
no longer sorts as "newer than any release" in CompareVersions — it
sorts oldest — so a stray `DEV` source/clone copy on a user's disk no
longer silently shadows a newer embedded release. The normal local
loop still works without the marker (local DEV DLL + DEV disk = tie =
disk wins); the marker is only needed to prefer disk against a released
DLL. The marker is gitignored and excluded from the embed (both globs),
and the release ships only the DLL, so it can exist only in a
developer working tree.
2026-08-06 16:37:36 -05:00
Brues d972433ab0 compat(puppeteer): guard EnemyTracker against mouseover-loss fire
ClassicAPI 1.8.4 added a UPDATE_MOUSEOVER_UNIT loss-fire polyfill
(matching retail): the event now also fires when the mouseover goes
unit -> nothing, with `mouseover` already cleared. Puppeteer's
EnemyTracker OnEvent reads `unit = UnitGUID("mouseover")` on that
event, which is now nil on the loss fire; it then calls
`UnitCanAttack("player", unit)`, and Puppeteer routes UnitCanAttack
through its DoubleUnitProxy (no nil guard) straight to native
UnitCanAttack -> engine raises `Usage: UnitCanAttack("unit",
"otherUnit")` on the nil arg.

Wrap PTEnemyUpdater's OnEvent from our AddOnCompat shim and skip the
handler when UPDATE_MOUSEOVER_UNIT fires with no live mouseover, so
the nil never reaches the proxy. Gains still pass through, so enemy
tracking is unaffected. Deferred via ContinueOnAddOnLoaded (Puppeteer
loads after us) and guarded on PTEnemyUpdater existing (it is only
created when SuperWoW is present).
2026-08-06 14:23:29 -05:00
Brues b5513c6b22 fix(cbor): emit canonical map key order (RFC 7049 §3.9)
The CBOR encoder walked string-keyed tables with lua_next (pairs()
order), which Lua does not guarantee to be stable. The pfUI export
path rebuilds its config tables on every press, so each rebuild
re-hashed the same keys into a different internal layout and the map
serialized its key/value pairs in a different byte order -> different
compressed bytes -> different Base64. Harmless (every variant decodes
to the same config) but no stable fingerprint.

EncodeTable's map branch now stashes each key, records its encoded
bytes, sorts by encoded-key bytes (shorter first, then bytewise per
RFC 7049 3.9), then re-fetches key/value in sorted order. Nested
maps sort recursively, so the whole byte stream is canonical. Decode
is order-agnostic, so the round trip stays lossless.

Verified in-game: encode/decode/re-encode of a pfUI profile now
produces a byte-identical Base64 string.
2026-08-06 14:02:19 -05:00
Brues 8a434fea0e fix(aura): keep SMSG_SPELL_GO caster through the descriptor-sync race
C_UnitAuras dropped sourceGUID/sourceUnit for buffs cast by other units
(e.g. a druid's Mark of the Wild / Gift of the Wild on the player).

SMSG_SPELL_GO arrives before the SMSG_UPDATE_OBJECT that adds the aura to
the target's descriptor. In that window an addon aura poll runs
ReconcileCache, which calls EvictAbsent. The just-captured cache entry
named an aura the descriptor did not list yet, so EvictAbsent dropped it
and lost the caster. OnAuraAdded then recreated the entry with no caster,
so sourceGUID stayed nil. Verified by trace: every "Store NEW caster=0x8"
was followed at once by "EvictAbsent (had caster=0x8)".

Give each cache entry a write timestamp (stampMs) and exempt entries
younger than kEvictGraceMs (2s) from EvictAbsent. A real removal is far
older and still gets dropped, and OnAuraRemoved evicts genuine removals
directly, so stale-aura cleanup is unaffected.

Verified in-game: a remote druid buff now reports sourceGUID; a self-cast
buff still reports sourceGUID + sourceUnit="player".
2026-08-06 01:26:39 -05:00
Brues dbbba88aad feat(tooltip): show spell rank in the corner of SetSpellByID
`GameTooltip:SetSpellByID` now shows the spell rank in the top-right
corner of the title line. The cross-class `SetTalentByID` fallback shows
it too. Both go through `Spell::Tooltip::ShowByID`.

The engine builder `BuildSpellTooltip` (`FUN_0052E610`) sets the title
line right-text to `Rank[locale]` when `param_5` or `param_2` is not
zero. `param_5` has no other effect in the builder (confirmed by
disassembly). `SetAction` and `SetPetAction` pass `param_5=1` for this.
Native `SetSpell` and the old `SetSpellByID` passed `0`, so they did not
show the rank. `ShowByID` now passes `param_5=1`.

The change is additive. A spell with no rank (passive or racial) shows
nothing there, the same as the engine.

With this change, the macro tooltips of SuperCleveRoidMacros match the
native action-button tooltips.
2026-08-06 00:32:16 -05:00
Brues b8bd0bce4e fix(unit): gate +0xE68 sub-struct reads on OBJECT_TYPE_PLAYER, not PLAYER_CONTROLLED
The CGPlayer sub-struct at [unit+0xE68] (guild key, PLAYER_FLAGS, quest
list, visible items) exists only on real player objects. Reads of it were
gated on UNIT_FLAG_PLAYER_CONTROLLED, but the server sets that flag on
pets, totems, guardians, companions, and MC'd creatures too (Turtle
UnitDefines.h; Pet.cpp/SpellEffects.cpp/SpellAuras.cpp setters) -- all
TYPEID_UNIT creatures with no such sub-struct. On one of them the +0xE68
pointer is garbage-but-non-null, so the null check passes and the deref
AVs (ERROR #132, pfUI issue #34: UnitIsInMyGuild on a nameplate pet/totem,
frequent in crowded dungeons/raids).

Add Unit::Flags::IsPlayerObject (m_objectType == OBJECT_TYPE_PLAYER via
[obj+0x14]) and use it as the gate at every arbitrary-unit +0xE68 reader:

  - UnitIsInMyGuild  (ReadGuildKey)      -- the reported crash
  - UnitIsAFK/UnitIsDND (TestPlayerFlag) -- same latent crash on a pet
  - IsUnitOnQuest    (Quest::Log::UnitHasQuest)
  - GetInventoryItemID (non-local unit visible-items path)

Local-player-only readers are unaffected. IsPlayerControlled stays for
minion/ownership classification (Unit::Pet), which wants the broad flag.
2026-08-05 20:53:30 -05:00
Brues c3a80053e9 equipment: Backported missing localization strings 2026-08-05 15:52:02 -05:00
Brues 5a3badb5bd feat(macro): add C_Macro.CreateMacro / EditMacro with string icons
The vanilla CreateMacro / EditMacro globals take an icon INDEX into the
GetMacroIconInfo list, and that list omits INV_* item icons entirely --
so item icons are unreachable through the stock API even though the
engine stores and renders them fine. C_Macro.CreateMacro / EditMacro
accept the icon as a texture string (a full Interface\Icons\<name> path
or a bare <name> basename) instead, writing the basename directly to the
macro struct; GetMacroInfo re-prefixes it as Interface\Icons\%s on read,
so any Interface\Icons\ texture round-trips.

Both go straight to the engine's own workers (FUN_004f1010 create /
FUN_004f1280 edit), which do the bounded copy, re-run the primary-spell
parser, set the persist-on-logout dirty flag, and refresh the action
bars. EditMacro resolves its index arg as a slot number or a macro name
(via the GetMacroIndexByName resolver), leaves any nil field unchanged,
and reads back the entry's local flag so the worker's unconditional
rewrite of it is a no-op.

The legacy globals stay untouched (index-only); string-icon callers opt
in through the C_Macro namespace. Verified in-game incl. an INV_* item
icon surviving a relog. Unblocks a pfUI macro-UI remodel.
2026-08-05 01:53:08 -05:00
Brues aae4eb8a2b fix(util): preserve nil holes in vararg forwarding; drop no-op unpack ranges
On this Lua 5.0 build, unpack() ignores its explicit i/j arguments entirely
and takes its element count from getn() -- which returns the table's `.n`
field, or border-searches to the first nil when `.n` is absent. Verified in
the binary (luaB_unpack 0x007030a0 -> luaL_getn 0x006f5050) and in-game
(unpack({1, 1, nil, 1}, 1, 4) yields just 1, 1).

- GenerateClosure built its forwarding table `rest` with no `.n`, so
  unpack(rest, 1, restN) border-searched and silently dropped every value
  past the first nil hole in the payload -- despite the comment claiming
  embedded nils survive. It now stamps `all.n` and unpack(all) forwards
  holes intact.

- Every other unpack(x, 1, x.n) / unpack(x, 1, count==x.n) call was a no-op:
  the table already carried `.n`, so unpack(x) is identical. Simplified
  across FunctionUtil, Pools, CallbackRegistry, and TableUtil's SafeUnpack,
  and corrected the header comments that recommended the redundant form.
2026-08-04 18:13:14 -05:00
Brues 0a31ee8a89 fix(events): stop EventRegistry dispatch on PLAYER_LOGOUT to prevent #132 crashes
A callback firing during the engine's logout teardown can touch state
that has already been freed, faulting with ERROR #132 (ACCESS_VIOLATION).
A dedicated frame owns PLAYER_LOGOUT and tears down frameEventFrame's
event delivery (UnregisterAllEvents + clear OnEvent) so nothing dispatches
during teardown. A separate frame keeps this decoupled from the
ref-counted consumer event set, which may no longer hold PLAYER_LOGOUT.
2026-08-04 13:25:02 -05:00
Brues 969273f5f7 tradeskilllink: link reagents in chat 2026-08-04 12:28:43 -05:00
Brues a4760386ed tradeskilllink: Properly format links for SetHyperlink 2026-08-04 11:55:47 -05:00
Brues f9ef12bc9b itemmixin: GetItemLink/GetInventoryTypeName switched to select
These were backported before select was
2026-08-03 22:39:16 -05:00
Brues 89d86bffb7 addon-compat: unregister ShaguTweaks libpredict's backported TBC events
ShaguTweaks' libpredict sender frame (ShaguTweaksPredictionSender) registers
TBC-era events alongside its vanilla ones — COMBAT_LOG_EVENT_UNFILTERED and
UNIT_SPELLCAST_START/STOP/FAILED/INTERRUPTED/SENT (libpredict.lua:342-347). In
stock 1.12.1 those never fire, so registering them was a harmless no-op and only
the vanilla SPELLCAST_* path ran. ClassicAPI now backports those events, so
libpredict's TBC branches fire too and double-process cast/heal predictions.

Unregister the six TBC events on libpredict.sender once ShaguTweaks loads,
restoring vanilla-only behavior. Guarded on the addon existing and the
libpredict table being present.
2026-08-03 17:06:07 -05:00
Brues a0d8d6d723 fix(events): forward leading-nil event payloads to callbacks
EventRegistry callbacks (RegisterOnceFrameEventAndCallback, etc.) dropped any
payload whose first arg was nil — e.g. this client's reload PLAYER_ENTERING_WORLD
(arg1=nil, arg2=1) delivered nil to every callback parameter. Root cause is a
WoW Lua 5.0 quirk: unpacking a table whose hole lands right after a present
element into a `func(first, ...)` collapses the receiving vararg count to 0, so
the payload after `owner` was silently lost.

Fix both build-then-unpack sites in CallbackRegistry to pass the leading arg
explicitly and the payload as the unpacked tail:
- TriggerEvent Function branch: func(owner, unpack(arg, 1, arg.n))
- GenerateClosure: func(bound[1], unpack(rest, 1, restN))

Verified in-game: RegisterOnceFrameEventAndCallback("PLAYER_ENTERING_WORLD", cb)
now delivers arg2=1 to the callback's 2nd parameter on reload.
2026-08-03 14:23:12 -05:00
Brues e0e4fbcf84 docs: list the new frame-attribute + modern-script-arg APIs
The attribute methods and the modern-script-args toggle shipped on this
branch but weren't in the README index, and the toggle wasn't documented
at all.

- README: add frame:SetAttribute / SetAttributeNoHandler / ClearAttribute /
  GetAttribute, the OnAttributeChanged script, and SetModernScriptArgs /
  GetModernScriptArgs to the Frame row.
- docs/API.md: document SetModernScriptArgs(enable) / GetModernScriptArgs()
  (opt-in modern positional handler args) with a Frame TOC entry. The
  attribute methods and OnAttributeChanged were already covered.
2026-08-03 01:57:45 -05:00
brues-code 1da06d4a73 frame: attribute backport, modern script args, and SMSG hook consolidation (#13)
* frame: backport Frame:SetAttribute/GetAttribute + unit-frame mouseover/click

Adds native SetAttribute / SetAttributeNoHandler / GetAttribute on the base
Frame registry: a per-frame, case-insensitive value store with the 3.3.5
prefix/name/suffix getter precedence, stored on the frame's own Lua table.

A `unit` attribute makes the frame a mouseover source. A WorldTick poll of the
engine's mouse-focus frame (GetMouseFocus's global) calls the real mouseover
setter (FUN_00492890) for its unit -- 1:1 with hovering the 3D model: model
highlight, mouseover tooltip, UPDATE_MOUSEOVER_UNIT, and the native GUID slot.
Stomp-proof (the engine's 3D-hover setter is event-driven) and independent of
the frame's scripts, so an addon setting its own OnEnter can't break it.

A `type1`/`type2`/`type` attribute installs a chained OnClick on that frame only
(PCall-guarded, Button-only) that dispatches target / assist / focus on the
unit at click time, then runs any prior handler.

No global hooks: mouseover reads the mouse-focus global; clicks use the frame's
own OnClick. Deliberately does NOT MinHook the button-click dispatcher
(FUN_00779540) -- SuperWoW's click-casting owns that prologue and a second
inline hook corrupts the trampoline (ERROR #132).

* frame: extend attribute backport (one-verb click dispatcher + OnAttributeChanged)

The `type*` click path now mirrors retail's SecureActionButton_OnClick:
resolve ONE verb per click from the modifier/button-qualified `type`
attribute and perform it, instead of a fixed `type1=target`.

- Modifier/button resolution: `[alt-/ctrl-/shift-]type[1..5]` with
  prefix..name..suffix -> name..suffix -> name precedence, read from the
  live key state and the click's button.
- Verbs: target (with the engine's default-interaction precedence -
  cast a pending spell / drop a cursor item on the unit instead of
  switching target), assist, focus, spell, stopcasting, macro, and menu.
- Own-the-click: when a verb resolves we perform it and do NOT chain the
  frame's previous OnClick, so a configured `type1` no longer double-
  dispatches alongside an addon's own conditional click handler.
- Self-healing wire: addons that re-SetScript OnClick (pfUI on every raid
  relayout) clobber our closure; WireOnClick re-wraps a clobbered Lua
  handler but skips its own C closure, so re-setting `type*` recovers
  without double-chaining.

Dispatch goes straight to C++ where we own the code - no Lua round-trip
through the global table: spell -> Spell::AtUnit::CastByName (a native
unit-targeted cast; the unit's GUID is fed to the engine's dispatcher so
there's no target juggling, and ground-target spells land at the unit's
feet), focus -> Unit::Focus::Set, and the target verb's predicates ->
Spell::AtCursor::IsPlacementActive / Cursor::Info::HasItem. Only genuine
engine entries (TargetUnit/AssistUnit/SpellTargetUnit/DropItemOnUnit/
SpellStopCasting) and addon-provided functions still go through Lua
globals. Spell::AtUnit gains a C++ header sharing its CastCore with the
C_Spell.CastAtUnit Lua entry.

macro verb: takes the content from the `macrotext`/`macro` attribute and
prefers an addon-provided RunMacro (SuperCleveRoidMacros etc. - handles
named macros and extended macro text), falling back to running the text
natively when no RunMacro global is present. The native path runs each
line through the stock FrameXML ChatEdit_ParseText via a throwaway
edit-box (GetText closure + no-op __index), so it needs no addon.

menu/togglemenu verb: pops the standard unit dropdown at the cursor via
the new ClassicAPI_ToggleUnitMenu (Util/SecureUnitMenu.lua), resolving
the unit -> menu type with Blizzard's own generic resolver
(self->SELF, pet->PET, grouped player->PARTY, other player->PLAYER,
NPC->RAID_TARGET_ICON). Keeps the FrameXML dropdown logic in Lua with
the C side as a thin call.

OnAttributeChanged is now a real SetScript/GetScript/HookScript-able
frame script on every frame: SetAttribute fires it, SetAttributeNoHandler
suppresses it. Made settable by co-hooking the base-frame script-name
resolver FUN_0076a0d0 (the one every frame type delegates through) and
handing back an external per-frame {handler, context} cell for that name -
frames are immortal in 1.12, so the cell never goes stale. One hook does
it (vs the tooltip OnTooltipSet* work's five) because we already own the
firing side. 1.12 convention: the handler reads this = frame, arg1 = name,
arg2 = value. Recursion-guarded so a handler may itself SetAttribute.

* frame: add Frame:ClearAttribute (retail 11.2.0)

`cleared = frame:ClearAttribute(name)` removes an attribute and returns
whether it was set. Registered on the base Frame method registry next to
SetAttribute/GetAttribute, so every frame has it.

Routes through DoSet's nil-value path (removes the stored key, drops a
`unit` mouseover binding) but does NOT fire OnAttributeChanged — verified
against a live retail 11.2 client: SetAttribute fires the handler,
ClearAttribute fires nothing, and the return is true for a set attribute /
false when there was nothing to clear.

* frame: store attributes in the Lua registry, hidden from Lua

Attributes were kept as a subtable on the frame's own Lua table under a
control-char key ("\1ClassicAPIAttributes"). That key, while collision-
safe, is still enumerable via pairs(frame) and readable/writable from Lua.

Move the store to registry[frame] in the Lua registry (LUA_REGISTRYINDEX),
which has no _G path — so the attribute map is invisible to pairs(frame)
and can't be read or tampered with from script, matching how retail keeps
attributes C-side. Only PushSub changed (create/fetch now key the registry
by the frame table); GetAttribute/DoSet/CopyAttr sit on top unchanged.
1.12 frames are immortal, so the strong registry key never leaks. The dead
kAttrKey constant is removed.

Verified in-game: SetAttribute/GetAttribute still round-trip, and a
pairs(frame) scan no longer finds any ClassicAPI key.

* frame: add the `item` click verb (SecureActionButton item action)

type*="item" uses the modifier-qualified `item` attribute:
- a "bag slot" string (e.g. "0 1") -> UseContainerItem(bag, slot);
- otherwise an item name / itemID / link -> C_Item.UseItemByName(item, unit),
  passing the unit as the use target (self-use items ignore it, targeted
  ones like scrolls/bandages apply to it);
- the deprecated `bag` + `slot` attributes are honored when `item` is unset.

Both bag paths go through lua_call (new CallTableFunc2Str / CallGlobalNum2)
rather than a direct C++ call: the item-use bag walk does SetTop(L, 0), so
it needs its own Lua stack frame — a direct call would wipe the dispatcher's
stack. (spell stays direct because Spell::AtUnit is stack-free; the helper
comment records the distinction.) Retail's "bag slot" string is the
preferred form; the deprecated per-attribute form is supported too.

* docs: format the attribute click verbs as a table

The verbs were one long paragraph of nested parentheticals - hard to scan.
Replace it with a Verb | Extra attributes | Action table so each verb's
inputs and behavior are legible at a glance. Content-equivalent; no API change.

* frame: clear target on the `target` verb when unit is "none"

Retail's SecureActionButton target action treats a "none" unit as "clear
target". Match it: the `target` verb calls ClearTarget() when the resolved
`unit` is "none" (case-insensitive), before the normal cursor/spell
precedence. Mouseover already handles "none" implicitly (it resolves to
GUID 0 = no unit). Docs' target-verb row updated.

* frame: pass modern positional args to script handlers

Vanilla always invokes frame-script handlers with zero Lua args; the
handler reads this/arg1/event as globals. Modern addon ports write
SetScript("OnMouseWheel", function(self, delta) ...) and get nothing.

Co-hook both script runners -- FUN_FRAME_INVOKE_SCRIPT (no-arg scripts)
and FUN_FRAME_RUN_SCRIPT_ARGS (scripts with values) -- and reimplement
their pcall tail: keep the engine's exact this/argN global save-set-
restore and message-handler errfunc, but additionally push self +
arg1..argN as real positional arguments and pcall with nargs = 1+N.
Purely additive: a zero-param vanilla handler ignores the extras and
reads globals; a modern (self, delta) handler binds them.

OnEvent gets (self, event, arg1..argN), detected structurally by
matching the invoked handler ref against the frame OnEvent slot
(frame+0x0C) -- exact, since each SetScript makes a distinct ref.

Runtime switch SetModernScriptArgs()/GetModernScriptArgs(), default on;
when off both detours are a straight passthrough to the original runner,
which matters because FUN_FRAME_RUN_SCRIPT_ARGS is the hottest Lua path
in the engine (every OnUpdate frame). Verified in-game across
OnMouseWheel/OnClick/OnValueChanged/OnUpdate/OnEnter/OnLeave/OnEvent.

* net: consolidate spell SMSG hooks onto one dispatch funnel

The spell subsystem's per-opcode SMSG handlers (SpellGo, SpellStart,
SpellDelayed, ChannelStart/Update, SpellFailure, SpellFailedOther,
SpellCooldown) were each co-hooked directly -- and nampower (hadesmem)
plus SuperWoW detour the same prologues. Two inline hookers on one
prologue is the documented MinHook collision risk, and a whole-protocol
bisect fingered exactly this cluster as the crash-suspect surface.

Add Net::PacketDispatch: a receive-side mirror of Net::SendObserver that
co-hooks the single incoming-SMSG dispatch funnel FUN_NET_MESSAGE_DISPATCH
(0x00537AA0) -- a chokepoint nampower does NOT hook (it detours the leaf
handlers) -- and fans each packet out to opcode subscribers with the
cursor at the body and centralized save/restore.

Migrate all eight handlers to subscribers: aura/Source (SpellGo, which
re-decodes the packed guids + spellId the leaf handler used to hand it
pre-decoded), spell/Cast (Start/Delayed/Channel*/Failure*), and
lossofcontrol (Cooldown). The parse bodies are unchanged; only the hook
site moved. Subscribers run before the engine's leaf handler instead of
after a co-hook's original -- verified order-independent for all eight
(self-contained cache/state updates; SpellGo's aura-application handoff
rides a separate SMSG_UPDATE_OBJECT).

Cuts the ClassicAPI-nampower shared spell prologues from 10 to 3 (the
OnAura* method hooks, which have no packet chokepoint to relocate to).
One hook on an uncontended function replaces eight on contended ones.

Verified in-game: cast bars, channels (incl. Mind Control cast-then-
channel), pushback, remote-cast interrupt eviction, and C_LossOfControl
school lockouts all intact.

* ScriptArgs is back to opt-in

* frame: fix ScriptArgs comments to match opt-in default

* frame/net: dedupe packet observers, reuse Lua/token helpers, fix attr edge cases

Post-review cleanup of the attribute + SMSG-consolidation work.

- net: extract the shared packet-observer plumbing (PacketObserver.h) — the
  intrusive subscriber list, Subscribe(), and the read-opcode / reset-cursor /
  fan-out / restore loop — so Net::PacketDispatch (SMSG, u16) and
  Net::SendObserver (CMSG, u32) each reduce to a one-line hook thunk over
  FanOutByOpcode<OpcodeT>. Drops the dead per-namespace Callback aliases
  rather than keeping them as compat shims.

- Game::Lua: promote PushGlobalFunction / CallGlobal / CallGlobalString into
  the shared Lua helpers (a pattern several modules open-code); Frame
  attributes use them instead of private copies.

- frame/Attributes: resolve unit tokens through Unit::Identity::GuidForToken
  (engine resolver + player fast-path) instead of a duplicate raw-cast wrapper.

- frame/Attributes: wire the chained OnClick for every "type" action attribute
  the click resolver reads (IsTypeAttr: modifier prefixes + type1..type5), not
  just type/type1/type2 — a middle-click or modifier-only binding now installs.

- frame/Attributes: FireAttributeChanged snapshots and restores the caller's
  arg1/arg2 around the fire, so calling SetAttribute from inside a running
  script handler no longer clobbers its globals.
2026-08-03 00:37:36 -05:00
Brues 960b37d7e6 README: trim addon helper list and remove legacy notes
Remove Mixin/CreateFromMixins from the AddOns helper list and streamline the surrounding text. Drop outdated paragraphs describing engine-dependent addon features and the note about the triple-`!!!` folder prefix/load-order. This is a documentation cleanup only; no code or behavior changes.
2026-08-02 01:19:16 -05:00
Brues 0c27d61839 unit: add markN raid-target-marker unit tokens
"mark1".."mark8" (star..skull) now work as unit tokens against the whole
Script_Unit* surface, and fire per-token unit events (UNIT_HEALTH, ...)
with arg1 == "markN" -- the same TokenObserver path focus/nameplateN use.

Resolution: a markN branch in the FUN_TOKEN_TO_GUID hook
(unit/TokenExtensions.cpp) reads the engine's 8-slot marker table
(VAR_RAID_TARGET_GUIDS = 0x00B71368, mark{1..8} = table[N-1]), so every
stock Unit* gains the token for free and "mark1target" composes via the
existing suffix walker.

Events: unit/RaidTarget.cpp runs a WorldTick watcher that diffs the marker
table and attaches/detaches one descriptor observer per marked unit,
deferring registration until the unit is in the object table (a unit can
be marked while out of range and enter later) -- the same range handling
Focus does, x8. One distinct callback per slot fires its fixed "markN"
token. Idle (no markers set) costs 8 compares and no object lookups; a
marker on a non-unit is never observed.

Verified the array shape against the binary (reverse lookup FUN_004bb190
walks 8 uint64 slots) and against nampower's CGRaidInfo_m_raidtargets.
Docs added under Unit tokens (markN); README token table updated.
2026-08-01 23:55:44 -05:00
Brues f02f576e70 addon: hide + force-enable embedded !!!ClassicAPI so it can't be disabled
The bundled library provides FrameXML-compat fixes (e.g. the tooltip
mouseover guard) that the DLL and other addons rely on; letting a user
uncheck it at character-select would silently break them.

FinalizeEmbeddedEntry now sets entry+0x29 (filter-out) to hide it from
the AddOns list -- the flat-display builder FUN_0051da70 copies only
+0x29==0 entries into GetNumAddOns/GetAddOnInfo -- and entry+0x2b
(DefaultState) enabled. A co-hook on the per-character enable resolver
FUN_ADDON_ENABLE_RESOLVE (0x0051e470) force-returns "enabled" for
!!!ClassicAPI, defeating any stale WTF disable-override that hiding
alone would otherwise leave stuck off.

Hiding only affects the index-based display array; the addon hash table
keeps the entry, so ## Dependencies: !!!ClassicAPI, IsAddOnLoaded, and
GetAddOnInfo-by-name all still resolve. README updated to match.
2026-08-01 22:53:26 -05:00
Brues 0bd85dd4f3 tooltip: guard mouseover-clear recolor, fix white name flash (#12)
The DLL now fires UPDATE_MOUSEOVER_UNIT on mouseover loss (retail
parity). The stock FrameXML GameTooltip OnEvent recolors the name
unconditionally via GameTooltip_UnitColor("mouseover"), which returns
white when no mouseover exists -- so the loss fire (and the transient
clear when clicking a unit) flashed NPC and PvP-flagged-player names
white during the tooltip fade.

Wrap the stock OnEvent in Util/GameTooltip.lua: swallow the event when
UnitExists("mouseover") is false, otherwise delegate to the original
untouched. The name keeps its color through the loss window and
recolors normally when a mouseover resolves again.
2026-08-01 22:53:15 -05:00
brues-code 29e3dab24a UNIT_SPELLCAST_* event family + GUID unit tokens (#11)
* spell: backport UNIT_SPELLCAST_* events for the local player

Adds the TBC+ cast/channel events (START, STOP, DELAYED, SUCCEEDED,
INTERRUPTED, FAILED, SENT, CHANNEL_START, CHANNEL_UPDATE, CHANNEL_STOP)
for the player, so cast-bar / rotation addons written against the modern
signature work on 1.12. Each carries (unit, castGUID, spellID, spellName,
rank) -- SENT inserts `target` at arg2 -- with a synthesized modern-shape
castGUID (Cast-3-0-0-0-<spellID>-<castUID>) shared across all of a cast's
events so START pairs with STOP. arg1 is always "player" (player-only phase).

The events are derived, not independently tracked: Spell::Cast already
hooks every cast/channel path for UnitCastingInfo/UnitChannelInfo, so
Spell::CastEvents reuses that state.
  - START/STOP/DELAYED/CHANNEL_START/STOP: polled from Cast's OnWorldTick
    against the previous snapshot.
  - SUCCEEDED: fired from the SMSG_SPELL_GO hook (covers instants too).
    Deferred for channels so it lands after CHANNEL_START (modern order).
  - INTERRUPTED/FAILED: split by result code in a Spell_C_SpellFailed
    co-hook -- a started cast that aborts (kick/move/LoS) fires INTERRUPTED
    (repeated per retry, reusing the cast guid); a pre-cast rejection fires
    FAILED.
  - SENT: minted from the CMSG_CAST_SPELL send, threading its castGUID
    forward so all of a cast's events share one guid.
  - CHANNEL_UPDATE: from a new MSG_CHANNEL_UPDATE co-hook, which also
    re-anchors g_channel.endMs to the server's remaining time so channel
    pushback (which shortens vanilla channels) shows in UnitChannelInfo.

Every fire is gated on Event::Custom::HasListeners (new), so the system
costs one pointer-compare per transition when no addon registers.

Supporting changes:
  - Net::SendObserver: shared co-hook on the NetClient send funnel with an
    AutoSubscribe list, so CastEvents (SENT) and ComboDuration (combo-point
    capture) both watch outgoing packets without owning the hook. Extracted
    from ComboDuration's private hook.
  - Cast.cpp: player channel now self-expires at its computed endMs in the
    poll (was cleared only by the ~1s-lagged +0x228 field, so CHANNEL_STOP
    trailed the real end by ~1s); wrap-safe delta compare so a long-uptime
    ms tick past 2^31 can't misfire it.

Player-only; remote-unit fan-out, FAILED_QUIET, and empowered casts are
not implemented.

* spell: extend UNIT_SPELLCAST_* to non-player units (phase 2)

Fires the six observable cast/channel events -- START, STOP,
CHANNEL_START, CHANNEL_STOP, SUCCEEDED, INTERRUPTED -- for other units,
not just the player. Each is fanned out once per unit token currently
mapping to the caster (target / focus / nameplateN / party / raid / pet /
mouseover), so a target frame, a party frame, and a nameplate cast bar
all get their own event with a shared castGUID -- matching retail.

Token fan-out reuses the engine's own GUID->token reverse map
(FUN_00515c50, nampower's unfilled GetNamesFromGUID) for native tokens,
plus focus/nameplate which the engine can't see, via
Unit::Identity::TokensForGUID. SuperWoW's raw-GUID token ("0x...") is
filtered out so only standard tokens fan out.

Remote events are derived from Spell::Cast's existing packet hooks:
  - START / CHANNEL_START from HandleSpellStart (SMSG_SPELL_START)
  - SUCCEEDED from the SPELL_GO hook (Aura::Source)
  - STOP / CHANNEL_STOP at the computed end, or on abort
  - INTERRUPTED (casts only) from HandleCastAborted (ClearCastingSpell /
    failure packets)
A per-caster state machine (PollRemote, from OnWorldTick) fires them in
modern order; a channel's SPELL_GO cast->channel ClearCastingSpell is
filtered so it doesn't self-interrupt at its own start.

Not fired for remotes (data is caster-only / client-local): SENT,
DELAYED, FAILED, CHANNEL_UPDATE.

Also in this change:
  - castGUID follows type rule from https://warcraft.wiki.gg/wiki/GUID#Cast : real casts are Type 3 with a
    time-based castUID (low 23 bits = UNIX second, high bits = per-second
    counter, matching retail's structure), threaded across a cast's events
    (player and remote converge on the same value); FAILED is Type 2 with
    a flat incrementing integer.
  - Channels never fire INTERRUPTED (player or remote) -- retail emits
    only CHANNEL_STOP whether a channel completes or is cut short.

arg1 is the caster's unit token ("player" or another token); the
spellName / rank tail extensions are unchanged.

* spell: return the castGUID as UnitCastingInfo's castID

UnitCastingInfo / CastingInfo now return the cast's castGUID as castID
(return 7) instead of nil -- the same string the UNIT_SPELLCAST_* events
carry, so an addon can correlate the polled info with the events. It's
pulled from Spell::CastEvents (the single source of the castUID) via a new
CurrentCastGuid accessor, and works for the player and remote units. The
timing stays sourced from Spell::Cast -- only the castGUID crosses over,
so there's no duplication of cast state.

UnitChannelInfo is left retail-exact (no castID; channels don't carry
one), even though our channel events do include the shared castGUID.

Also corrects the spell-cast-GUID spec attribution in comments/docs to
warcraft.wiki.gg/wiki/GUID#Cast (it was wrongly credited to Wowhead).

* spell: add UNIT_SPELLCAST_RETICLE_TARGET / _CLEAR events

Backports the ground-target reticle events for the player:
RETICLE_TARGET when an AoE placement reticle comes up (Blizzard, Flare,
Rain of Fire, ...), RETICLE_CLEAR when it's cancelled. Args are
(unit, "", spellID, spellName, rank); arg1 is always "player".

Driven by polling the engine's targeting-flag global (0x00CECAC0 -- the
predicate behind SpellIsTargeting) from OnWorldTick: 0->nonzero fires
RETICLE_TARGET with the pending spellID (0x00CEAC58, written by
Spell_C_CastSpell); nonzero->0 fires RETICLE_CLEAR. CLEAR is suppressed
on placement (a CMSG_CAST_SPELL for the reticle spell went out, seen via
the shared send observer), so it fires only on a genuine cancel --
matching retail, which emits no CLEAR when the spell is placed/cast.

The castGUID slot (arg2) is "" rather than retail's nil: there's no cast
to identify yet, and the engine's event dispatcher can't emit a nil
mid-argument-list (a NULL %s corrupts the args after it -- the nil trick
only survives as the last arg). unit (arg1) and spellID (arg3) are exact.

* spell: fire UNIT_SPELLCAST_FAILED_QUIET for the quiet result whitelist

Vanilla's Spell_C_SpellFailed dropped SPELL_FAILED_DONT_REPORT silently.
Modern surfaces suppressed (no-error-text) failures as
UNIT_SPELLCAST_FAILED_QUIET rather than FAILED.

Verified against the 3.3.5 client's unit-spellcast dispatch (FUN_007fecc0),
which selects the QUIET event for a fixed 3-code whitelist, mapped to 1.12's
SpellCastResult enum (FUN_006e23e0):
  CHARMED (0x14) . DONT_REPORT (0x17) . SPELL_IN_PROGRESS (0x61)

SPELL_IN_PROGRESS (casting while already casting / a nampower spell-queue
rejection) is the common case that previously mis-fired as a loud FAILED.

* unit: accept raw GUID literals as unit tokens

Fold a "0x<hex>" branch into the existing FUN_TOKEN_TO_GUID hook so every
Unit* function (UnitName, UnitHealth, UnitClass, UnitExists, ...) accepts a
64-bit GUID literal in place of a token. The parsed GUID goes straight to
the engine's own object-manager lookup (the path "player" takes), so an
unloaded/out-of-range GUID resolves to nil like any absent unit, no error.
Reuses the existing suffix walker, so "0x...target" composes.

Gated on SuperWoW's absence (GetModuleHandleA("SuperWoWhook.dll"), cached):
SuperWoW already provides GUID input via its own resolver hook, so when it
is loaded we defer to avoid two layers parsing the same string. Behaves
identically either way. Input direction only.
2026-07-31 21:38:44 -05:00
Brues 555ea02447 loot: add C_Loot.LootAllCorpses to auto-loot nearby corpses
C_Loot.LootAllCorpses([max]) loots every nearby lootable corpse in
sequence -- coin (CMSG_LOOT_MONEY) plus every item slot (CMSG_LOOT_ITEM)
from each window before releasing and advancing. Optional max caps the
corpse count.

Shares Loot::Scan's existing corpse-walk state machine and its single
FUN_LOOT_CONTROLLER hook -- MinHook allows one hook per target, so this
can't be a separate module. ScanNearbyLoot scrapes each window and
releases; LootAllCorpses does the same walk but actually loots it first.
The walk stays silent (LOOT_OPENED/LOOT_CLOSED suppressed, so LootFrame
never flickers) while the item-received / money chat + inventory events
fire normally.

Packet-based and target-clean, matching the rest of the C_Loot suite:
no right-click, no target change, no auto-walk toward out-of-range
corpses. Money goes through FUN_005ea8c0 (CMSG_LOOT_MONEY) with the SAME
player pointer passed to FUN_CMSG_LOOT_UNIT -- whose send wrote this
window's target GUID to the player's +0x1D28 guard slot, so the money
send's internal guard passes. Item sends bypass the client BoP/unique
confirm dialog (the server still enforces real loot permissions).

Reimplemented in spirit from WeirdUtils's LootAllCorpses (public domain)
on our own packet loot primitives + object-scan helpers, rather than its
right-click-autoloot approach. Wires the existing AOELOOT keybinding to
it (was ScanNearbyLoot).
2026-07-31 14:51:24 -05:00
Brues e3f5318747 bag: gate BAG_UPDATE_DELAYED on VAR_IN_WORLD, drop the PEW latch
f91ffd3 gated the drain on a one-way PLAYER_ENTERING_WORLD latch
(Event::EnteringWorld::HasEnteredWorld), added on the belief that no
pollable in-world flag existed. cd674aa found the real one:
VAR_IN_WORLD (0x00B4B424), 1 while in world and 0 during any loading
screen.

The one-way latch stays true forever after the first login, so if the
object burst re-populates bag descriptors during a LATER zone/instance
loading screen it would fire BAG_UPDATE_DELAYED mid-load again -- the
very thing the gate was meant to prevent, recurring on every zone.
VAR_IN_WORLD clears on every transition, so polling it re-suppresses
during later loads and emits one DELAYED once actually in-world
(matching retail's one-per-load).

Switch the gate to VAR_IN_WORLD and remove the now-dead HasEnteredWorld
latch/accessor and its header; EnteringWorld keeps only its PEW-payload
role.
2026-07-31 13:04:15 -05:00
Brues cd674aa115 hearth: gate HEARTHSTONE_BOUND on in-world flag, not location diff
Turtle's server re-sends SMSG_BINDPOINTUPDATE on every map/zone
transition with the unchanged bind location, behind a loading screen.
The valid-flag gate only suppresses the initial post-login sync, so
every map change fired HEARTHSTONE_BOUND.

A player can't bind mid-load, so gate on the engine's "world is live"
flag instead: VAR_IN_WORLD (0x00B4B424), set to 1 by the enter-world
init FUN_004908C0 and back to 0 by the world-teardown FUN_00490A80 that
runs at the start of every transition (verified via the enter/leave
pair in Ghidra). The resync arrives with the flag 0 (suppressed); a
real innkeeper bind always arrives with it 1 (fires) -- including a
rebind at the same inn, unlike a location diff would.

Add VAR_IN_WORLD to Offsets.h (the real pollable in-world boolean;
0x00B4E37C, once wrongly labeled this, is only instance grace-period
state). Update the bind-handler offset comment and docs/API.md.
2026-07-31 12:58:32 -05:00
Brues f91ffd3189 bag: gate BAG_UPDATE_DELAYED on PLAYER_ENTERING_WORLD, not VAR_IN_WORLD
The in-world gate added in 5d785ea polled 0x00B4E37C, believed to be an
"in world" flag. It isn't: verified in the binary it's instance
grace-period state, written from a packet handler (FUN_0049e6c0) and
read only by the "recently left an instance" 90-second window check
(FUN_00495c90). It never reliably goes non-zero from being in the world,
so OnWorldTick's early-return held g_pending forever and
BAG_UPDATE_DELAYED stopped firing entirely. There is no clean pollable
in-world boolean at these offsets (0x00B4E378 is the map ID; map 0 is
valid).

Gate on the correct signal instead: PLAYER_ENTERING_WORLD, already owned
by Event::EnteringWorld. Expose a one-way HasEnteredWorld() latch flipped
the first time PEW fires (loading screen done, world live) and stays true
across /reload; UpdateDelayed holds g_pending until then, then emits one
DELAYED covering the settled login inventory. Correct the mislabeled
0x00B4E37C note in Offsets.h so the trap isn't repeated.
2026-07-30 20:13:05 -05:00
Brues 8361ae01eb equipment: unregister equip observers before re-registering on reload
PLAYER_EQUIPMENT_CHANGED fired an extra time per /reload. The event's
descriptor-field observers are registered in ObserverSetup_h, a co-hook
of the engine's per-enter-world inventory-observer setup. /reload
re-runs that setup but does NOT destroy the CGPlayer object, so the
prior session's observer nodes are still attached; the engine registrar
always appends (no dedup by callback), stacking a second observer per
equipment field each reload. Logout/login recreates the player object,
old nodes die with it, so the count resets to one -- matching the
observed "doubles only on /reload" symptom.

Unregister each equipment-field observer before re-registering it. On
/reload this drops the stale node (same object/guid/callback key) so we
keep exactly one per slot; on logout/login the object is already gone so
the unregister no-ops. Mirrors the objects-survive-/reload guard
Unit::TokenObserver uses for nameplates.
2026-07-30 20:02:59 -05:00
Brues 5d785ea0d3 bag: hold BAG_UPDATE_DELAYED until in-world
The per-frame world tick (FUN_0066FD50) runs behind the initial-login
loading screen, and the engine populates every bag slot during the
initial object burst -- setting g_pending while still loading. That made
OnWorldTick fire BAG_UPDATE_DELAYED repeatedly mid-load, so addons
rescanned bags for inventory that wasn't a real change yet.

Gate the drain on the engine's in-world flag (new VAR_IN_WORLD =
0x00B4E37C): hold g_pending -- without clearing -- until the flag is set.
The first in-world frame then emits a single DELAYED covering the settled
login inventory (matching retail's one BAG_UPDATE_DELAYED at login), and
a pending update suppressed during a later zone transition fires on
return. The three bag hooks are unchanged (still just set the flag).
2026-07-30 11:33:55 -05:00
Brues e84673dde5 item: clamp bank-bag slot count in NewItems baseline scan
AppendNonBagOwned read each bank-bag container's slot count straight
from the invMgr's first dword with no upper bound. A container resolved
while only partially initialized (plausible right at login) can yield a
garbage value there, driving AppendGuidArray into a multi-billion-
iteration loop -- a multi-second freeze. Clamp to kMaxContainerSlots
(64), far above any real vanilla/Turtle bag, so it's invisible to real
inventories and only trips on a bogus count.
2026-07-30 11:33:45 -05:00
Brues a2c5db7b76 time: add GetTimeCached() frame-stable timestamp
Backports retail 4.x's frame-cached GetTime() behavior under a distinct
name, leaving vanilla GetTime() live/per-call. 1.12's Script_GetTime
recomputes the OS tick on every call, so same-frame calls can differ;
GetTimeCached() samples once per frame (via the shared Tick::WorldTick
hook) and returns that until the next frame, on the same epoch as
GetTime() so the two are directly comparable. Falls back to a live
sample before the first world tick so it never returns 0.

Deliberately NOT a perf feature and documented as such: the 1.12 tick
source is cheap (GetTickCount shared-page read or rdtsc) and both
functions pay the same dominant Lua->C dispatch, so caching saves
nothing measurable. The only benefit is frame-stable semantics; the
real hot-loop fix is caching GetTime() in a Lua local, which no C
function can do for the caller. New global chosen over modifying
GetTime() to avoid changing a core vanilla function's behavior.
2026-07-30 03:12:07 -05:00
Brues 46597f6587 unit: fire UPDATE_MOUSEOVER_UNIT on mouseover loss
Vanilla fires UPDATE_MOUSEOVER_UNIT only when a mouseover unit is gained;
moving the cursor off a unit clears the mouseover and signals nothing.
Retail fires for both gain and loss, so addons watching
UnitExists("mouseover") can react when it goes false. This restores the
loss fire.

Co-hooks the engine's mouseover set/clear chokepoint (FUN_00492890) --
the ONLY writer of the mouseover GUID globals, so every gain/loss/change
flows through it. Resolves whether the mouseover is a unit before and
after the original and fires the event on the unit->non-unit transition.

Gating on unit TYPE (not just "GUID present -> absent") is load-bearing:
the same GUID slot also holds gameobjects and items, which never fire a
gain, so a naive present->absent test spuriously fired when moving off a
herb node or chest (reported in testing). The unit|player object-mask
resolve fixes that; the !hasUnit half prevents a double-fire on any
->unit transition the engine already covered inline.

Change-gated target (per-frame would spam the gain event while
hovering), so it's a cool hook. Documented in API.md (loss-fire fix
subsection + TOC) and Events.md notes.
2026-07-30 00:45:00 -05:00
Brues 4e1c1863e9 debugtools: add Refresh button to TableInspector
Re-reads the current level's live table in place. The nav stack holds
the real table reference (never a copy), so refresh reflects mutations
made since the view was built -- keys added/removed, values changed --
without leaving the current view. Scroll position is preserved (Back and
drill reset to top; refresh deliberately doesn't) and Update re-clamps
the offset if the entry count shrank. No-op if the held reference is no
longer a table.

Adds TableInspector_Refresh() and a Refresh button in the header between
Back and the breadcrumb (breadcrumb shifted right and narrowed to fit).
2026-07-29 23:58:01 -05:00
Brues a4c57614bc debugtool: Use MouseIsOver global for fallback
WIthout ClassicAPI this will fallback to the 1.12.1 implementention of MouseIsOver
2026-07-29 13:14:13 -05:00
Brues e1a9d9b5c4 docs: document IsIndoors / IsOutdoors
Add the two globals to the State section of docs/API.md (with TOC
entries) and the README State row. Notes the 1/nil return contract
(retail + SuperWoW 2.2 parity) and that the state is a live WMO
geometry query, not zone/area based.
2026-07-29 11:04:45 -05:00
Brues cd280ac307 state: add IsIndoors / IsOutdoors globals
Backport the modern `IsIndoors()` / `IsOutdoors()` no-arg globals to 1.12.
Both return 1 when the condition holds, nil otherwise (the historical
retail contract, also what SuperWoW 2.2 uses), so `if IsOutdoors()` and
`IsOutdoors() == 1` both work. Recomputed live from the engine's WMO
geometry query, so they flip as the player crosses an interior threshold
rather than tracking zone/area; nil pre-world.

The signal is a live WMO query, not a cached flag. Vanilla ships no Lua
binding for it, and the static anchors all dead-ended (the WMO runtime
flag bits differ from 3.3.5; ZONE_CHANGED_INDOORS is never fired with a
literal event ID; WMOAreaTable is only the area-naming path). SuperWoW
2.2 exposes `IsIndoors`, so decoding its handler gave the exact recipe:
resolve the local player object, then call FUN_00612cc0(unit) -- a thunk
that reads the unit world/collision context at +0xE0 and tail-jumps to
the WMO query FUN_006706f0 (nonzero = outdoors, fully null-safe). Same
shape as the 3.3.5 client's IsIndoors (unit+0xB8), so cross-confirmed.

Added Offsets::FUN_UNIT_IS_OUTDOORS with the full trail; the two
functions live in Unit::State next to IsMounted / IsSwimming and share
Unit::Identity::PlayerObject().
2026-07-29 11:01:59 -05:00
Brues 564495248c item: apply random suffix in by-string GetItemNameByID / GetItemInfo
C_Item.GetItemNameByID and C_Item.GetItemInfo resolved an item link down
to just the itemID and dropped the random suffix (3rd `item:` field), so
"item:12022:0:769:0" returned "Iridium Chain" instead of "Iridium Chain of
the Owl" (and GetItemInfo's rebuilt link lost the 769). The global
GetItemInfo applies it; now these match.

Item::Arg::Resolve parses the suffix alongside the itemID. New
Item::Link::NameFromIDSuffix builds the suffix-decorated name from
(itemID, suffixID) without a live CGItem, via the engine's inner name
builder (FUN_005d8b00 = the formatter behind FUN_ITEM_BUILD_INSTANCE_NAME,
which reads the localized "of the X" suffix from ItemRandomProperties).
Item::Link::BasicFromIDSuffix preserves the suffix in the rebuilt link
(payload + bracketed name); BasicFromItemID is now a suffix=0 wrapper.

GetItemNameByID and GetItemInfo route through these; numeric/suffixless
input still yields the base name. Numeric item stats are unaffected (the
suffix only decorates the name/link). Enchant/unique fields are normalized
to 0 in the rebuilt link (suffix is what affects the name).

Verified in-game: item:12022:0:769:0 -> "Iridium Chain of the Owl" (name +
link); bare 12022 / item:12022:0:0:0 -> "Iridium Chain".
2026-07-28 16:33:50 -05:00
Brues 1f3f3cb9f6 aura: infer server-side DoT-duration changes from the triggering cast
C_UnitAuras expirationTime for a debuff on another unit is derived from
SMSG_SPELL_GO at cast time; the vanilla server never retransmits a LATER
server-side duration change on a non-self unit (verified in the server
source: SetAuraDuration/RefreshHolder, and UpdateAuraDuration is self-
scoped -- on a mob no packet is built at all). So a refresh/consume goes
stale in our cache. We can't hear the change, but we DO see the triggering
cast, so mirror the server's edit off that.

Aura::Source gains a rule engine applied in SpellGo_h (before the aura
gate -- triggers apply no aura themselves): a trigger matched by exact
spellID OR by SpellFamilyName + school index, an affected aura matched by
family + family-flag overlap (+ optional icon, rank-proof like the server
scripts), and an op (refresh/reduce/set/remove). Proc-applied affected
auras enter the cache unattributed (casterGuid 0); the rule accepts those
and attributes them to the trigger caster (also gives them a sourceUnit).

Lua: C_UnitAuras.RegisterAuraDurationModifier (exact-ID trigger) and
RegisterAuraDurationModifierByTrigger (family+school trigger), so the
server-specific ruleset lives in the addon, not the DLL.

Default Turtle ruleset (AuraDurationModifiers.lua, gated on
TURTLE_WOW_VERSION; IDs/flags DBC-verified):
  Conflagrate  -> reduce Immolate 3s
  Molten Blast -> refresh Flame Shock
  Shadow Weaving (5/5 talent 15334) -> priest shadow-school casts refresh
    Shadow Vulnerability 15258 (covers the max-stack refresh blind spot;
    Turtle reworked it, so stock DBC proc masks don't apply -- match by
    school). Best-effort: DoT-tick procs emit no cast packet.
Carnage's roll-gated Rip/Rake refresh is deliberately excluded (the client
can't know the server roll).

Verified in-game (debug trace): Conflagrate/Molten Blast, and Mind Blast /
SW:P / Mind Flay all refresh 15258 with advancing expiration + sourceUnit.
2026-07-28 16:14:45 -05:00
Brues 7ae9b83ad7 spell: CastAtUnit casts unit-target spells directly at the unit
Previously CastAtUnit only handled ground-target spells (placement at the
unit's feet); a non-ground spell fired with no implicit target (fell to
current selection). Now it resolves the unit once to its object, reads
position AND GUID, and dispatches the cast WITH that GUID -- the engine's
FUN_SPELL_CAST_DISPATCH substitutes it exactly where it would the current
selection (verified: passing 0 substitutes VAR_CURRENT_SELECTION_GUID), so
a unit-target/normal spell fires straight at that unit regardless of
current target.

Branch on placement state after dispatch: no placement -> cast went out
(unit-target); ground placement -> commit at the unit's feet (existing);
other placement -> unit not accepted as target, cancel + false.

AtCursor gains an optional targetGuid on DispatchSpellCast[ByName] (0 =
prior behavior, so CastAtCursor is unchanged) and IsPlacementActive().

Verified in-game: ground spells still land at the unit's feet; Frostbolt/
Renew/PW:Fortitude cast on the named unit while another unit stays targeted.
2026-07-27 23:36:03 -05:00
Brues 26e5fdc052 readme: place C_Glue helpers in the right sections
IsFirstLoadThisSession is glue-only -> GlueXML-calls section only.
IsOnGlueScreen is registered on both states -> main in-game table AND the
GlueXML-calls section.
2026-07-27 22:18:52 -05:00
Brues 4281cd8253 glue: add C_Glue.IsFirstLoadThisSession and C_Glue.IsOnGlueScreen
IsFirstLoadThisSession() - glue-only; true only during the first glue
screen since process launch, false after a world->glue logout (login <->
character-select stays within the first session). Backed by a
process-static glue-boot counter incremented in the GlueModuleAutoRegister
callback (fires once per glue boot).

IsOnGlueScreen() - true on a GlueXML screen, false in the world.
Registered on both Lua states; since glue and in-game states are mutually
exclusive the answer is a constant per state (true from glue, false
in-game), so it's callable from either environment with no runtime flag.

Verified in-game: first login true/true, post-world-logout false; in-game
C_Glue.IsOnGlueScreen() false.
2026-07-27 22:12:28 -05:00
Brues 9239756153 frame: add frame:GetEffectiveAlpha()
Own alpha times every ancestor's alpha -- the product WoW composites at
render time. Vanilla has only GetAlpha (a frame's own alpha); this walks
self -> parent -> ... via the engine's own GetAlpha/GetParent, so it
reflects the engine exactly, including 8-bit alpha quantization
(SetAlpha(0.5) -> 127/255). Frame-scoped, matching GetAlpha's registry.

Verified in-game: matches an equivalent Lua GetAlpha/GetParent walk, and
a two-level 0.5/0.5 chain returns (127/255)^2 = 0.248043 exactly.
2026-07-27 21:40:16 -05:00
Brues f13aa1943d unit: add UnitPosition(unit)
Backport the modern UnitPosition -> posY, posX, posZ, instanceID. Reads
any visible unit's world position through the shared Unit::Position helper
(CGObject::GetPosition vtable slot 5 -- same path UnitDistanceSquared
uses), so no group/taint restriction like retail: works for target,
mouseover, nameplates, in-range party/raid. nil when the unit has no
known position.

Retail return order (posY=world Y/west, posX=world X/north, posZ=height);
instanceID is the current Map.dbc id. Verified in-game: squared delta of
two UnitPosition reads matches UnitDistanceSquared exactly.

Note: SuperWoW also defines a global UnitPosition with a different shape;
last registrant wins if both load. Documented in docs/API.md.
2026-07-27 21:19:58 -05:00
Brues 438ad97f8f readme: list IsMouseOver/GetRect/IsDragging in the Frame row 2026-07-27 20:59:20 -05:00
Brues 7f4b20c6ec frame: add region:GetRect and region:IsDragging
Two more Region-base methods backported from 3.3.5, both on the Region
registry so they resolve on every region type.

GetRect() -> left, bottom, width, height, composed from the engine's own
GetLeft/GetBottom/GetWidth/GetHeight (all UI-scale conversion is engine
code). Returns nothing for an unpositioned region, matching retail.

IsDragging() -> true while the region is the frame currently being
moved/sized. Reads the engine's single global drag target
(*(VAR_UI_CONTEXT_PTR) + 0xCFC); StopMovingOrSizing (0x00776990) gates its
own clear on the identical `context+0xCFC == self` predicate. Non-frame
regions never match the stored frame, so they report false.

Verified in-game: GetRect on UIParent/Minimap; IsDragging toggles with a
live frame drag.
2026-07-27 20:56:04 -05:00
Brues 3f11f942bd addon: use native region:IsMouseOver
Add a MouseIsOver(region, top, bottom, left, right) global that forwards
to the native Region:IsMouseOver method (mirrors modern FrameXML's
forwarding; nil offsets default to 0 in the C method).

Drop DebugTools' hand-rolled DebugTools_IsMouseOver helper in favor of
the native method. This also fixes _CollectFramesUnderMouse's showHidden
path: the old helper baked a visibility check into the geometric test, so
a hidden-but-hovered frame could never surface; the native method is
purely geometric and visibility is already gated separately at the call
site.
2026-07-27 20:38:10 -05:00
Brues 9622ea7742 frame: add region:IsMouseOver([top, bottom, left, right])
Native Region method (all region types) for cursor-in-rect testing with
optional per-edge offsets. Mirrors the engine's own math: delegates to
the Region-registry rect getters (GetLeft/Right/Top/Bottom) and
GetCursorPosition, dividing the cursor by the region's effective scale
read from obj+0x7C -- the same field GetLeft/GetEffectiveScale read
internally, so it works on textures/fontstrings where GetEffectiveScale
(Frame-only) would typecheck-fail. Returns false for an unpositioned
region. Offset semantics match modern FrameXML's MouseIsOver forwarding.
2026-07-27 20:30:37 -05:00
Brues 659dd7e159 newitems: fix false "new" flags from transient bag-scan drops
A bag update in progress transiently drops items from the pure-C++ bag
read -- sometimes the entire backpack (all 16 slots) for a frame or two.
The old logic rebuilt the seen-set from each scan, so a momentarily-
missing item fell out of the set and was re-flagged "new" the instant it
reappeared (e.g. dragging/swapping an item into the backpack lit it up).

Replace the rebuilt-each-scan set with a persistent seen-set whose entries
carry a consecutive-absent-scan counter (SeenReconcile): present items
reset it, absent ones age by one, and only items absent kMaxMissStreak
scans running are evicted. Counting scans rather than wall-clock means a
pause with no bag activity never ages an owned item, and any reappearance
resets the streak -- so transient drops are absorbed while the set stays
bounded to ~currently-owned items (genuinely-removed items age out).
Verified in-game: backpack repeatedly vanishing from the scan, seen-set
holds steady, zero false flags.
2026-07-27 20:13:49 -05:00
Brues 1ae102b554 add string.split to workaround certain addons replacing strsplit 2026-07-27 15:41:23 -05:00
Brues a5b6cf367d aura: cache fallback for out-of-range group auras (server delta gap)
The server resends out-of-range party/raid stats as deltas only, so a
member seen in range and then moved away again leaves an empty party-
stats aura array (it's cleared when they become visible and never re-
sent), and C_UnitAuras showed nothing until a full roster resync (relog).
Their auras still sit in the Aura::Source cache from when we observed them
in range, so the four group enumerate paths now fall back to the cache
when the group array is empty -- gated by GroupArrayHasVisibleAura, the
same "container non-empty => authoritative" rule the descriptor path uses
(DescriptorHasVisibleAura). A populated array is trusted as-is (no
phantoms); only the empty-array gap consults the cache, deduped and
visibility/playerOnly-filtered.
2026-07-26 18:19:47 -05:00
Brues b496568670 Add C_Spell.GetSpellEffectMechanics(spellID)
Returns the spell's three per-effect SpellMechanic ids (Spell.dbc
EffectMechanic[3], +0x13C) as a 1-based array, or nil for an invalid spell.
Complements GetSpellMechanicByID, which reads only the spell-level Mechanic
field (+0x14): vanilla stores some mechanics on an effect instead -- e.g.
Rake's bleed is effect-level (spell-level 0, EffectMechanic[2]=15), so
GetSpellMechanicByID misses it while this returns {0,15,0}.

Add OFF_SPELL_RECORD_EFFECT_MECHANIC offset, API.md section + TOC entry, and
README index entry.
2026-07-26 15:42:25 -05:00
Brues a37c590a72 aura: clamp elapsed player-buff expiration to 0 (permanent auras)
Permanent auras (paladin auras, passives, racials) carry an infinite
SpellDuration (base -1); the server stores their fixed cast-time in the
player buff-expiration table, so it reads as already-elapsed and drifts
into the past. We were reporting that stale timestamp, giving a permanent
aura like Devotion Aura a negative time-left. Mirror the engine's own
GetPlayerBuffTimeLeft (Script at 0x004e4930 -> FUN_004e4450), which reads
the same table and returns 0 once now >= expiration. Signed compare
absorbs the GetTickCount wrap, matching the engine.
2026-07-26 12:16:48 -05:00
Brues 60479677c1 aura: best-effort duration guess for out-of-range group/raid auras
An out-of-range party/raid member has no CGUnit and never yields
SMSG_SPELL_GO, so the caster/duration cache never learned their auras'
timing and expirationTime stayed 0. But SMSG_PARTY_MEMBER_STATS carries
their aura spell IDs and the server sends it on change
(SendUpdateToOutOfRangeGroupMembers flushes the moment an aura add/remove
dirties the group mask), so a spell ID that newly appears in the group
array did so ~one tick + latency after the real application.

Aura::Source::ObserveGroupAuras (called from the group enumerate paths in
Aura::Data) keeps a per-member snapshot of the last-seen spell-ID set and,
on a genuine absent->present transition, stamps now + base duration into
the same cache PushEnriched already reads. Covers party and raid alike
(AuraArray reads the raid slot too; snapshot table sized for a 40-man).
Base duration only (no remote caster mods) and no caster recorded
(sourceUnit/GUID stay nil). Auras already present at first sight have
unknown age and are not stamped; snapshots reset on map change and after
30s unpolled. The Store guard keeps any real SpellGo timing, so this
never downgrades better data.
2026-07-26 03:05:54 -05:00
Brues b5038dc3fd util: add VectorLib (Vector2D / Vector3D / Vector4D)
Verbatim backport of retail's Blizzard_SharedXML vector mixins
(CreateVector2D/3D/4D + Vector*DMixin + the free Vector*_ helpers).
Pure math on CreateFromMixins and Lua 5.0 math.*, so no adaptation was
needed. Registered the three files in the TOC (we list .lua directly
rather than via retail's VectorLib.xml include).
2026-07-26 03:00:03 -05:00
Brues 0bbf802316 Use BASE_MOVEMENT_SPEED for speed calculation
Replace the magic divisor 7 with the BASE_MOVEMENT_SPEED constant
2026-07-26 02:53:34 -05:00
Brues 0fcaa2e064 util: add RectangleMixin / CreateRectangle
Backport of retail's Rectangle helper (sides, stretch/move/adjust,
contains/intersects, center, width/height accessors), built on
CreateFromMixins. Registered in the TOC.
2026-07-26 02:49:02 -05:00
Brues 6724ddd2bd mathutil: CreateCounter takes an optional step
Retail added a second `step` parameter (default 1); mirror it. Backward
compatible -- existing no-step callers are unchanged.
2026-07-26 02:46:58 -05:00
Brues 50d001a71f mathutil: add CreateAccumulator / AccumulatorMixin
Backport of retail's AccumulatorMixin (Init/Add/Subtract/Count/Reset)
and the CreateAccumulator factory, built on CreateAndInitFromMixin.
2026-07-26 02:42:40 -05:00
Brues 3aad341a91 table: add table.count(tbl)
Backport of retail's table.count (added 11.2.5): returns
(numTableNodes, numArrayNodes, maxArrayIndex) -- total entries, entries
with an integer key in [1..numTableNodes], and the largest positive
integral key (0 if none). Pure lua_next walk, no Lua-internal layout
dependency; keys read via ToNumber only (ToString on a numeric key
would corrupt traversal). All five cases verified to match retail,
including negative-key handling ({[-3]=x} -> 1, 0, 0).
2026-07-26 02:24:19 -05:00
Brues 3141acf547 baselib: provide Mixin and CreateFromMixins as C natives
Modern WoW implements these FrameXML table primitives as engine C
functions (TableUtil); do the same so they exist whenever the DLL is
injected -- even if the !!!ClassicAPI addon is disabled. Behaviour
matches the Lua form exactly: shallow key/value copy from each table
arg, non-table args skipped, returns the object / new table. Registered
on both the in-game and glue states.

Slimmed the addon Mixin.lua to just CreateAndInitFromMixin (calls a Lua
:Init, so it stays Lua), mirroring retail's Mixin.lua. The taint-based
SecureMixin family is omitted -- vanilla 1.12 has no execution taint.
2026-07-26 02:12:22 -05:00
Brues 05e7bda5da color: add C_ColorUtil and wire the addon Color files to it
New C_ColorUtil native module (src/color/Util.cpp) with the modern color
helpers, all pure math/string: ConvertRGBToHSV, ConvertHSVToRGB,
ConvertHSVToHSL, ConvertHSLToHSV, ConvertHSLToRGB, GenerateTextColorCode,
WrapTextInColor, WrapTextInColorCode. Conventions verified in-game against
a retail-family client: hue in degrees [0,360), -1 achromatic sentinel,
bare AARRGGBB from GenerateTextColorCode, |c..|r wrapping.

Ported the addon Color.lua / ColorUtil.lua to match retail now that the
natives exist: ColorMixin:GetHSL/GenerateHexColor/WrapText* delegate to
C_ColorUtil (GetHSL now returns degrees), SetRGBA defaults alpha to 1,
byte getters use Round; added COLOR_FORMAT_* constants and
CreateColorFromBestRGBHexString. Kept 1.12-Lua-5.0-safe syntax and the
ClassicAPI-only CAPI_ApplyStandardColorGlobals / ITEM_QUALITY_COLORS bits.
2026-07-26 01:57:56 -05:00
Brues c4b48f7f42 spell: add CanDualWield()
New global returning whether the player can equip an off-hand weapon.
The server tracks this as Player::m_canDualWield, flipped on only by
SPELL_EFFECT_DUAL_WIELD (effect 40); exactly one client Spell.dbc row
carries that effect -- 674 "Dual Wield" -- so CanDualWield() is exactly
IsPlayerSpell(674). Factored the known-spell bitmap read into a shared
PlayerKnowsSpell() helper used by both.
2026-07-26 01:30:11 -05:00
Brues 4d97b1c449 clipboard: add CopyToClipboard(text [, removeMarkup])
New Win32-backed global that copies text to the Windows clipboard and
returns the byte length copied. Optional removeMarkup strips WoW UI
escape sequences (color codes, hyperlinks keeping visible text,
textures, |n, || -> |) so the clipboard gets plain text. UTF-8 in,
CF_UNICODETEXT out. Registered on both the in-game and glue states.

Docs: folded the lone Screen section and this into a single System
section rather than adding a one-function Clipboard section.
2026-07-26 01:18:42 -05:00
Brues e447c70ca3 aura: recover player-talented DoT duration in the application hooks
A player DoT with a duration talent (e.g. Improved Shadow Word: Pain,
2/2 -> 24s vs 18s base) intermittently showed the base duration. The
displayed value is the Aura::Source cache entry's durationMs, which is
computed two ways: SpellGo knows caster == player so it applies the
player's duration mods (skipMod 0 -> 24s), while the OnAuraAdded /
OnAuraStacksChanged application hooks have no caster and take the
unmodified base (skipMod 1 -> 18s).

Normally SpellGo owns the entry and the application hooks can't clobber
it, but the base value wins whenever SpellGo can't land its talented
value on THIS target's entry: an empty SMSG_SPELL_GO hit list files it
under the caster instead (numTargets == 0), or a slot reshuffle
evicts + refills the entry after SpellGo ran. Both are packet-order
dependent, hence the flicker.

Fix: SpellGo (which knows caster == player) records recent player aura
casts by spellId; the application hooks recompute with skipMod 0 and
attribute the caster when the spellId matches a recent player cast.
Robust against both the attribution miss and the refill race, and
leaves other units' auras on the base path (we lack their mod tables).
2026-07-26 00:00:42 -05:00
Brues 72fa36956e tooltip: add OnTooltipSetSpell/Unit/GameObject scripts
Generalize the item-only Tooltip::SetItemEvent into Tooltip::SetEvents,
backing all four object tooltip scripts through one shared fire path
(stack save/restore + recursion guard + Suppressor). One resolver
co-hook recognizes all four OnTooltipSet* names (the per-tooltip cell
now holds a slot per kind), plus one builder co-hook per kind firing its
script after the original build:

- Spell  -> FUN_0052E610 (SetSpell/SetSpellByID/SetTalent/SetShapeshift;
  7 stack args, RET 0x1c)
- Unit   -> FUN_00529fe0 (SetUnit + mouseover; 1 arg, returns an int the
  caller tests -> co-hook forwards it)
- GameObject -> FUN_0052aa20 (GO hover populator; 1 arg)

Each hook's declared stack-arg count matches its callee RET N (verified
from disassembly) so the caller's stack isn't corrupted.

Auras deliberately do NOT fire OnTooltipSetSpell: SetUnitBuff/Debuff/
PlayerBuff funnel through a separate aura-tooltip builder (FUN_0052f880)
that bypasses the spell builder -- matching retail, where unit auras are
a distinct tooltip data type.

Verified in-game: all three new scripts fire (spell hover, unit
mouseover, gameobject mouseover), item still fires, no crash.
2026-07-25 22:57:32 -05:00
Brues 40813a715c talent: GetTalentSpellID takes an optional classID too
Mirror the classID extension just added to GetTalentIDByIndex. classID
is the 4th arg (GetTalentSpellID(tab, idx[, rank[, classID]])); pass rank
as nil to keep its default. A numeric classID resolves the talent
against the Talent.dbc / TalentTab.dbc flat arrays via the shared
ResolveTalentRecordForClass, then reads SpellRank[rank-1] off the record.

One semantic difference from the same-class path: there's no player
currentRank for a class you aren't, so a cross-class query with no
explicit rank defaults to rank 1 (the canonical spellID) rather than
delegating to the engine's GetTalentInfo.

Verified in-game: own-class parity at rank 1 (14524 == 14524), Mage (8)
tab1 talent1 -> Arcane Subtlety (11210 r1, 12592 r2, r9 nil),
default-rank cross-class == rank 1, and bad tab/index/class all nil.
2026-07-25 15:05:34 -05:00
Brues a14ccfacbb talent: GetTalentIDByIndex takes an optional classID
Previously it only read the local player's talent tree (the engine's
runtime per-tab arrays at VAR_TALENT_TAB_INFO_ARRAY, built only for the
player's class). Addons that want another class's talent IDs -- build
tooling, spec previews -- had no way to get them.

Add an optional 3rd arg: nil/omitted keeps the runtime path (player's
class, unchanged); a numeric classID resolves against the Talent.dbc /
TalentTab.dbc flat arrays instead, so any class works.

Ordering matches the player-class path exactly because it replicates the
engine's tree builder FUN_004f2c00: tabs taken from TalentTab.dbc in row
order filtered by classMask (+0x30; mask 0 = all, race not a factor --
FUN_004f2e50 is a plain bitmask test), then talents from Talent.dbc in
row order within each TabID. The runtime arrays are built from that same
walk, so GetTalentIDByIndex(t,i) == GetTalentIDByIndex(t,i,<player class>).

New Offsets: the flat (row-order) DBC array globals the builder uses
(VAR_TALENTTAB_DBC_FLAT_* / VAR_TALENT_DBC_FLAT_*), distinct from the
sparse by-ID index SetTalentByID uses.

Verified in-game: own-class parity (166 == 166), Mage (8) tabs 1/2/3
first talents = 37/1/18, and out-of-range tab/index/class all return nil
with no fault.
2026-07-25 14:58:49 -05:00
Brues 4afe0b9eae aura: read out-of-range groupmate auras from the roster
C_UnitAuras.* returned nothing for a party/raid member with no live
CGUnit (different map / far out of range): every path bailed on the
null unit, and the only fallback was the Aura::Source SMSG_SPELL_GO
cache, which needs the object to exist.

The server keeps transmitting each group member's current aura spell
IDs via SMSG_PARTY_MEMBER_STATS (GROUP_UPDATE_FLAG_AURAS / _NEGATIVE)
and the client stashes them in the group-member roster structs -- the
same source the built-in UnitBuff/UnitDebuff read out of range. Surface
them.

- Group::MemberStats::AuraArray(guid) mirrors the engine's own
  out-of-range fallback in Script_UnitBuff/Script_UnitDebuff exactly
  (party stats block +0x1a gated on the online flag, else raid slot
  +0x64; both 48x u16).
- Factor aura-table enrichment (caster/expiration/duration) into a
  single descriptor-independent PushEnriched core; Push and
  PushFromCache now delegate to it.
- New group-array enumerate branch (PushNthGroupAura /
  PushGroupAuraBySpellID / ...BySpellName / AppendGroupAuras), applying
  the engine's per-slot visibility predicate and merging any
  caster/timing the SpellGo cache observed.
- Api.cpp routes unit==null to the group array for every C_UnitAuras
  entry point. Safe: the token resolver never raises (returns 0), and
  the GUID lookup only runs after ResolveUnit already accepted the
  token.

Spell IDs only (no stacks; IDs are u16-truncated) -- same limitations
as UnitBuff. Verified in-game: GetUnitAuras("raid1") cross-map returns
Power Word: Fortitude (10938), matching UnitBuff.
2026-07-25 14:33:58 -05:00
Brues c01dceabb8 unit: class/race fall back to the player-info cache out of range
UnitClassBase / UnitRaceBase resolved via FUN_RESOLVE_UNIT_TOKEN and returned
(nil,nil) for a groupmate on another map -- but the engine's own UnitClass /
UnitRace fall back to the cached player-info record there (verified in-game:
UnitClass("raid1")="Priest", UnitRace("raid1")="Human" while our descriptor
path yielded nil). Give ours the same fallback: on a descriptor miss, read
class (+0x140) / race (+0x138) from the NameCache record by GUID. Verified
fixed: UnitClassBase("raid1")=("PRIEST",5), UnitRaceBase("raid1")=("Human",1).

That engine-NameCache peek existed in four places (the two above plus
loot/History and player/Info). Collapsed them into one primitive,
Unit::Identity::PlayerInfoRecord(guid) -> record* (null callback = peek only),
and pointed all four at it. FUN_PLAYER_INFO_LOOKUP is now called in exactly
one spot.
2026-07-25 13:19:04 -05:00
Brues 365df6b882 group: add Group::MemberStats, generalize the out-of-range roster reader
Extract the roster fallback (added for UnitPower) into a shared
Group::MemberStats reader and give it the full picture. Lookup(guid) tries
the party stats block (FUN_GROUP_MEMBER_STATS_LOOKUP) then the raid slot
(FUN_GROUP_MEMBER_SLOT_LOOKUP) -- the object-independent group roster fed by
SMSG_PARTY_MEMBER_STATS -- and returns health, maxHealth, power, maxPower,
powerType, level, areaId, online (both paths) plus dead, subgroup, rank (raid
path). Raid-struct layout mapped from Script_GetRaidRosterInfo (0x004bb560):
subgroup@+8, rank@+0xc, flags@+0x18 (bit0 online, bit2 dead), level@+0x22,
areaId@+0x24, plus the health/power fields already used.

UnitPower/Max/Missing now go through it (no behavior change -- verified target
still reads UnitPower == UnitMana == 2896 on a different map), and
UnitHealthMissing gains the same fallback so it no longer returns 0 for an
out-of-range groupmate. UnitPowerType already had the engine's own roster
fallback (0x00517940) and needs nothing.

The extra fields (level/area/online/dead/subgroup/rank) are populated but not
yet consumed -- groundwork for fixing UnitLevel / UnitIsConnected / UnitIsDead
and a full GetRaidRosterInfo for out-of-range members.
2026-07-25 12:59:55 -05:00
Brues 72de6c0960 unit: fall back to the group roster for out-of-range power reads
UnitPower / UnitPowerMax / UnitPowerMissing read the CGUnit descriptor
directly, so they returned 0 for a party/raid member on a different map
(no live object). The engine's UnitMana/UnitManaMax don't -- they fall back
to the cached member stats from SMSG_PARTY_MEMBER_STATS.

Mirror that fallback: when ResolveArgs finds no live object, resolve
token -> GUID and read the member's primary power from the party stats block
(FUN_GROUP_MEMBER_STATS_LOOKUP: type@+9, cur@+0xe, max@+0x10) then the raid
slot (FUN_GROUP_MEMBER_SLOT_LOOKUP: type@+0x58, cur@+0x60, max@+0x62), all
u16 -- exactly Script_UnitMana (0x00517670) / Script_UnitManaMax (0x005177e0).
Verified in-game: a different-map target now reports UnitPower == UnitMana
(2896) instead of 0.

Limitation (inherent): the roster caches only the member's primary power, so
an explicit non-primary powerType returns 0 out of range. Reached only after
ResolveArgs fails on an already-valid token, so TokenToGUID can't raise.
2026-07-25 12:42:06 -05:00
Brues eeba4ada9d focus: keep focus on party/raid members that leave range
3.3.5 does not drop focus the instant a unit leaves the object table -- its
object-teardown purge (FUN_00524350) is gated by FUN_00512a30, which is
"party-member (FUN_0052d310) OR raid-member (FUN_00573200)". So a groupmate
whose CGUnit is destroyed by going out of range/LoS keeps focus and reattaches
when they return; only non-group units clear on despawn. (Verified in 3.3.5
via Ghidra: focus GUID at DAT_00bd07d0/d4, sole writer FUN_0051ff20, purge
gate decoded to the party/raid roster checks.)

OnWorldTick now mirrors that gate: on an ObjectByGUID miss it keeps focus when
IsGroupMemberGuid(g_focusGUID) -- reading the object-independent roster GUIDs
(VAR_PARTY_GUIDS 4-slot array + VAR_RAID_MEMBER_PTRS 40-slot member-ptr array,
GUID at *member+0) -- otherwise clears as before. A new g_focusObserved flag
tracks whether the "focus" unit-event observers are attached to a live object;
they die with the object on despawn, so OnWorldTick re-registers them when a
groupmate returns to range, and Set() registers only against a resolvable
object (deferring to OnWorldTick when focusing an out-of-range member).

Corrects the earlier note claiming vanilla/modern drop focus on mere range
loss -- that was wrong; the drop is object-teardown gated by group membership.
While a groupmate is out of range UnitExists("focus") is still false (no client
object to read), but the focus survives and snaps back live on their return.
2026-07-25 12:19:51 -05:00
Brues 5f13c63c99 unit: add UnitSpellHaste(unit) -- backport of the TBC+ haste getter
Vanilla 1.12 has no spell-haste API. UnitSpellHaste reads UNIT_MOD_CAST_SPEED
(the cast-time multiplier at descriptor +0x22c that the server folds into
SpellEntry::GetCastTime) and converts it to the modern haste percentage:

    haste% = (1 / modCastSpeed - 1) * 100

so 1.0 -> 0, 0.5 (half cast time) -> 100, and a slow like Curse of Tongues
(>1.0) -> negative. This is the Blizzard-shaped surface over the same field
nampower exposes raw as GetUnitField(unit, "modCastSpeed"), letting addons
drop that dependency (raw multiplier recoverable as 1/(1 + haste/100)).

Formula verified byte-exact against 4.3.4's Script_UnitSpellHaste
(FUN_004555b0): `(C1 / mult) * C2 - C2` with C1=1.0, C2=100.0 and a
div-by-zero guard -- the constants are forced by "unhasted must read 0" and
"return a percentage". The 4.3.4 field lives at desc+0x10c; +0x22c is the
verified 1.12 offset. Our div-guard uses `> 0` vs retail's `!= 0` (identical
for the always-positive cast-speed field, marginally more defensive).

New src/unit/SpellHaste.cpp + OFF_UNIT_MOD_CAST_SPEED; docs + README updated.
2026-07-25 00:27:17 -05:00
Brues afa1f62720 unit: add the unmodified arg to UnitPower / UnitPowerMax
Completes the retail signature UnitPower(unit [, powerType [, unmodified]])
(and UnitPowerMax). A truthy third arg skips the per-type display divisor and
returns the raw internal value, via the same ToBoolean(L, 3) path already used
by UnitPowerMissing.

Verified in-game on a warrior (rage, divisor 10):
  UnitPower(1)=96  UnitPower(1,true)=961   (floor(961/10)=96)
  UnitPowerMax(1)=100  UnitPowerMax(1,true)=1000
  UnitPowerMissing(1)=4  UnitPowerMissing(1,true)=39
The missing=4 (not 3) confirms the display-value subtraction: 100-96, not
(1000-961)/10 floored to 3.

Docs updated (signature, TOC anchor, unmodified note).
2026-07-25 00:10:09 -05:00
Brues c369373efe unit: add UnitPowerMissing(unit [, powerType [, unmodified]])
The power analogue of UnitHealthMissing: returns UnitPowerMax - UnitPower for
the given type in one native call. Reuses the Power module's ResolveArgs /
PowerDivisor, so it inherits the primary-power fallback (powerType omitted)
and per-type display divisor. The unmodified third arg (truthy) skips the
divisor for the raw internal deficit, matching retail's UnitPower.

Subtracts the two display values (each integer-divided) rather than dividing
the raw difference, so it equals UnitPowerMax(...) - UnitPower(...) exactly --
the rage case would otherwise be off by one (100/5 -> 95, not (1000-55)/10 =
94). Returns 0 for invalid units and for types the unit has no pool for
(e.g. energy on a mana user). Verified in-game: mana 6685-5929 = 756.

Docs + README updated.
2026-07-25 00:04:16 -05:00
Brues 1c86d0dead unit: add UnitHealthMissing(unit)
Returns the health deficit -- the equivalent of
UnitHealthMax(unit) - UnitHealth(unit) -- in one native call, so healing
addons don't call both engine getters and subtract in Lua every frame.
One token resolve + two descriptor field reads (HEALTH +0x40, MAXHEALTH
+0x58); no display divisor (health is stored at face value), clamped at 0.

Tracks the engine's own UnitHealth/UnitHealthMax exactly in both forms:
real values (player 3600-1 = 3599) and vanilla's percentage form for
non-grouped units (target 100-85 = 15). Returns 0 for a valid-but-absent
unit, matching UnitHealth's convention.
2026-07-24 23:58:46 -05:00
Brues cedbd04553 cast: mirror the server's +500ms ranged aim time in the cast-time prediction
FUN_006e3340 (the spell tooltip's cast-time helper, which our zero-latency
CastStartSet_h stamp reuses) omits the flat +500ms "aim time" the server adds
to every ranged, non-autorepeat spell -- SpellEntry::GetCastTime does
`castTime += 500` for SPELL_ATTR_RANGED. So the client prediction ran ~500ms
short of the server on Volley / Aimed Shot / Multi-Shot (Volley's real cast is
3000ms base + 500 = 3500ms), and the confirming SMSG_SPELL_START then snapped
the bar out ~1 RTT later. This was the dominant cause of the Volley overhang,
present with or without Quick Shots.

CastTimeMs now adds the +500 for SPELL_ATTR_RANGED spells (excluding Auto Shot
/ Shoot via the AttributesEx2 autorepeat bit, matching the server), so the
prediction is right from frame 1. Only applied when the spell already has a
cast time; instant ranged abilities the server gives a 500ms aim time still
surface through the packet path. The HandleSpellStart reconcile stays as the
backstop for any residual client/server divergence.
2026-07-24 23:21:26 -05:00
Brues 3b1bba2131 cast: reconcile local-player cast time to the server on the confirming packet
The client-side FUN_CAST_START_SET hook stamps g_cast with a zero-latency
prediction from FUN_006e3340, which folds in the caster's cast-speed /
ranged-haste multiplier (descriptor +0x22c). That is correct for spells the
server also hastes (Aimed Shot / Multi-Shot), but wrong for ones it does
not. Volley (1510 / 14294 / 14295) is the case: it is NOT channeled in the
client DBC (AttrEx has no 0x4/0x40) so it takes the regular-cast path, and
its Attributes & 0x30 == 0 so FUN_006e3340 applies the +0x22c multiplier --
yet Volley gains no benefit from Quick Shots server-side. The predicted bar
therefore ended ~before the real cast finished ("still casting after the bar
ends").

SMSG_SPELL_START carries the server's authoritative cast time, but for a
cast the client already stamped we were deduping the packet away entirely to
avoid restarting the bar. Instead, keep startMs (no visual restart) and snap
endMs to the server's castTime (preserving any accumulated pushback). Aimed
Shot / Multi-Shot are unaffected -- the server hastes them too, so client and
server agree and the reconcile is a no-op.

Closes https://github.com/brues-code/pfUI/issues/28
2026-07-24 22:40:27 -05:00
Brues 786428c9fb focus/who: reset per-character state on logout
Both modules held per-character state in process globals that the DLL
never cleared on a logout -> character-select -> login (the DLL is not
reloaded across that transition), the same bug class as the equipment-set
leak.

Focus: g_focusGUID / g_focusTargetGUID carried a dead GUID from the prior
character into the next. The WorldTick despawn probe self-healed it within
a tick, but fired a spurious PLAYER_FOCUS_CHANGED and could not cover a
GUID collision. Clear it from a GlueModuleAutoRegister, which fires on the
world->glue return a logout triggers but NOT on /reload -- so focus still
survives /reload (retail behavior) and drops only on logout.

Who: a /who whose SMSG_WHO response never crossed a logout left g_inFlight
stuck (IsWhoQueryPending wrongly true for the next character) and the
VAR_WHO_TO_UI_FLAG override forced on (routing the next character's /who to
the WhoList). Reset in-flight state and restore the flag on every in-game
Lua (re)build via ModuleAutoRegister, which fires per login and per /reload
before any addon runs -- previously only ResetIfStuck's lazy 10s-on-next-
send backstop covered this.
2026-07-24 22:29:05 -05:00
Brues f5ecdc69c5 equipmentset: reload sets per character, not once per process
EnsureLoaded latched on a process-global g_loaded flag that was never
reset. The injected DLL survives a logout -> character-select -> login
(no process restart), so opening the Equipment Manager on character A
cached A's file path and sets permanently; character B then read, and
saved into, A's data. Only a full game restart cleared the globals, and
never opening the manager on A left B correct -- matching the report.

Re-resolve the per-character path on every EnsureLoaded call and reload
when it changes, so the cache self-heals across any character/account
switch. Also clear the per-session ignored-slot state on switch. Every
mutation Persist()s immediately, so dropping the old in-memory sets
never loses unsaved work.

Fixes https://github.com/brues-code/pfUI/issues/29
2026-07-24 22:13:55 -05:00
Brues 7531223b9a nameplate: assign stable slots instead of a compacting list
Switch nameplateN indexing from a compacting vector (append on ADDED,
erase + shift-down on REMOVED) to retail-exact slot assignment: a plate
keeps its slot for its whole lifetime, a removal frees the slot, and the
next new plate reuses the lowest free one. Survivors are never renumbered
when another plate is removed - matching modern WoW, where a middle plate
vanishing leaves the others' tokens unchanged and its slot goes vacant
until reused.

g_orderedGUIDs -> g_slots (sparse; free slots hold GUID 0, trailing
empties trimmed). GetGUIDByIndex returns 0 for a free or out-of-range
slot; new GetSlotCount exposes the high-water mark. The reverse token walk
in Unit::Identity now scans 1..GetSlotCount() and skips gaps instead of
stopping at the first 0. The per-plate observer lifecycle is unchanged
(keyed on GUID: register on ADD, unregister on REMOVE).

Verified in-game: 3 plates in slots 1/2/3, removing the middle one leaves
slots 1 and 3 occupied and 2 vacant - no renumber.
2026-07-24 18:45:42 -05:00
Brues cb4e428592 unit: fire per-token unit events for focustarget
Extend the focus observer to the focus unit's current target, so
UNIT_HEALTH / UNIT_AURA / ... fire with arg1 == "focustarget" like a
native token. Unlike focus (a sticky GUID), the focustarget GUID is
volatile - it changes whenever the focus re-targets, and vanilla fires no
event for a unit changing its target (UNIT_FIELD_TARGET, field 10, has no
event-table slot). So the WorldTick watcher re-reads the focus's target
each tick and re-points a second TokenObserver at it only when that
identity changes; the focustarget events themselves still fire
event-driven through the observer (only the re-pointing is tick-gated,
<=1 tick lag). Set() drops the stale focustarget observer immediately on
a focus change/clear.

Verified in-game: focustarget events fire and re-point when the focus
switches targets.
2026-07-24 18:13:08 -05:00
Brues e7e2fe05dc unit: guard TokenObserver against a null observer-registry root
Register/Unregister funnel through FUN_00464890, which dereferences the
client/player-rooted observer registry at [VAR_LOCAL_PLAYER_PTR] (+0x24).
That root is null out of world (login, loading screens, logout teardown),
where the helper null-derefs -> ERROR #132 (ACCESS_VIOLATION reading 0x24).

The nameplate teardown on world-exit fires NAME_PLATE_UNIT_REMOVED for
every plate, each calling Unregister while the root was already null ->
crash. Player::Equipment never hit this because it only registers from the
enter-world hook; focus/nameplate register from Lua / WorldTick, which can
run mid-transition.

Gate both Register and Unregister on RegistryReady() ([VAR_LOCAL_PLAYER_PTR]
!= null). Out of world there are no live objects or observer nodes to touch
anyway - nodes die with their objects on teardown, and in-world callers
re-register / clean up on the next tick.
2026-07-24 18:10:42 -05:00
Brues d7078827fd unit: fire per-token unit events for focus and nameplateN
Make the synthetic focus / nameplateN unit tokens first-class sources of
the standard per-token unit events (UNIT_HEALTH, UNIT_MANA, UNIT_AURA,
UNIT_LEVEL, ...): arg1 == "focus" / "nameplateN" now fires just like
"party1", so focus and nameplate frames can update event-driven instead
of polling.

Vanilla fires these events only for units it "watches"
(target/party/raid/pet/mouseover) - FUN_0060c520 recomputes a unit's
token-category mask and, on 0->nonzero, FUN_0051bbb0 registers bank-UNIT
descriptor-field observers whose callback broadcasts the field's event to
the guid's tokens (field index == event id; health +0x40 = event 16 =
UNIT_HEALTH). Focus and nameplates are not watch categories, so they
never fired.

New Unit::TokenObserver replicates that watch registration (FUN_0051bbb0's
field set + per-index sizes) with a caller-supplied callback, via the
engine's own observer registrar - the same API Player::Equipment uses. No
hooks: zero MinHook trampolines, hence no collision risk (the reason this
beats co-hooking the broadcast). The registrar appends a fresh node per
(guid, field), so our observers coexist with the engine's - a unit that is
target AND focus AND a nameplate fires all three tokens. Node lifetime is
engine-managed (nodes die with the object; unregister no-ops post-despawn).

- Focus: register/unregister in Unit::Focus::Set.
- Nameplates: register on NAME_PLATE_UNIT_ADDED, unregister on _REMOVED
  and (all) in PrepareForReload, since objects survive /reload; the
  callback resolves the changed guid to its current plate index at fire
  time.

Offsets cross-confirmed against nampower's offsets.hpp (SendUnitSignal
0x515e50, StringParamFormat "%s" 0x82e280, SignalEventParam 0x703f50,
UNIT_HEALTH 16); the GUID->token reverse map nampower left as a TODO is
FUN_00515c50.
2026-07-24 17:26:23 -05:00
Brues c571c1a852 equipmentset: build the tooltip natively, not via Lua method dispatch
SetEquipmentSet built its lines by calling back through self:SetText /
self:AddLine / self:Show - a PushValue/GetTable/Insert/Call dance per
line. Replace that with the engine's own per-tooltip clear plus raw
add-line (with a small r,g,b to packed-color helper) - the same native
path SetTotem and SetHyperlinkCompareItem use. Localization is unchanged:
PushLocalizedFormatInt for the count lines, Lua string.format for the
ITEM_MISSING %s template.

Shows itself via Script_Show at the end, matching retail (the inner
builder tail-calls the show) and PaperDollFrame.lua, which sets the
anchor then calls SetEquipmentSet with no trailing :Show().
2026-07-23 22:38:12 -05:00
Brues a7b205cd56 totem: add GameTooltip:SetTotem(slot)
Backport of the TBC (2.4.0) tooltip method. Fills the tooltip with the
slot's active totem as two native lines - name (NORMAL_FONT_COLOR) and
time remaining (white) - mirroring retail's Script_GameTooltip_SetTotem
(verified against the 3.3.5 binary; the two line colors are read verbatim
from its color data). Built with the engine's own per-tooltip clear plus
raw add-line - no Lua method dispatch - and shows itself via Script_Show,
matching retail's TotemFrame.xml OnEnter which calls SetTotem with no
trailing :Show().

The time line mirrors SecondsToTimeAbbrev (raw seconds under a minute,
minutes rounded up above) and localizes through the FrameXML GlobalStrings
SPELL_TIME_REMAINING_SEC/_MIN with English fallbacks; slot data is the
existing GetTotemInfo tracker.
2026-07-23 22:38:00 -05:00
Brues a3f224876f event: add a correctly-timed on-demand table-grow valve
Re-introduces the event-table grow valve (removed in 538beda as
mis-timed) at the correct point. Event::Custom::Grow runs from
RetryClaims -- the frame:RegisterEvent hook -- on the first in-game
registration, which is AFTER RebuildEventTable (and SuperWoW/nampower's
clamp-to-700 that co-hooks it) but BEFORE any chain is populated. So
every chain is still an empty self-sentinel and the buffer can be moved
safely. A one-shot latch (reset per reload) runs it at most once per
build, and a hard "abort if any chain is live" scan backs the timing.

Same realloc logic the old EnsureCapacity used -- SMemAlloc a new
buffer, copy name pointers, re-init empty-sentinel anchors, swap the
{cap,count,base} globals, SMemFree the old -- bypassing SetEventCount's
destructive =700 clamp. Consumers read base fresh; the engine's reload
teardown frees our buffer + names as its own.

Dormant in practice (~200 low gaps vs ~46 reservations). Validated via a
new _classicapi_ForceEventGrow() diagnostic that forces a grow on the
next /reload: table grew 700->716, all events (ours, engine,
SuperWoW/nampower, VMT) survived with names intact, dispatch worked on
the grown table, and a following /reload cleanly freed the buffer.
2026-07-23 21:27:29 -05:00
Brues a40236eb84 spell: add GameTooltip:AddSpellByID(spellID)
Append counterpart to SetSpellByID -- adds a spell's tooltip (name, cast
time, cost, range, description) to the current tooltip WITHOUT clearing
existing lines, so callers can compose tooltips like
AddLine(header) + AddSpellByID(id).

BuildSpellTooltip's last arg gates the clear: SetSpellByID passes 0
(clears via FUN_00530050 first); AddSpellByID passes 1 (skips the clear
and appends). That append path is the engine's talent "next rank"
preview, so it emits a "Next rank:" header instead of the spell name and
doesn't stash the displayed spellID -- we overwrite that header line (at
the pre-build numLines index) with the real name from Spell.dbc and let
GetSpell keep reflecting the base tooltip. Verified in-game.
2026-07-23 21:12:06 -05:00
Brues 538beda499 event: remove the mis-timed EnsureCapacity table-grow valve
EnsureCapacity was meant to grow the event table when the engine's ~200
low NULL gaps ran short of our ~46 reservations, by reallocating the
entry buffer via Storm and swapping the base/count globals. But it was
called from LoadScriptFunctions_h, which runs BEFORE RebuildEventTable
inside FUN_0048FBF0 -- so it always operated on the previous cycle's
still-populated table, tripped its own "abort if any chain is live"
guard, and never actually grew anything. Mis-timed dead code doing risky
buffer surgery on the shared event table.

Claim-NULL into the engine's low gaps is the sole mechanism now; it
always suffices, and TryClaim logs once if it somehow doesn't. Drop the
function, its Storm alloc/free helpers, and the now-inaccurate offset
comments.
2026-07-23 21:06:36 -05:00
Brues af01a4b408 event: claim NULL slots low-first + on-demand table expansion
Reworks custom-event slot reservation to be coordination-free and
durable without depending on DLL load/hook order.

- Claim NULL-name slots walking LOW->high instead of from the tail.
  SuperWoW/nampower own the high range (fixed IDs 549..655) and
  destructively clamp the table to 700, and VanillaMinimapTracking also
  walks the tail, so the top is contested; the engine's low gaps (~200,
  incl. a ~64-slot hole at 42..106) are stable and uncontested. Verified
  in-game: our events now land at 1..64, VMT alone at 699 -- clean
  separation, independent of the clamp.

- Add Event::Custom::EnsureCapacity: on-demand grow run once from
  LoadScriptFunctions_h (after module registration so all reservations
  incl. lazy TTS are counted, before any frame registers). If NULL slots
  < reserved, it reallocates the entry buffer via Storm and swaps the
  {cap,count,base} globals directly, bypassing SetEventCount's clamp --
  so it's load/hook-order independent. Defensive: reads fresh, verifies
  all chains empty before moving the buffer (aborts otherwise), frees
  only the old buffer (names are shared), publishes base before count.
  Dormant in the common case (low gaps dwarf our count).

- Drops the SetEventCount co-hook (Capacity.cpp) entirely -- nothing new
  on a SuperWoW/nampower-contended function. Keeps the one-shot
  table-full log as the backstop.

Adds Storm SMemAlloc/SMemFree + VAR_EVENT_TABLE_CAP offsets.
2026-07-23 18:13:41 -05:00
Brues 33c8c3e37c Add C_TaxiMap.GetTaxiRoute(slotIndex)
Exposes the client's computed flight route to a taxi-map slot as an array
of TaxiNodes.dbc IDs in flight order { currentNodeID, ..., destNodeID } --
the exact chain TakeTaxiNode sends the server.

Mirrors the TakeTaxiNode worker (FUN_004dc8d0): a single direct hop when a
TaxiPath edge exists (via the FUN_004dbad0 adjacency matrix), otherwise the
client's precomputed multi-hop route array (slot +0x14, length +0x18). This
lets addons identify every hop of a per-character route without matching
flight-map coordinates back to nodes.

Offsets added: per-slot route array/count/flag, the current-node record
pointer, and the node adjacency matrix. Documented in docs/API.md + README.
2026-07-23 06:00:55 -05:00
Brues c5d5415afe taxi: add C_TaxiMap.GetTaxiPaths + GetTaxiPathWaypoints
Expose the taxi routing graph and per-path geometry that vanilla keeps in
TaxiPath.dbc / TaxiPathNode.dbc but never surfaces to Lua (and addons can't
read DBCs at all).

- GetTaxiPaths() -> array of { pathID, fromNodeID, toNodeID, cost } (every
  directed flight-path edge; cost is the routing weight in copper).
- GetTaxiPathWaypoints(pathID) -> array of { x, y, z, mapID } world
  coordinates ordered by node index; sum the 3D chords for path length
  (/ 32 yd/s ~= single-hop flight seconds).

Lets addons build the flight graph, run travel-time / routing math, or drive
a map overlay without baking the geometry in. Pure DBC readers, no flight
master needed. Offsets for TaxiPathNode.dbc + TaxiPath cost added to
Offsets.h.
2026-07-23 03:59:04 -05:00
Brues 39dcfc73de event: add GetFramesRegisteredForEvent(event)
Returns the frames currently subscribed to an event as multiple return
values (retail's vararg signature). Reads the engine's own subscriber
chain off the event-table entry (entry+0xC head; node next @+0x04,
frame @+0x08, low-bit/null end sentinel) — the exact list
frame:RegisterEvent appends to, so it stays in sync with real
registrations, including AutoReserve custom events. Each frame is
pushed via the shared UI::FrameObject wrapper so frame methods work.
A counting pass drives lua_checkstack up front against heavily
subscribed events.
2026-07-21 14:41:10 -05:00
Brues 6e5f7c3e43 tradeskilllink: gate detail wheel on overflow
The detail ScrollFrame had mouse wheel enabled unconditionally, so even
when the content fit (bar hidden, 0 range) it still consumed wheel events
— clamping to a no-op but swallowing the scroll instead of letting it pass
through. Enable the wheel from SetDetailBarShown alongside the bar, so it's
on only when there's something to scroll.
2026-07-21 14:25:43 -05:00
Brues 2e05e86362 tradeskilllink: fix detail-pane scroll range + auto-hide
The trade-skill-link viewer's reagent pane couldn't scroll to the last
row for some recipes (issue #10). Root cause, found by comparing against
Blizzard's Blizzard_TradeSkillUI: resizing a ScrollFrame's child does NOT
recompute its scroll range in 1.12 — you must call UpdateScrollChildRect()
(TradeSkillFrame_SetSelection does exactly this). We resized the child but
never refreshed the range, so the bar's travel was capped at a stale max.

- Call detailScroll:UpdateScrollChildRect() after (re)sizing the child in
  RenderDetail and ClearDetail.
- Size the child from actual laid-out geometry (bottom of the last reagent
  button relative to the child top + name-plate overhang) instead of a
  hand-tallied constant that undercounted.
- Drive scrollbar visibility from the computed overflow (SetDetailBarShown)
  rather than the engine's range-changed event: scrollBarHideable only
  auto-hides on a range CHANGE, so content that fits from the start (e.g.
  Turtle's roomy pane) never hid, and GetVerticalScrollRange isn't reliably
  readable the same tick as UpdateScrollChildRect.

Net removes the old detailNeedScroll bool-tracking, per-need mouse-wheel
toggling, and the OnShow re-assert workaround — all of which were
compensating for the missing range recompute.

Fixes #10
2026-07-21 14:21:25 -05:00
Brues bcc631c4ed tradeskilllink: fix ColorMixin clobber from Fonts.xml re-includers
Addons that re-include ..\..\FrameXML\Fonts.xml in their TOC (notably
SpecialTalentUI, LoadOnDemand — pulled in when the talent frame is first
opened) re-run its inline script, which resets NORMAL_FONT_COLOR /
HIGHLIGHT_FONT_COLOR / GRAY_FONT_COLOR to plain {r,g,b} tables and strips
the ColorMixin methods our UI relies on. That crashed the trade-skill-link
viewer's NORMAL_FONT_COLOR:GetRGB() (issue #10).

Factor the standard color-global setup into CAPI_ApplyStandardColorGlobals
(idempotent — only (re)creates a tag that's missing :GetRGB) and re-run it
after SpecialTalentUI loads via EventUtil.ContinueOnAddOnLoaded, next to the
existing pfUI compat in AddOnCompat.lua.

Also simplify the viewer frame anchor to SetPoint("CENTER").
2026-07-21 12:54:39 -05:00
Brues f9fb32186d TradeSkillLink: Play Open/Close Sound 2026-07-21 11:11:41 -05:00
Brues 60adbba7c3 pointless fallback 2026-07-21 11:11:09 -05:00
Brues c35d883af0 targeting: backport TargetDirection*/TargetNearest* + GetPlayerFacing
Adds the post-vanilla TargetScript selection functions 1.12.1 lacks:

  TargetDirectionEnemy(facing[,cone]) / TargetDirectionFriend
  TargetNearest([reverse])
  TargetNearestEnemyPlayer([reverse]) / TargetNearestFriendPlayer
  GetPlayerFacing()

All five targeting functions reuse the engine's own tab-target core: the
visible-unit enumeration, the per-mode validity predicate FUN_00493e40
(reaction/attackability/alive, modes 1=enemy/2=friend), and the selection
commit. The only new logic is a player-GUID filter for the *Player variants
and the cone geometry for the direction variants. Cycle functions keep a
frozen per-filter snapshot advanced within a ~1s window (no ping-pong).

Target-setting routes through FUN_TARGET_BY_GUID (0x00489A40), which
rejects a GUID that no longer resolves to a live unit rather than
committing it — a stale snapshot GUID would otherwise leave "target"
pointing at freed unit data and crash the next handler that reads it.

GetPlayerFacing reads the CGUnit movement-block orientation at +0x9C4
(located by probing the player object across a known 180-degree turn); it's
the facing source the direction-target functions need.

Not backported (no vanilla equivalent): TargetPriorityHighlightStart/End,
IsTargetLoose, TargetToggle.

New offsets: FUN_TARGET_CANDIDATE_VALID (0x00493E40),
FUN_SET_SELECTION_BY_GUID (0x00493540), VAR_CURRENT_SELECTION_GUID_LO/HI,
OFF_UNIT_FACING (0x9C4).
2026-07-21 00:49:05 -05:00
Brues c202c1000f chatbubbles: add C_ChatBubbles.GetAllChatBubbles
Returns the active chat-bubble Frame objects, matching modern WoW's
7.2.5 signature. Vanilla's CGChatBubbleFrames are full CSimpleFrames
the engine creates in C++ without CreateFrame (same as default
nameplates), kept on an intrusive Storm TSList at 0x00B6F038 (next
link +0x318, low-bit sentinel). Mirror the engine's own walk
(FUN_004b1060) and push each as its canonical engine wrapper, so the
returned frames are real and method-capable and their text FontString
(+0x31c) surfaces via :GetRegions().

includeForbidden is accepted and ignored (vanilla has no forbidden
frames).

Factor the ScriptRegister->rawgeti wrapper push out of nameplate/Info
into a shared UI::FrameObject::Push; PushNamePlateFrame now forwards to
it (no behavior change).
2026-07-20 23:00:03 -05:00
Brues 98b4ae14e7 totem: add GetTotemDuration(slot)
Returns the slot's active totem total duration in seconds (0 when none) —
the totem's full lifetime from cast, same value as GetTotemInfo's 4th
return, companion to GetTotemTimeLeft. Reads the tracker's durationMs.
Verified in-game (constant 45 while time-left counts down).
2026-07-20 21:27:58 -05:00
Brues 8d957eb424 totem: add TargetTotem(slot)
Target the player's totem in a slot (1-4). Looks up the totem creature's
live GUID (object-manager scan for the player-owned creature of the
slot's tracked entry) and sets the target via FUN_00489a40 — the
set-target-by-GUID path Script_TargetUnit uses after its token→GUID
resolve. No-op when the slot is empty or the totem isn't visible. Added
Offsets::FUN_TARGET_BY_GUID.
2026-07-20 21:25:34 -05:00
Brues 78618ea2cc totem: add GetTotemTimeLeft(slot)
Returns seconds remaining before the slot's totem auto-destroys (0 when
no totem is active), computed from the existing Totem::Tracker's
startMs + durationMs. Slots 1-4 = Fire/Earth/Water/Air. Verified in-game
counting down.
2026-07-20 21:11:01 -05:00
Brues 1a4583e16e totem: fire PLAYER_TOTEM_UPDATE on slot changes
Backport the TBC PLAYER_TOTEM_UPDATE(totemSlot) event (absent from
vanilla's table, so reserved as a custom event) on top of the existing
Totem::Tracker. Every slot state change marks a dirty bit — the cast
path (SpellGo), duration expiry, and the WorldTick death-scan removal —
and OnTick flushes the dirty slots with Fire(id, "%d", slot). Flushing
from the tick keeps Lua handlers out of mid-packet context. Drops fire
next frame; removals within the ~250ms scan interval. Verified in-game.
2026-07-20 21:04:15 -05:00
Brues a20e1206fb totem: add GetTotemInfo(slot) with a self-tracking totem subsystem
Vanilla 1.12 tracks nothing about totems client-side (the totem bar,
PLAYER_TOTEM_UPDATE, and GetTotemInfo are all TBC additions), so
Totem::Tracker tracks the four element slots itself — data-driven:

- Slot from the summon spell's Spell.dbc effect (87-90 =
  SUMMON_TOTEM_SLOT1..4 = Fire/Earth/Water/Air; slot = effect - 86).
- Creature entry from the effect's EffectMiscValue (for death detection).
- Duration from SpellDuration.dbc via the engine duration helper.
- haveTotem tool item from the spell's Totem[0] field (0xA0) — the totem
  the spell requires (a tool, not a consumed reagent) — resolved from the
  player's spellbook on SPELLS_CHANGED, not hardcoded.

Casts are observed through Aura::Source's existing SMSG_SPELL_GO hook (a
one-line notify before its aura gate, since a summon applies no aura) —
no new hook, respects one-hook-per-target. Tool items resolve on
SPELLS_CHANGED (a no-arg event via the shared Event::SignalHook), when
the spellbook is populated. A slot clears when its duration elapses OR
when the totem creature disappears early (killed / Totemic Recall /
destroyed), detected by a throttled WorldTick object-manager scan for the
player-owned creature of that entry (seen-then-gone latch; CreatedBy ==
player so another shaman's same-entry totem can't mask yours).

GetTotemInfo returns haveTotem, totemName, startTime, duration, icon,
modRate=1, spellID. Turtle-custom totems are tracked automatically.
Also exposes Item::Count::InInventory (equipped + bags) as a shared
helper for the tool-presence check. Verified in-game: Earthbind lands in
slot 2, haveTotem reflects the Earth Totem tool, and Totemic Recall
clears the slot early.
2026-07-20 20:53:01 -05:00
Brues 9f67cade87 unit: add UnitOwnerGUID(unit)
Returns the GUID string of a unit's owner — the summoner of a
pet/guardian/totem or the charmer of a charmed unit — or nil for
players, world creatures, and unresolved units. Reuses Unit::Pet's
existing OwnerGuid helper (CharmedBy, else CreatedBy in m_objectFields),
the same field the pet predicates share. Verified in-game:
UnitOwnerGUID('target') == UnitGUID('targetowner').
2026-07-20 18:35:47 -05:00
Brues 8629e13e1f gameobject: add ClosestGameObjectPosition(gameObjectID)
Returns xPos, yPos, distance for the nearest visible game object of a
given GO-template ID, or nothing when none is in range — the game-object
counterpart of ClosestUnitPosition.

Extract the shared visible-object-manager scan into
Object::ClosestByEntry (object/ClosestByEntry.{h,cpp}): enumerate visible
objects, match GUID type + template entry (bits 24-47), return the
closest one's world position + center distance. Both ClosestUnitPosition
(TYPEMASK_UNIT / Creature) and ClosestGameObjectPosition
(TYPEMASK_GAMEOBJECT / GameObject) are now thin wrappers over it;
ClosestUnitPosition behavior is unchanged.

Same retail-vs-vanilla caveat as the unit version: returns the nearest
CURRENTLY-VISIBLE object rather than reading retail's static starting-
zone database (which vanilla lacks). Documented in docs/API.md.
2026-07-20 18:26:46 -05:00
Brues ca9bfbab5e unit: add ClosestUnitPosition(creatureID)
Returns xPos, yPos, distance for the nearest creature of a given NPC
entry ID, or nothing when none is in range. Walks the visible-object
manager (Loot::Nearby pattern), matches creatures whose GUID encodes
the entry (bits 24-47, per guid/CreatureInfo), and returns the closest
one's world position + center distance in yards.

Semantic note: retail's ClosestUnitPosition reads a static client spawn
database (starting-zone mobs only); vanilla 1.12 has none, so this
returns the nearest CURRENTLY-VISIBLE creature of that entry (anywhere)
— more general than retail, but can't point at un-synced spawns.
Documented in docs/API.md.
2026-07-20 18:18:52 -05:00
Brues 8fdc767cc7 item: add Enum.ItemQuality
Poor=0 through Artifact=6, matching the numeric quality GetItemInfo /
C_Item.GetItemQuality report. Heirloom (7) and WoWToken (8) are omitted
— no such items exist on 1.12. Registered in src/item/TypeInfo.cpp
alongside Enum.ItemClass / Enum.InventoryType. Docs + README updated.
2026-07-20 17:36:47 -05:00
Brues aa31c25be5 item: add GetItemClassInfo, GetItemSubClassInfo global, Enum.ItemClass
- GetItemClassInfo(classID) -> localized ItemClass.dbc name, registered
  as both a global and C_Item.GetItemClassInfo. Obsolete class slots
  return the client's literal "...(OBSOLETE)" string.
- GetItemSubClassInfo(classID, subClassID) exposed as a global (the
  C_Item form already existed); returns name + the armor-material bool.
- Enum.ItemClass: 0..15 with modern key names on the obsolete slots
  (Gem/ItemEnhancement/CurrencyTokenObsolete/PermanentObsolete), plus
  post-vanilla 16..19 for parity, matching the Enum.InventoryType
  precedent in the same module.

All added to src/item/TypeInfo.cpp alongside the existing subclass /
inventory-type surface. Docs + README updated.
2026-07-20 17:27:28 -05:00
Brues 29aafe88f8 turtle: move combo-finisher & armor-healing custom data out of stock
Two pieces of hardcoded Turtle-specific data were embedded in stock
modules; relocate them to src/turtle/ behind extension points so the
stock code carries only generic mechanism.

- aura/ComboDuration: add an AutoDanglingResolver extension point; the
  stock module keeps the dangling-row detection + scaling formula, and
  Turtle's reworked-Rip base/max values move to
  src/turtle/ComboDuration.cpp. Not gated on Turtle::Detected() — the
  dangling-row condition stays the gate (gate on the bug, not the
  realm), preserving prior behavior exactly.

- spell/BonusDamage: add a Spell::HealingAura resolver extension point;
  the stock GetSpellBonusHealing decoder keeps auras 135 (flat) and 175
  (xSpirit), and Turtle's Ironclad aura 199 (xArmor) moves to
  src/turtle/HealingAura.cpp, gated on Turtle::Detected() (an aura index
  is a generic slot that could collide on another server).

Behavior is unchanged on Turtle; both extractions are pure relocation.
2026-07-20 16:49:45 -05:00
Brues 461f15a4a6 item: derive bag family from (class, subclass) for bags with empty field
Turtle's item data leaves m_bagFamily at 0 on the bags/quivers
themselves — the four profession bags (Soul/Herb/Enchanting/
Engineering, Container class) and quivers/ammo pouches (Quiver
class) all read 0, so C_Item.GetItemFamily and
C_Container.GetContainerNumFreeSlots reported 0 for them even
though the tooltip clearly shows "Soul Bag" etc. Recover the family
from the item's class + subclass instead (keyed on class because
subclass numbers repeat: Container sub 2 = Herb Bag, Quiver sub 2 =
Quiver). Loot items keep carrying the field directly.

Factored the derivation into a shared Item::BagFamily helper
(BitmaskForRecord) used by both GetItemFamily and the free-slot
functions, so a bag and the items it holds always agree on the
family bit. Side effect: CalculateTotalNumberOfFreeBagSlots now
correctly excludes specialty bags from the general free-slot total.

Non-stock data lives in its own module: src/turtle/ with a
TURTLE_WOW_VERSION-based Turtle::Detected() gate (no realm sniffing)
and the custom Meat/Fish/Leather/Mining families (ItemBagFamily.dbc
rows 10-13) plugged into Item::BagFamily via an AutoResolver
extension point, so the stock table stays pure vanilla.
2026-07-20 16:38:42 -05:00
Brues 6245d72694 unit: make UnitInRange reach-aware
Compare center distance against `40 + playerReach + targetReach`
instead of a flat 40, reading each unit's bounding radius from
`[m_objectFields + 0x1F0]` — the same per-unit size factor the
engine's spell-range helper (FUN_006e3480) and the interact/loot
range checks add. A 40-yard heal reaches a target whose center is
slightly past 40 yards but whose body is within reach; the old flat
cap reported those out-of-range. UnitDistanceSquared stays raw
center-to-center, matching retail.
2026-07-20 14:52:35 -05:00
Brues bd14228ad7 event: add PLAYER_ENTERING_WORLD payload (isInitialLogin, isReloadingUi)
Backport the 8.0.1 PEW payload onto vanilla's arg-less PLAYER_ENTERING_WORLD.

PEW is a no-arg event, so it broadcasts through FUN_FIRE_EVENT_NO_ARGS
(__fastcall(int), the no-arg dispatcher) — NOT the variadic FUN_FIRE_EVENT.
So we intercept that dispatcher and, for the PEW event id, suppress the
no-arg fire and re-broadcast through the variadic dispatcher with the two
booleans (true->1 / false->nil, the %d/%s+nullptr trick). Both fire paths
walk the same frame chain, so PEW-registered frames get the args once.

Flags need no PEW-specific engine hook — they fall out of signals we already
produce: GlueModuleAutoRegister (glue-screen boot) latches "next enter-world
is a fresh login"; ModuleAutoRegister (in-game Lua re-init) marks
login-or-reload; a zone/instance transition rebuilds neither, so both false.

New shared Event::SignalHook: FUN_FIRE_EVENT_NO_ARGS was already hooked by
loot/Scan (silent-scan suppression), and MinHook allows one hook per target,
so — like Tick::WorldTick for the per-frame tick — this module now owns the
single hook and fans out to interceptors. loot/Scan refactored from owning
the hook to being a subscriber (behavior identical). Event::EnteringWorld is
a second subscriber.

Verified in-game: fresh login, /reload, and instance transition each report
the correct (isInitialLogin, isReloadingUi).
2026-07-20 03:22:07 -05:00
Brues 5b15172894 coroutine: guard AuxResume xmoves with lua_checkstack
lua_xmove moves values via api_incr_top, which asserts room on the
destination rather than growing it. AuxResume xmoved an unbounded count both
ways without a guard: nres (a coroutine body can yield/return arbitrarily
many values) onto L, and nargs (a caller can resume(co, unpack(bigTable)))
onto co. Moving more than the ~20-slot C-call guarantee corrupted the stack
-- the same latent bug strsplit had, now fixable thanks to the bound
Game::Lua::CheckStack.

Guard both xmoves; on overflow (>2048) push an error and return the error
path (false,msg for resume; re-raise for wrap). Verified in-game: a coroutine
yielding 100 values via unpack now returns all 100 cleanly (previously stack
corruption), and normal small returns still work.
2026-07-20 00:48:28 -05:00
Brues b5470e8d3d lua: add string.reverse/strrev, math.modf, math.huge
Close the remaining 5.0->5.1 string/math stdlib gaps:

- string.reverse(s) / strrev(s) — byte-wise reverse; 5.1 added it, 5.0's
  strlib lacks it. Registered on the string table and as the strrev global.
- math.modf(x) -> (integral, fractional) — integral truncated toward zero,
  fractional keeps x's sign (via C modf). Genuinely new in 5.1 (unlike fmod,
  which 1.12 has as math.mod).
- math.huge — positive-infinity constant (HUGE_VAL) set directly on the math
  table (it's a value, not a function).

All on both Lua states. Verified in-game: reverse, modf on +/- inputs, and
math.huge as a true infinity sentinel.
2026-07-20 00:31:20 -05:00
Brues c601967fdb lua: add strjoin / strtrim / strreplace
Round out the WoW string globals alongside strsplit. These are FrameXML Lua
in 3.3.5 (no C original — no name strings in the binary), so they're
implemented from standard semantics rather than disassembled:

- strjoin(delimiter, ...) — inverse of strsplit; concatenate varargs with the
  delimiter. nil/non-coercible pieces become "" so delimiters stay aligned.
- strtrim(str [, chars]) — trim a literal character set (default whitespace)
  from both ends via a two-ended scan; no allocation, embedded NULs preserved.
- strreplace(str, find, replace) -> (result, count) — plain-text (non-pattern)
  substring replace; empty find returns str unchanged. Not a stock WoW global
  (that build's compat script added it); documented as a ClassicAPI
  convenience complementing gsub.

Registered as globals on both Lua states. Verified in-game: join, empty
delimiter, trim (default + custom set), plain replace with count, and a
strsplit->strjoin round-trip.
2026-07-20 00:21:59 -05:00
Brues 5c063ade73 lua: add strsplit; bind lua_checkstack
Backport the WoW global strsplit(sep, str [, pieces]) to 1.12, ported from
3.3.5's strsplit (FUN_00816a60): split str on any character in sep, returning
the pieces as multiple values; pieces>0 caps the count with the unsplit
remainder as the final piece (0/omitted = unlimited, 1 = whole string).
Consecutive/trailing delimiters yield empty pieces, matching retail.

Two deliberate changes from the 3.3.5 source:
- Skip its lua_settop(L, 0). Popping the args un-roots the source string, so a
  GC step during a pushlstring could free the buffer we're still scanning.
  Leaving the args on the stack keeps the source GC-rooted; Lua returns the
  top N (the pieces) regardless of the args below.
- Bind lua_checkstack (0x006F2F30, __fastcall(L, size) -> int, found via its
  cmp ...,0x800 LUA_MAXCSTACK test) as Game::Lua::CheckStack and guard every
  push with it, erroring "strsplit(): Stack overflow" like 3.3.5 on genuine
  exhaustion (strsplit pushes an unbounded number of results). Reusable for
  any future multi-return C function.

Registered on both Lua states. Verified in-game across basic split, pieces
cap, multi-char separator set, empty middle piece, and empty-separator cases.
2026-07-20 00:05:24 -05:00
379 changed files with 41288 additions and 5505 deletions
+98 -16
View File
@@ -4,48 +4,130 @@ on:
push:
branches: ['**']
tags: ['v*']
# Manual escape hatch (e.g. when push webhooks are throttled during a
# GitHub Actions incident). Run from the Actions UI:
# - select a `vX.Y.Z` tag ref -> release build of that tag, OR
# - run from a branch and pass `tag: vX.Y.Z` -> release build that
# creates/attaches the release to that tag.
# Leave `tag` blank on a branch run for a plain dev build (artifact).
workflow_dispatch:
inputs:
tag:
description: 'Release tag to build (e.g. v2.0.0). Blank = dev build.'
required: false
default: ''
permissions:
contents: write
jobs:
# Decide which runner the build lands on. `runs-on` is fixed before a job
# starts and a job pointed at an offline self-hosted runner queues forever
# instead of failing over — so we probe first and hand the label down.
pick-runner:
runs-on: ubuntu-latest
outputs:
label: ${{ steps.pick.outputs.label }}
steps:
- name: Probe for an online self-hosted runner
id: pick
env:
# Listing runners needs repo-admin access; the default GITHUB_TOKEN
# does NOT have it. Create a PAT (classic: `repo`; fine-grained:
# Administration → read) and store it as the RUNNER_CHECK_TOKEN
# secret. If the secret is missing, we fall back to windows-latest.
GH_TOKEN: ${{ secrets.RUNNER_CHECK_TOKEN }}
run: |
label=windows-latest
if [ -n "$GH_TOKEN" ]; then
online=$(gh api "repos/${{ github.repository }}/actions/runners" \
--jq '[.runners[] | select(.status=="online") | select([.labels[].name] | index("self-hosted"))] | length' \
2>/dev/null || echo 0)
if [ "${online:-0}" -gt 0 ]; then
label=self-hosted
fi
fi
echo "Selected runner: $label"
echo "label=$label" >> "$GITHUB_OUTPUT"
build:
runs-on: windows-latest
needs: pick-runner
runs-on: ${{ needs.pick-runner.outputs.label }}
# Windows PowerShell 5.1 is always present; the runner's default `pwsh`
# (PowerShell Core) is not installed by default, and `shell: bash` on a
# self-hosted box resolves to the WSL launcher in System32.
defaults:
run:
shell: powershell
steps:
- uses: actions/checkout@v4
with:
submodules: recursive
- name: Stamp embedded TOC version
if: startsWith(github.ref, 'refs/tags/v')
shell: pwsh
# Resolve the effective version once so every step below agrees.
# is_release=true when a vX.Y.Z tag is in play (pushed tag,
# dispatched-from-tag, or a `tag` input); the embedded addon's
# `## Version:` and the compiled CLASSIC_API_VERSION are both
# stamped from it at embed/compile time (see cmake/embed_addon.cmake)
# — no source edit.
- name: Resolve version
id: ver
env:
INPUT_TAG: ${{ inputs.tag }}
run: |
$version = '${{ github.ref_name }}' -replace '^v',''
$toc = 'AddOns/!!!ClassicAPI/!!!ClassicAPI.toc'
(Get-Content $toc) -replace '^## Version:.*', "## Version: $version" |
Set-Content $toc
if ($env:GITHUB_EVENT_NAME -eq 'workflow_dispatch' -and $env:INPUT_TAG) {
$tag = $env:INPUT_TAG
} elseif ($env:GITHUB_REF -like 'refs/tags/v*') {
$tag = $env:GITHUB_REF_NAME
} else {
$tag = ''
}
$isRelease = if ($tag) { 'true' } else { 'false' }
Add-Content -Path $env:GITHUB_OUTPUT -Value "tag=$tag" -Encoding utf8
Add-Content -Path $env:GITHUB_OUTPUT -Value "is_release=$isRelease" -Encoding utf8
- name: Configure (tag)
if: startsWith(github.ref, 'refs/tags/v')
run: cmake -B build -A Win32 "-DCLASSICAPI_TAG=${{ github.ref_name }}"
- name: Configure (release)
if: steps.ver.outputs.is_release == 'true'
run: cmake -B build -A Win32 "-DCLASSICAPI_TAG=${{ steps.ver.outputs.tag }}"
- name: Configure (branch)
if: "!startsWith(github.ref, 'refs/tags/v')"
- name: Configure (dev)
if: steps.ver.outputs.is_release != 'true'
run: cmake -B build -A Win32
- name: Build
run: cmake --build build --config Release
- name: Upload artifact
if: "!startsWith(github.ref, 'refs/tags/v')"
if: steps.ver.outputs.is_release != 'true'
uses: actions/upload-artifact@v4
with:
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: startsWith(github.ref, 'refs/tags/v')
if: steps.ver.outputs.is_release == 'true'
uses: softprops/action-gh-release@v2
with:
files: build/Release/ClassicAPI.dll
tag_name: ${{ steps.ver.outputs.tag }}
files: |
build/Release/ClassicAPI.dll
build/Release/Crash-Symbols-Use-For-Debugging-${{ steps.ver.outputs.tag }}.zip
generate_release_notes: true
+3 -1
View File
@@ -45,4 +45,6 @@
# debug information files
*.dwo
CLAUDE.md
CLAUDE.md
/AddOns/!!!ClassicAPI/.classicapi-dev
+12
View File
@@ -7,6 +7,7 @@
locales\locales.xml
SecureTemplates.xml
Compat.lua
Util\Print.lua
Util\Mixin.lua
@@ -38,3 +39,14 @@ Util\LinkUtil.lua
Util\TradeSkillLink.lua
Util\IconDataProvider.lua
Util\GameTooltip.lua
Util\Rectangle.lua
Util\Vector2D.lua
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>
+80 -6
View File
@@ -15,12 +15,15 @@ if C_AddOns.DoesAddOnExist('pfUI') then
-- written for on their pfActionBar buttons, which breaks them. While
-- the actionbar module builds those buttons, temporarily wrap
-- CreateFrame to shadow HookScript with a falsy field so those forks
-- fall back to their vanilla SetScript path. The maintained "brues"
-- fork handles HookScript itself, so it's detected via the TOC
-- X-Website tag and left alone. The wrapper is removed as soon as the
-- actionbar module finishes loading so it affects nothing else.
-- fall back to their vanilla SetScript path. The wrapper is removed as
-- soon as the actionbar module finishes loading so it affects nothing
-- else. A pfUI is trusted to handle HookScript itself if EITHER signal
-- is present: the maintained "brues" X-Website tag OR the
-- `pfUI.handlesHookScript` capability flag. Without the website the
-- flag is required; if neither is present we apply the shim.
local website = GetAddOnMetadata("pfUI", "X-Website")
if not website or not strfind(website, 'brues') then
local hasBruesWebsite = website and strfind(website, 'brues')
if not hasBruesWebsite and not pfUI.handlesHookScript then
local _createFrame = CreateFrame
local HookedCreateFrame = function(frameType, name, parent, template)
local frame = _createFrame(frameType, name, parent, template)
@@ -38,4 +41,75 @@ if C_AddOns.DoesAddOnExist('pfUI') then
end)
end
end)
end
end
if C_AddOns.DoesAddOnExist("SpecialTalentUI") then
EventUtil.ContinueOnAddOnLoaded("SpecialTalentUI", CAPI_ApplyStandardColorGlobals)
end
-- ShaguTweaks libpredict register's TBC events that ClassicAPI backports.
if C_AddOns.DoesAddOnExist("ShaguTweaks") then
EventUtil.ContinueOnAddOnLoaded("ShaguTweaks", function()
local libp = ShaguTweaks.libpredict
if libp then
libp.sender:UnregisterEvent("COMBAT_LOG_EVENT_UNFILTERED")
libp.sender:UnregisterEvent("UNIT_SPELLCAST_START")
libp.sender:UnregisterEvent("UNIT_SPELLCAST_STOP")
libp.sender:UnregisterEvent("UNIT_SPELLCAST_FAILED")
libp.sender:UnregisterEvent("UNIT_SPELLCAST_INTERRUPTED")
libp.sender:UnregisterEvent("UNIT_SPELLCAST_SENT")
end
end)
end
-- Guard UPDATE_MOUSEOVER_UNIT handlers (issue #12).
function CAPI_MouseoverClearedCompat(frame)
if not frame then return end
local originalOnEvent = frame:GetScript("OnEvent")
frame:SetScript("OnEvent", function()
if event == "UPDATE_MOUSEOVER_UNIT" and not UnitExists("mouseover") then
return
end
if originalOnEvent then
originalOnEvent()
end
end)
end
CAPI_MouseoverClearedCompat(GameTooltip)
if C_AddOns.DoesAddOnExist("Puppeteer") then
EventUtil.ContinueOnAddOnLoaded("Puppeteer", function()
CAPI_MouseoverClearedCompat(PTEnemyUpdater)
end)
end
-- Cartographer_Notes gates a WorldMap release-spirit hook behind an
-- `if lua51` probe, treating a 5.1-capable Lua as a modern client that has
-- the WorldMapDeathRelease frame. Our 5.1 syntax backport makes the probe
-- pass, so Cartographer takes the modern branch and hooks a frame 1.12's
-- WorldMap never had (WorldMapDeathRelease:SetScript in OnEnable) -> nil
-- index. Provide an inert, hidden stub so the hook succeeds and does nothing
-- -- the same outcome vanilla had, where the branch never ran. The embedded
-- !!!ClassicAPI addon loads first, so the frame exists before Cartographer's
-- OnEnable.
if C_AddOns.DoesAddOnExist("Cartographer") and not WorldMapDeathRelease then
CreateFrame("Button", "WorldMapDeathRelease", WorldMapButton or UIParent):Hide()
end
-- ModernSpellBook (Spellbook\MSB_Spellbook.lua) re-hooks its "show all ranks"
-- checkbox by calling HookScript as a GLOBAL -- HookScript(frame, "OnClick",
-- handler). That global never existed on any client: HookScript has always
-- been the widget method frame:HookScript, which our DLL provides. MSB even
-- guards the call with `if checkbox.HookScript` (the method IS present), then
-- calls the global (nil) -> "attempt to call global 'HookScript'". Define the
-- global so the call resolves, forwarding to the method. Gated on the addon
-- being present so the global namespace stays clean otherwise.
if C_AddOns.DoesAddOnExist("ModernSpellBook") and not HookScript then
function HookScript(frame, script, handler)
if frame and frame.HookScript then
frame:HookScript(script, handler)
end
end
end
@@ -0,0 +1,40 @@
-- Server-side DoT-duration adjustments the 1.12 client is never told about.
--
-- ClassicAPI derives C_UnitAuras expirationTime for a debuff on another unit
-- from the cast packet (SMSG_SPELL_GO). The vanilla server never retransmits a
-- LATER server-side duration change on a non-self unit -- verified in the
-- server source: the change runs through SetAuraDuration / RefreshHolder, and
-- the only duration packet (SMSG_UPDATE_AURA_DURATION) is self-scoped to the
-- aura-bearer, so an observing caster hears nothing (and on a mob the packet
-- isn't even built). We can't hear the change, but we DO see the *triggering*
-- cast, so a registered rule mirrors the server's edit off that trigger.
--
-- A trigger keyed to a whole class of spells (rather than a named ability) is
-- registered from Lua with RegisterAuraDurationModifierByTrigger, matched by
-- SpellFamilyName + school index (see Shadow Weaving below). Exact-spellID
-- triggers are registered from C++ (Aura::Source::AddDurationMod): Turtle's
-- Conflagrate -> Immolate and Molten Blast -> Flame Shock live in the DLL
-- (src/turtle/DurationMods.cpp), as does Carnage, whose per-combo-point roll no
-- rule can express (src/turtle/Carnage.cpp).
-- Shadow Weaving (priest): any shadow-school priest cast refreshes the caster's
-- Shadow Vulnerability (15258, 15s, stacks to 5). The 1->5 stack changes fire
-- OnAuraStacksChanged (already handled); the 5->5 refresh is the silent blind
-- spot this covers. Matched by family(6) + school(5) so it spans every priest
-- shadow spell/rank incl. Turtle additions, excluding holy/discipline casts
-- (Smite, heals) which are priest-family but not shadow-school.
--
-- Gated on the 5/5 talent (15334), where the proc is 100% -> deterministic;
-- below 5/5 it's chance-based and inferring on every cast would over-refresh.
-- Deferred to SPELLS_CHANGED because talent/spell state isn't ready at load,
-- and re-checked there so a respec INTO 5/5 registers without a reload.
-- Caveat: DoT *ticks* (SW:P / Devouring Plague) refresh it server-side too but
-- emit no cast packet, so a pure DoT-only phase (no direct casts) under-counts.
local PRIEST, SHADOW = 6, 5
local SW_FLAG, SW_ICON = tonumber("4000000", 16), 9 -- 15258 SpellFamilyFlags + icon
EventUtil.RegisterOnceFrameEventAndCallback("SPELLS_CHANGED", function()
if IsPlayerSpell(15334) then
C_UnitAuras.RegisterAuraDurationModifierByTrigger(PRIEST, SHADOW, PRIEST, SW_FLAG, SW_ICON, "refresh")
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
+19 -9
View File
@@ -26,10 +26,17 @@ local function GenerateClosure(func, ...)
for i = 1, boundN do bound[i] = arg[i] end
return function(...)
local callN = arg.n
local merged = {}
for i = 1, boundN do merged[i] = bound[i] end
for i = 1, callN do merged[boundN + i] = arg[i] end
return func(unpack(merged, 1, boundN + callN))
-- Forward bound + call args faithfully, embedded/leading nils and all.
-- The ONLY thing that makes unpack span holes on this Lua 5.0 build is
-- a `.n` field: unpack takes its count from getn (which returns t.n)
-- and ignores any explicit i/j, so a `.n`-less table stops dead at the
-- first hole (verified: unpack({1, 1, nil, 1}, 1, 4) yields just 1, 1).
local all = {}
local n = 0
for i = 1, boundN do n = n + 1; all[n] = bound[i] end
for i = 1, callN do n = n + 1; all[n] = arg[i] end
all.n = n
return func(unpack(all))
end
end
@@ -145,11 +152,14 @@ function CallbackRegistryMixin:TriggerEvent(event, ...)
if funcs then
for owner, func in pairs(funcs) do
-- Stock 3.3.5 passes the owner as the first arg so callers can
-- distinguish multiple registrations of the same function.
local n = arg.n
local merged = { owner }
for i = 1, n do merged[i + 1] = arg[i] end
func(unpack(merged, 1, n + 1))
-- distinguish multiple registrations of the same function. `arg`
-- carries its own `.n`, so unpack(arg) forwards the payload with
-- leading/embedded nils intact. Do NOT instead merge into a
-- `{ owner, ... }` table and unpack that WITHOUT setting `.n`: this
-- build's unpack counts via getn, so a `.n`-less table collapses at
-- the first hole (verified in-game: reload's PLAYER_ENTERING_WORLD,
-- arg1=nil arg2=1, dropped the payload).
func(owner, unpack(arg))
end
end
end
+24 -44
View File
@@ -24,8 +24,14 @@ function ColorMixin:GetRGB()
return self.r, self.g, self.b
end
function ColorMixin:GetHSL()
local r, g, b, a = self.r, self.g, self.b, self.a
local h, s, l = C_ColorUtil.ConvertHSVToHSL(C_ColorUtil.ConvertRGBToHSV(r, g, b))
return h, s, l, a or 1
end
function ColorMixin:GetRGBAsBytes()
return self.r * 255, self.g * 255, self.b * 255
return Round(self.r * 255), Round(self.g * 255), Round(self.b * 255)
end
function ColorMixin:GetRGBA()
@@ -33,14 +39,14 @@ function ColorMixin:GetRGBA()
end
function ColorMixin:GetRGBAAsBytes()
return self.r * 255, self.g * 255, self.b * 255, (self.a or 1) * 255
return Round(self.r * 255), Round(self.g * 255), Round(self.b * 255), Round((self.a or 1) * 255)
end
function ColorMixin:SetRGBA(r, g, b, a)
self.r = r
self.g = g
self.b = b
self.a = a
self.a = a or 1
end
function ColorMixin:SetRGB(r, g, b)
@@ -48,56 +54,27 @@ function ColorMixin:SetRGB(r, g, b)
end
function ColorMixin:GenerateHexColor()
return string.format("ff%.2x%.2x%.2x", self:GetRGBAsBytes())
return C_ColorUtil.GenerateTextColorCode(self)
end
function ColorMixin:GenerateHexColorNoAlpha()
return string.format("%.2X%.2X%.2X", self:GetRGBAsBytes())
end
function ColorMixin:GenerateHexColorMarkup()
return "|c"..self:GenerateHexColor()
end
function ColorMixin:GenerateHexColorNoAlpha()
return string.format("%.2x%.2x%.2x", self:GetRGBAsBytes())
end
function ColorMixin:GetHSL()
local r, g, b, a = self.r, self.g, self.b, self.a
local max, min = math.max(r, g, b), math.min(r, g, b)
local h, s, l
l = (max + min) / 2
if max == min then
h, s = 0, 0 -- achromatic
else
local d = max - min
if l > 0.5 then
s = d / (2 - max - min)
else
s = d / (max + min)
end
if max == r then
h = (g - b) / d
if g < b then
h = h + 6
end
elseif max == g then
h = (b - r) / d + 2
elseif max == b then
h = (r - g) / d + 4
end
h = h / 6
end
return h, s, l, a or 1
end
function ColorMixin:WrapTextInColorCode(text)
return WrapTextInColorCode(text, self:GenerateHexColor())
return C_ColorUtil.WrapTextInColor(text, self)
end
function WrapTextInColorCode(text, colorHexString)
return string.format("|c%s%s|r", colorHexString, text)
return C_ColorUtil.WrapTextInColorCode(text, colorHexString)
end
function WrapTextInColor(text, color)
return WrapTextInColorCode(text, color:GenerateHexColor())
return C_ColorUtil.WrapTextInColor(text, color)
end
function ColorMixin:WrapTextInColorTableCode(text)
@@ -108,11 +85,14 @@ for _, v in pairs(ITEM_QUALITY_COLORS) do
v.color = CreateColor(v.r, v.g, v.b, 1)
end
do
function CAPI_ApplyStandardColorGlobals()
local envTbl = _G
for _, dbColor in ipairs(C_UIColor.GetColors()) do
local color = CreateColor(dbColor.color.r, dbColor.color.g, dbColor.color.b, dbColor.color.a)
envTbl[dbColor.baseTag] = color
envTbl[dbColor.baseTag.."_CODE"] = color:GenerateHexColorMarkup()
if not envTbl[dbColor.baseTag] or not envTbl[dbColor.baseTag].GetRGB then
local color = CreateColor(dbColor.color.r, dbColor.color.g, dbColor.color.b, dbColor.color.a)
envTbl[dbColor.baseTag] = color
envTbl[dbColor.baseTag.."_CODE"] = color:GenerateHexColorMarkup()
end
end
end
CAPI_ApplyStandardColorGlobals()
+26 -13
View File
@@ -1,3 +1,8 @@
COLOR_FORMAT_RGBA = "RRGGBBAA"
COLOR_FORMAT_RGB = "RRGGBB"
COLOR_FORMAT_ARGB = "AARRGGBB"
FONT_COLOR_CODE_CLOSE = "|r"
do
local ColorMixin = ColorMixin
for _, classColor in pairs(RAID_CLASS_COLORS) do
@@ -12,30 +17,40 @@ function ExtractColorValueFromHex(str, index)
end
function CreateColorFromHexString(hexColor)
if string.len(hexColor) == 8 then
if string.len(hexColor) == string.len(COLOR_FORMAT_ARGB) then
local a, r, g, b = ExtractColorValueFromHex(hexColor, 1), ExtractColorValueFromHex(hexColor, 3), ExtractColorValueFromHex(hexColor, 5), ExtractColorValueFromHex(hexColor, 7);
return CreateColor(r, g, b, a);
else
error("CreateColorFromHexString input must be hexadecimal digits in this format: AARRGGBB.", 2);
end
error("CreateColorFromHexString input must be hexadecimal digits in this format: "..COLOR_FORMAT_ARGB..".", 2);
return nil;
end
function CreateColorFromRGBAHexString(hexColor)
if string.len(hexColor) == 8 then
if string.len(hexColor) == string.len(COLOR_FORMAT_RGBA) then
local r, g, b, a = ExtractColorValueFromHex(hexColor, 1), ExtractColorValueFromHex(hexColor, 3), ExtractColorValueFromHex(hexColor, 5), ExtractColorValueFromHex(hexColor, 7);
return CreateColor(r, g, b, a);
else
error("CreateColorFromRGBAHexString input must be hexadecimal digits in this format: RRGGBBAA.", 2);
end
error("CreateColorFromRGBAHexString input must be hexadecimal digits in this format: "..COLOR_FORMAT_RGBA..".", 2);
return nil;
end
function CreateColorFromRGBHexString(hexColor)
if string.len(hexColor) == 6 then
if string.len(hexColor) == string.len(COLOR_FORMAT_RGB) then
local r, g, b = ExtractColorValueFromHex(hexColor, 1), ExtractColorValueFromHex(hexColor, 3), ExtractColorValueFromHex(hexColor, 5);
return CreateColor(r, g, b, 1);
else
error("CreateColorFromRGBHexString input must be hexadecimal digits in this format: RRGGBB.", 2);
end
error("CreateColorFromRGBHexString input must be hexadecimal digits in this format: "..COLOR_FORMAT_RGB..".", 2);
return nil;
end
function CreateColorFromBestRGBHexString(hexColor)
if string.len(hexColor) == string.len(COLOR_FORMAT_RGBA) then
return CreateColorFromRGBAHexString(hexColor);
elseif string.len(hexColor) == string.len(COLOR_FORMAT_RGB) then
return CreateColorFromRGBHexString(hexColor);
end
error("CreateColorFromBestRGBHexString input must be hexadecimal digits in either of these formats: "..COLOR_FORMAT_RGBA.." / "..COLOR_FORMAT_RGB..".", 2);
return nil;
end
function CreateColorFromBytes(r, g, b, a)
@@ -68,9 +83,7 @@ end
function GetClassColoredTextForUnit(unit, text)
local _, classFilename = UnitClass(unit);
local color = GetClassColorObj(classFilename);
if (color) then
return color:WrapTextInColorCode(text);
end
return color and color:WrapTextInColorCode(text) or text;
end
function GetFactionColor(factionGroupTag)
@@ -82,7 +95,7 @@ function GetFactionColor(factionGroupTag)
end
function RGBToColorCode(r, g, b)
return string.format("|cff%.2x%.2x%.2x", r * 255, g * 255, b * 255);
return string.format("|cff%02x%02x%02x", r * 255, g * 255, b * 255);
end
function RGBTableToColorCode(rgbTable)
+6 -1
View File
@@ -132,4 +132,9 @@ INSCRIPTION_AVAILABLE = false;
EQUIPPED_FIRST = 1;
EQUIPPED_LAST = 19;
BASE_MOVEMENT_SPEED = 7;
BASE_MOVEMENT_SPEED = 7;
UPPER_LEFT_VERTEX = 1;
LOWER_LEFT_VERTEX = 2;
UPPER_RIGHT_VERTEX = 3;
LOWER_RIGHT_VERTEX = 4;
+10 -8
View File
@@ -2,12 +2,15 @@
-- 1.12 / Lua 5.0.
--
-- 5.0 notes: no `#`/`%`/string-method syntax — uses string.*, math.mod.
-- The currency/cost helpers (C_CurrencyInfo etc.) are omitted. GetMoneyString
-- uses text symbols, not coin texture markup (markup doesn't render in 1.12).
-- GetMoneyString gives the text-symbol form ("12g 34s 56c"). The coin-icon form
-- (GetCoinTextureString / C_CurrencyInfo.GetCoinTextureString) is provided by the
-- ClassicAPI DLL in C++ (src/currency/CoinText.cpp) — like retail, where it's a
-- C function — now that the inline-texture backport renders |T markup in 1.12.
--
-- The FrameXML format-string globals these functions consume
-- (LARGE_NUMBER_SEPERATOR, PERCENTAGE_STRING, GOLD_AMOUNT_SYMBOL, …) are
-- defined in the locale layer (locales/enUS.lua), which loads first.
-- (LARGE_NUMBER_SEPERATOR, PERCENTAGE_STRING, GOLD_AMOUNT_SYMBOL,
-- GOLD_AMOUNT_TEXTURE, …) are defined in the locale layer (locales/enUS.lua),
-- which loads first.
COPPER_PER_SILVER = 100;
SILVER_PER_GOLD = 100;
@@ -73,10 +76,9 @@ function SplitTextIntoHeaderAndNonHeader(text)
end
-- (money, separateThousands [, checkGoldThreshold, showZeroAsGold])
-- Text-symbol form ("12g 34s 56c"); the coin-texture form from the
-- reference is dropped since texture markup doesn't render in 1.12. The
-- checkGoldThreshold arg is accepted for signature compatibility but not
-- applied (it relied on modern Constants tables).
-- Text-symbol form ("12g 34s 56c"); for the coin-icon form use
-- GetCoinTextureString below. The checkGoldThreshold arg is accepted for
-- signature compatibility but not applied (it relied on modern Constants tables).
function GetMoneyString(money, separateThousands, checkGoldThreshold, showZeroAsGold)
local gold = math.floor(money / COPPER_PER_GOLD);
local silver = math.floor(math.mod(money, COPPER_PER_GOLD) / COPPER_PER_SILVER);
+16 -14
View File
@@ -2,7 +2,9 @@
--
-- Implementation differences from the modern source:
-- - Lua 5.0 has no select() and no `...` as expression; use the
-- implicit `arg` table (arg.n + arg[i]) and unpack(arg, 1, arg.n).
-- implicit `arg` table (arg.n + arg[i]). unpack(arg) already spans
-- arg[1..arg.n] via getn — this build's unpack ignores explicit i/j
-- args, so the count must live in the table's `.n`.
-- - Lua 5.0 has no `#` operator; use table.getn().
-- - 5.0's xpcall is strictly (f, handler) — no trailing args. We wrap
-- the script call in a local closure that captures frame + arg.
@@ -32,7 +34,7 @@ function ExecuteFrameScript(frame, scriptName, ...)
if script then
local args = arg;
local function invoke()
return script(frame, unpack(args, 1, args.n));
return script(frame, unpack(args));
end
xpcall(invoke, CallErrorHandler);
end
@@ -45,7 +47,7 @@ function CallMethodOnNearestAncestor(self, methodName, ...)
end
if ancestor then
return true, ancestor[methodName](ancestor, unpack(arg, 1, arg.n));
return true, ancestor[methodName](ancestor, unpack(arg));
end
return false;
@@ -58,7 +60,7 @@ function GetValueOrCallFunction(tbl, key, ...)
local value = tbl[key];
if type(value) == "function" then
return value(unpack(arg, 1, arg.n));
return value(unpack(arg));
else
return value;
end
@@ -117,12 +119,12 @@ function IterateTables(iteratorFunction, ...)
end
local s_passThroughClosureGenerators = {
function(f) return function(...) return f(unpack(arg, 1, arg.n)); end; end,
function(f, a) return function(...) return f(a, unpack(arg, 1, arg.n)); end; end,
function(f, a, b) return function(...) return f(a, b, unpack(arg, 1, arg.n)); end; end,
function(f, a, b, c) return function(...) return f(a, b, c, unpack(arg, 1, arg.n)); end; end,
function(f, a, b, c, d) return function(...) return f(a, b, c, d, unpack(arg, 1, arg.n)); end; end,
function(f, a, b, c, d, e) return function(...) return f(a, b, c, d, e, unpack(arg, 1, arg.n)); end; end,
function(f) return function(...) return f(unpack(arg)); end; end,
function(f, a) return function(...) return f(a, unpack(arg)); end; end,
function(f, a, b) return function(...) return f(a, b, unpack(arg)); end; end,
function(f, a, b, c) return function(...) return f(a, b, c, unpack(arg)); end; end,
function(f, a, b, c, d) return function(...) return f(a, b, c, d, unpack(arg)); end; end,
function(f, a, b, c, d, e) return function(...) return f(a, b, c, d, e, unpack(arg)); end; end,
};
local s_flatClosureGenerators = {
@@ -138,7 +140,7 @@ local function GenerateClosureInternal(generatorArray, f, ...)
local count = arg.n;
local generator = generatorArray[count + 1];
if generator then
return generator(f, unpack(arg, 1, count));
return generator(f, unpack(arg));
end
assertsafe(false, "Closure generation does not support more than " .. (table.getn(generatorArray) - 1) .. " parameters");
@@ -147,14 +149,14 @@ end
-- Syntactic sugar for function(...) return f(a, b, c, ...); end
function GenerateClosure(f, ...)
return GenerateClosureInternal(s_passThroughClosureGenerators, f, unpack(arg, 1, arg.n));
return GenerateClosureInternal(s_passThroughClosureGenerators, f, unpack(arg));
end
-- Generates a closure with the specified arguments that will ignore extra arguments when called later. Useful for passing
-- through callbacks to APIs where we don't want extra arguments to be passed through, i.e. simple OnClick scripts.
-- This is equivalent to: function() return f(a, b, c); end INSTEAD OF function(...) return f(a, b, c, ...); end
function GenerateFlatClosure(f, ...)
return GenerateClosureInternal(s_flatClosureGenerators, f, unpack(arg, 1, arg.n));
return GenerateClosureInternal(s_flatClosureGenerators, f, unpack(arg));
end
function RunNextFrame(callback)
@@ -166,7 +168,7 @@ FunctionUtil = {};
function FunctionUtil.SafeInvokeMethod(table, methodName, ...)
local method = table[methodName];
if method then
return method(table, unpack(arg, 1, arg.n));
return method(table, unpack(arg));
end
return nil;
@@ -1,89 +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)
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);
-- 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();
+2 -5
View File
@@ -216,8 +216,7 @@ function ItemMixin:GetItemLink() -- requires item data to be loaded
end
if self.itemID then
local _, link = GetItemInfo(self.itemID)
return link
return (select(2, C_Item.GetItemInfo(self.itemID)));
end
if not self:IsItemEmpty() then
@@ -299,9 +298,7 @@ end
function ItemMixin:GetInventoryTypeName()
if not self:IsItemEmpty() then
-- GetItemInfoInstant returns: itemID, itemType, itemSubType, equipLoc, ...
local _, _, _, equipLoc = C_Item.GetItemInfoInstant(self:GetItemID())
return equipLoc
return select(4, C_Item.GetItemInfoInstant(self:GetItemID()));
end
end
+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
+38 -3
View File
@@ -11,12 +11,19 @@ local random = math.random
local atan2 = math.atan2
local sqrt = math.sqrt
math.huge = 1 / 0
MathUtil =
{
Epsilon = .000001,
};
function CreateCounter(initialCount)
MathUtil.ApproxZero = MathUtil.Epsilon;
MathUtil.ApproxOne = 1.0 - MathUtil.Epsilon;
function CreateCounter(initialCount, step)
local count = initialCount or 0
step = step or 1
return function()
count = count + 1
count = count + step
return count
end
end
@@ -130,3 +137,31 @@ end
function CalculateAngleBetween(x1, y1, x2, y2)
return atan2(y2 - y1, x2 - x1)
end
AccumulatorMixin = {};
function AccumulatorMixin:Init(initialCount)
self.count = initialCount or 0;
end
function AccumulatorMixin:Add(count)
self.count = self.count + count;
return self.count;
end
function AccumulatorMixin:Subtract(count)
self.count = self.count - count;
return self.count;
end
function AccumulatorMixin:Count()
return self.count;
end
function AccumulatorMixin:Reset(resetCount)
self.count = resetCount or 0;
end
function CreateAccumulator(initialCount)
return CreateAndInitFromMixin(AccumulatorMixin, initialCount);
end
+7 -20
View File
@@ -1,24 +1,11 @@
-- Backport of 3.3.5 FrameXML/Mixin.lua to vanilla 1.12 / Lua 5.0.
-- `Mixin` and `CreateFromMixins` are engine (DLL) natives — see
-- src/baselib/Mixin.cpp — so they exist whenever ClassicAPI is injected,
-- even with this addon disabled, mirroring retail where they live in
-- TableUtil as C functions. Only the composite helper (which calls a Lua
-- `:Init` method) stays here, like retail's Mixin.lua.
--
-- Implementation differences from the 3.3.5 source:
-- - Lua 5.0 has no select(); inside a vararg function the args are
-- gathered into an implicit `arg` table (`arg.n` for the count).
function Mixin(object, ...)
for i = 1, arg.n do
local mixin = arg[i]
if mixin then
for k, v in pairs(mixin) do
object[k] = v
end
end
end
return object
end
function CreateFromMixins(...)
return Mixin({}, unpack(arg))
end
-- Lua 5.0 note: varargs arrive in the implicit `arg` table (`arg.n` count),
-- so we forward them with unpack(arg) rather than 5.1's `...`.
function CreateAndInitFromMixin(mixin, ...)
local object = CreateFromMixins(mixin)
+1 -1
View File
@@ -28,7 +28,7 @@ function PlayerUtil.GetClassColor()
end
function PlayerUtil.GetCurrentSpeed()
return GetUnitSpeed("player") / 7 * 100
return GetUnitSpeed("player") / BASE_MOVEMENT_SPEED * 100
end
function GetPlayerGuid()
+6 -5
View File
@@ -14,7 +14,8 @@
-- consumer addons doesn't fail; they'll error at Acquire() time
-- if anything actually invokes them.
-- - Lua 5.0 has no `...` as expression; the converter mixins use
-- `unpack(arg, 1, arg.n)` to forward varargs.
-- `unpack(arg)` to forward varargs (spans arg[1..arg.n] via getn;
-- this build's unpack ignores explicit i/j — the count lives in `.n`).
-- - assertsafe / nop aren't globals in stock 1.12; defined as local
-- fallbacks (same pattern as FunctionUtil.lua).
@@ -381,12 +382,12 @@ function FramePoolCollectionConverterMixin:CreatePoolKeyFromPoolArgs(args)
end
function FramePoolCollectionConverterMixin:GetOrCreatePool(...)
local args = FramePoolCollection_ArgsToTable(unpack(arg, 1, arg.n));
local args = FramePoolCollection_ArgsToTable(unpack(arg));
return self:GetOrCreatePoolWithArgs(args);
end
function FramePoolCollectionConverterMixin:CreatePool(...)
local args = FramePoolCollection_ArgsToTable(unpack(arg, 1, arg.n));
local args = FramePoolCollection_ArgsToTable(unpack(arg));
return self:CreatePoolWithArgs(args);
end
@@ -432,12 +433,12 @@ local function FontStringPoolCollection_ArgsToTable(parent, layer, subLayer, tem
end
function FontStringPoolCollectionMixin:GetOrCreatePool(...)
local args = FontStringPoolCollection_ArgsToTable(unpack(arg, 1, arg.n));
local args = FontStringPoolCollection_ArgsToTable(unpack(arg));
return self:GetOrCreatePoolWithArgs(args);
end
function FontStringPoolCollectionMixin:CreatePool(...)
local args = FontStringPoolCollection_ArgsToTable(unpack(arg, 1, arg.n));
local args = FontStringPoolCollection_ArgsToTable(unpack(arg));
return self:CreatePoolWithArgs(args);
end
+141
View File
@@ -0,0 +1,141 @@
RectangleMixin = {};
function CreateRectangle(left, right, top, bottom)
local rectangle = CreateFromMixins(RectangleMixin);
rectangle:OnLoad(left, right, top, bottom);
return rectangle;
end
function RectangleMixin:OnLoad(left, right, top, bottom)
self:SetSides(left or 0.0, right or 0.0, top or 0.0, bottom or 0.0);
end
function RectangleMixin:SetSides(left, right, top, bottom)
self.left = left;
self.right = right;
self.top = top;
self.bottom = bottom;
end
function RectangleMixin:Reset()
self.left = 0.0;
self.right = 0.0;
self.top = 0.0;
self.bottom = 0.0;
end
function RectangleMixin:Stretch(x, y)
self:Adjust(-x, x, -y, y);
end
function RectangleMixin:Move(x, y)
self:Adjust(x, x, y, y);
end
function RectangleMixin:Adjust(left, right, top, bottom)
self.left = self.left + left;
self.right = self.right + right;
self.top = self.top + top;
self.bottom = self.bottom + bottom;
end
function RectangleMixin:IsEmpty()
return self.left == self.right or self.top == self.bottom;
end
function RectangleMixin:IsInsideOut()
return self.left > self.right or self.top > self.bottom;
end
function RectangleMixin:EnclosesPoint(x, y)
return x >= self.left and x <= self.right and y >= self.top and y <= self.bottom;
end
function RectangleMixin:EnclosesRect(otherRect)
return self:EnclosesPoint(otherRect:GetLeft(), otherRect:GetTop()) and self:EnclosesPoint(otherRect:GetRight(), otherRect:GetBottom());
end
function RectangleMixin:IntersectsRect(otherRect)
return not (
self.left > otherRect.right or
self.right < otherRect.left or
self.top > otherRect.bottom or
self.bottom < otherRect.top
);
end
function RectangleMixin:GetTop()
return self.top;
end
function RectangleMixin:GetBottom()
return self.bottom;
end
function RectangleMixin:GetLeft()
return self.left;
end
function RectangleMixin:GetRight()
return self.right;
end
function RectangleMixin:GetWidth()
return self.right - self.left;
end
function RectangleMixin:GetHeight()
return self.bottom - self.top;
end
function RectangleMixin:GetCenter()
return Lerp(self.left, self.right, .5), Lerp(self.top, self.bottom, .5);
end
function RectangleMixin:GetCenterH()
return Lerp(self.left, self.right, .5);
end
function RectangleMixin:GetCenterV()
return Lerp(self.top, self.bottom, .5);
end
function RectangleMixin:SetTop(top)
self.top = top;
end
function RectangleMixin:SetBottom(bottom)
self.bottom = bottom;
end
function RectangleMixin:SetLeft(left)
self.left = left;
end
function RectangleMixin:SetRight(right)
self.right = right;
end
function RectangleMixin:SetWidth(width)
self.right = self.left + width;
end
function RectangleMixin:SetHeight(height)
self.bottom = self.top + height;
end
function RectangleMixin:SetSize(width, height)
self:SetWidth(width);
self:SetHeight(height);
end
function RectangleMixin:SetCenter(x, y)
local width = self:GetWidth();
local height = self:GetHeight();
self.left = x - width * .5;
self.right = x + width * .5;
self.top = y - height * .5;
self.bottom = y + height * .5;
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
@@ -0,0 +1,125 @@
-- Unit right-click menu for attribute-driven unit frames.
--
-- Backs the `menu` / `togglemenu` click verb of the Frame:SetAttribute
-- backport (see src/frame/Attributes.cpp). When a frame with a `unit`
-- attribute resolves a click to the `menu` verb, the DLL calls
-- ClassicAPI_ToggleUnitMenu(unit), which pops the standard unit dropdown at
-- the cursor -- the same UnitPopup menu Blizzard's PlayerFrame / TargetFrame /
-- PartyMemberFrame show on right-click. Lets a unit frame express its
-- right-click menu purely as an attribute (e.g. type2 = "menu") instead of a
-- hand-rolled OnClick handler. pfUI's unit frames use exactly this path
-- (api/unitframes.lua -> ClassicAPI_ToggleUnitMenu).
--
-- The unit -> menu-type resolution mirrors Blizzard's own generic resolver
-- (TargetFrameDropDown_Initialize in FrameXML): self -> SELF, pet -> PET, a
-- grouped player -> PARTY (whisper / inspect / trade / follow / promote / ...),
-- any other player -> PLAYER (adds INVITE), anything else (NPC) ->
-- RAID_TARGET_ICON (the raid-marker list). Vanilla's stock UI never wires a
-- unit to the "RAID" menu (there are no clickable raid unit frames in 1.12),
-- and PARTY carries the options players actually want on a grouped member, so
-- grouped players (party or raid) use PARTY -- matching how pfUI drives its
-- raid menu.
--
-- Focus / Clear Focus: we add one contextual entry -- "Set Focus" when the
-- unit is not the current focus, "Clear Focus" when it is -- for every real,
-- non-self unit, NPCs included. This is done entirely inside our own dropdown:
-- no hooksecurefunc, and no mutation of Blizzard's shared UnitPopupMenus tables
-- (so default frames and other addons are unaffected). Because
-- UnitPopup_ShowMenu hardwires every button's click to UnitPopup_OnClick (which
-- does not know our keys), we add the Focus button ourselves with our own func.
-- To keep Cancel last, we show the standard menu MINUS its trailing Cancel,
-- then append Focus and Cancel.
--
-- This also fixes NPCs opening no menu at all: an NPC resolves to the
-- RAID_TARGET_ICON menu, whose entries UnitPopup_HideButtons hides entirely
-- unless you are in a group and can place markers -- so solo the menu was
-- empty and never opened. Appending Focus + Cancel ourselves guarantees the
-- menu always opens (markers still show when they are allowed).
--
-- Backed by the DLL's FocusUnit / ClearFocus globals and the `focus` unit
-- token (src/unit/Focus.cpp). Labels come from the localized SET_FOCUS /
-- CLEAR_FOCUS globals (locales/*.lua).
local dropdown;
-- Scratch menu key we own, rebuilt each open as a copy of the resolved base
-- menu with its trailing CANCEL removed. Kept out of Blizzard's tables so the
-- stock menus are never mutated.
local SCRATCH_MENU = "CLASSICAPI_UNITMENU";
local function EnsureDropdown()
if not dropdown then
dropdown = CreateFrame("Frame", "ClassicAPIUnitMenuDropDown", UIParent, "UIDropDownMenuTemplate");
dropdown.displayMode = "MENU";
end
return dropdown;
end
local function ResolveMenu(unit)
if UnitIsUnit(unit, "player") then
return "SELF";
elseif UnitIsUnit(unit, "pet") then
return "PET";
elseif UnitIsPlayer(unit) then
if UnitInParty(unit) or UnitInRaid(unit) then
return "PARTY";
end
return "PLAYER";
end
return "RAID_TARGET_ICON";
end
-- Append the contextual Focus / Clear Focus button for `unit`, with our own
-- click func. Skipped for the player themselves (focusing yourself is a no-op).
local function AddFocusButton(unit)
if UnitIsUnit(unit, "player") then
return;
end
local info = {};
info.notCheckable = 1;
if UnitExists("focus") and UnitIsUnit(unit, "focus") then
info.text = CLEAR_FOCUS;
info.func = function() ClearFocus(); end;
else
info.text = SET_FOCUS;
info.func = function() FocusUnit(unit); end;
end
UIDropDownMenu_AddButton(info);
end
local function AddCancelButton()
local info = {};
info.text = CANCEL;
info.notCheckable = 1;
info.func = function() end;
UIDropDownMenu_AddButton(info);
end
function ClassicAPI_ToggleUnitMenu(unit)
if not unit or not UnitExists(unit) then
return;
end
local which = ResolveMenu(unit);
local dd = EnsureDropdown();
dd.initialize = function(level)
level = level or UIDROPDOWNMENU_MENU_LEVEL or 1;
if level > 1 then
UnitPopup_ShowMenu(dd, which, unit);
return;
end
local base = UnitPopupMenus[which];
local scratch = {};
for _, value in ipairs(base) do
if value ~= "CANCEL" then
table.insert(scratch, value);
end
end
UnitPopupMenus[SCRATCH_MENU] = scratch;
UnitPopup_ShowMenu(dd, SCRATCH_MENU, unit);
AddFocusButton(unit);
AddCancelButton();
end
ToggleDropDownMenu(1, nil, dd, "cursor");
end
@@ -7,19 +7,42 @@ function RegisterNewSlashCommand(callback, command, commandAlias)
end
SlashCmdList["FOCUS"] = function(msg)
if msg == "" then
FocusUnit();
else
FocusUnit(msg);
end
if ( msg == "" ) then
FocusUnit();
else
local action, target = SecureCmdOptionParse(msg);
if ( action ) then
if ( not target or target == "focus" ) then
target = action;
end
FocusUnit(target);
end
end
end
SlashCmdList["CLEARFOCUS"] = function(msg)
ClearFocus()
if ( SecureCmdOptionParse(msg) ) then
ClearFocus();
end
end
SlashCmdList["EQUIP_SET"] = function(msg)
if msg ~= "" then
C_EquipmentSet.UseEquipmentSet(C_EquipmentSet.GetEquipmentSetID(msg))
end
local set = SecureCmdOptionParse(msg);
if ( set and set ~= "" ) then
C_EquipmentSet.UseEquipmentSet(C_EquipmentSet.GetEquipmentSetID(set))
end
end
SlashCmdList["CLICK"] = function(msg)
local action = SecureCmdOptionParse(msg);
if ( action and action ~= "" ) then
local name, mouseButton = string.match(action, "([^%s]+)%s+([^%s]+)");
if ( not name ) then
name = action;
end
local button = GetClickFrame(name);
if ( button and button:IsObjectType("Button") ) then
button:Click(mouseButton);
end
end
end
+1 -1
View File
@@ -431,7 +431,7 @@ function SafePack(...)
end
function SafeUnpack(tbl)
return unpack(tbl, 1, tbl.n)
return unpack(tbl)
end
-- Returns the length of a table, accounting for the possibility of a table constructed using SafePack.
+94
View File
@@ -0,0 +1,94 @@
TextureUtil = {};
function SetClampedTextureRotation(texture, rotationDegrees)
if (rotationDegrees ~= 0 and rotationDegrees ~= 90 and rotationDegrees ~= 180 and rotationDegrees ~= 270) then
error("SetRotation: rotationDegrees must be 0, 90, 180, or 270");
return;
end
if not (texture.rotationDegrees) then
texture.origTexCoords = {texture:GetTexCoord()};
texture.origWidth = texture:GetWidth();
texture.origHeight = texture:GetHeight();
end
if (texture.rotationDegrees == rotationDegrees) then
return;
end
texture.rotationDegrees = rotationDegrees;
if (rotationDegrees == 0 or rotationDegrees == 180) then
texture:SetWidth(texture.origWidth);
texture:SetHeight(texture.origHeight);
else
texture:SetWidth(texture.origHeight);
texture:SetHeight(texture.origWidth);
end
if (rotationDegrees == 0) then
texture:SetTexCoord( texture.origTexCoords[1], texture.origTexCoords[2],
texture.origTexCoords[3], texture.origTexCoords[4],
texture.origTexCoords[5], texture.origTexCoords[6],
texture.origTexCoords[7], texture.origTexCoords[8] );
elseif (rotationDegrees == 90) then
texture:SetTexCoord( texture.origTexCoords[3], texture.origTexCoords[4],
texture.origTexCoords[7], texture.origTexCoords[8],
texture.origTexCoords[1], texture.origTexCoords[2],
texture.origTexCoords[5], texture.origTexCoords[6] );
elseif (rotationDegrees == 180) then
texture:SetTexCoord( texture.origTexCoords[7], texture.origTexCoords[8],
texture.origTexCoords[5], texture.origTexCoords[6],
texture.origTexCoords[3], texture.origTexCoords[4],
texture.origTexCoords[1], texture.origTexCoords[2] );
elseif (rotationDegrees == 270) then
texture:SetTexCoord( texture.origTexCoords[5], texture.origTexCoords[6],
texture.origTexCoords[1], texture.origTexCoords[2],
texture.origTexCoords[7], texture.origTexCoords[8],
texture.origTexCoords[3], texture.origTexCoords[4] );
end
end
function ClearClampedTextureRotation(texture)
if (texture.rotationDegrees) then
SetClampedTextureRotation(texture, 0);
texture.origTexCoords = nil;
texture.origWidth = nil;
texture.origHeight = nil;
texture.rotationDegrees = nil;
end
end
function GetTexCoordsByGrid(xOffset, yOffset, textureWidth, textureHeight, gridWidth, gridHeight)
local widthPerGrid = gridWidth / textureWidth;
local heightPerGrid = gridHeight / textureHeight;
return (xOffset - 1) * widthPerGrid, (xOffset) * widthPerGrid, (yOffset - 1) * heightPerGrid, (yOffset) * heightPerGrid;
end
-- left/right/top/bottom are normalized [0..1] coords within the file (the
-- markup's texel fields are produced by multiplying back up by the file size).
function CreateTextureMarkup(file, fileWidth, fileHeight, width, height, left, right, top, bottom, xOffset, yOffset)
return string.format("|T%s:%d:%d:%d:%d:%d:%d:%d:%d:%d:%d|t",
file
, height
, width
, xOffset or 0
, yOffset or 0
, fileWidth
, fileHeight
, left * fileWidth
, right * fileWidth
, top * fileHeight
, bottom * fileHeight
);
end
function CreateSimpleTextureMarkup(file, width, height, xOffset, yOffset)
return string.format("|T%s:%d:%d:%d:%d|t",
file
, height or width
, width
, xOffset or 0
, yOffset or 0
);
end
+69 -67
View File
@@ -248,7 +248,7 @@ end
local function CreateFrame_TradeSkillLink()
local f = CreateFrame("Frame", "ClassicAPITradeSkillLinkFrame", UIParent);
f:SetSize(FRAME_W, FRAME_H);
f:SetPoint("CENTER", 0, 0);
f:SetPoint("CENTER");
f:SetMovable(true);
f:EnableMouse(true);
f:SetHitRectInsets(LO.hit[1], LO.hit[2], LO.hit[3], LO.hit[4]);
@@ -259,6 +259,9 @@ local function CreateFrame_TradeSkillLink()
f:Hide();
tinsert(UISpecialFrames, f:GetName())
f:SetScript("OnShow", function() PlaySound("igCharacterInfoOpen"); end);
f:SetScript("OnHide", function() PlaySound("igCharacterInfoClose"); end);
local portrait = f:CreateTexture(nil, "BACKGROUND");
portrait:SetSize(60, 60);
portrait:SetPoint("TOPLEFT", 7, -6);
@@ -296,7 +299,7 @@ local function CreateFrame_TradeSkillLink()
local exit = CreateFrame("Button", nil, f, "UIPanelButtonTemplate");
exit:SetSize(80, 22);
exit:SetPoint(LO.exit[1], f, LO.exit[2], LO.exit[3], LO.exit[4]);
exit:SetText(EXIT or "Exit");
exit:SetText(EXIT);
exit:SetScript("OnClick", function() f:Hide(); end);
local bar = CreateFrame("StatusBar", nil, f);
@@ -393,29 +396,13 @@ local function CreateFrame_TradeSkillLink()
"ClassicAPITradeSkillLinkDetailScroll", f, DETAIL_SCROLL_TEMPLATE);
detailScroll:SetSize(LO.detail[3], LO.detail[4]);
detailScroll:SetPoint("TOPLEFT", LO.detail[1], LO.detail[2]);
detailScroll.scrollBarHideable = 1; -- template sets this too; belt-and-suspenders
f.detailScroll = detailScroll;
-- Off until a recipe overflows the pane (toggled in RenderDetail).
-- The template shows its slider and enables the wheel by default; on
-- the roomy Turtle pane that leaves a dead scrollbar that still eats
-- wheel events and over-scrolls a hair. Start both disabled.
-- Bar visibility + wheel are driven per-render by SetDetailBarShown (from
-- the computed overflow). `scrollBarHideable` still lets the template's
-- ScrollFrame_OnScrollRangeChanged manage the slider on range changes.
-- Start with the wheel off; the first RenderDetail/ClearDetail sets it.
detailScroll.scrollBarHideable = 1;
detailScroll:EnableMouseWheel(false);
local detailSB = getglobal("ClassicAPITradeSkillLinkDetailScrollScrollBar");
if detailSB then
detailSB:Hide();
end
-- The template re-shows its slider when the frame transitions to shown
-- (on first open, before the auto-selected recipe's RenderDetail runs),
-- leaving a dead scrollbar until the first click. Re-assert the hidden
-- state on show unless the current recipe genuinely overflows.
detailScroll:SetScript("OnShow", function()
f:UpdateDetailScrollbar();
RunNextFrame(function()
if f:IsShown() then
f:UpdateDetailScrollbar();
end
end);
end);
f.detailScroll = detailScroll;
local detailChild = CreateFrame("Frame",
"ClassicAPITradeSkillLinkDetailChild", detailScroll);
@@ -461,8 +448,8 @@ local function CreateFrame_TradeSkillLink()
prodIcon:SetScript("OnEnter", function()
GameTooltip:SetOwner(this, "ANCHOR_RIGHT");
if this.itemLink then
GameTooltip:SetHyperlink(this.itemLink);
if this.itemID then
GameTooltip:SetHyperlink("item:"..this.itemID);
elseif this.spellID then
GameTooltip:SetSpellByID(this.spellID);
end
@@ -503,29 +490,36 @@ local function CreateFrame_TradeSkillLink()
nm:SetJustifyV("MIDDLE");
btn.nameText = nm;
btn:SetScript("OnEnter", function()
if this.itemLink then
if this.itemID then
GameTooltip:SetOwner(this, "ANCHOR_RIGHT");
GameTooltip:SetHyperlink(this.itemLink);
GameTooltip:SetHyperlink("item:"..this.itemID);
GameTooltip:Show();
end
end);
btn:SetScript("OnClick", function()
if this.itemLink and IsShiftKeyDown() and ChatFrameEditBox:IsVisible() then
ChatFrameEditBox:Insert(this.itemLink);
end
end);
btn:SetScript("OnLeave", GameTooltip_Hide);
f.reagents[i] = btn;
return btn;
end
-- Show the detail slider + enable the wheel only when the current
-- recipe overflows the pane (tracked by `detailNeedScroll`).
function f:UpdateDetailScrollbar()
local need = self.detailNeedScroll and true or false;
self.detailScroll:EnableMouseWheel(need);
-- Show the detail bar/borders only when the content overflows the pane.
-- Driven by our own computed overflow, NOT the engine: scrollBarHideable's
-- auto-hide only fires on a range CHANGE (so content that fits from the
-- start never hides), and GetVerticalScrollRange isn't reliably readable
-- the same tick as UpdateScrollChildRect.
function f:SetDetailBarShown(show)
self.detailScroll:EnableMouseWheel(show);
local base = "ClassicAPITradeSkillLinkDetailScroll";
local sb = getglobal(base .. "ScrollBar");
if sb then sb:SetShown(need) end
if sb then sb:SetShown(show) end
local top = getglobal(base .. "Top");
if top then top:SetShown(need) end
if top then top:SetShown(show) end
local bottom = getglobal(base .. "Bottom");
if bottom then bottom:SetShown(need) end
if bottom then bottom:SetShown(show) end
end
function f:RenderDetail(entry)
@@ -571,19 +565,22 @@ local function CreateFrame_TradeSkillLink()
local total = table.getn(reagents);
for i = 1, total do
local btn = AcquireReagent(i);
local r = reagents[i];
local name, tex, link = itemInfo(r.itemID);
local reagent = reagents[i];
local name, tex, link = itemInfo(reagent.itemID);
btn:SetNormalTexture(tex or "Interface\\Icons\\INV_Misc_QuestionMark");
btn.itemLink = link;
btn.nameText:SetText(name or ("item:" .. r.itemID));
local need = r.count or 1;
local have = C_Item.GetItemCount(r.itemID) or 0;
btn.itemID = reagent.itemID
btn.nameText:SetText(name or ("item:" .. reagent.itemID));
local need = reagent.count or 1;
local have = C_Item.GetItemCount(reagent.itemID) or 0;
if have >= need then
btn:GetNormalTexture():SetVertexColor(1, 1, 1);
btn.nameText:SetTextColor(HIGHLIGHT_FONT_COLOR:GetRGB());
local r, g, b = HIGHLIGHT_FONT_COLOR:GetRGB()
btn:GetNormalTexture():SetVertexColor(r, g, b);
btn.nameText:SetTextColor(r, g, b);
else
btn:GetNormalTexture():SetVertexColor(0.5, 0.5, 0.5);
btn.nameText:SetTextColor(GRAY_FONT_COLOR:GetRGB());
local r, g, b = GRAY_FONT_COLOR:GetRGB()
btn:GetNormalTexture():SetVertexColor(r, g, b);
btn.nameText:SetTextColor(r, g, b);
end
local haveText = (have >= 100) and "*" or tostring(have);
btn.count:SetText(haveText .. " /" .. need);
@@ -591,32 +588,36 @@ local function CreateFrame_TradeSkillLink()
end
for i = total + 1, table.getn(self.reagents) do
self.reagents[i].itemLink = nil;
self.reagents[i].itemID = nil;
self.reagents[i]:Hide();
end
-- Grow the scroll child to the content height so the pane scrolls
-- once the header + reagent rows exceed the visible area. Reagent
-- row 0 sits ~67px below the child top (icon + label); add the
-- wrapped description height for spell recipes.
local descH = (descText ~= "") and self.prodDesc:GetHeight() or 0;
local rows = math.ceil(total / 2);
local contentH = 67 + descH + rows * REAGENT_ROW_HEIGHT + 8;
self.detailChild:SetHeight(math.max(contentH, LO.detail[4]));
-- Reset to the top and manage the slider explicitly: a range that
-- stays 0 (content fits, e.g. Turtle's roomy pane) never fires the
-- range-changed event that `scrollBarHideable` relies on, so the
-- slider would otherwise sit visible-but-useless. Show it only when
-- the content actually overflows.
self.detailNeedScroll = contentH > LO.detail[4];
local function applyContentHeight()
local contentH = LO.detail[4];
if total > 0 then
local childTop = self.detailChild:GetTop();
local lastBtn = self.reagents[total];
local lastBottom = lastBtn and lastBtn:GetBottom();
if childTop and lastBottom then
-- +22: ~12px name-plate overhang below the icon + ~10px pad.
contentH = (childTop - lastBottom) + 22;
end
end
self.detailChild:SetHeight(math.max(contentH, LO.detail[4]));
-- Recompute the scroll range from the child's new height, then
-- show the bar only if the content actually overflows the pane.
self.detailScroll:UpdateScrollChildRect();
self:SetDetailBarShown(contentH > LO.detail[4] + 0.5);
end
applyContentHeight();
local sb = getglobal("ClassicAPITradeSkillLinkDetailScrollScrollBar");
if sb then
sb:SetValue(0);
end
self.detailScroll:SetVerticalScroll(0);
self:UpdateDetailScrollbar();
RunNextFrame(function()
if self:IsShown() then
self:UpdateDetailScrollbar();
if self:IsShown() and self.selectedSpellID == entry.spellID then
applyContentHeight();
end
end)
end
@@ -636,11 +637,12 @@ local function CreateFrame_TradeSkillLink()
self.prodDesc:SetText("");
for i = 1, table.getn(self.reagents) do
self.reagents[i].itemLink = nil;
self.reagents[i].itemID = nil;
self.reagents[i]:Hide();
end
self.detailChild:SetHeight(LO.detail[4]);
self.detailNeedScroll = false;
self:UpdateDetailScrollbar();
self.detailScroll:UpdateScrollChildRect();
self:SetDetailBarShown(false);
self.detailScroll:SetVerticalScroll(0);
end
@@ -732,7 +734,7 @@ local function ShowTradeSkillLink(link, text)
local r = recipes[i];
if r.isKnown then
r.band = DifficultyBand(cur or 0, r.trivialLevel, r.greenLevel);
r.name = GetSpellInfo(r.spellID) or ("spell:" .. r.spellID);
r.name = C_Spell.GetSpellName(r.spellID) or ("spell:" .. r.spellID);
table.insert(knownList, r);
end
end
@@ -752,8 +754,8 @@ local function ShowTradeSkillLink(link, text)
frame.bar:SetMinMaxValues(0, (max and max > 0) and max or 1);
frame.bar:SetValue(cur or 0);
frame.barName:SetText(prof);
frame.barRank:SetText(string.format("%d/%d", cur or 0, max or 0));
frame.summary:SetText(string.format("%d of %d recipes known", known, total));
frame.barRank:SetFormattedText("%d/%d", cur or 0, max or 0);
frame.summary:SetFormattedText("%d of %d recipes known", known, total);
frame.recipes = knownList;
frame.subclassFilter = nil;
+5
View File
@@ -0,0 +1,5 @@
function MouseIsOver(region, topOffset, bottomOffset, leftOffset, rightOffset)
if region:IsMouseOver(topOffset, bottomOffset, leftOffset, rightOffset) then
return 1;
end
end
+142
View File
@@ -0,0 +1,142 @@
-- Verbatim backport of retail's Blizzard_SharedXML/Vector2D.lua. Pure vector
-- math on CreateFromMixins (a ClassicAPI C native) + Lua 5.0 math.*; no 1.12
-- adaptation needed. (Retail's "used in secure code" note doesn't apply here —
-- vanilla has no taint system.)
local cos = math.cos;
local sin = math.sin;
local atan2 = math.atan2;
local sqrt = math.sqrt;
function Vector2D_ScaleBy(scalar, x, y)
return x * scalar, y * scalar;
end
function Vector2D_DivideBy(divisor, x, y)
return x / divisor, y / divisor;
end
function Vector2D_Add(leftX, leftY, rightX, rightY)
return leftX + rightX, leftY + rightY;
end
function Vector2D_Subtract(leftX, leftY, rightX, rightY)
return leftX - rightX, leftY - rightY;
end
function Vector2D_Cross(leftX, leftY, rightX, rightY)
return leftX * rightY - leftY * rightX;
end
function Vector2D_Dot(leftX, leftY, rightX, rightY)
return leftX * rightX + leftY * rightY;
end
function Vector2D_GetLengthSquared(x, y)
return Vector2D_Dot(x, y, x, y);
end
function Vector2D_GetLength(x, y)
return sqrt(Vector2D_GetLengthSquared(x, y));
end
function Vector2D_Normalize(x, y)
return Vector2D_DivideBy(Vector2D_GetLength(x, y), x, y);
end
function Vector2D_CalculateAngleBetween(leftX, leftY, rightX, rightY)
return atan2(Vector2D_Cross(leftX, leftY, rightX, rightY), Vector2D_Dot(leftX, leftY, rightX, rightY));
end
function Vector2D_RotateDirection(rotationRadians, x, y)
local cosValue = cos(rotationRadians);
local sinValue = sin(rotationRadians);
return x * cosValue - y * sinValue, x * sinValue + y * cosValue;
end
Vector2DMixin = {};
function CreateVector2D(x, y)
local vector = CreateFromMixins(Vector2DMixin);
vector:OnLoad(x, y);
return vector;
end
function AreVector2DEqual(left, right)
if left and right then
return left:IsEqualTo(right);
end
return left == right;
end
function Vector2DMixin:OnLoad(x, y)
self:SetXY(x, y);
end
function Vector2DMixin:IsEqualTo(otherVector)
return self.x == otherVector.x
and self.y == otherVector.y;
end
function Vector2DMixin:GetXY()
return self.x, self.y;
end
function Vector2DMixin:SetXY(x, y)
self.x = x;
self.y = y;
end
function Vector2DMixin:ScaleBy(scalar)
self:SetXY(Vector2D_ScaleBy(scalar, self:GetXY()));
return self;
end
function Vector2DMixin:DivideBy(scalar)
self:SetXY(Vector2D_DivideBy(scalar, self:GetXY()));
return self;
end
function Vector2DMixin:Add(other)
self:SetXY(Vector2D_Add(self.x, self.y, other:GetXY()));
return self;
end
function Vector2DMixin:Subtract(other)
self:SetXY(Vector2D_Subtract(self.x, self.y, other:GetXY()));
return self;
end
function Vector2DMixin:Cross(other)
self:SetXY(Vector2D_Cross(self.x, self.y, other:GetXY()));
return self;
end
function Vector2DMixin:Dot(other)
return Vector2D_Dot(self.x, self.y, other:GetXY());
end
function Vector2DMixin:IsZero()
return self.x == 0 and self.y == 0;
end
function Vector2DMixin:GetLengthSquared()
return Vector2D_GetLengthSquared(self:GetXY());
end
function Vector2DMixin:GetLength()
return Vector2D_GetLength(self:GetXY());
end
function Vector2DMixin:Normalize()
self:SetXY(Vector2D_Normalize(self:GetXY()));
return self;
end
function Vector2DMixin:RotateDirection(rotationRadians)
self:SetXY(Vector2D_RotateDirection(rotationRadians, self:GetXY()));
return self;
end
function Vector2DMixin:Clone()
return CreateVector2D(self:GetXY());
end
+173
View File
@@ -0,0 +1,173 @@
-- Verbatim backport of retail's Blizzard_SharedXML/Vector3D.lua. Pure vector
-- math on CreateFromMixins (a ClassicAPI C native) + Lua 5.0 math.*; no 1.12
-- adaptation needed. (Retail's "used in secure code" note doesn't apply here —
-- vanilla has no taint system.)
local cos = math.cos;
local sin = math.sin;
local atan2 = math.atan2;
local asin = math.asin;
local sqrt = math.sqrt;
function Vector3D_ScaleBy(scalar, x, y, z)
return x * scalar, y * scalar, z * scalar;
end
function Vector3D_DivideBy(divisor, x, y, z)
return x / divisor, y / divisor, z / divisor;
end
function Vector3D_Add(leftX, leftY, leftZ, rightX, rightY, rightZ)
return leftX + rightX, leftY + rightY, leftZ + rightZ;
end
function Vector3D_Subtract(leftX, leftY, leftZ, rightX, rightY, rightZ)
return leftX - rightX, leftY - rightY, leftZ - rightZ;
end
function Vector3D_Cross(leftX, leftY, leftZ, rightX, rightY, rightZ)
return leftY * rightZ - leftZ * rightY, leftZ * rightX - leftX * rightZ, leftX * rightY - leftY * rightX;
end
function Vector3D_Dot(leftX, leftY, leftZ, rightX, rightY, rightZ)
return leftX * rightX + leftY * rightY + leftZ * rightZ;
end
function Vector3D_GetLengthSquared(x, y, z)
return Vector3D_Dot(x, y, z, x, y, z);
end
function Vector3D_GetLength(x, y, z)
return sqrt(Vector3D_GetLengthSquared(x, y, z));
end
function Vector3D_Normalize(x, y, z)
return Vector3D_DivideBy(Vector3D_GetLength(x, y, z), x, y, z);
end
function Vector3D_AddVector(left, right)
local clone = left:Clone();
clone:Add(right);
return clone;
end
function Vector3D_SubtractVector(left, right)
local clone = left:Clone();
clone:Subtract(right);
return clone;
end
function Vector3D_NormalizeVector(vector)
local clone = vector:Clone();
clone:Normalize();
return clone;
end
function Vector3D_ScaleVector(scalar, vector)
local clone = vector:Clone();
clone:ScaleBy(scalar);
return clone;
end
function Vector3D_CalculateNormalFromYawPitch(yaw, pitch)
return cos(-pitch) * cos(yaw),
cos(-pitch) * sin(yaw),
sin(-pitch);
end
function Vector3D_CalculateYawPitchFromNormal(x, y, z)
if x ~= 0 or y ~= 0 then
return atan2(y, x), asin(-z);
end
return 0, asin(-z);
end
function Vector3D_CalculateYawPitchFromNormalVector(vector)
return Vector3D_CalculateYawPitchFromNormal(vector:GetXYZ());
end
function Vector3D_CreateNormalVectorFromYawPitch(yawRadians, pitchRadians)
return CreateVector3D(Vector3D_CalculateNormalFromYawPitch(yawRadians, pitchRadians));
end
Vector3DMixin = {};
function CreateVector3D(x, y, z)
local vector = CreateFromMixins(Vector3DMixin);
vector:OnLoad(x, y, z);
return vector;
end
function AreVector3DEqual(left, right)
if left and right then
return left:IsEqualTo(right);
end
return left == right;
end
function Vector3DMixin:OnLoad(x, y, z)
self:SetXYZ(x, y, z);
end
function Vector3DMixin:IsEqualTo(otherVector)
return self.x == otherVector.x
and self.y == otherVector.y
and self.z == otherVector.z;
end
function Vector3DMixin:GetXYZ()
return self.x, self.y, self.z;
end
function Vector3DMixin:SetXYZ(x, y, z)
self.x = x;
self.y = y;
self.z = z;
end
function Vector3DMixin:ScaleBy(scalar)
self:SetXYZ(Vector3D_ScaleBy(scalar, self:GetXYZ()));
return self;
end
function Vector3DMixin:DivideBy(scalar)
self:SetXYZ(Vector3D_DivideBy(scalar, self:GetXYZ()));
return self;
end
function Vector3DMixin:Add(other)
self:SetXYZ(Vector3D_Add(self.x, self.y, self.z, other:GetXYZ()));
return self;
end
function Vector3DMixin:Subtract(other)
self:SetXYZ(Vector3D_Subtract(self.x, self.y, self.z, other:GetXYZ()));
return self;
end
function Vector3DMixin:Cross(other)
self:SetXYZ(Vector3D_Cross(self.x, self.y, self.z, other:GetXYZ()));
return self;
end
function Vector3DMixin:Dot(other)
return Vector3D_Dot(self.x, self.y, self.z, other:GetXYZ());
end
function Vector3DMixin:GetLengthSquared()
return Vector3D_GetLengthSquared(self:GetXYZ());
end
function Vector3DMixin:GetLength()
return Vector3D_GetLength(self:GetXYZ());
end
function Vector3DMixin:Normalize()
self:SetXYZ(Vector3D_Normalize(self:GetXYZ()));
return self;
end
function Vector3DMixin:Clone()
return CreateVector3D(self:GetXYZ());
end
+143
View File
@@ -0,0 +1,143 @@
-- Verbatim backport of retail's Blizzard_SharedXML/Vector4D.lua. Pure vector
-- math on CreateFromMixins (a ClassicAPI C native) + Lua 5.0 math.*; no 1.12
-- adaptation needed. (Retail's "used in secure code" note doesn't apply here —
-- vanilla has no taint system.)
local cos = math.cos;
local sin = math.sin;
local atan2 = math.atan2;
local asin = math.asin;
local sqrt = math.sqrt;
function Vector4D_ScaleBy(scalar, x, y, z, w)
return x * scalar, y * scalar, z * scalar, w * scalar;
end
function Vector4D_DivideBy(divisor, x, y, z, w)
return x / divisor, y / divisor, z / divisor, w / divisor;
end
function Vector4D_Add(leftX, leftY, leftZ, leftW, rightX, rightY, rightZ, rightW)
return leftX + rightX, leftY + rightY, leftZ + rightZ, leftW + rightW;
end
function Vector4D_Subtract(leftX, leftY, leftZ, leftW, rightX, rightY, rightZ, rightW)
return leftX - rightX, leftY - rightY, leftZ - rightZ, leftW - rightW;
end
function Vector4D_Dot(leftX, leftY, leftZ, leftW, rightX, rightY, rightZ, rightW)
return leftX * rightX + leftY * rightY + leftZ * rightZ + leftW * rightW;
end
function Vector4D_GetLengthSquared(x, y, z, w)
return Vector4D_Dot(x, y, z, w, x, y, z, w);
end
function Vector4D_GetLength(x, y, z, w)
return sqrt(Vector4D_GetLengthSquared(x, y, z, w));
end
function Vector4D_Normalize(x, y, z, w)
return Vector4D_DivideBy(Vector4D_GetLength(x, y, z, w), x, y, z, w);
end
function Vector4D_AddVector(left, right)
local clone = left:Clone();
clone:Add(right);
return clone;
end
function Vector4D_SubtractVector(left, right)
local clone = left:Clone();
clone:Subtract(right);
return clone;
end
function Vector4D_NormalizeVector(vector)
local clone = vector:Clone();
clone:Normalize();
return clone;
end
function Vector4D_ScaleVector(scalar, vector)
local clone = vector:Clone();
clone:ScaleBy(scalar);
return clone;
end
Vector4DMixin = {};
function CreateVector4D(x, y, z, w)
local vector = CreateFromMixins(Vector4DMixin);
vector:OnLoad(x, y, z, w);
return vector;
end
function AreVector4DEqual(left, right)
if left and right then
return left:IsEqualTo(right);
end
return left == right;
end
function Vector4DMixin:OnLoad(x, y, z, w)
self:SetXYZW(x, y, z, w);
end
function Vector4DMixin:IsEqualTo(otherVector)
return self.x == otherVector.x
and self.y == otherVector.y
and self.z == otherVector.z
and self.w == otherVector.w;
end
function Vector4DMixin:GetXYZW()
return self.x, self.y, self.z, self.w;
end
function Vector4DMixin:SetXYZW(x, y, z, w)
self.x = x;
self.y = y;
self.z = z;
self.w = w;
end
function Vector4DMixin:ScaleBy(scalar)
self:SetXYZW(Vector4D_ScaleBy(scalar, self:GetXYZW()));
return self;
end
function Vector4DMixin:DivideBy(scalar)
self:SetXYZW(Vector4D_DivideBy(scalar, self:GetXYZW()));
return self;
end
function Vector4DMixin:Add(other)
self:SetXYZW(Vector4D_Add(self.x, self.y, self.z, self.w, other:GetXYZW()));
return self;
end
function Vector4DMixin:Subtract(other)
self:SetXYZW(Vector4D_Subtract(self.x, self.y, self.z, self.w, other:GetXYZW()));
return self;
end
function Vector4DMixin:Dot(other)
return Vector4D_Dot(self.x, self.y, self.z, self.w, other:GetXYZW());
end
function Vector4DMixin:GetLengthSquared()
return Vector4D_GetLengthSquared(self:GetXYZW());
end
function Vector4DMixin:GetLength()
return Vector4D_GetLength(self:GetXYZW());
end
function Vector4DMixin:Normalize()
self:SetXYZW(Vector4D_Normalize(self:GetXYZW()));
return self;
end
function Vector4DMixin:Clone()
return CreateVector4D(self:GetXYZW());
end
+36
View File
@@ -2,7 +2,33 @@ if GetLocale() ~= "deDE" then return end
BINDING_NAME_FOCUSTARGET = "Fokus setzen";
BINDING_NAME_TARGETFOCUS = "Fokus anvisieren";
CONFIRM_DELETE_EQUIPMENT_SET = "Seid Ihr sicher, dass Ihr das Ausrüstungsset \"%s\" löschen wollt?";
CONFIRM_SAVE_EQUIPMENT_SET = "Möchtet Ihr das Ausrüstungsset \"%s\" speichern?";
DEBUFF_SYMBOL_CURSE = "Fl";
DEBUFF_SYMBOL_DISEASE = "Kr";
DEBUFF_SYMBOL_MAGIC = "Ma";
DEBUFF_SYMBOL_POISON = "Gi";
DEBUG_FRAMESTACK = "Frame Stack";
EQUIPMENT_MANAGER = "Ausrüstungsmanager";
EQUIPMENT_MANAGER_IGNORE_SLOT = "Diesen Platz ignorieren";
EQUIPMENT_MANAGER_IS_DISABLED = "Der Ausrüstungsmanager ist abgeschaltet.";
EQUIPMENT_MANAGER_ITEMS_MISSING_TOOLTIP = "%d %s |cffff0000(%d fehlen)|r";
EQUIPMENT_MANAGER_PLACE_IN_BAGS = "In Beuteln platzieren";
EQUIPMENT_MANAGER_UNIGNORE_SLOT = "Diesen Platz einbeziehen";
EQUIPMENT_SETS = "Ausrüstungs-Sets: |cFFFFFFFF%s|r";
EQUIPMENT_SETS_CANT_RENAME = "Ein Ausrüstungsset mit diesem Namen gibt es bereits.";
EQUIPMENT_SETS_TOO_MANY = "Ihr könnt keine weiteren Ausrüstungssets erstellen.";
EQUIPMENT_SET_ASSIGN_TO_SPEC = "Spezialisierung zuweisen:";
EQUIPMENT_SET_EDIT = "Name/Icon ändern";
EQUIPMENT_SET_SETTINGS = "Einstellungen";
EQUIPSET_EQUIP = "Anlegen";
ERR_EQUIPMENT_MANAGER_BAGS_FULL = "Ausrüstungstausch fehlgeschlagen. Inventar ist voll.";
ERR_EQUIPMENT_MANAGER_COMBAT_SWAP_S = "Einige Gegenstände des Sets '%s' können nicht gewechselt werden, da Ihr Euch im Kampf befindet.";
ERR_EQUIPMENT_MANAGER_MISSING_ITEM_S = "Mindestens ein Teil des Sets '%s' fehlt und kann nicht ausgerüstet werden.";
CLEAR_FOCUS = "Fokus löschen";
SET_FOCUS = "Fokus setzen";
FOCUSTARGET = "Ziel als Fokus festlegen";
GEARSETS_POPUP_TEXT = "Setnamen eingeben (Max. 16 Zeichen):";
ITEMS_VARIABLE_QUANTITY = "%d |4IGegenstand:Gegenstände;";
ITEMS_EQUIPPED = "%d |4Gegenstand:Gegenstände; ausgerüstet";
ITEMS_IN_INVENTORY = "%d |4Gegenstand:Gegenstände; im Inventar";
@@ -15,6 +41,10 @@ COPPER_AMOUNT_SYMBOL = "K";
GENERIC_FRACTION_STRING = "%d/%d";
GOLD_AMOUNT_SYMBOL = "G";
HOURS_MINUTES_SECONDS = "%.2d:%.2d:%.2d";
ICON_FILTER_ALL = "Alle Symbole";
ICON_FILTER_ITEM = "Gegenstände";
ICON_FILTER_SPELL = "Zauber";
ICON_SELECTION_TITLE_CURRENT = "Aktuell ausgewählt";
ITEM_DELTA_DESCRIPTION = "Wenn Ihr diesen Gegenstand ersetzt, werden sich folgende Werte ändern:";
ITEM_MOD_MANA_SHORT = "Mana";
ITEM_MOD_HEALTH_SHORT = "Gesundheit";
@@ -42,10 +72,16 @@ ITEM_MOD_BLOCK_VALUE_SHORT = "Blocken";
ITEM_MOD_DAMAGE_PER_SECOND_SHORT = "Schaden pro Sekunde";
LARGE_NUMBER_SEPERATOR = ".";
MINUTES_SECONDS = "%.2d:%.2d";
PAPERDOLL_EQUIPMENTMANAGER = "Ausrüstungsmanager";
PAPERDOLL_NEWEQUIPMENTSET = "Neues Set";
PERCENTAGE_STRING = "%d%%";
SET_FRIENDNOTE_LABEL = "Notizen für %s eintragen:";
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";
+40
View File
@@ -1,11 +1,44 @@
BINDING_NAME_AOELOOT = "AoE Loot";
BINDING_NAME_FOCUSTARGET = "Focus Target";
BINDING_NAME_TARGETFOCUS = "Target Focus";
CONFIRM_DELETE_EQUIPMENT_SET = "Are you sure you want to delete the equipment set %s?";
CONFIRM_SAVE_EQUIPMENT_SET = "Would you like to save the equipment set \"%s\"?";
COPPER_AMOUNT_SYMBOL = "c";
COPPER_AMOUNT_TEXTURE = "%d|TInterface\\MoneyFrame\\UI-MoneyIcons:%d:%d:0:0:4:1:2:3:0:1|t";
DEBUFF_SYMBOL_CURSE = "Cu";
DEBUFF_SYMBOL_DISEASE = "Di";
DEBUFF_SYMBOL_MAGIC = "Ma";
DEBUFF_SYMBOL_POISON = "Po";
DEBUG_FRAMESTACK = "Frame Stack";
EQUIPMENT_MANAGER = "Equipment Manager";
EQUIPMENT_MANAGER_IGNORE_SLOT = "Ignore This Slot";
EQUIPMENT_MANAGER_IS_DISABLED = "The Equipment Manager is disabled.";
EQUIPMENT_MANAGER_ITEMS_MISSING_TOOLTIP = "%d %s |cffff0000(%d missing)|r";
EQUIPMENT_MANAGER_PLACE_IN_BAGS = "Place In Bags";
EQUIPMENT_MANAGER_UNIGNORE_SLOT = "Include This Slot";
EQUIPMENT_SETS = "Equipment Sets: |cFFFFFFFF%s|r";
EQUIPMENT_SETS_CANT_RENAME = "An equipment set with that name already exists.";
EQUIPMENT_SETS_TOO_MANY = "You cannot create any more new equipment sets.";
EQUIPMENT_SET_ASSIGN_TO_SPEC = "Assign to Specialization:";
EQUIPMENT_SET_EDIT = "Change Name/Icon";
EQUIPMENT_SET_SETTINGS = "Settings";
EQUIPSET_EQUIP = "Equip";
ERR_EQUIPMENT_MANAGER_BAGS_FULL = "Equipment swap failed. Inventory is full.";
ERR_EQUIPMENT_MANAGER_COMBAT_SWAP_S = "Some items from set %s cannot be swapped because you are in combat.";
ERR_EQUIPMENT_MANAGER_MISSING_ITEM_S = "One or more items from set %s were missing and could not be equipped.";
CLEAR_FOCUS = "Clear Focus";
SET_FOCUS = "Set Focus";
FOCUSTARGET = "Focus Target";
GEARSETS_POPUP_TEXT = "Enter Set Name (Max 16 Characters):";
GENERIC_FRACTION_STRING = "%d/%d";
GOLD_AMOUNT_SYMBOL = "g";
GOLD_AMOUNT_TEXTURE = "%d|TInterface\\MoneyFrame\\UI-MoneyIcons:%d:%d:0:0:4:1:0:1:0:1|t";
GOLD_AMOUNT_TEXTURE_STRING = "%s|TInterface\\MoneyFrame\\UI-MoneyIcons:%d:%d:0:0:4:1:0:1:0:1|t";
HOURS_MINUTES_SECONDS = "%.2d:%.2d:%.2d";
ICON_FILTER_ALL = "All Icons";
ICON_FILTER_ITEM = "Items";
ICON_FILTER_SPELL = "Spells";
ICON_SELECTION_TITLE_CURRENT = "Currently Selected";
ITEM_DELTA_DESCRIPTION = "If you replace this item, the following stat changes will occur:";
ITEM_MOD_MANA_SHORT = "Mana";
ITEM_MOD_HEALTH_SHORT = "Health";
@@ -39,10 +72,17 @@ ITEM_MISSING = "%s missing";
ITEM_SLOTS_IGNORED = "%d |4slot:slots; ignored";
LARGE_NUMBER_SEPERATOR = ",";
MINUTES_SECONDS = "%.2d:%.2d";
PAPERDOLL_EQUIPMENTMANAGER = "Equipment Manager";
PAPERDOLL_NEWEQUIPMENTSET = "New Set";
PERCENTAGE_STRING = "%d%%";
SET_FRIENDNOTE_LABEL = "Set Notes for %s:";
SET_NOTE = "Set Note";
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";
+36
View File
@@ -2,7 +2,33 @@ if GetLocale() ~= "esES" then return end
BINDING_NAME_FOCUSTARGET = "Establecer objetivo";
BINDING_NAME_TARGETFOCUS = "Seleccionar enfocado";
CONFIRM_DELETE_EQUIPMENT_SET = "¿Seguro que quieres eliminar el equipamiento %s?";
CONFIRM_SAVE_EQUIPMENT_SET = "¿Quieres guardar el conjunto de equipamiento \"%s\"?";
DEBUFF_SYMBOL_CURSE = "Mal";
DEBUFF_SYMBOL_DISEASE = "En";
DEBUFF_SYMBOL_MAGIC = "Mag";
DEBUFF_SYMBOL_POISON = "Ve";
DEBUG_FRAMESTACK = "Trama de pila";
EQUIPMENT_MANAGER = "Gestor de equipamiento";
EQUIPMENT_MANAGER_IGNORE_SLOT = "Ignorar esta casilla";
EQUIPMENT_MANAGER_IS_DISABLED = "El Gestor de equipamiento está desactivado.";
EQUIPMENT_MANAGER_ITEMS_MISSING_TOOLTIP = "%d %s |cffff0000(falta %d)|r";
EQUIPMENT_MANAGER_PLACE_IN_BAGS = "Colocar en bolsas";
EQUIPMENT_MANAGER_UNIGNORE_SLOT = "Incluir esta casilla";
EQUIPMENT_SETS = "Conjuntos de equipamiento: |cFFFFFFFF%s|r";
EQUIPMENT_SETS_CANT_RENAME = "Ya existe un conjunto de equipamiento con ese nombre.";
EQUIPMENT_SETS_TOO_MANY = "Ya no puedes crear más equipamientos.";
EQUIPMENT_SET_ASSIGN_TO_SPEC = "Asignar a especialización:";
EQUIPMENT_SET_EDIT = "Cambiar nombre/icono";
EQUIPMENT_SET_SETTINGS = "Config.";
EQUIPSET_EQUIP = "Equipar";
ERR_EQUIPMENT_MANAGER_BAGS_FULL = "No se ha podido cambiar el equipamiento. Inventario lleno.";
ERR_EQUIPMENT_MANAGER_COMBAT_SWAP_S = "Algunos objetos del equipo %s no se cambiarán porque estás en combate.";
ERR_EQUIPMENT_MANAGER_MISSING_ITEM_S = "Falta uno o más objetos del equipo %s y no se ha podido equipar.";
CLEAR_FOCUS = "Quitar foco";
SET_FOCUS = "Establecer foco";
FOCUSTARGET = "Establecer objetivo";
GEARSETS_POPUP_TEXT = "Nombra este equipamiento (Máx. 16 carac.):";
ITEMS_VARIABLE_QUANTITY = "%d |4objeto:objetos;";
ITEMS_EQUIPPED = "Equipado: %d |4objeto:objetos;";
ITEMS_IN_INVENTORY = "En inventario: %d |4objeto:objetos;";
@@ -15,6 +41,10 @@ COPPER_AMOUNT_SYMBOL = "c";
GENERIC_FRACTION_STRING = "%d/%d";
GOLD_AMOUNT_SYMBOL = "o";
HOURS_MINUTES_SECONDS = "%.2d:%.2d:%.2d";
ICON_FILTER_ALL = "Todos los iconos";
ICON_FILTER_ITEM = "Objetos";
ICON_FILTER_SPELL = "Hechizos";
ICON_SELECTION_TITLE_CURRENT = "Selección actual";
ITEM_DELTA_DESCRIPTION = "Si reemplazas este objeto, se producirán los siguientes cambios en las estadísticas:";
ITEM_MOD_MANA_SHORT = "Maná";
ITEM_MOD_HEALTH_SHORT = "Salud";
@@ -42,10 +72,16 @@ ITEM_MOD_BLOCK_VALUE_SHORT = "Valor de bloqueo";
ITEM_MOD_DAMAGE_PER_SECOND_SHORT = "daño por segundo";
LARGE_NUMBER_SEPERATOR = "";
MINUTES_SECONDS = "%.2d:%.2d";
PAPERDOLL_EQUIPMENTMANAGER = "Gestor de equipamiento";
PAPERDOLL_NEWEQUIPMENTSET = "Nuevo conjunto";
PERCENTAGE_STRING = "%d%%";
SET_FRIENDNOTE_LABEL = "Establecer nota para %s:";
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";
+36
View File
@@ -2,6 +2,31 @@ if GetLocale() ~= "frFR" then return end
BINDING_NAME_FOCUSTARGET = "Définir cible";
BINDING_NAME_TARGETFOCUS = "Reprendre cible";
CONFIRM_DELETE_EQUIPMENT_SET = "Voulez-vous vraiment supprimer l'ensemble d'équipement %s ?";
CONFIRM_SAVE_EQUIPMENT_SET = "Voulez-vous sauvegarder l'ensemble d'équipement %s ?";
DEBUFF_SYMBOL_CURSE = "Mn";
DEBUFF_SYMBOL_DISEASE = "Md";
DEBUFF_SYMBOL_MAGIC = "Mg";
DEBUFF_SYMBOL_POISON = "Po";
DEBUG_FRAMESTACK = "Empilement des cadres";
EQUIPMENT_MANAGER = "Gestionnaire d'équipement";
EQUIPMENT_MANAGER_IGNORE_SLOT = "Ignorer cet emplacement";
EQUIPMENT_MANAGER_IS_DISABLED = "Le Gestionnaire d'équipement est désactivé.";
EQUIPMENT_MANAGER_ITEMS_MISSING_TOOLTIP = "%d %s |cffff0000(objet manquant : %d)|r";
EQUIPMENT_MANAGER_PLACE_IN_BAGS = "Mettre en sac";
EQUIPMENT_MANAGER_UNIGNORE_SLOT = "Inclure cet emplacement";
EQUIPMENT_SETS = "Ensembles d'équipement : |cFFFFFFFF%s|r";
EQUIPMENT_SETS_CANT_RENAME = "Un ensemble d'équipement portant ce nom existe déjà.";
EQUIPMENT_SETS_TOO_MANY = "Vous ne pouvez plus créer de nouveaux ensembles d'équipement.";
EQUIPMENT_SET_ASSIGN_TO_SPEC = "Assigner à la spécialisation :";
EQUIPMENT_SET_EDIT = "Modifier le nom/l'icône";
EQUIPMENT_SET_SETTINGS = "Réglages";
EQUIPSET_EQUIP = "Equiper";
ERR_EQUIPMENT_MANAGER_BAGS_FULL = "Échec de l'échange d'équipement. L'inventaire est plein.";
ERR_EQUIPMENT_MANAGER_COMBAT_SWAP_S = "Certains objets de l'ensemble %s ne peuvent pas être échangés car vous êtes en combat.";
ERR_EQUIPMENT_MANAGER_MISSING_ITEM_S = "Une ou plusieurs pièces de l'ensemble %s manquaient et n'ont pu être équipées.";
CLEAR_FOCUS = "Effacer la focalisation";
SET_FOCUS = "Définir la focalisation";
FOCUSTARGET = "Définir la cible de focalisation";
ITEMS_VARIABLE_QUANTITY = "%d |4objet:objets;";
ITEMS_EQUIPPED = "%d |4objet:objets; équipés";
@@ -12,9 +37,14 @@ ITEMS_NOT_IN_INVENTORY = "%d |4objet:objets; manquants";
TARGETFOCUS = "Cibler l'unité focalisée";
-- BINDING_NAME_AOELOOT = "AoE Loot";
COPPER_AMOUNT_SYMBOL = "c";
GEARSETS_POPUP_TEXT = "Nom de l'ensemble (16 caractères max.) :";
GENERIC_FRACTION_STRING = "%d/%d";
GOLD_AMOUNT_SYMBOL = "o";
HOURS_MINUTES_SECONDS = "%.2d:%.2d:%.2d";
ICON_FILTER_ALL = "Toutes les icônes";
ICON_FILTER_ITEM = "Objets";
ICON_FILTER_SPELL = "Sorts";
ICON_SELECTION_TITLE_CURRENT = "Sélection actuelle";
ITEM_DELTA_DESCRIPTION = "Si vous remplacez cet objet, les caractéristiques suivantes seront modifiées :";
ITEM_MOD_MANA_SHORT = "Mana";
ITEM_MOD_HEALTH_SHORT = "Vie";
@@ -42,10 +72,16 @@ ITEM_MOD_BLOCK_VALUE_SHORT = "Valeur de blocage";
ITEM_MOD_DAMAGE_PER_SECOND_SHORT = "Dégâts par seconde";
LARGE_NUMBER_SEPERATOR = "";
MINUTES_SECONDS = "%.2d:%.2d";
PAPERDOLL_EQUIPMENTMANAGER = "Gestionnaire d'équipement";
PAPERDOLL_NEWEQUIPMENTSET = "Nouvel ensemble";
PERCENTAGE_STRING = "%d%%";
SET_FRIENDNOTE_LABEL = "Écrire une note pour %s :";
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";
+36
View File
@@ -2,6 +2,31 @@ if GetLocale() ~= "ruRU" then return end
BINDING_NAME_FOCUSTARGET = "Запомненная цель";
BINDING_NAME_TARGETFOCUS = "Восстановить цель";
CONFIRM_DELETE_EQUIPMENT_SET = "Вы действительно хотите удалить комплект экипировки \"%s\"?";
CONFIRM_SAVE_EQUIPMENT_SET = "Хотите сохранить комплект экипировки \"%s\"?";
DEBUFF_SYMBOL_CURSE = "Пр";
DEBUFF_SYMBOL_DISEASE = "Бз";
DEBUFF_SYMBOL_MAGIC = "Мг";
DEBUFF_SYMBOL_POISON = "Яд";
DEBUG_FRAMESTACK = "Соединение рамок";
EQUIPMENT_MANAGER = "Комплекты экипировки";
EQUIPMENT_MANAGER_IGNORE_SLOT = "Пропустить ячейку";
EQUIPMENT_MANAGER_IS_DISABLED = "Управление экипировкой отключено.";
EQUIPMENT_MANAGER_ITEMS_MISSING_TOOLTIP = "%d %s |cffff0000(предмет для ячейки \"%d\" отсутствует)|r";
EQUIPMENT_MANAGER_PLACE_IN_BAGS = "Поместить в сумку";
EQUIPMENT_MANAGER_UNIGNORE_SLOT = "Учитывать ячейку";
EQUIPMENT_SETS = "Комплекты экипировки: |cFFFFFFFF%s|r";
EQUIPMENT_SETS_CANT_RENAME = "Это название уже используется для другого комплекта.";
EQUIPMENT_SETS_TOO_MANY = "Вы не можете создавать новые комплекты экипировки.";
EQUIPMENT_SET_ASSIGN_TO_SPEC = "Выбрать специализацию:";
EQUIPMENT_SET_EDIT = "Изменить название/значок";
EQUIPMENT_SET_SETTINGS = "Параметры";
EQUIPSET_EQUIP = "Надеть";
ERR_EQUIPMENT_MANAGER_BAGS_FULL = "Не удалось заменить комплект обмундирования. Инвентарь переполнен.";
ERR_EQUIPMENT_MANAGER_COMBAT_SWAP_S = "Некоторые предметы из набора \"%s\" не будут экипированы, поскольку вы участвуете в бою.";
ERR_EQUIPMENT_MANAGER_MISSING_ITEM_S = "Недостает одного или более предметов из набора \"%s\".";
CLEAR_FOCUS = "Отменить запоминание цели";
SET_FOCUS = "Запомнить цель";
FOCUSTARGET = "Запомнить цель";
ITEMS_VARIABLE_QUANTITY = "%d |4предмет:предмета:предметов;";
ITEMS_EQUIPPED = "%d |4предмет экипирован:предмета экипировано:предметов экипировано;";
@@ -12,9 +37,14 @@ ITEMS_NOT_IN_INVENTORY = "%d |4предмет отсутствует:предм
TARGETFOCUS = "Восстановить цель";
-- BINDING_NAME_AOELOOT = "AoE Loot";
COPPER_AMOUNT_SYMBOL = "м";
GEARSETS_POPUP_TEXT = "Введите название комплекта (не более 16 символов):";
GENERIC_FRACTION_STRING = "%d/%d";
GOLD_AMOUNT_SYMBOL = "з";
HOURS_MINUTES_SECONDS = "%.2d:%.2d:%.2d";
ICON_FILTER_ALL = "Все значки";
ICON_FILTER_ITEM = "Предметы";
ICON_FILTER_SPELL = "Заклинания";
ICON_SELECTION_TITLE_CURRENT = "Текущий выбор";
ITEM_DELTA_DESCRIPTION = "Если вы замените этот предмет, произойдут следующие изменения:";
ITEM_MOD_MANA_SHORT = "к мане";
ITEM_MOD_HEALTH_SHORT = "к здоровью";
@@ -42,10 +72,16 @@ ITEM_MOD_BLOCK_VALUE_SHORT = "к показателю блокирования";
ITEM_MOD_DAMAGE_PER_SECOND_SHORT = "к урону в секунду";
LARGE_NUMBER_SEPERATOR = "";
MINUTES_SECONDS = "%.2d:%.2d";
PAPERDOLL_EQUIPMENTMANAGER = "Комплекты экипировки";
PAPERDOLL_NEWEQUIPMENTSET = "Новый комплект";
PERCENTAGE_STRING = "%d%%";
SET_FRIENDNOTE_LABEL = "Заметка о |3-5(%s):";
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";
+36
View File
@@ -2,6 +2,31 @@ if GetLocale() ~= "zhCN" then return end
BINDING_NAME_FOCUSTARGET = "焦点目标";
BINDING_NAME_TARGETFOCUS = "目标焦点";
CONFIRM_DELETE_EQUIPMENT_SET = "你确认要删除装备方案%s吗?";
CONFIRM_SAVE_EQUIPMENT_SET = "你想要保存装备方案\"%s\"吗?";
DEBUFF_SYMBOL_CURSE = "诅";
DEBUFF_SYMBOL_DISEASE = "疾";
DEBUFF_SYMBOL_MAGIC = "魔";
DEBUFF_SYMBOL_POISON = "毒";
DEBUG_FRAMESTACK = "框架栈";
EQUIPMENT_MANAGER = "装备管理";
EQUIPMENT_MANAGER_IGNORE_SLOT = "忽略此栏位";
EQUIPMENT_MANAGER_IS_DISABLED = "装备管理已禁用。";
EQUIPMENT_MANAGER_ITEMS_MISSING_TOOLTIP = "%d %s |cffff0000(缺少%d)|r";
EQUIPMENT_MANAGER_PLACE_IN_BAGS = "放入背包";
EQUIPMENT_MANAGER_UNIGNORE_SLOT = "包括此栏位";
EQUIPMENT_SETS = "装备配置方案:|cFFFFFFFF%s|r";
EQUIPMENT_SETS_CANT_RENAME = "一个重名的装备方案已经存在。";
EQUIPMENT_SETS_TOO_MANY = "你不能再创建新的装备方案了。";
EQUIPMENT_SET_ASSIGN_TO_SPEC = "指定专精:";
EQUIPMENT_SET_EDIT = "修改名称/图标";
EQUIPMENT_SET_SETTINGS = "设置";
EQUIPSET_EQUIP = "装备";
ERR_EQUIPMENT_MANAGER_BAGS_FULL = "装备更换失败。背包已满。";
ERR_EQUIPMENT_MANAGER_COMBAT_SWAP_S = "装备方案%s中的某些物品不能在战斗中切换。";
ERR_EQUIPMENT_MANAGER_MISSING_ITEM_S = "未找到套装方案%s中的一件或多件物品,无法装备。";
CLEAR_FOCUS = "清除焦点";
SET_FOCUS = "设置焦点";
FOCUSTARGET = "设定焦点目标";
ITEMS_VARIABLE_QUANTITY = "%d件物品";
ITEMS_EQUIPPED = "已装备%d件物品";
@@ -12,9 +37,14 @@ ITEMS_NOT_IN_INVENTORY = "缺少%d件物品";
TARGETFOCUS = "选中焦点目标";
-- BINDING_NAME_AOELOOT = "AoE Loot";
COPPER_AMOUNT_SYMBOL = "铜";
GEARSETS_POPUP_TEXT = "输入方案名称(最多16个字符):";
GENERIC_FRACTION_STRING = "%d/%d";
GOLD_AMOUNT_SYMBOL = "金";
HOURS_MINUTES_SECONDS = "%.2d:%.2d:%.2d";
ICON_FILTER_ALL = "所有图标";
ICON_FILTER_ITEM = "物品";
ICON_FILTER_SPELL = "法术";
ICON_SELECTION_TITLE_CURRENT = "当前已选择";
ITEM_DELTA_DESCRIPTION = "如果你替换该物品,将会产生以下的属性变更:";
ITEM_MOD_MANA_SHORT = "法力值";
ITEM_MOD_HEALTH_SHORT = "生命值";
@@ -42,10 +72,16 @@ ITEM_MOD_BLOCK_VALUE_SHORT = "格挡值";
ITEM_MOD_DAMAGE_PER_SECOND_SHORT = "每秒伤害";
LARGE_NUMBER_SEPERATOR = ",";
MINUTES_SECONDS = "%.2d:%.2d";
PAPERDOLL_EQUIPMENTMANAGER = "装备管理";
PAPERDOLL_NEWEQUIPMENTSET = "新的方案";
PERCENTAGE_STRING = "%d%%";
SET_FRIENDNOTE_LABEL = "为%s设置备注:";
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";
+36
View File
@@ -2,6 +2,31 @@ if GetLocale() ~= "zhTW" then return end
BINDING_NAME_FOCUSTARGET = "鎖定目標";
BINDING_NAME_TARGETFOCUS = "選定鎖定目標";
CONFIRM_DELETE_EQUIPMENT_SET = "你確定要刪除裝備設定:%s嗎?";
CONFIRM_SAVE_EQUIPMENT_SET = "你想要儲存裝備設定「%s」嗎?";
DEBUFF_SYMBOL_CURSE = "咒";
DEBUFF_SYMBOL_DISEASE = "疾";
DEBUFF_SYMBOL_MAGIC = "魔";
DEBUFF_SYMBOL_POISON = "毒";
DEBUG_FRAMESTACK = "框架堆疊";
EQUIPMENT_MANAGER = "裝備管理員";
EQUIPMENT_MANAGER_IGNORE_SLOT = "忽略此欄位";
EQUIPMENT_MANAGER_IS_DISABLED = "裝備管理員已關閉。";
EQUIPMENT_MANAGER_ITEMS_MISSING_TOOLTIP = "%d件%s|cffff0000(缺少%d件)|r";
EQUIPMENT_MANAGER_PLACE_IN_BAGS = "放進背包";
EQUIPMENT_MANAGER_UNIGNORE_SLOT = "包含此欄位";
EQUIPMENT_SETS = "裝備設定:|cFFFFFFFF%s|r";
EQUIPMENT_SETS_CANT_RENAME = "您所選擇的名稱已經有裝備設定使用了。";
EQUIPMENT_SETS_TOO_MANY = "你無法製造更多的新裝備設定。";
EQUIPMENT_SET_ASSIGN_TO_SPEC = "指定專精:";
EQUIPMENT_SET_EDIT = "改變名稱/圖示";
EQUIPMENT_SET_SETTINGS = "設定";
EQUIPSET_EQUIP = "裝備";
ERR_EQUIPMENT_MANAGER_BAGS_FULL = "裝備切換失敗。物品欄已滿。";
ERR_EQUIPMENT_MANAGER_COMBAT_SWAP_S = "%s設定中的某些物品必須等到你脫離戰鬥後才能更換。";
ERR_EQUIPMENT_MANAGER_MISSING_ITEM_S = "%s設定中的一個或多個物品未尋獲,因此無法裝備。";
CLEAR_FOCUS = "清除專注目標";
SET_FOCUS = "設定專注目標";
FOCUSTARGET = "設定鎖定目標";
ITEMS_VARIABLE_QUANTITY = "%d件物品";
ITEMS_EQUIPPED = "裝備%d件物品";
@@ -12,9 +37,14 @@ ITEMS_NOT_IN_INVENTORY = "缺少%d件物品";
TARGETFOCUS = "選定鎖定單位";
-- BINDING_NAME_AOELOOT = "AoE Loot";
COPPER_AMOUNT_SYMBOL = "銅";
GEARSETS_POPUP_TEXT = "輸入設定名稱(最多16個字元):";
GENERIC_FRACTION_STRING = "%d/%d";
GOLD_AMOUNT_SYMBOL = "金";
HOURS_MINUTES_SECONDS = "%.2d:%.2d:%.2d";
ICON_FILTER_ALL = "所有圖示";
ICON_FILTER_ITEM = "物品";
ICON_FILTER_SPELL = "法術";
ICON_SELECTION_TITLE_CURRENT = "目前選擇";
ITEM_DELTA_DESCRIPTION = "若你更換此裝備,下列屬性將會改變:";
ITEM_MOD_MANA_SHORT = "法力";
ITEM_MOD_HEALTH_SHORT = "生命力";
@@ -42,10 +72,16 @@ ITEM_MOD_BLOCK_VALUE_SHORT = "格擋值";
ITEM_MOD_DAMAGE_PER_SECOND_SHORT = "每秒傷害";
LARGE_NUMBER_SEPERATOR = ",";
MINUTES_SECONDS = "%.2d:%.2d";
PAPERDOLL_EQUIPMENTMANAGER = "裝備管理員";
PAPERDOLL_NEWEQUIPMENTSET = "新設定";
PERCENTAGE_STRING = "%d%%";
SET_FRIENDNOTE_LABEL = "設定%s的註記:";
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";
+4 -23
View File
@@ -155,25 +155,6 @@ _initFrame:SetScript("OnEvent", function()
end
end);
-- Vanilla has no Frame:IsMouseOver(); do it ourselves from cursor + bounds.
local function DebugTools_IsMouseOver(frame)
if (not frame) then return false; end
local visOK, visible = pcall(frame.IsVisible, frame);
if (not visOK or not visible) then return false; end
local sOK, scale = pcall(frame.GetEffectiveScale, frame);
if (not sOK or not scale or scale == 0) then return false; end
local cx, cy = GetCursorPosition();
cx = cx / scale;
cy = cy / scale;
local lOK, left = pcall(frame.GetLeft, frame);
local rOK, right = pcall(frame.GetRight, frame);
local tOK, top = pcall(frame.GetTop, frame);
local bOK, bottom = pcall(frame.GetBottom, frame);
if (not (lOK and rOK and tOK and bOK)) then return false; end
if (not (left and right and top and bottom)) then return false; end
return cx >= left and cx <= right and cy >= bottom and cy <= top;
end
local _EventTraceFrame;
_framesSinceLast = 0;
@@ -566,7 +547,7 @@ function EventTraceFrame_Update()
button:GetHighlightTexture():SetVertexColor(.8, .8, 1, .15);
button:UnlockHighlight();
end
if (DebugTools_IsMouseOver(button)) then
if MouseIsOver(button) then
EventTraceFrameEvent_OnEnter(button);
end
end
@@ -1016,8 +997,8 @@ local function _CollectFramesUnderMouse(showHidden)
local frame = EnumerateFrames(nil);
while (frame) do
local visOK, visible = pcall(frame.IsVisible, frame);
local mouseOver = DebugTools_IsMouseOver(frame);
if (mouseOver and (showHidden or (visOK and visible))) then
local overOK, mouseOver = pcall(frame.IsMouseOver, frame);
if (overOK and mouseOver and (showHidden or (visOK and visible))) then
local nameOK, name = pcall(frame.GetName, frame);
if (not nameOK or not name) then name = "<anonymous>"; end
table.insert(matches, { name = name, frame = frame });
@@ -1037,7 +1018,7 @@ end
local function _UpdateFrameStackTooltip(tooltip, showHidden)
tooltip:ClearLines();
tooltip:AddLine("Frame Stack", 1, 1, 1);
tooltip:AddLine(DEBUG_FRAMESTACK or "Frame Stack", 1, 1, 1);
local matches = _CollectFramesUnderMouse(showHidden);
local count = table.getn(matches);
if (count == 0) then
+13 -5
View File
@@ -456,14 +456,11 @@
</Anchor>
</Anchors>
</FontString>
<!-- Breadcrumb showing the active navigation path,
e.g. "_G > MyFrame > backdrop". Updated each
`TableInspectorFrame_Update` tick. -->
<FontString name="$parentBreadcrumb" inherits="GameFontHighlightSmall" justifyH="LEFT">
<Size x="380" y="16"/>
<Size x="300" y="16"/>
<Anchors>
<Anchor point="TOPLEFT">
<Offset x="80" y="-34"/>
<Offset x="156" y="-34"/>
</Anchor>
</Anchors>
</FontString>
@@ -510,6 +507,17 @@
<OnClick>TableInspector_Back();</OnClick>
</Scripts>
</Button>
<Button name="$parentRefreshButton" inherits="UIPanelButtonTemplate" text="Refresh">
<Size x="70" y="20"/>
<Anchors>
<Anchor point="LEFT" relativeTo="$parentBackButton" relativePoint="RIGHT">
<Offset x="4" y="0"/>
</Anchor>
</Anchors>
<Scripts>
<OnClick>TableInspector_Refresh();</OnClick>
</Scripts>
</Button>
<Slider name="$parentScroll" orientation="VERTICAL">
<Size x="16" y="0"/>
<Anchors>
+8
View File
@@ -20,6 +20,7 @@
-- TableInspector_Show(tbl, label) -- open with `tbl` as root, `label` shown in breadcrumb
-- TableInspector_Push(tbl, label) -- drill in (called by row click)
-- TableInspector_Back() -- pop one level
-- TableInspector_Refresh() -- re-read the current level's live table (scroll kept)
-- TableInspector_Close() -- hide the window
-- /tinspect <luaexpr> -- evaluate `<luaexpr>` as Lua and open if result is a table
------------------------------------------------------------------------------
@@ -184,6 +185,13 @@ function TableInspector_Back()
TableInspectorFrame_Update();
end
function TableInspector_Refresh()
local top = _navStack[table.getn(_navStack)];
if (not top or type(top.tbl) ~= "table") then return; end
_RebuildEntries();
TableInspectorFrame_Update();
end
function TableInspector_Close()
if (_TableInspectorFrame) then _TableInspectorFrame:Hide(); end
_navStack = {};
+64 -30
View File
@@ -25,6 +25,39 @@ set(TINYCBOR_SOURCES
external/tinycbor/src/cborparser_float.c
external/tinycbor/src/cborerrorstrings.c)
# Version derivation. Dev builds use a sentinel "newer than every
# plausible release" so addon-side `CLASSIC_API_VERSION >= X` gates
# always pass locally. CI release builds pass `-DCLASSICAPI_TAG=vX.Y.Z`,
# which drives BOTH the compiled `CLASSIC_API_VERSION` int AND the
# embedded addon's `## Version:` toc line (substituted in
# embed_addon.cmake). The on-disk source toc stays `## Version: DEV`;
# only the embedded copy is stamped, so a release build needs nothing
# but the flag — no source edit.
set(CLASSICAPI_VERSION_VALUE 99999999)
set(CLASSICAPI_VER_MAJOR 9999)
set(CLASSICAPI_VER_MINOR 99)
set(CLASSICAPI_VER_PATCH 99)
set(CLASSICAPI_TOC_VERSION "DEV")
set(CLASSICAPI_TAG
""
CACHE STRING "Release tag like v1.2.3")
if(CLASSICAPI_TAG MATCHES "^v([0-9]+)\\.([0-9]+)\\.([0-9]+)$")
set(CLASSICAPI_VER_MAJOR "${CMAKE_MATCH_1}")
set(CLASSICAPI_VER_MINOR "${CMAKE_MATCH_2}")
set(CLASSICAPI_VER_PATCH "${CMAKE_MATCH_3}")
math(EXPR CLASSICAPI_VERSION_VALUE
"${CLASSICAPI_VER_MAJOR} * 10000 + ${CLASSICAPI_VER_MINOR} * 100 + ${CLASSICAPI_VER_PATCH}")
set(CLASSICAPI_TOC_VERSION
"${CLASSICAPI_VER_MAJOR}.${CLASSICAPI_VER_MINOR}.${CLASSICAPI_VER_PATCH}")
elseif(CLASSICAPI_TAG)
message(
FATAL_ERROR
"Invalid CLASSICAPI_TAG: ${CLASSICAPI_TAG} (expected vMAJOR.MINOR.PATCH)"
)
endif()
# Bundle `AddOns/!!!ClassicAPI/` into a generated header so the DLL can
# surface the addon to the engine even when the user doesn't have it
# installed on disk. The file-read hook in `src/addons/Embedded.cpp`
@@ -34,12 +67,16 @@ set(EMBEDDED_CLASSICAPI_SRC "${CMAKE_CURRENT_SOURCE_DIR}/AddOns/!!!ClassicAPI")
set(EMBEDDED_CLASSICAPI_HEADER "${CMAKE_BINARY_DIR}/generated/embedded_classicapi.h")
file(GLOB_RECURSE EMBEDDED_CLASSICAPI_FILES CONFIGURE_DEPENDS
"${EMBEDDED_CLASSICAPI_SRC}/*")
# The `.classicapi-dev` marker is a disk-only developer signal — keep it
# out of the embed inputs (embed_addon.cmake filters it too).
list(FILTER EMBEDDED_CLASSICAPI_FILES EXCLUDE REGEX "/\\.classicapi-dev$")
add_custom_command(
OUTPUT "${EMBEDDED_CLASSICAPI_HEADER}"
COMMAND ${CMAKE_COMMAND}
-DSRC_DIR=${EMBEDDED_CLASSICAPI_SRC}
-DOUT_HEADER=${EMBEDDED_CLASSICAPI_HEADER}
-DADDON_NAME=ClassicAPI
-DADDON_VERSION=${CLASSICAPI_TOC_VERSION}
-P "${CMAKE_CURRENT_SOURCE_DIR}/cmake/embed_addon.cmake"
DEPENDS
${EMBEDDED_CLASSICAPI_FILES}
@@ -71,33 +108,6 @@ add_library(${PROJECT_NAME} SHARED
${EMBEDDED_CLASSICAPI_HEADER}
${EMBEDDED_BINDINGS_HEADER})
# Dev-build sentinel — represents "newer than every plausible
# release". Encoded as v9999.99.99 = 9999 * 10000 + 99 * 100 + 99 =
# 99,999,999. Picked so addon-side feature gates of the form
# `CLASSIC_API_VERSION >= X` always pass during local development;
# CI release builds overwrite it via `-DCLASSICAPI_TAG=vX.Y.Z`.
set(CLASSICAPI_VERSION_VALUE 99999999)
set(CLASSICAPI_VER_MAJOR 9999)
set(CLASSICAPI_VER_MINOR 99)
set(CLASSICAPI_VER_PATCH 99)
set(CLASSICAPI_TAG
""
CACHE STRING "Release tag like v1.2.3")
if(CLASSICAPI_TAG MATCHES "^v([0-9]+)\\.([0-9]+)\\.([0-9]+)$")
set(CLASSICAPI_VER_MAJOR "${CMAKE_MATCH_1}")
set(CLASSICAPI_VER_MINOR "${CMAKE_MATCH_2}")
set(CLASSICAPI_VER_PATCH "${CMAKE_MATCH_3}")
math(EXPR CLASSICAPI_VERSION_VALUE
"${CLASSICAPI_VER_MAJOR} * 10000 + ${CLASSICAPI_VER_MINOR} * 100 + ${CLASSICAPI_VER_PATCH}")
elseif(CLASSICAPI_TAG)
message(
FATAL_ERROR
"Invalid CLASSICAPI_TAG: ${CLASSICAPI_TAG} (expected vMAJOR.MINOR.PATCH)"
)
endif()
target_compile_definitions(
${PROJECT_NAME}
PRIVATE CLASSICAPI_VERSION_VALUE=${CLASSICAPI_VERSION_VALUE})
@@ -118,7 +128,9 @@ target_sources(${PROJECT_NAME} PRIVATE "${CLASSICAPI_VERSION_RC}")
# ole32: COM (CoInitializeEx / CoCreateInstance / CoTaskMemFree) for the
# SAPI text-to-speech backend in src/voicechat/Tts.cpp. The SAPI GUIDs are
# instantiated in that TU via <initguid.h>, so no sapi.lib is needed.
target_link_libraries(${PROJECT_NAME} PRIVATE minhook advapi32 ole32)
# user32: Win32 clipboard API (OpenClipboard / SetClipboardData) for
# CopyToClipboard in src/clipboard/Copy.cpp.
target_link_libraries(${PROJECT_NAME} PRIVATE minhook advapi32 ole32 user32)
target_include_directories(
${PROJECT_NAME} PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/src
@@ -130,7 +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
"/OPT:REF /OPT:ICF /MERGE:.rdata=.text /MERGE:.pdata=.text /SECTION:.text,EWR /NODEFAULTLIB:MSVCRT"
# 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 /DEBUG /DEF:${CMAKE_CURRENT_SOURCE_DIR}/src/ClassicAPI.def"
)
+163 -80
View File
@@ -1,52 +1,87 @@
# ClassicAPI
A small DLL for World of Warcraft 1.12.1 (Vanilla / Turtle WoW) that adds a
collection of Lua API calls Blizzard never exposed in 1.12 but which make
addon authoring noticeably less painful — primarily for backporting addons
written against later API versions (3.3.5+) where these calls do exist.
ClassicAPI is a DLL for World of Warcraft 1.12.1 (Vanilla / Turtle WoW). It
backports a large part of the modern WoW API — 550+ functions and 50+ events,
plus much of Lua 5.1 — into the 1.12 client, so addons written for later
versions (3.3.5+ or Classic Era) run with little or no change.
The DLL hooks the FrameScript engine after WoW boots and registers its
extensions through the same in-engine mechanisms WoW uses for its own Lua
functions. No companion addon is required.
It hooks the engine after WoW boots and registers everything the same way WoW
registers its own Lua functions. Nothing else to install — the DLL is
self-contained.
## What's added
## Highlights
Full per-function reference: **[docs/API.md](docs/API.md)**.
The headline features. Each one takes effect as soon as the DLL loads, so your
existing addons benefit without any code changes. The complete per-function
reference is in **[docs/API.md](docs/API.md)**.
### Calls
### Lua 5.1 compatibility
The flagship feature: run modern Lua 5.1 addon code on 1.12's Lua 5.0 VM.
| Feature | Effect |
|---------|--------|
| [Syntax](docs/API.md#lua-51-syntax) | Compiles the Lua 5.1 length (`#`), modulo (`%`), `...`-expression, `0x` hex-literal, and leveled long bracket (`[=[ ]=]`) syntax that vanilla's Lua 5.0 rejects, by rewriting addon source before it compiles. Each addon file also receives its `(name, table)` through `...` (`local name, tbl = ...`). |
| [String methods](docs/API.md#string-methods-supper-sformat) | Every string value accepts method calls — `("asd"):upper()`, `("%d gold"):format(n)`, `msg:match("^!(%w+)")` — resolving through the `string` table, the way Lua 5.1 works. Works on literals and variables, in-world and on the login screen, and inside coroutines. |
| [Script-handler arguments](docs/API.md#setmodernscriptargsenable--getmodernscriptargs) | Frame-script handlers receive their values as positional arguments — `OnMouseWheel(self, delta)`, `OnClick(self, button)`, `OnEvent(self, event, ...)`, etc. — so modern addon ports work unmodified. The `this` / `arg1` globals stay set. A handler that declares no parameters is unaffected. A handler that declared a parameter and expected nil now receives its real value. On by default. `SetModernScriptArgs(false)` restores exact vanilla dispatch. |
### Modern client behaviors
| Feature | Effect |
|---------|--------|
| Inline textures | Draws inline texture markup (`\|T…\|t`) as icons in FontStrings, chat, and tooltips. This covers item and spell icons, raid-target markers, and the coin icons in money strings. `GetStringWidth` and `GetStringHeight` count the icons, so measured width and text wrapping stay correct. Done in pure C++ by hooking the engine's text pipeline — no addon. |
| Tooltip line cap | Lifts `GameTooltip`'s hard 30-line limit to 60 for every `GameTooltipTemplate` frame (`GameTooltip`, `ShoppingTooltip1/2`, `ItemRefTooltip`, AtlasLoot, …). Stat-heavy tooltips and comparison blocks (e.g. pfUI's eqcompare) no longer have their extra lines silently dropped. Done in pure C++ by growing the engine's FontString pool at tooltip-creation time. |
| [Event-driven nameplates](docs/API.md#nameplate) | The modern `C_NamePlate` API, driven by real events. `NAME_PLATE_UNIT_ADDED` / `NAME_PLATE_UNIT_REMOVED` fire as plates appear and vanish, `nameplate1`..`nameplateN` tokens resolve with every `UnitX` function (and fire `UNIT_HEALTH`, `UNIT_AURA`, … as `arg1 == "nameplateN"`), and `C_NamePlate.GetNamePlateForUnit` / `GetNamePlates` hand back the live frames. |
| [Focus target](docs/API.md#focus) | A sticky focus unit. `FocusUnit("target")` pins it and `ClearFocus()` drops it, with `PLAYER_FOCUS_CHANGED` on every change. The `focus` / `focustarget` tokens resolve with every `UnitX` function and fire unit events (`UNIT_HEALTH`, `UNIT_AURA`, … as `arg1 == "focus"`), and the predefined `FOCUSTARGET` / `TARGETFOCUS` keybinds are ready to set. |
| [Retail-like `/reload`](docs/API.md#reload-picks-up-new-addons-and-new-files) | `/reload` picks up addon changes made while the game runs. A new folder under `Interface\AddOns\` registers and loads as a normal addon, new files added to an existing addon's TOC load, a newly installed addon's first SavedVariables save survives `/reload`, `##` metadata edits (`## SavedVariables:`, `## Dependencies:`, `## Title:`, …) take effect, and a deleted addon folder drops from the addon list. |
| [Multi-flavor & conditional TOC](docs/API.md#conditional-and-multi-flavor-toc-loading) | Loads modern multi-flavor addons that ship one folder. Selects a version-specific TOC (`<Name>_ClassicAPI.toc` or `<Name>_Turtle.toc`) and the matching keybinding file (`Bindings_ClassicAPI.xml` / `Bindings_Turtle.xml`), accepts a comma-separated `## Interface:` version list (compatible when it includes the client version `11200`), and honors per-line `[AllowLoadGameType]` / `[AllowLoadTextLocale]` conditions and `[Family]` / `[Game]` / `[TextLocale]` path variables inside a TOC. |
## Full API reference
Everything ClassicAPI adds, in full. Each section is collapsed — click a
heading to expand it. For signatures and return values, see the per-function
reference in **[docs/API.md](docs/API.md)**.
<details>
<summary><b>In-game Lua calls</b> — 550+ functions across ~60 namespaces</summary>
| Namespace | Calls |
|-----------|-------|
| [Action](docs/API.md#action) | `GetActionInfo` |
| [AddOns](docs/API.md#addons) | `C_AddOns.DoesAddOnExist`, `C_AddOns.GetAddOnName`, `C_AddOns.GetAddOnNotes`, `C_AddOns.GetAddOnOptionalDependencies`, `C_AddOns.GetAddOnSecurity`, `C_AddOns.GetAddOnTitle`, `C_AddOns.IsAddOnLoadable`, `C_AddOns.IsAddOnLoaded` |
| [AddOns](docs/API.md#addons) | `C_AddOns.DoesAddOnExist`, `C_AddOns.GetAddOnLocalTable`, `C_AddOns.GetAddOnName`, `C_AddOns.GetAddOnNotes`, `C_AddOns.GetAddOnOptionalDependencies`, `C_AddOns.GetAddOnSecurity`, `C_AddOns.GetAddOnTitle`, `C_AddOns.IsAddOnLoadable`, `C_AddOns.IsAddOnLoaded` |
| [AuctionHouse](docs/API.md#auctionhouse) | `C_AuctionHouse.PostItem` |
| [Bindings](docs/API.md#bindings) | `SetBindingSpell`, `SetBindingItem`, `SetBindingMacro`, `SetBindingClick`, `SetOverrideBinding`, `SetOverrideBindingSpell`, `SetOverrideBindingItem`, `SetOverrideBindingMacro`, `SetOverrideBindingClick`, `ClearOverrideBindings` |
| [Chat](docs/API.md#chat) | `GetCurrentChatGUID` |
| [ChatBubbles](docs/API.md#chatbubbles) | `C_ChatBubbles.GetAllChatBubbles` |
| [Class](docs/API.md#class) | `FillLocalizedClassList` |
| [Combat](docs/API.md#combat) | `InCombatLockdown` |
| [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`, `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` |
| [Creature](docs/API.md#creature) | `C_CreatureInfo.GetCreatureID`, `C_CreatureInfo.GetCreatureInfoByID`, `C_CreatureInfo.RequestLoadCreatureByID`, `C_CreatureInfo.GetRaceInfo`, `C_CreatureInfo.GetClassInfo`, `C_CreatureInfo.GetCreatureFamilyInfo`, `C_CreatureInfo.GetCreatureFamilyIDs`, `C_CreatureInfo.GetFactionInfo`, `C_CreatureInfo.GetCreatureTypeInfo`, `C_CreatureInfo.GetCreatureTypeIDs` |
| [Currency](docs/API.md#currency) | `GetCoinTextureString`, `C_CurrencyInfo.GetCoinTextureString` |
| EncodingUtil | `C_EncodingUtil.CompressString`, `C_EncodingUtil.DecompressString`, `C_EncodingUtil.EncodeBase64`, `C_EncodingUtil.DecodeBase64`, `C_EncodingUtil.EncodeHex`, `C_EncodingUtil.DecodeHex`, `C_EncodingUtil.SerializeJSON`, `C_EncodingUtil.DeserializeJSON`, `C_EncodingUtil.SerializeCBOR`, `C_EncodingUtil.DeserializeCBOR` |
| [EquipmentSet](docs/API.md#equipmentset) | `C_EquipmentSet.CanUseEquipmentSets`, `C_EquipmentSet.ClearIgnoredSlotsForSave`, `C_EquipmentSet.CreateEquipmentSet`, `C_EquipmentSet.DeleteEquipmentSet`, `C_EquipmentSet.EquipmentSetContainsLockedItems`, `C_EquipmentSet.GetEquipmentSetID`, `C_EquipmentSet.GetEquipmentSetIDs`, `C_EquipmentSet.GetEquipmentSetInfo`, `C_EquipmentSet.GetIgnoredSlots`, `C_EquipmentSet.GetItemIDs`, `C_EquipmentSet.GetItemLocations`, `C_EquipmentSet.GetNumEquipmentSets`, `C_EquipmentSet.IgnoreSlotForSave`, `C_EquipmentSet.IsSlotIgnoredForSave`, `C_EquipmentSet.ModifyEquipmentSet`, `C_EquipmentSet.SaveEquipmentSet`, `C_EquipmentSet.UnignoreSlotForSave`, `C_EquipmentSet.UseEquipmentSet` |
| [Events](docs/API.md#events) | `C_EventUtils.IsEventValid` |
| [Events](docs/API.md#events) | `C_EventUtils.IsEventValid`, `GetFramesRegisteredForEvent` |
| [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`, `frame:SetShown`, `frame:SetResizeBounds`, `frame:HookScript`, `frame:IsEventRegistered` |
| [FriendList](docs/API.md#friendlist) | `C_FriendList.IsWhoQueryPending`, `C_FriendList.SendWhoQueryByName` |
| [GameObject](docs/API.md#gameobject) | `C_GameObjectInfo.GetGameObjectInfoByID`, `C_GameObjectInfo.RequestLoadGameObjectByID` |
| [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` |
| [Gossip](docs/API.md#gossip) | `C_GossipInfo.CloseGossip`, `C_GossipInfo.GetActiveQuests`, `C_GossipInfo.GetAvailableQuests`, `C_GossipInfo.GetNumActiveQuests`, `C_GossipInfo.GetNumAvailableQuests`, `C_GossipInfo.GetNumOptions`, `C_GossipInfo.GetOptions`, `C_GossipInfo.GetText`, `C_GossipInfo.SelectActiveQuest`, `C_GossipInfo.SelectAvailableQuest`, `C_GossipInfo.SelectOption`, `C_GossipInfo.SelectOptionByIndex` |
| [GameTooltip](docs/API.md#gametooltip) | `GameTooltip:GetGameObject`, `GameTooltip:GetItem`, `GameTooltip:GetOwner`, `GameTooltip:GetSpell`, `GameTooltip:GetUnitGUID`, `GameTooltip:HasGameObject`, `GameTooltip:HasItem`, `GameTooltip:HasSpell`, `GameTooltip:HasUnit`, `GameTooltip:IsEquippedItem`, `GameTooltip:SetEquipmentSet`, `GameTooltip:SetHyperlinkCompareItem`, `GameTooltip:SetInventoryItemByID`, `GameTooltip:SetItemByGUID`, `GameTooltip:SetItemByID`, `GameTooltip:SetSpellByID`, `GameTooltip:SetTalentByID`, `GameTooltip:SetUnitAura`, `OnTooltipSetItem` (script) |
| [GameTooltip](docs/API.md#gametooltip) | `GameTooltip:AddSpellByID`, `GameTooltip:GetGameObject`, `GameTooltip:GetItem`, `GameTooltip:GetOwner`, `GameTooltip:GetSpell`, `GameTooltip:GetUnitGUID`, `GameTooltip:HasGameObject`, `GameTooltip:HasItem`, `GameTooltip:HasSpell`, `GameTooltip:HasUnit`, `GameTooltip:IsEquippedItem`, `GameTooltip:SetEquipmentSet`, `GameTooltip:SetHyperlinkCompareItem`, `GameTooltip:SetInventoryItemByID`, `GameTooltip:SetItemByGUID`, `GameTooltip:SetItemByID`, `GameTooltip:SetSpellByID`, `GameTooltip:SetTalentByID`, `GameTooltip:SetTotem`, `GameTooltip:SetUnitAura`, `OnTooltipSetItem`, `OnTooltipSetSpell`, `OnTooltipSetUnit`, `OnTooltipSetGameObject` (scripts) |
| [Hooks](docs/API.md#hooks) | `hooksecurefunc` |
| [Input](docs/API.md#input) | `GetMouseButtonClicked`, `IsLeftAltKeyDown`, `IsLeftControlKeyDown`, `IsLeftShiftKeyDown`, `IsModifierKeyDown`, `IsMouseButtonDown`, `IsRightAltKeyDown`, `IsRightControlKeyDown`, `IsRightShiftKeyDown` |
| [Instance](docs/API.md#instance) | `GetInstanceInfo` |
| [Item](docs/API.md#item) | `C_Item.DoesItemExist`, `C_Item.DoesItemExistByID`, `C_Item.EquipItemByName`, `C_Item.GetCurrentItemLevel`, `C_Item.GetDetailedItemLevelInfo`, `C_Item.GetEnchantInfo`, `C_Item.GetItemCount`, `C_Item.GetItemFamily`, `C_Item.GetItemGUID`, `C_Item.GetItemIcon`, `C_Item.GetItemIconByID`, `C_Item.GetItemID`, `C_Item.GetItemInfo`, `C_Item.GetItemInfoInstant`, `C_Item.GetItemInventorySlotInfo`, `C_Item.GetItemInventorySlotKey`, `C_Item.GetItemInventoryType`, `C_Item.GetItemInventoryTypeByID`, `C_Item.GetItemLink`, `C_Item.GetItemLocation`, `C_Item.GetItemMaxStackSize`, `C_Item.GetItemMaxStackSizeByID`, `C_Item.GetItemName`, `C_Item.GetItemNameByID`, `C_Item.GetItemQuality`, `C_Item.GetItemQualityByID`, `C_Item.GetItemSellPrice`, `C_Item.GetItemSellPriceByID`, `C_Item.GetItemSetID`, `C_Item.GetItemSetIDByID`, `C_Item.GetItemSetInfo`, `C_Item.GetItemSpell`, `C_Item.GetItemStatDelta`, `C_Item.GetItemStats`, `C_Item.GetItemSubClassInfo`, `C_Item.GetItemUniqueness`, `C_Item.GetItemUniquenessByID`, `C_Item.GetStackCount`, `C_Item.GetWeaponEnchantInfo`, `C_Item.IsBound`, `C_Item.IsConsumableItem`, `C_Item.IsEquippableItem`, `C_Item.IsEquippedItem`, `C_Item.IsItemDataCached`, `C_Item.IsItemDataCachedByID`, `C_Item.IsItemGUIDInInventory`, `C_Item.IsItemInRange`, `C_Item.IsItemOpenable`, `C_Item.IsLocked`, `C_Item.LockItem`, `C_Item.LockItemByGUID`, `C_Item.PickupItem`, `C_Item.UnlockAllItems`, `C_Item.UnlockItem`, `C_Item.RequestLoadItemData`, `C_Item.RequestLoadItemDataByID`, `C_Item.UseAtCursor`, `C_Item.UseAtUnit`, `C_Item.UseItemByName`, `GetAuctionItemID`, `GetAuctionSellItemID`, `GetAverageItemLevel`, `GetCraftReagentItemID`, `GetInboxItemID`, `GetInventoryItemDurability`, `GetInventoryItemID`, `GetInventoryItemsForSlot`, `GetInventoryItemRepairCost`, `GetItemIcon`, `GetLootRollItemID`, `GetLootSlotItemID`, `GetMerchantItemID`, `GetQuestItemID`, `GetQuestLogItemID`, `GetTradePlayerItemID`, `GetTradeSkillItemID`, `GetTradeSkillReagentItemID`, `GetTradeTargetItemID`, `OffhandHasWeapon` |
| [Loot](docs/API.md#loot) | `C_Loot.GetNearbyLootableUnits`, `C_Loot.GetLastScanResults`, `C_Loot.IsScanInProgress`, `C_Loot.LootUnit`, `C_Loot.LootUnitItem`, `C_Loot.ScanNearbyLoot` |
| [Item](docs/API.md#item) | `C_Item.DoesItemExist`, `C_Item.DoesItemExistByID`, `C_Item.EquipItemByName`, `C_Item.GetCurrentItemLevel`, `C_Item.GetDetailedItemLevelInfo`, `C_Item.GetEnchantInfo`, `C_Item.GetItemCount`, `C_Item.GetItemFamily`, `C_Item.GetItemGUID`, `C_Item.GetItemIcon`, `C_Item.GetItemIconByID`, `C_Item.GetItemID`, `C_Item.GetItemInfo`, `C_Item.GetItemInfoInstant`, `C_Item.GetItemInventorySlotInfo`, `C_Item.GetItemInventorySlotKey`, `C_Item.GetItemInventoryType`, `C_Item.GetItemInventoryTypeByID`, `C_Item.GetItemLink`, `C_Item.GetItemLocation`, `C_Item.GetItemMaxStackSize`, `C_Item.GetItemMaxStackSizeByID`, `C_Item.GetItemName`, `C_Item.GetItemNameByID`, `C_Item.GetItemQuality`, `C_Item.GetItemQualityByID`, `C_Item.GetItemSellPrice`, `C_Item.GetItemSellPriceByID`, `C_Item.GetItemSetID`, `C_Item.GetItemSetIDByID`, `C_Item.GetItemSetInfo`, `C_Item.GetItemSpell`, `C_Item.GetItemStatDelta`, `C_Item.GetItemStats`, `C_Item.GetItemClassInfo`, `C_Item.GetItemSubClassInfo`, `C_Item.GetItemUniqueness`, `C_Item.GetItemUniquenessByID`, `C_Item.GetStackCount`, `C_Item.GetWeaponEnchantInfo`, `C_Item.IsBound`, `C_Item.IsConsumableItem`, `C_Item.IsEquippableItem`, `C_Item.IsEquippedItem`, `C_Item.IsItemDataCached`, `C_Item.IsItemDataCachedByID`, `C_Item.IsItemGUIDInInventory`, `C_Item.IsItemInRange`, `C_Item.IsItemOpenable`, `C_Item.IsLocked`, `C_Item.LockItem`, `C_Item.LockItemByGUID`, `C_Item.PickupItem`, `C_Item.UnlockAllItems`, `C_Item.UnlockItem`, `C_Item.RequestLoadItemData`, `C_Item.RequestLoadItemDataByID`, `C_Item.UseAtCursor`, `C_Item.UseAtUnit`, `C_Item.UseItemByName`, `GetAuctionItemID`, `GetAuctionSellItemID`, `GetAverageItemLevel`, `GetCraftReagentItemID`, `GetInboxItemID`, `GetInventoryItemDurability`, `GetInventoryItemID`, `GetInventoryItemsForSlot`, `GetInventoryItemRepairCost`, `GetItemClassInfo`, `GetItemIcon`, `GetItemSubClassInfo`, `GetLootRollItemID`, `GetLootSlotItemID`, `GetMerchantItemID`, `GetQuestItemID`, `GetQuestLogItemID`, `GetTradePlayerItemID`, `GetTradeSkillItemID`, `GetTradeSkillReagentItemID`, `GetTradeTargetItemID`, `OffhandHasWeapon` |
| [Loot](docs/API.md#loot) | `C_Loot.GetNearbyLootableUnits`, `C_Loot.GetLastScanResults`, `C_Loot.IsScanInProgress`, `C_Loot.LootAllCorpses`, `C_Loot.LootUnit`, `C_Loot.LootUnitItem`, `C_Loot.ScanNearbyLoot` |
| [LootHistory](docs/API.md#loothistory) | `C_LootHistory.GetNumItems`, `C_LootHistory.GetItem`, `C_LootHistory.GetPlayerInfo`, `C_LootHistory.Clear` |
| [LossOfControl](docs/API.md#lossofcontrol) | `C_LossOfControl.GetActiveLossOfControlData`, `C_LossOfControl.GetActiveLossOfControlDataCount` |
| [Lua](docs/API.md#lua) | `coroutine.create`, `coroutine.resume`, `coroutine.status`, `coroutine.wrap`, `coroutine.yield`, `math.fmod`, `select`, `string.gmatch`, `string.match`, `table.wipe` |
| [Macros](docs/API.md#macros) | `GetLooseMacroIcons`, `GetLooseMacroItemIcons`, `GetMacroIcons`, `GetMacroItemIcons`, `GetMacroSpell` |
| [Lua](docs/API.md#lua) | `collectgarbage` (5.1 options), `coroutine.create`, `coroutine.resume`, `coroutine.running`, `coroutine.status`, `coroutine.wrap`, `coroutine.yield`, `CreateFromMixins`, `math.fmod`, `math.huge`, `math.modf`, `Mixin`, `select`, `string.gmatch`, `string.gsub` (table replacement), `string.match`, `string.reverse`, `strjoin`, `strreplace`, `strrev`, `strsplit`, `strtrim`, `table.count`, `table.maxn`, `table.wipe`, `unpack` (range args) |
| [Macros](docs/API.md#macros) | `C_Macro.CreateMacro`, `C_Macro.EditMacro`, `GetLooseMacroIcons`, `GetLooseMacroItemIcons`, `GetMacroIcons`, `GetMacroItemIcons`, `GetMacroSpell` |
| [Mail](docs/API.md#mail) | `GetInboxItemLink`, `GetSendMailItemLink` |
| [Map](docs/API.md#map) | `C_Map.GetAreaInfo`, `C_Map.GetAreas`, `C_Map.GetAreaTriggerInfo`, `C_Map.GetAreaTriggers`, `C_Map.GetBestMapForUnit`, `C_Map.GetMapAreaIDs`, `C_Map.GetMapOverlays`, `C_Map.GetMapWorldSize` |
| [MapExplorationInfo](docs/API.md#mapexplorationinfo) | `C_MapExplorationInfo.GetExploredMapTextures`, `C_MapExplorationInfo.GetUnexploredMapTextures` |
@@ -56,28 +91,32 @@ Full per-function reference: **[docs/API.md](docs/API.md)**.
| [NewItems](docs/API.md#newitems) | `C_NewItems.ClearAll`, `C_NewItems.IsNewItem`, `C_NewItems.RemoveNewItem` |
| [PlayerInfo](docs/API.md#playerinfo) | `C_PlayerInfo.CanUseItem`, `C_PlayerInfo.GetClass`, `C_PlayerInfo.GetName`, `C_PlayerInfo.GetRace`, `C_PlayerInfo.GetSex`, `C_PlayerInfo.GUIDIsCreature`, `C_PlayerInfo.GUIDIsGameObject`, `C_PlayerInfo.GUIDIsPet`, `C_PlayerInfo.GUIDIsPlayer`, `C_PlayerInfo.IsConnected` |
| [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` |
| [Screen](docs/API.md#screen) | `GetPhysicalScreenSize` |
| [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.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.IsRangedAutoAttackSpell`, `C_Spell.IsSelfBuff`, `C_Spell.IsSpellHarmful`, `C_Spell.IsSpellHelpful`, `C_Spell.IsSpellInRange`, `C_Spell.IsSpellPassive`, `C_Spell.IsSpellUsable`, `C_Spell.SpellHasRange`, `C_Spell.UnitCastingInfo`, `C_Spell.UnitChannelInfo`, `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.GetSpellLevelLearned`, `C_SpellBook.GetSpellSkillLine`, `C_SpellBook.IsAutoAttackSpellBookItem`, `C_SpellBook.IsRangedAutoAttackSpellBookItem`, `FindSpellBookSlotByID` |
| [State](docs/API.md#state) | `CancelShapeshiftForm`, `Dismount`, `GetMirrorTimerInfo`, `GetMirrorTimerProgress`, `GetShapeshiftFormID`, `IsAssistingRitual`, `IsFalling`, `IsInGroup`, `IsInRaid`, `IsLoggedIn`, `IsMounted`, `IsStealthed`, `IsSwimming` |
| [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`, `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` |
| [TaxiMap](docs/API.md#taximap) | `C_TaxiMap.GetTaxiNodesForMap`, `C_TaxiMap.GetAllTaxiNodes` |
| [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` |
| [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) | `GetUnitSpeed`, `UnitClassBase`, `UnitCreatureFamilyID`, `UnitCreatureTypeID`, `UnitDistanceSquared`, `UnitGUID`, `UnitInLineOfSight`, `UnitInRange`, `UnitIsAFK`, `UnitIsDND`, `UnitIsFeignDeath`, `UnitIsInMyGuild`, `UnitIsMinion`, `UnitIsOtherPlayersPet`, `UnitIsPet`, `UnitIsPossessed`, `UnitPower`, `UnitPowerMax`, `UnitPowerType`, `UnitRaceBase`, `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.RegisterComboDuration` |
| [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`, `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.
@@ -86,13 +125,17 @@ the glue state because GlueXML had no way to reach them otherwise.
| [Account](docs/API.md#account) | `SaveAccount`, `DeleteAccount`, `GetSavedAccounts`, `LoginWithSavedAccount` (passwords encrypted in Windows Credential Manager, scoped per realmlist; plaintext never returned to Lua) |
| [CharacterList](docs/API.md#characterlist) | `GetSavedCharacterOrder`, `SetSavedCharacterOrder` (persist to `WTF\Account\...\ClassicAPI.txt`) |
| CVar | `GetCVar`, `SetCVar`, `RegisterCVar`, `GetCVarDefault`, `C_CVar.GetCVarBool` (storage is process-global — writes from glue are visible in-world and vice versa) |
| [Glue](docs/API.md#glue) | `C_Glue.IsFirstLoadThisSession`, `C_Glue.IsOnGlueScreen` |
| 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 |
@@ -101,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
@@ -113,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 |
|-------|---------|
@@ -144,10 +193,24 @@ when launching with `-console`), not as Lua functions. See the
| `PLAYER_STOPPED_MOVING` | *(none)* |
| `PLAYER_STARTED_TURNING` | *(none)* |
| `PLAYER_STOPPED_TURNING` | *(none)* |
| `PLAYER_TOTEM_UPDATE` | `totemSlot` |
| `QUEST_ACCEPTED` | `questLogIndex, questID` |
| `QUEST_DATA_LOAD_RESULT` | `questID, success` |
| `QUEST_REMOVED` | `questID` |
| `QUEST_TURNED_IN` | `questID, xpReward, moneyReward` |
| `UNIT_SPELLCAST_SENT` | `"player", target, castGUID, spellID, spellName, rank` |
| `UNIT_SPELLCAST_START` | `unit, castGUID, spellID, spellName, rank` |
| `UNIT_SPELLCAST_STOP` | `unit, castGUID, spellID, spellName, rank` |
| `UNIT_SPELLCAST_DELAYED` | `"player", castGUID, spellID, spellName, rank` |
| `UNIT_SPELLCAST_SUCCEEDED` | `unit, castGUID, spellID, spellName, rank` |
| `UNIT_SPELLCAST_INTERRUPTED` | `unit, castGUID, spellID, spellName, rank` |
| `UNIT_SPELLCAST_FAILED` | `"player", castGUID, spellID, spellName, rank` |
| `UNIT_SPELLCAST_FAILED_QUIET` | `"player", castGUID, spellID, spellName, rank` |
| `UNIT_SPELLCAST_CHANNEL_START` | `unit, castGUID, spellID, spellName, rank` |
| `UNIT_SPELLCAST_CHANNEL_UPDATE` | `"player", castGUID, spellID, spellName, rank` |
| `UNIT_SPELLCAST_CHANNEL_STOP` | `unit, castGUID, spellID, spellName, rank` |
| `UNIT_SPELLCAST_RETICLE_TARGET` | `"player", "", spellID, spellName, rank` |
| `UNIT_SPELLCAST_RETICLE_CLEAR` | `"player", "", spellID, spellName, rank` |
| `UPDATE_INVENTORY_DURABILITY` | *(none)* |
| `UPDATE_SHAPESHIFT_FORM` | *(none)* |
| `VOICE_CHAT_TTS_PLAYBACK_STARTED` | `numConsumers, utteranceID, durationMS, destination` |
@@ -155,45 +218,64 @@ 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` |
| Addon security | `Enum.AddOnSecurityStatus.{Secure,Insecure,Banned,NotAvailable}` |
| Power type | `Enum.PowerType.{HealthCost,None,Mana,Rage,Focus,Energy,Happiness}` |
| Inventory type | `Enum.InventoryType.Index*Type` (0–34, e.g. `IndexHeadType`=1 … `IndexRelicType`=28) |
| Item class | `Enum.ItemClass.{Consumable,Container,Weapon,Gem,Armor,Reagent,Projectile,Tradegoods,ItemEnhancement,Recipe,Quiver,Questitem,Key,Miscellaneous,…}` (0–19) |
| Item quality | `Enum.ItemQuality.{Poor,Common,Uncommon,Rare,Epic,Legendary,Artifact}` (0–6) |
| 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 |
|-------|-------------|
| `nameplate1`..`nameplateN` | Unit behind the Nth visible nameplate, in creation-order. Works with every `UnitX` function — `UnitName`, `UnitGUID`, `UnitClass`, `UnitHealth`, etc. Suffix chains (`nameplate1target`, `nameplate1targettarget`) compose. See [NamePlate / Unit tokens](docs/API.md#unit-tokens-nameplaten). |
| `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>
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.
<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.
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.
| Binding | Action |
|---------|--------|
| `FOCUSTARGET` | `FocusUnit("target")` — pin current target as focus |
| `TARGETFOCUS` | `TargetUnit("focus")` — switch target to the focus |
See [Focus / Bindings](docs/API.md#bindings-focustarget--targetfocus) for the implementation note.
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. |
</details>
## Installation
@@ -216,41 +298,43 @@ The Lua-side companion library lives in
[`AddOns/!!!ClassicAPI/`](AddOns/!!!ClassicAPI/). It's a 1.12.1 /
Lua 5.0 backport of the modern Blizzard helpers that aren't engine
functions but that consumer code still expects to find as globals —
`Mixin` / `CreateFromMixins`, `CallbackRegistryMixin`, `EventRegistry`,
`ColorMixin` + `CreateColor`, `Item` / `ItemLocation`, `MathUtil`
(`Lerp` / `Clamp` / `CreateCounter`), `TableUtil` (`tCompare`,
`MergeTable`, `SafePack`, etc.), and `EventUtil`
`CallbackRegistryMixin`, `EventRegistry`, `ColorMixin` + `CreateColor`,
`Item` / `ItemLocation`, `MathUtil`(`Lerp` / `Clamp` / `CreateCounter`),
`TableUtil` (`tCompare`, `MergeTable`, `SafePack`, etc.), and `EventUtil`
(`ContinueOnAddOnLoaded` etc.).
Some of the addon's surface depends on engine functions ClassicAPI
provides (`C_UIColor.GetColors` populates the modern color globals,
`C_Item.GetItemInfoInstant` powers `ItemMixin`, the
`ITEM_DATA_LOAD_RESULT` event drives `ContinueOnItemLoad`); the rest
is pure Lua and would work standalone. Without the DLL the addon
still loads, but those features no-op.
The triple-`!` prefix is **load-order-significant** — WoW loads
addons alphabetically, so `!!!ClassicAPI` is guaranteed to run before
any consumer that depends on its globals. Don't rename the folder.
**You don't have to install the addon manually** — the DLL embeds
the contents of [`AddOns/!!!ClassicAPI/`](AddOns/!!!ClassicAPI/) and
registers them with the engine as a synthetic addon on startup, so
the library is always available when the DLL is loaded. The engine
sees it as a normal addon: it appears in `GetAddOnInfo`, fires
`ADDON_LOADED`, supports `SavedVariables`, the works.
the library is always available when the DLL is loaded. It fires
`ADDON_LOADED`, supports `SavedVariables`, and is resolvable by name —
`IsAddOnLoaded("!!!ClassicAPI")`, `GetAddOnInfo("!!!ClassicAPI")`, and
`## Dependencies: !!!ClassicAPI` all work as usual.
If you do drop the folder into your `Interface/AddOns/` directory,
**that copy wins** — the DLL's embedded version is only registered
when the engine's normal scan doesn't already have an entry under
that name. Useful for editing the Lua locally without rebuilding the
DLL. The dispatch is transparent: addons consuming `Mixin`,
`ColorMixin`, `TableUtil`, etc. behave identically in both cases.
**It is intentionally always-on and cannot be disabled.** The library
provides FrameXML-compat fixes that other addons and the DLL's own
features rely on, so leaving it toggleable would let a user (or a
one-off character-select uncheck) silently break them. The DLL
therefore hides it from the character-select AddOns list and
force-enables it every login — so it never appears as a checkbox and
can't be switched off. Being hidden means it's omitted from the
index-based `GetNumAddOns()` / `GetAddOnInfo(i)` enumeration, but every
by-name lookup and dependency reference still resolves normally.
If you drop the folder into your `Interface/AddOns/` directory, **that
copy's files win** — the DLL's embedded version only serves files when
the engine's normal scan doesn't already have a newer entry under that
name. Useful for editing the Lua locally without rebuilding the DLL.
The hidden + force-enabled treatment applies either way (it's keyed to
the addon name, not to which copy serves the files), so the local copy
is also absent from the AddOns list. The dispatch is transparent:
addons consuming `Mixin`, `ColorMixin`, `TableUtil`, etc. behave
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
@@ -265,12 +349,11 @@ Slash commands provided:
| `/framestack` (or `/fstack`) | Tooltip showing the frame hierarchy under the mouse cursor. |
| `/luaerrors` (or `/scripterrors`) | Lua error display window. |
Lua globals provided (backports of 3.3.5 helpers that don't exist in
1.12):
Lua globals provided (backports of Blizzard helpers):
| Global | Purpose |
|--------|---------|
| `print(...)` | Backport of the 3.3.5 `print` — concats varargs with `" "` and pushes to `DEFAULT_CHAT_FRAME`. Routes through `setprinthandler`'s handler; falls back via `geterrorhandler` if the handler errors. |
| `print(...)` | Backport of Blizzard's `print` — concats varargs with `" "` and pushes to `DEFAULT_CHAT_FRAME`. Routes through `setprinthandler`'s handler; falls back via `geterrorhandler` if the handler errors. |
| `setprinthandler(func)` / `getprinthandler()` | Install / query a custom print handler. Useful for redirecting print output in tests. |
| `tostringall(...)` | Apply `tostring()` to every vararg, preserving the count. Lua 5.0-compatible (uses `arg.n` since `select` doesn't exist in 5.0). |
+39
View File
@@ -3815,3 +3815,42 @@ estimate. Anyone who wants vanilla threat should run an existing threat
addon. ClassicAPI's only worthwhile contribution in this space is an
unrelated precise-combat-log event feed — filed as its own thing, not as
"threat".
## 99. `C_AddOns.GetAddOnLocalTable(name)` — gate on loaded + opt-in TOC flag — DONE
Shipped gated (branch `feat/lua-syntax-transpile`,
[src/addons/LocalTable.cpp](src/addons/LocalTable.cpp)). Returns the addon's
private namespace table **only** when BOTH hold, else `nil`:
1. the addon `name` is **loaded** (`FUN_ADDON_IS_LOADED`, by-name), and
2. its `.toc` declares `## AllowAddOnTableAccess: <nonzero>` (scanned via
`FUN_FILE_READ` on `Interface\AddOns\<name>\<name>.toc`, read as a TOC
boolean flag; approach (a) from the notes below — no parser hook).
The two paths stay separate, as required:
- **Internal** `__addonns` (the `local name, ns = ...` preamble) stays
**ungated** — an addon always gets its own namespace. Still in
`luasyntax/Transpile.cpp`.
- **Public** `C_AddOns.GetAddOnLocalTable` carries the gate, in
`addons/LocalTable.cpp`.
Both resolve to the SAME table per addon via `LuaSyntax::PushAddonNamespace`
(the one owner of the registry-backed name→table map, exposed by
[src/luasyntax/AddonNamespace.h](src/luasyntax/AddonNamespace.h)) — so an addon
reads back exactly what it (or its files' `...`) stored.
Directive spelling + function name verified present in the 1.15.8 Classic Era
binary (`AllowAddOnTableAccess:` TOC key, `GetAddOnLocalTable`).
Deliberate scope choices (revisit only if an addon needs otherwise):
- **Name (string) input only.** A numeric index returns `nil` — cross-addon
table access is by name, and the map is name-keyed. Every other `C_AddOns.*`
wrapper accepts name-or-index; this one does not.
- **`nil` on denial, not a Lua error.** Matches the project's modern-API
convention (nil over `lua_error`). Retail may raise instead; switch the
denial branches to `Game::Lua::Error` if strict parity is wanted.
- **No per-name cache** on the TOC scan — GetAddOnLocalTable is not a hot path.
Not shipped by default: no addon opts in yet. The embedded `!!!ClassicAPI`
addon could add `## AllowAddOnTableAccess: 1` to its `.toc` to expose its own
namespace as a worked example, but that's a product/security decision left to
the maintainer.
+24 -1
View File
@@ -34,6 +34,10 @@ if(NOT ADDON_NAME)
endif()
file(GLOB_RECURSE INPUT_FILES RELATIVE "${SRC_DIR}" "${SRC_DIR}/*")
# Never embed the developer marker — it must reflect on-disk presence
# only (see src/addons/Embedded.cpp DiskHasDevMarker), and it is
# gitignored so it only exists in a developer working tree anyway.
list(FILTER INPUT_FILES EXCLUDE REGEX "(^|/)\\.classicapi-dev$")
list(SORT INPUT_FILES) # deterministic codegen
set(BODY "")
@@ -41,7 +45,26 @@ set(MANIFEST "")
set(INDEX 0)
foreach(REL_PATH ${INPUT_FILES})
set(ABS_PATH "${SRC_DIR}/${REL_PATH}")
file(READ "${ABS_PATH}" CONTENT_HEX HEX)
# Stamp the addon's `## Version:` line from the build flag rather
# than the committed source (which stays `DEV`). This is what makes
# a release build a pure function of `-DCLASSICAPI_TAG` — the source
# toc is never edited. Only the `.toc` file is rewritten, in-memory,
# before hex-encoding; every other file is embedded verbatim.
if(ADDON_VERSION AND REL_PATH MATCHES "\\.toc$")
file(READ "${ABS_PATH}" TOC_TEXT)
string(REGEX REPLACE "## Version:[^\n\r]*"
"## Version: ${ADDON_VERSION}" TOC_TEXT "${TOC_TEXT}")
# Round-trip through a temp file to hex-encode the modified text
# (portable to CMake 3.10; avoids requiring string(HEX), 3.18+).
set(TOC_TMP "${OUT_HEADER}.toc.tmp")
file(WRITE "${TOC_TMP}" "${TOC_TEXT}")
file(READ "${TOC_TMP}" CONTENT_HEX HEX)
file(REMOVE "${TOC_TMP}")
else()
file(READ "${ABS_PATH}" CONTENT_HEX HEX)
endif()
string(LENGTH "${CONTENT_HEX}" HEX_LEN)
math(EXPR FILE_SIZE "${HEX_LEN} / 2")
+4943 -1251
View File
File diff suppressed because it is too large Load Diff
+11
View File
@@ -435,6 +435,17 @@ and other DLLs claim for custom events.
## Notes on specific entries
- **`[336] UPDATE_MOUSEOVER_UNIT` now fires on loss too (ClassicAPI).**
Stock 1.12 fires this only when a mouseover unit is *gained*; moving
the cursor off a unit clears the mouseover and signals nothing.
ClassicAPI co-hooks the engine's mouseover set/clear chokepoint
(`0x00492890`, the sole writer of the mouseover GUID) and fires
`UPDATE_MOUSEOVER_UNIT` on the unit→non-unit transition, matching
retail. Gated on the *old* mouseover being a unit and the *new* one
not, so moving off a gameobject / item (which never fired a gain)
doesn't spuriously fire. No payload either way — handlers read
`UnitExists("mouseover")`, false at fire time on loss. See
[API.md](API.md#update_mouseover_unit-event-loss-fire-fix).
- **Repeated `UNIT_*` entries at low indices** (e.g. four slots named
`UNIT_DAMAGE` at 128–131, five `UNIT_STATS` at 144–148, seven
`UNIT_RESISTANCES` at 149–155) — these are the per-stat /
+778
View File
@@ -0,0 +1,778 @@
# Backporting inline `|T…|t` texture escape sequences to 1.12
The 1.12 text engine shows `|Tpath:height:width:…|t` markup as literal text.
This project makes it draw the icon inline in FontStrings, chat, and tooltips —
the way 4.3.4 and later clients do. The feature is complete and shipped
([src/text/InlineTexture.cpp](../src/text/InlineTexture.cpp),
[src/text/InlineTexturePool.cpp](../src/text/InlineTexturePool.cpp)).
This file is the reverse-engineering map. **Current design** below states what
ships today. Everything from **Goal & spec** down is the historical RE trail —
the 4.3.4 study, the 1.12 injection map, and the bring-up plan. It is kept for
the addresses and the reasoning, not as a description of the current code.
## Current design (shipped)
Each icon renders as an **engine-managed region** (a pooled `CSimpleTexture`)
anchored to its owning FontString. The engine draws the region every frame and
moves it with its line, so texture residency and scroll tracking are correct by
construction. (A raw-GxU-quad renderer existed during bring-up and was removed
in `56c2670`; its RE is the "REMOVED" section below.)
The DLL hooks the engine's own text pipeline — no companion addon:
- **Positioning** — a co-hook on the glyph emitter `FUN_005ccbe0` records each
icon at its pen position and delegates the plain text runs to the engine
(segmented delegation, below). The pen read-back needed a fix: the engine's
pen is lazy and omits the last glyph's own advance, so a coin sat on its
digits; the emitter now adds the true terminal advance from the engine's own
glyph-advance helpers (`550cf5e`).
- **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
`FUN_005cdf70` multiplies by the integer raster dimensions
`[0xC2B9A4]`/`[0xC2B9A0]` (rounded, with the justify shift and vertical
align folded in). So the flush converts pen → fs-relative anchor with
`K = raster ÷ anchorExtent`, **per axis**, from those four live globals — no
calibration, no cache, correct at every resolution and UI scale (`968335d`).
Two earlier forms both failed: an empirical K (`origin ÷ rect edge`) that
chat could never self-derive (its rect edge sits near screen x = 0 and
failed the divide guard) and so rode a global cache seeded by whichever
fontstring derived first — right only at the layout it was calibrated on;
then a closed form built from the SetPoint px factor, which used the wrong
global pair. Full derivation chain at `VAR_TEXT_RASTER_X/Y` in `Offsets.h`.
- **Fade / parent alpha** — icons fade with their line (the pool mirrors the
fs's folded colour alpha per frame, `3ca3b7e`), and segmented GLYPHS do too:
a bit-3 (standalone FontString) node's baked per-glyph colours never re-bake
on an alpha change (the engine's colour-change invalidate `FUN_005ccb40`
skips bit-3 nodes), so the flush mirrors the node's live alpha byte
(node+0x2c — SetTextColor alpha × frame-chain alpha, kept folded by the
engine) into every baked colour, change-detected per page (`bd14a93`). An
earlier `0xFF` alpha force at emit was a misdiagnosis of a bubble that built
while faded out; it froze icon-line text fully opaque while everything
around it faded.
- **Outlined fonts** — outline ink extends past the glyph advances; the
engine itself grows line height by exactly 4.0 / 2.0 pen px for thick / thin
outline (`face+0x180` bits 3 / 0, `.rdata` constants). `OutlineInkPen` reads
the same flags and constants, and half the ink leads each icon, half trails
— threaded through the shared advance helper so the emitter advance, the
justify pre-shift, and the wrap-stepper probes all agree. Without it, coins
clipped into outlined digits the moment the exact K removed the old
miscalibration's accidental slack (`968335d`).
- **Measure** — three cold FontString-level co-hooks make measure match the
drawn result: width (`FUN_00772890`), height (`FUN_007729B0`), and wrap
(`FUN_005C7260`). `GetStringWidth`, `fontstring:GetStringHeight`, wrap
breaks, and line height all count the icon.
- **Tall icons** — an icon taller than the font grows its line height, so it
does not overlap its neighbors, and it centers in the grown line. A hyperlink
that contains a tall icon is hoverable across the whole icon
(`49934b0`, `b9d0705`).
- **Chat fade** — icons fade in lockstep with their chat line (`3ca3b7e`).
- **Editboxes** — an editbox keeps the raw markup, so the caret stays aligned
(two complementary signals, below).
- **Payload** — the full
`|Tpath:height:width:offsetX:offsetY:texW:texH:left:right:top:bottom:r:g:b|t`:
size, pen offset, sprite-sheet crop, and vertex tint. BLP and uncompressed
TGA.
- **Anti-spoof** — chat strips player-injected `|T` icons.
Every fs-level measure path now counts icons: width (`FUN_00772890`), height
(`FUN_007729B0`), wrap (`FUN_005C7260`), and the substring measure
(`FUN_00772AE0` — an earlier note claimed nothing consumed it; its xrefs
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
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
This was the FIRST renderer: each icon drawn as a raw textured quad through the
text VB path. It shipped, then was removed in `56c2670` for the engine-region
model (regions are resident by construction; the quad path needed the texture-
residency machinery below and still flickered on VRAM pressure — see
`docs/InlineTextureResidency.md`). This section is the address record for the
removed code, not a description of the current renderer. `LoadTextureByPath`,
`DrawTexturedQuad`, and the GxU device offsets it names are gone from
`Offsets.h`; the values live here and in git history.
An arbitrary texture draws through the text VB path as a coloured, alpha-blended
quad. The end-to-end recipe:
1. **Load by path** — `LoadTextureByPath` replicates `FUN_00770200`'s load: build
flags via `FUN_0058a980(&flags, DAT_00878cf0, 0,0, 0,0,0, 1, 0)`, then
`FUN_00449d90(path, &desc, flags, 0, 1)` with a byte-identical 5-dword desc
(`{PTR_FUN_007ffa10, 8, &self8, (&self8)|1, 0}`). Returns the **HTEXTURE**
(0x15c-byte struct; path@+0xc, flags@+0x110, refcount@+4).
2. **THE KEY — residency + correct bind via `FUN_0044acf0(HTEXTURE, 1, 0)`.**
This is the engine's own "get renderable texture" call (what the widget
render uses every frame). It returns the bindable **CGxTexture** at
`[HTEXTURE+0x140]` and, with force=1, drives the streaming load
(`FUN_0044ad50` submits/prioritises it via `[HTEXTURE+0x138]`). **Calling it
every frame is the live reference that makes the texture resident** — no
hidden widget, no manual BLP decode. This was the entire multi-session
blocker: WoW streams textures and only loads ones a live frame references; a
standalone `FUN_00449d90` texture never loads on its own. Binding the raw
HTEXTURE is `SetTexture([HTEXTURE+0x48]=0)` = a **white** quad; the D3D
handle lives on the CGxTexture (`[CGxTexture+0x48]`), not the HTEXTURE.
Dead ends that wasted time: manually invoking the decode callback
`FUN_0044a260` (fills `[+0x120]` but not the bindable `+0x140`, and faults);
binding `[HTEXTURE+0x48]` or `[HTEXTURE+0x120]` (both wrong offsets); forcing
the streamable flag `tex[0x3c]|=0x80` (crashes in d3d9 — the bind's stream-in
vfunc corrupts the resource).
3. **Bind** — `FUN_00589e80(0x17, cgxTex)` (selector 0x17 = text texture stage).
4. **Colour** — the font stage's default stage-0 COLOROP is already MODULATE, so
`texture.rgb × diffuse(white)` = the icon's true colours; texture alpha works
too. No colorop override is needed for a plain white vertex. (If a tint is
wanted later: COLOROP is D3DTSS internal state `0x42` via `FUN_005a2570`; the
engine leaves COLORARG1 at the D3D default = TEXTURE. Do NOT poke it directly
mid-draw — it leaks `MODULATE` to the whole UI when a fault skips the restore.
Use the engine's selector `0x1f` (value→COLOROP/ALPHAOP preset table at
`DAT_0080a25c`/`DAT_0080a274`; value 1 = MODULATE/MODULATE).)
5. **Quad** — lock the shared dynamic VB `FUN_0058a140(0, 0x18, 0x800)`, get the
write ptr `FUN_0058a080`, write **4 verts** of the format
`{float x,y,z; u32 colorBGRA; float u,v}` (stride 0x18) in corner order
**TL, TR, BL, BR** (matches the shared quad index buffer `{0,1,2, 2,1,3}` and
the device cull winding), submit `FUN_005c8f40(&handle, 4)`, unlock
`FUN_0058a0a0(handle, 0)`.
6. **Where** — co-hook the text paint pass `FUN_005c8fe0` and draw after the
original glyph flush (device is in the correct textured-UI state there).
Coordinate space is **per-layout pixels, y-down**; glyph verts live at small
local coords (a probed glyph was at ~(9,33)–(17,50)). Draw coords must be
within the layout's rendered area (a fixed (100,100) test lands in chat).
The build-side gotcha: the D3D device (default backend, `FUN_0058dd70`) is
actually **OpenGL** (`glMaterialfv`/`glFogi`) — the D3D backend is `FUN_0058ba70`
(`FUN_00598ce0`, vtable `PTR_FUN_00809ef8`); its state applier is `vtable[1]` =
`FUN_005a2f00`, which maps engine selectors to D3D via `FUN_005a2570`
(SetRenderState `+0xe4`, SetTextureStageState `+0x10c`, SetSamplerState `+0x114`,
SetTexture `+0x104`). Selector map (shared across GL/D3D backends): 7=blend,
0x10=depthtest, 0x14=cullface, 0x17-0x1e=texture bind (8 units),
0x1f-0x26=colorop/alphaop preset (unit 0-7).
## SOLVED — positioning, measure/wrap, editbox exclusion, texcoords
The primitive draws at arbitrary coords; the rest is knowing WHERE and WHEN.
Everything is in [src/text/InlineTexture.cpp](../src/text/InlineTexture.cpp);
offsets under the "Inline texture escape" + positioning blocks in
[Offsets.h](../src/Offsets.h).
### The text render hierarchy (verified)
```
frame → renderNode [frame+0x118] → group [renderNode+0x18]
→ node [group+0x18] → line [node+0x24] ← the emitter's `this`
```
- Main UI render `FUN_007657d0(DAT_00cf0bd8)` walks strata → frames, calls
`FUN_0076fb00(renderNode)` per frame's render node.
- `FUN_0076fb00` draws the frame's Texture regions then `FUN_005c1ef0(group)` →
`FUN_005c8b70(group)` → `FUN_005c8fe0(node)` per node.
- `FUN_005c8fe0(node)` = the **paint pass** we co-hook: ensures each line built
(`FUN_005cd6a0` → `FUN_005cdc20` → the emitter), then binds each font page and
copies the line's per-page glyph batches into the dynamic VB, translated by the
line origin `[line+0x70]/[+0x74]`.
- The **emitter** `FUN_005ccbe0(line, text, len, colorState, penXYZ, pageMask,
linkState)` builds one wrapped line's glyph quads. `this` (the "line"/node the
icon list keys on) owns batches `[+0xa0+page*4]` and origin `[+0x70]`. **Note
the render node the emitter sees has NO back-pointer up to the frame** — the
editbox discriminator had to be found another way (see below).
### Positioning — segmented delegation (co-hook the emitter)
We do NOT reimplement the emitter's intricate glyph vertex math. Instead, in the
`FUN_005ccbe0` co-hook, for a line containing an inline texture:
1. Split the line at icon boundaries. Render each **plain run** by calling the
ORIGINAL emitter on just that substring — the engine still draws every glyph.
2. Thread the pen across runs: on return the emitter leaves the final node-local
pen x in **`linkState[4]`**, stored with `FSTP dword` — a **FLOAT bit
pattern**, not an int. Read it as a float (`*(float*)&linkState[4]`); an
int-cast yields garbage (this bug hid the icon off-screen and dropped the
trailing text).
3. Record an `IconRecord` at the pen (node-local `x`,`y`) into `g_nodeIcons[line]`
and advance the pen by the icon width. No draw runs during the build — icons
are recorded and **flushed** in the `FUN_005c8fe0` paint co-hook, which
computes each icon's pen-space position (line origin `[line+0x70]/[+0x74]`
maps node-local → pen, the same translate the glyph copy `FUN_005c8710`
applies), converts pen → anchor with the per-axis K (see **Placement
coordinates** in Current design), and queues it to `Text::InlineTexturePool`
as a placement RELATIVE TO THE OWNING FONTSTRING. The pool applies
placements on the next frame tick.
4. Restore `penXYZ[0]` on exit (the original never writes it; the draw builder
does NOT reset it between left-justified lines, so leaving it mutated
cascade-shifts every following line).
### FontString pipe-doubling — the escape arrives as `||T…||t`
The FontString text sanitizer **doubles any pipe that doesn't begin a recognized
escape** so it renders as a literal `|`. 1.12 doesn't know `|T`, so a caller's
`|Tpath:h|t` reaches the emitter as `||Tpath:h||t` (verified: emitter text length
+2, pipes doubled). The detector therefore matches BOTH the doubled form (the
real-world case) and a clean `|T` (should one ever arrive undoubled). Normal
doubled pipes (`|| `) never match because the next char must be `T`.
### bit-3 batch-clear mode
The emitter clears the line's page batches at its top only when node flag
`[line+0x5c] & 0x08` is set (the FontString/static case). Calling the original
per segment would then wipe all but the last run. Handled by doing the clear
ONCE per build (a `len==0` original call with bit 3 still set), then clearing
bit 3 so the per-segment calls APPEND, restoring flags on exit. Bit 5 (shadow)
isn't set for these nodes, so the only added work is a harmless colour append.
Clearing bit 3 has one more consequence: it switches the engine from the
UNIFORM colour path (node+0x2c, consulted live at every paint) to baked
PER-GLYPH colours the paint uses verbatim. The build-time alpha in colorState
is already correct (the engine folds SetTextColor alpha × frame-chain alpha
into node+0x2c on every alpha change), but the baked copies never refresh —
the flush's alpha mirror keeps them tracking later fades. See **Fade / parent
alpha** in Current design.
### Multi-line / multi-icon accumulation
The draw builder walks a node's wrapped lines by calling the emitter repeatedly
on the SAME node, advancing the text pointer. So the icon list is cleared ONCE
per build — on the first wrapped line, detected by `text == [node+0x48]` (the
node's text start) — and icons ACCUMULATE across lines. Multiple icons per line
fall out of the segmentation loop naturally.
### Vertical centring — font-relative
`penXYZ[1]` sits near the text TOP, so the icon centre = `penY + fontHeight *
g_centerFrac` (0.6, calibrated in-game across chat + pfUI's bubble). Font height
comes from the engine's own `FUN_005c6fa0(flag, [node+0x1c])` (the emitter's
glyph-sizing call), so centring holds across font sizes without a fixed pixel
nudge. A TALL icon (height > font) centres in its GROWN line (see the tall-icon
sections in Current design) — the string-height co-hook adds the overflow, so
the extra room sits half above and half below the text.
### Measure / wrap — co-hook the tokenizer
Line wrap is decided by the measure/fit functions (`FUN_005c6940`, `FUN_005c7470`
via `FUN_005c7260`), which all share the `|`-tokenizer `FUN_005c2810`. Left
alone, they count the hidden path text and wrap early. Fix: co-hook the
tokenizer — when it's at an inline-texture span, consume the whole span as ONE
near-zero-width token so every measure/fit caller ignores the path characters.
The emitter detects icons independently (it scans the raw bytes), so the draw
path is unaffected.
### Measure width: FIXED at the fs-level choke (`GetStringWidth` counts icons)
The tokenizer reports an icon span as a **near-zero-width** token, so at the
gxu loop level every measure caller counts the icon as ~0 pixels wide. (The
emitter still reserves the render width, so text after an icon positions
correctly.) An earlier version of this doc concluded that the gap could not
be closed cleanly, because the gxu engine has **no single cold choke**:
- `FUN_005c6940` (width), `FUN_005c7470`/`FUN_005c7260` (wrap), and the
hit-test loop each tokenize and accrue width independently.
- The token contract has no width field.
- The per-glyph helpers (`FUN_005cabd0`/`FUN_005c6b70`) are the hottest text
functions in the engine.
That analysis was right about the gxu level. The choke sits ONE LEVEL UP:
**`FUN_00772890` = `CSimpleFontString::GetStringWidthInternal`**
(`Offsets::FUN_FONTSTRING_STRING_WIDTH`) is cold, MinHook-safe, and the single
funnel for exactly 4 callers:
- `Script_GetStringWidth` (0x0079E510)
- the GameTooltip auto-size (0x00530640)
- the editbox caret positioner (0x0077DE70)
- the layout effective-width vmethod (`FUN_00772930` = fs+0x24 vtbl +0x1C,
which feeds auto-width fontstring layout)
It lazily computes into the `fs+0xFC` cache (anchor units, 0.0 sentinel) via
the measure core `FUN_0044D670`, then divides the pen-space result by
`fs+0x7C`.
The co-hook lives in `src/text/InlineTexture.cpp`. It calls the original,
then adds `SumIconAdvances(text) / K` when two gates pass:
- `InlineInterceptActive(text)` is true — the **same predicate** the tokenizer
hook stands down on, factored into one function so measure and render can
never disagree.
- The fs is not an editbox: `fs+0x120 & 0x1000` is clear (the fs-level bit
`FUN_0044D670` translates into the gxu editbox flag 0x40).
The icon sum is computed in TRUE PEN PX — the exact numbers the emitter
reserves — and bridged into the getter's return space by the font height
known in both spaces: `sumPen × (fontHInt / fontHPen)`. `ResolveFsPenFont`
replicates the render chain: `fontHPen = round((fontHInt × fs+0x7C, floored
at [0x801628]/rasterY) × rasterY)` (the rebuild's node fontSize + the node
ctor's min clamp + the emitter's own height helper), and the outline ink
resolves through `face = [[fs+0xE0]+0x20]` — the same object the node stores
at +0x44. fs-built nodes are always in snap mode (no fs-flag maps to node
bit 7), so the snap-truncated advances are exact. The per-icon advance comes
from `IconAdvancePen`, the **same helper** the emitter reserves with — so the
measured width matches the rendered width by construction, outline ink and
pixel snapping included. (An earlier bridge used the SetPoint push factor
`div×1024/mul ≈ 1468` as the intermediate — the units cancelled for `:0`
icons, but it couldn't express the truncation or the ink.)
Unit trap (hit on first flight): escape sizes are UI PIXELS. Do NOT divide
the icon sum by `fs+0x7C` — that field is the layout UI *scale* (~0.68–1.0).
The original's own `out / fs+0x7C` applies to a value the measure core
already ran through the `FUN_0041AD80` gxu→internal converter, not to raw
pixels. The `/0x7C` version measured a 16px icon as +44k px.
The hook NEVER writes the `fs+0xFC` cache: the original can serve the cached
value, so the icon sum is re-derived and re-added per call (idempotent).
The same hook also fixes two layout consumers for free: tooltips auto-size
wide enough for icon-bearing lines, and auto-width fontstrings lay out at
their true rendered width.
### Wrap: FIXED at the shared stepper (`FUN_TEXT_WRAP_STEPPER` = 0x005C7260)
Wrap decisions also measured icons as ~0, so a chat line with prefix icons
broke as if the icons were not there and the emitter's real advances ran past
the right edge. The choke is the wrap-stepper dispatcher `FUN_005C7260`: it
lays out one wrapped line per call, and its exactly 4 callers are EVERY wrap
consumer — the draw builder `FUN_005CDC20` (render breaks), the height
measure `FUN_005C2070`, the chars-that-fit counter `FUN_005C21C0` (ellipsis),
and the break-array computer `FUN_005C2430` (behind `FUN_00772B60`).
The co-hook shrinks the `wrapWidth` argument by a **minimal feasible
shrink**, gated on the same `InlineInterceptActive` predicate plus the flags
editbox bit 0x40 the dispatcher already carries. The stepper reports each
probe's measured (icons-at-zero) line width in `outWidth`, which gives a
direct feasibility check: the line renders inside the frame iff its icons
fit in the shrink plus the spare the break left (`iconSum ≤ s + (probeW −
outWidth)`). The hook probes sandboxed (local out-params plus a COPY of the
`p10` in/out state byte, so the caller's first-line state is not consumed):
s = 0 first (feasible → no shrink at all — the common short-message case),
escalate by the measured deficit when infeasible, bisect down after the
first feasible hit; ≤8 probes, and with no feasible hit the last escalation
target wins (wraps early rather than overflowing). The feasibility check
carries a HALF-GLYPH tolerance: an auto-width fontstring (single anchor, no
width — e.g. pfUI's addon-list labels) wraps at the icon-inclusive string
width our GetStringWidth hook feeds the effective-width vmethod, so its line
fits with exactly zero slack, and cross-computation drift between the two
hooks' unit chains (plus OUTLINE extras and the trailing-glyph quirk) read
"fits exactly" as "1px over" — shrinking a zero-slack line forces a wrap
that had no business existing. Genuine icon overflow is ≥ a full icon, so
half a glyph separates drift from real overflow. `outBreak` and
`outNext − text` are byte counts; `outBreak == len` means the whole text
fit. Two rejected earlier schemes, for the record: a naive whole-remaining-
text sum shredded an 8-icon line to one word per line, and a fixed-point
iteration on the icon count oscillated (a ~55-byte zero-width escape makes
the break↔icon-count coupling discontinuous — no fixed point exists).
`text` always points at the REMAINING text, so a continuation line whose
icons are behind it gets sum = 0. Because all four consumers share the hook,
render breaks, `GetStringHeight`, truncation, and break arrays shift
together. (A `_classicapi_InlineTexWrap(n)` ring dumped the last 8
icon-bearing calls during bring-up; removed with the other diagnostics in
`5e5677b`.)
Unit trap #2 (hit TWICE): the stepper's inputs are gxu-normalized, PER AXIS —
fontH is normalized-Y (× rasterY = pen px) and wrapWidth/outWidth are
normalized-X (× rasterX = pen px). Both caller flavors land there: the draw
builder passes node+0x1C/+0x3C (normalized at block creation), and the
fs-level callers (the path chat wraps through) x-normalize their widths
inside `FUN_0044d670` before gxu. First flight subtracted raw pixels and
annihilated the small normalized widths (2-glyph chat fragments). The second
converted with `fontH / fontHPx` — a Y-AXIS factor applied to an X-axis
width — overshrinking the budget by the render aspect (×1.33 at 4:3, ×2.37
at 2560×1080): icon-bearing chat lines wrapped a word or two early, worse per
icon and per aspect. The correct pen-x → width-units factor is `1/rasterX`,
and the glyph-relative tolerances use `fontHPx/rasterX` so "half a glyph"
means an actual glyph width.
**Chat hyperlink hover over inline icons works (verified).** An emote wrapped in a
hyperlink — `|Htel:name|h|T…|t|h`, the TwitchEmotes pattern — pops its tooltip when
you hover the *icon*, not just adjacent text. The hover region is NOT re-measured
on hover: the engine records each hyperlink's screen rect at layout time into a
`GXUFONTHYPERLINKINFO` array (32-byte entries: rect + link data) via `FUN_005cd310`,
called from the draw builder `FUN_005cdc20` — the same layout pass that runs the
line through our co-hooked tokenizer (`FUN_005c2810`) and wrap-stepper
(`FUN_005c7260`). So the recorded rect rides the same icon-aware position
accounting the wrap fix installs; the icon lands inside the hover region for free,
and hovering it fires the frame's `OnHyperlinkEnter`. This is a consequence of the
wrap fix, not a separate feature. (Ghidra caveat: the decompile of the
`FUN_005cd310` call args is mangled — a pointer-as-float + an uninitialized temp —
so this is confirmed by the call path plus in-game test, not the byte-level
formula.)
**Still icon-blind (accepted residuals):**
- Sub-pixel only for a string-final icon: the width hooks report its INK edge
(advance minus the trail half-pad/ink — `TrailingIconTrimPen`), matching the
engine's own last-glyph convention (the gxu width loop ends on ink, not
advance — the source of the old −0.8px measurement, whose previous-glyph
advance treatment is semantically CORRECT once an icon renders after it).
What remains is the icon region's ±0.5px placement rounding and the base
loop's ~0 contribution for the payload-0 icon token.
- An fs whose `fs+0xFC` cache was filled while the feature was toggled
differently (`_classicapi_InlineTexEnable`) keeps its old base width until
the next `SetText`/font change re-dirties it. Debug-toggle-only.
**Editbox measure stays raw (by design).** The caret/measure path reads the
focused editbox's INPUT buffer in place, and the tokenizer stands down on it
per-editbox (`TextInFocusedEditbox` pointer-range test — not a global focus
check), so caret math measures the raw `|T…|t` literal the editbox renders.
The width hook mirrors that with the `fs+0x120 & 0x1000` editable gate. One
marginal wobble: the focused single-line chat editbox's display fontstring
carries editable=0 (only multi-line editors set the bit), so the caret
positioner's line-boundary branch (multi-line-only in practice) can see an
icon-adjusted `FUN_00772890` on it. Closing that costs a per-token content
compare, which is not worth it.
### Editbox exclusion — two COMPLEMENTARY signals (bit 6 + focus global)
Editable text must show raw, editable `|T…|t` markup, not rendered icons — you
edit macros, tweak icon fields, copy/paste texture strings. **This is
retail-correct, verified in-game on a modern client:** typing
`|TInterface\Icons\INV_Misc_Coin_01:16|t` into the chat entry box OR a macro body
shows it **raw** (no icon) — Blizzard neutralizes player-*typed* escape sequences
as anti-abuse — while addon/system text (`AddMessage`, `GetCoinTextureString`)
renders. Retail's discriminator is the text's SOURCE (trusted vs player-typed),
not editbox-vs-display; a shift-clicked item link renders in the chat box because
it's inserted through the trusted path, but a typed `|T`/`|c` does not. Our
editbox-raw + display-renders split reproduces the same observable outcome. (One
mechanism-level divergence we accept: our editbox-raw uses the GLOBAL focus signal
below, so while an editbox is focused OTHER display text that rebuilds also
briefly shows raw; retail suppresses per-source. Cosmetic, and a per-editbox fix
would need hot-path text-buffer matching — not worth it.) 1.12's tokenizer,
unlike 4.3.4's, has no per-render texture-disable bit (4.3.4 uses tokenizer flag
**0x1000** to kill only `|T`, **0x800** to kill all escapes), and editbox text
nodes are NOT distinguishable by class from the render node — the emitter can't
reach the owning frame. It takes **two signals together**, not one (an earlier
version of this note wrongly called the focus global a redundant "belt-and-
suspenders fallback" — removing it renders the chat/copy boxes' `|T` as icons):
- **Node flag `[node+0x5c]` bit 6 (0x40) = EDITABLE** — catches the **macro
editor** (`MacroFrameText`, flags `0x4D`), which builds its layout once,
**un-focused**, so the focus global never sees it. But single-line inputs
(chat entry box, `pfChatCopyBox`) have bit 6 **CLEAR** — their nodes are
`0x0D`/`0x20D`, identical to display FontStrings — so bit 6 can't catch them.
- **Focused-editbox global `DAT_00cf4dc8`** (`InputFocused()`; holds the editbox
with keyboard focus, cleared by `ClearFocus` = `FUN_0077e410`) — catches those
single-line inputs. It's GLOBAL (true UI-wide, not per-node), which is why,
while an editbox is focused, other display text that *rebuilds* also briefly
renders raw. That's an accepted tradeoff: there is no per-node signal for
single-line inputs, and when you're in an editbox seeing raw markup is what you
want anyway.
**Why the tokenizer's bit-6 gate is load-bearing for RENDER, not just measure:**
when the emitter suppresses an editbox it delegates to the ORIGINAL emitter,
which **re-enters the tokenizer** to lay out glyphs (`FUN_005ccbe0` →
`FUN_005c2810`). On that render path `flags` is the node's own flags, so bit
0x40 makes the tokenizer stand down and the `|T` draws as literal text. Drop the
bit-6 gate and the (un-focused) macro editor draws **BLANK** — the tokenizer eats
the span as a zero-width token and the original emitter renders nothing (verified
in-game). Focused single-line inputs get the same "don't intercept" via
`Suppressed()` → `InputFocused()`. So both the emitter and the tokenizer consult
`Suppressed() || bit-6`; the two must agree or you get blank-vs-raw mismatches.
(Dead end: **monochrome render flag `DAT_00c2b9dc`** (`FUN_005c8b70` sets it per
group) — un-set at MacroFrameText's build time and flickered as a flush gate.
Dropped. The editbox class vtable is `0x0081c8c0`, its script-slot resolver
`FUN_0077a310` with focus slots at `+0x438`/`+0x440` — useful if a frame-level
hook is ever needed.)
### Descriptor fields + the vertical flip
`ParseIcon` handles the full positional payload
`path:height:width:offsetX:offsetY:texW:texH:left:right:top:bottom:r:g:b`.
Texcoords crop a sprite sheet to one cell: `u = left/texW, right/texW`,
`v = top/texH, bottom/texH` — e.g. `UI-RaidTargetingIcons` is a 256×256 sheet,
4×2 grid of 64×64, so marker N's cell is `256:256:<col*64>:<+64>:<row*64>:<+64>`.
**Vertex colour (`r:g:b`, 0-255):** the last three positional fields tint the
icon. They pack to `0xFFrrggbb` — the same `0xAARRGGBB` order the tokenizer's
`|cAARRGGBB` colour path builds (verified from `FUN_005c2810`'s colour branch),
so the value flows straight into the quad's vertex colour and the font stage's
default MODULATE COLOROP multiplies `texture.rgb × tint`. White (`255:255:255`,
the default) = untinted. Because parsing is positional, a tint needs all the
preceding fields present; the `TintIcon` helper passes `0`s for width..bottom
(texW/texH `0` disables the texcoord crop → full texture) so the colour lands in
the trailing slots.
**Texcoord order:** the region's 4-float texcoord setter
(`FUN_SIMPLETEXTURE_SET_TEXCOORD`) takes the rect **Y-FIRST INTERLEAVED**
(`{v0, u0, v1, u1}`), matching the engine's `{yA, left, yB, right}` rect
convention — diagnosed in-game when `{u0,v0,u1,v1}` rendered a raid-mark crop as
the wrong cell. v is top-down, same as the `|T` payload's top/bottom fields.
(The removed quad path had the inverse concern: the OpenGL backend put v=0 at
the bottom, so the quad mapped the TOP screen corners to v1 — kept here as the
backend note.)
### Build / deploy / test cadence
- `set -o pipefail; cmake --build build --config Release 2>&1 | tail -5 && cp build/Release/ClassicAPI.dll "C:/WoW/Octo/dll/ClassicAPI.dll"`
— `pipefail` stops a failed build from deploying a stale DLL.
- **The client must be fully EXITED to relink** (it loads a symlink to the
linker's output). DLL changes need a full restart, not `/reload`.
- **The embedded `!!!ClassicAPI` addon is symlinked**, so Lua-only changes (the
`TextureTest()` harness in `Util/AddOnCompat.lua`) need only `/reload`.
### Remaining / optional
- **Measure width, height, wrap** — all DONE (the three fs-level co-hooks in
Current design). Vertex-colour tint (`:r:g:b`) — DONE. The bring-up
diagnostics (`_classicapi_InlineTexSuppress/Tune/Stats/ProbeFS/RegionCal/Wrap`
and the capture globals) were REMOVED (`5e5677b`); only
`_classicapi_InlineTexEnable` remains.
- **Animated emotes** — no DLL work is needed: a TwitchEmotes-style animator
re-`SetText`s each line ~30fps and rewrites the `|T` texcoords to crop the
current film-strip frame. That is addon Lua. RLE-compressed TGAs do not
decode in 1.12 (uncompressed only) — convert with
`magick in.tga -compress none in.tga` (no `-orient`/`-flip`: the region
renderer's texcoord order shows the frame right-side-up).
- **Still icon-blind** (accepted): the hyperlink hit-test past a tall icon
OUTSIDE the link on the same line. The substring measure `FUN_00772AE0` was
hooked once its real consumer surfaced (the tooltip auto-size measures
wrapped SEGMENTS with it), and the trailing-icon width residual is trimmed
to sub-pixel (`TrailingIconTrimPen` — a string-final icon reports its ink
edge, not its advance, per the engine's own last-glyph convention).
## Goal & spec
Render the modern inline-texture escape (warcraft.wiki.gg/wiki/UI_escape_sequences#Textures):
```
|Tpath:height[:width[:offsetX:offsetY[:texWidth:texHeight:left:right:top:bottom[:rV:gV:bV]]]]|t
```
Atlas markup (`|A:atlas:…|a`) is Legion+ (needs an atlas DB 1.12 lacks) — out
of scope. FileDataID paths are Legion+ — 1.12 resolves texture PATH strings
only.
## Confirmed: 1.12 has ZERO inline-texture support
In-game test on the target client — all of these render as literal text, no
icon:
```
|TInterface\Icons\INV_Misc_Coin_01:16|t
|TInterface\Icons\INV_Misc_Coin_01:16:16|t
|TInterface\Icons\INV_Misc_Coin_01:16:16:0:0|t
```
Root cause (see 1.12 map below): the shared escape tokenizer has no `T`/`t`
case, so `|T` falls through to "literal `|` + ordinary glyphs".
## Reference: 4.3.4 (`C:\WoW\Proudmoore\apwow.exe`, build 15595)
4.3.4's text pipeline is a shared `|`-tokenizer feeding per-purpose loops that
`switch` on the returned token type. It DOES support `|T`.
- **Tokenizer `FUN_00613980`** — reads one token, returns a type code, bytes
consumed (`*param2`), payload char (`*param5`). `|`-switch token types:
`1`=color(`|c`+8 hex), `2`=reset(`|r`), `3`=break(`|n`/newline), `4`=`||`,
`5`=`|H`, `6`=`|h`, **`7`=texture(`|T`…`|t`)**, `8`=`|t`, `9`/`10`=`|K`/`|k`
callback, `0xb`/`0xc`=`|W`/`|w`, `0`=ordinary glyph. The `T` case scans
forward to the closing `|t` and returns 7 with the whole span's byte length.
- **Field parser `FUN_00617ba0(tokenStart, &desc, size)`** — splits the `|T`
payload on `:` (helpers: `FUN_0040fa50`=strchr, `FUN_004b3d00`=atof,
`FUN_004b3480`=atoi) into a **0x40-byte descriptor**:
| off | field |
|----|-------|
| `+0x10`/`+0x14` | path ptr / length (up to first `:`) |
| `+0x18` | height (font-scaled) — required |
| `+0x1C` | width (defaults to height) |
| `+0x20`/`+0x24` | offsetX / offsetY |
| `+0x28`–`+0x34` | 4 normalized texcoords (texel ÷ texW/texH) |
| `+0x3C` | hasTexCoords flag |
| `+0x38`–`+0x3B` | vertex B/G/R/A |
| `+0x3D` | hasColor flag |
- **Measure loop `FUN_00618430`** — walks tokens; `case 7` calls
`FUN_00617ba0` and adds the texture width to the pen.
- **Draw emitter `FUN_0061ea10`** (analog of 1.12 `FUN_005ccbe0`) — its
`case 7` is the texture draw:
1. `FUN_00617ba0(tokenStart, &desc)` — parse fields into the descriptor
(stored inline on the line-state struct at `param7+0xb`, 0x10 dwords).
2. Compute pen x, width (`desc.width[+0x18 dword = param7[0x12]] * scale`),
and y from the justify mode `[obj+0x58]` (0=baseline, 1=center, 2=top).
3. Copy the descriptor to a stack struct and call **`FUN_0061e820`** — the
inline-texture quad emit. (`FUN_0061dd50` handles a region-state 1→2
transition when a texture interrupts a hyperlink capture.)
- **`FUN_0061e820`** — normalizes the 4 quad corners (÷ screen w/h from
`DAT_00dd9ea0`/`DAT_00dd9ea4`) and calls `FUN_0061e410(1, &verts)`.
- **`FUN_0061e410(count=1, &verts)`** (`__thiscall`, ECX = a vertex-buffer
object) — appends `count` quads of **0x40 bytes** (one quad = pos + texcoords)
to a **dedicated inline-texture vertex buffer**, growing it as needed
(`FUN_0061db40`). So inline textures accumulate in their OWN batch, separate
from the per-font-page glyph batches, each quad carrying its own texture
(bound at flush). This is the concrete confirmation of the "separate quad
path" — the backport must add such a batch + flush on the 1.12 side.
## Target: 1.12 (`C:\WoW\Octo\WoW.exe`, build 5875) injection map
Same architecture as 4.3.4, different addresses and token numbering. Reached
from `Script_GetStringWidth` (`FUN_0079e510`) → `FUN_00772890` →
`FUN_0044d670` → `FUN_005c2050` → the loops.
- **Shared tokenizer `FUN_005c2810`** — `~11 callers`. `|`-switch token types:
`0`=color(`|c`), `1`=reset(`|r`), `2`=break(`|n`/newline), `3`=`||`,
`4`=`|H`, `5`=`|h`, `6`=ordinary glyph. **No `T`/`t` case** → `|T` hits the
fall-through (`*param5=0x7c; return 6`) = literal `|` then glyphs. Adding a
texture token means picking an unused code (e.g. `7`).
- **Measure loop `FUN_005c6940`** — `switch`: `0/1/4/5` zero-width, `2` break,
default glyph (advance via `FUN_005c6b70`; kerning `FUN_005ca2d0`/
`FUN_005ca4b0`). No texture case.
- **Fit/width loops** `FUN_005c6c50` (chars-into-width + per-char x, for
cursor/wrap) and `FUN_005c7470` (truncation/ellipsis). Same switch shape.
- **Draw builder `FUN_005cdc20`** — reads the layout object's text at `[+0x48]`,
handles line-wrap / justify (`[+0x54]`) / color state, per line calls the
glyph emitter and `FUN_005cd310` (hyperlink rect registration).
- **Glyph emitter `FUN_005ccbe0`** — the real quad emitter. Per glyph:
`FUN_005cabd0(fontFace,char)` → glyph record (atlas **page** at `rec[10]`,
advance `rec[0x12]`, texcoords `rec[0x18..0x1b]`), then appends 4 verts to a
**per-atlas-page** batch at `[obj + page*4 + 0xa0]`. `case 0/1` set/reset
color, `4/5` open/close hyperlink region, default = glyph.
### The architectural wrinkle
Glyph quads batch **per font-atlas page**. An inline `|T` texture is an
arbitrary texture, not a font page, so it cannot join that batch — it needs a
**separate textured-quad draw** (its own draw call, correctly ordered against
the glyph batches so it appears inline). This is the substantive work, not the
tokenizing. 4.3.4 solves it exactly this way — captured below: its draw `case 7`
appends the quad to a dedicated inline-texture vertex buffer at
`renderObject+0x9c`, separate from the per-font-page glyph batches (see the
`FUN_0061ea10` / `FUN_0061e820` items under Open items).
## Strategy
The tokenizer is shared by ~11 callers, so DON'T just make it return a new
type — the other callers' `switch`es would hit `default` and mis-handle it.
Instead:
1. **Reimplement (co-hook) the measure loop `FUN_005c6940` and the draw
builder `FUN_005cdc20`/emitter `FUN_005ccbe0`** — copy existing logic
(fully decompiled), delegate every non-texture token to the original
handling, and add an inline `|T…|t` case: measure → advance pen by texture
width; draw → emit the arbitrary-texture quad.
2. **Port `FUN_00617ba0`** (field parse → 0x40 descriptor). Standalone, safe to
write now from the 4.3.4 decompilation; needs 1.12 equivalents of strchr/
atof/atoi (trivial or already in `Offsets.h`).
3. **Leave the shared tokenizer `FUN_005c2810` and the other ~9 callers
untouched** — they keep rendering `|T` literally (wrap/cursor math slightly
off inside a texture in v1 — cosmetic, extend later).
4. **Texture load by path** — reuse the engine's `Texture:SetTexture` loader;
**quad emit** — the primitive 4.3.4's draw case uses (to pin).
Risk: the hot text path (every string every frame). But these are
deterministic C functions, NOT the contended nampower/SuperWoW targets, so the
MinHook-collision concern is low. Reimplementing two complex loops faithfully
is the real cost.
## Incremental slices
1. Fixed-size `|Tpath:height|t` — render + width only (ignore width/offset/
texcoord/color). Proves the end-to-end quad path.
2. width, offsetX/Y.
3. texcoords (`texW:texH:l:r:t:b`).
4. vertex color.
5. correct wrap/cursor (extend `FUN_005c6c50`/`FUN_005c7470`).
## Open items / next steps
- [x] **4.3.4 draw-side `case 7`** — captured: `FUN_0061ea10` case 7 →
`FUN_00617ba0` (parse) + `FUN_0061e820`→`FUN_0061e410` (append quad to a
dedicated inline-texture vertex buffer). Separate batch, per-quad texture.
- [x] **4.3.4 inline-texture buffer located** — `FUN_0061e820` (`RET 0x40`,
i.e. a 0x40-byte quad struct by value) does `ADD ECX,0x9c` before
`FUN_0061e410`, so the dedicated inline-texture vertex buffer is at
**renderObject+0x9c** (glyph batches are the `+0xa0 + page*4` array — the
two are adjacent but distinct). Quad struct: positions at `+0x8..+0x14`,
texcoords at `+0x20..+0x2c` (rest = z / texture handle / color). Appended
one quad at a time via `FUN_0061e410(this=buf@+0x9c, count, &quad)`.
- [x] **Flush + path→texture load** — NOT traced on the 4.3.4 side by design;
its purpose (the flush + per-quad texture bind + path→load mechanism) is
fully answered on the **1.12 side** below: paint/flush `FUN_005c8fe0`,
bind `FUN_00589e80(0x17,tex)` + draw `FUN_005c8f40`, load `FUN_00449d90`.
4.3.4's own `+0x9c`-buffer flush is not needed — we implement against 1.12.
- [x] **1.12 path→texture loader** — `FUN_00770200(textureObj, path, flag,
blend=DAT_00878cf0, flag)`. It's the string-path branch of
`Script_Texture_SetTexture` (`FUN_0079bb40`, method-table entry at
`0x0087c1a0`). This is how a `|T` path becomes a bound texture.
- [x] **1.12 batch render + textured-quad GPU primitive — FOUND.** The paint
pass is `FUN_005c8fe0(layoutObj)`: it ensures each line built
(`FUN_005cd6a0`), then per font page (stride 0x18, indices 0..7 over
`[obj+0x18c+page*0x18]` = the page's texture object) does:
- **bind texture:** `FUN_00589e80(0x17, texturePage)` (0x17 = the texture
render-state selector),
- **lock dynamic VB:** `FUN_0058a140(0, stride=0x18, 0x800)` → handle;
`FUN_0058a080(handle)` → vertex data ptr,
- copy the page's built verts from the per-page batch at
`[lineObj + 0xa0 + page*4]` (`FUN_005ce090`=count, `FUN_005ce0c0`=copy),
- **draw:** `FUN_005c8f40(&handle, vertCount)` (flush/submit),
- **unlock:** `FUN_0058a0a0(handle, 0)`.
**Vertex stride = 0x18** (24 bytes = pos + uv + packed color). So an inline
`|T` quad = 4 (or 6) such verts, bound to its own texture. Reuse path for
the `|T` draw: `FUN_00589e80(0x17, inlineTex)` → write quad verts →
`FUN_005c8f40`. **`FUN_005c8fe0` is the co-hook site for strategy B** —
after the glyph batches are drawn, draw the frame's collected `|T` quads.
- [x] **Path → bindable texture handle — RESOLVED.** `FUN_00770200`
(SetTexture-by-path) internally calls **`FUN_00449d90(path, &desc, flags,
0, 1)`** — the general path→CGxTexture loader (normalizes path, by-name
cache, creates on miss via `FUN_0044a140`/`FUN_0044a310`, returns a
**fallback texture on failure so never null**) — and stores the handle at
`textureObj+0xcc`. That handle is the SAME CGxTexture type
`FUN_00589e80(0x17, tex)` binds (the `tex[0x3c]` streamable flag +
non-null `*tex` that `FUN_00593840` checks are CGxTexture fields; font
pages and Texture widgets bind identically). So a `|T` draws as:
`tex = FUN_00449d90(path,&desc,flags,0,1)` → `FUN_00589e80(0x17,tex)` →
write 4 verts (stride 0x18) → `FUN_005c8f40(&vb,n)`. No scratch widget
needed if we replicate `FUN_00770200`'s descriptor setup
(`FUN_0058a980(&desc, blend, …)` then `FUN_00449d90`); or, simplest, keep
one scratch Texture object, `FUN_00770200(scratch,path,…)`, read
`[scratch+0xcc]`. The internal by-name cache makes repeats cheap.
Two implementation strategies once the primitive is pinned:
- **A (mirror 4.3.4):** own a separate inline-texture vertex buffer, flush it
in the paint pass alongside the glyph batches. Faithful, more integration.
- **B (immediate):** at paint time, draw each collected `|T` quad immediately
via the GxU primitive (bind `FUN_00770200`'d texture, draw one quad). Simpler;
inline textures are few, so per-quad draw calls are fine. Likely the better
first cut.
- [ ] Confirm 1.12's tokenizer flag bits (`param4 & 0x100/0x200/0x400/0x800`)
that gate color/escape handling, so the reimplemented loops pass them
through unchanged.
## Reality check on scope — how it actually landed
The plan above feared reimplementing 1.12's glyph emitter `FUN_005ccbe0` (a
long, intricate function) and the measure loop. It did not come to that. The
shipped design avoids both:
- **Positioning** does NOT reimplement the emitter — it SEGMENTS the line at
icon boundaries and delegates each plain run to the ORIGINAL emitter, so the
engine still lays out every glyph. We only track where the icons go.
- **Rendering** does NOT stand up a separate quad batch — icons are
engine-managed regions anchored to their FontString (the removed quad batch
was strategy B; regions replaced it).
- **Measure** does NOT reimplement the loops — three cold co-hooks on the
fs-level width / height / wrap functions add the icons' contribution and call
the originals.
The result is the largest feature in the project, but built from co-hooks that
delegate to the engine rather than from reimplemented layout code. It was
verified by in-game iteration over many build/test rounds, as expected.
+115
View File
@@ -0,0 +1,115 @@
# Why inline icons render as engine regions
Inline `|T…|t` icons render as pooled `CSimpleTexture` regions
([src/text/InlineTexturePool.cpp](../src/text/InlineTexturePool.cpp)) — one per
visible icon, anchored to the icon's owning FontString. The engine draws each
region every frame like any UI texture, so the icon is resident by construction
and moves with its line for free. This is 4.3.4's `CSimpleEmbeddedTexture`
model, built from the parts 1.12 ships.
This file records why that design was chosen and the RE behind it. A raw-GxU-
quad renderer came first and was removed (`56c2670`).
## History — a residency misdiagnosis, corrected
The quad renderer flickered: a random subset of chat icons blanked on scroll,
and a second client showed no icons at all. The first diagnosis was **texture
residency** — the theory that a raw quad bound a texture the VRAM manager had
evicted, because a raw quad is owned by no frame object.
That diagnosis was wrong. The real cause was the Large-Address-Aware pointer
bound (`dc61f77`): a 2GB cap in the flush's `LooksReadable` check rejected valid
heap pointers above `0x80000000`, and the flush's node walk BREAKS on a rejected
node. So one high node truncated the walk, and every node after it lost its
icon. That is the "random subset blanks" pattern, and a second client shows none
because it grows the heap past 2GB sooner. With the bound fixed, the raw quads
did not flicker either.
So engine regions are kept for **4.3.4 parity**, not necessity. They are
resident by construction, which closes the residency question for good — but the
question was never the real bug. Do not re-open the residency investigation; the
D3D notes at the end are the record of what was ruled out.
## The region the pool builds
1.12 ships `CSimpleTexture` — the ordinary `Texture` widget
(`frame:CreateTexture()`). It is a managed, reference-counted, `CGxTexCache`-
backed, engine-drawn texture. The only thing 3.3.5's `CSimpleEmbeddedTexture`
adds is automatic inline layout inside the FontString, and the emitter already
computes the icon positions — so the pool supplies them. (`CSimpleEmbeddedTexture`
is compiled into 3.3.5 only; RTTI confirms it is absent from 1.12.)
Per icon, the pool takes a pooled region and sets the texture, texcoords,
colour, and two anchor points relative to the owning FontString's bottom-left.
The engine then owns load, residency, managed-pool reload, clipping, and draw.
### 1.12 entry points (used by the pool)
RTTI name strings: `.?AVCSimpleTexture@@` @ `0x00846588`,
`.?AVCSimpleFontString@@` @ `0x00846544`.
Create a `CSimpleTexture` from C (mirrors `Tooltip::LinePool`'s FontString path):
```
void* mem = FUN_00760450(&DAT_00cf4ce0, 0, /*rtti*/0x00846588, 0xFFFFFFFE); // pool alloc
void* tex = FUN_0076fc40(mem, parentFrame, /*drawLayer*/2, /*sublayer*/1); // ctor
```
Set the texture path (loads through the engine's own loader, owns the handle):
```
FUN_00770200(tex, path, 0, DAT_00878cf0, 0); // CSimpleTexture::SetTexture(path)
```
`FUN_00770200` builds GxTexFlags (`FUN_0058a980`), loads via `FUN_00449d90`,
stores the HTEXTURE at `tex+0xCC` (owned ref), releases the old one
(`FUN_0041aed0`), and marks the region dirty. This ownership is the whole
residency story — the engine holds the texture and reloads it on eviction.
Region methods the pool calls directly:
| method | C++ target |
|---|---|
| SetTexCoord | `FUN_00770410(tex, &{v0,u0,v1,u1})` — Y-FIRST interleaved |
| SetColor | `FUN_0077F750(tex, &BGRA)` — also the chat-fade alpha mirror |
| SetPoint | `FUN_00767C70` — internal units (see LinePool `PixelToInternal`) |
| Show / Hide | `FUN_0077FCB0` / `FUN_0077FC60` (desired-shown `+0xC4`, realized `+0xC8`) |
`CSimpleTexture` layout (from ctor `FUN_0076fc40`): vtable `0x0081c718`;
region-render vtable at `+0x24`; HTEXTURE `+0xCC`; blend `+0xD0`; per-corner
quad position/texcoord arrays `+0xD4..+0x100` (`OFF_SIMPLETEXTURE_CORNERS`);
`+0x128` a flag. The region-render vtable slot 0 (`0x00770670`) computes the
screen rect and calls `FUN_007705b0` to STORE the quad corners; the GPU draw is
deferred to the engine's batched region paint.
## The pen↔anchor bridge
The emitter computes icon positions in PEN units; the pool stores region
`SetPoint` offsets in ANCHOR units, relative to the FontString's rect. The
bridge is one scale `K` (pen units per anchor unit) plus the FontString's own
rect: the flush reads the FontString rect and derives `K` per node, converts
each icon's screen rect to FontString-relative anchor offsets, and queues that.
The pool applies the queued placement on the next frame tick — never mid-render.
`K` derivation and the FontString-relative store are in `FlushLayout`
([src/text/InlineTexture.cpp](../src/text/InlineTexture.cpp)); the
convergence pass in `Maintain`
([src/text/InlineTexturePool.cpp](../src/text/InlineTexturePool.cpp)) self-heals
any residual scale from the region's own frame-scale chain.
## What was ruled out during the residency chase (do not re-investigate)
The blank icons had zero CPU-side signature — records present, node chain
contiguous, geometry sane, index buffer ready at submit, batched draw, z-buffer
excluded twice. The full D3D9 render state at the true draw point was benign:
`zFunc=8` always, `aBlend=1`, `colorW=0xF`, scissor and stencil off, texture
stage MODULATE with a valid bound texture every draw. All of this was consistent
with the LAA-bound truncation (the node never reached the draw), not with a lost
texture surface.
D3D9 device access, if it is ever needed again:
- `IDirect3DDevice9* = *(void**)(*(void**)0x00C0ED38 + 0x38a8)` (CGxDeviceD3d).
- `GetRenderState` = device vtable `+0xE8`; `SetRenderState` `+0xE4`.
- The GxU re-applies its cached render states before every draw (`FUN_005a1b20`),
so a raw `SetRenderState` for a GxU-managed state is overwritten; `ZENABLE` is
not in that path, so a direct set survives.
+9
View File
@@ -0,0 +1,9 @@
; Exports for ClassicAPI.dll.
;
; `Load` is VanillaFixes' post-injection entry point: after the launcher
; injects each dlls.txt DLL, the VanillaFixes runtime calls the DLL's `Load`
; export on the game's MAIN thread (GetProcAddress(module, "Load")), outside
; the loader lock and with no timeout. Listing it here exports it undecorated
; as "Load" so GetProcAddress finds it regardless of calling convention.
EXPORTS
Load
-20
View File
@@ -13,26 +13,6 @@
namespace Common {
#define HOOK_FUNCTION(offset, hook, original) \
{ \
auto *target = reinterpret_cast<LPVOID>(offset); \
if (MH_CreateHook(target, reinterpret_cast<LPVOID>(hook), \
reinterpret_cast<LPVOID *>(&original)) != MH_OK) \
return FALSE; \
if (MH_EnableHook(target) != MH_OK) \
return FALSE; \
}
#define UNHOOK_FUNCTION(offset, original) \
{ \
auto *target = reinterpret_cast<LPVOID>(offset); \
if (MH_DisableHook(target) != MH_OK) \
return FALSE; \
if (MH_RemoveHook(target) != MH_OK) \
return FALSE; \
(original) = nullptr; \
}
bool PatchBytes(void *dst, const void *src, size_t len);
} // namespace Common
+164 -54
View File
@@ -16,8 +16,11 @@
#include "MinHook.h"
#include "Offsets.h"
#include "event/Custom.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;
static Game::LoadScriptFunctions_t LoadScriptFunctions_o = nullptr;
@@ -33,24 +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();
// 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();
@@ -71,6 +67,14 @@ static void __fastcall LoadScriptFunctions_h() {
static void __stdcall LoadGlueScriptFunctions_h() {
LoadGlueScriptFunctions_o();
Game::RunGlueModuleRegistrations();
// World→glue teardown destroyed every world fontstring and text node the
// inline-icon maps reference; forget them now that the glue UI is booting.
// (Glue→world is covered by FrameScript_Initialize_h; the node-free hook
// handles individual deaths, but bulk teardown paths may bypass it.)
Text::InlineTexture::PrepareForReload();
Text::InlineTexturePool::PrepareForReload();
Texture::Transform::PrepareForReload();
Texture::Mask::PrepareForReload();
}
// Every Lua-side `frame:RegisterEvent(...)` is a chance to claim a slot
@@ -86,47 +90,153 @@ static void __fastcall FrameRegisterEvent_h(void *frame, void *edx,
FrameRegisterEvent_o(frame, edx, eventName);
}
// ---------------------------------------------------------------------------
// Hook install — kept OFF the Windows loader lock.
//
// Every MH_EnableHook freezes all process threads (CreateToolhelp32Snapshot +
// SuspendThread/GetThreadContext each). Doing ~90 of those from DllMain, under
// the loader lock, is the pattern MinHook documents as unsafe: on machines
// whose security stack intercepts thread suspension it stalled the remote
// LoadLibrary thread past VanillaFixes' 10-second injection deadline, so VF
// read the still-running thread as STILL_ACTIVE (259) and showed its generic
// "compatible client" error — a false diagnosis of a slow load.
//
// So DllMain installs nothing. The install runs later, on a thread that does
// NOT hold the loader lock, via one of two triggers that both funnel through
// the latched EnsureInitialized():
// * VanillaFixes' `Load` export, called on the game's main thread after
// injection (the sanctioned VF extension point), or
// * a fallback worker thread we spawn from DllMain, ONLY when VanillaFixes
// is not the loader (no VfPatcher.dll in the process), for injectors that
// never call `Load`.
//
// The worker MUST stand down under VanillaFixes. VF creates the process
// suspended and injects every dlls.txt DLL sequentially via remote threads;
// a worker spawned from our DllMain starts the moment our own LoadLibrary
// releases the loader lock — i.e. while VF is still injecting the NEXT
// dlls.txt DLL. Its ~90-prologue install (plus the MH_ApplyQueued
// thread-freeze) then races that DLL's DllMain patching the same engine
// functions, which made SuperWoWhook.dll fail to load whenever ClassicAPI
// preceded it in dlls.txt. `Load` has no such race: VfPatcher calls it on the
// game's main thread after the process resumes, when every DllMain in the
// injection chain has already completed, serialized in dlls.txt order.
// ---------------------------------------------------------------------------
static volatile LONG g_initClaimed = 0; // 0 until a thread takes the installer role
static volatile LONG g_initDone = 0; // 0 until the install has finished
static volatile LONG g_initResult = 1; // 0 = success, 1 = failure (until proven)
static volatile LONG g_mhInitialized = 0; // MH_Initialize succeeded (gates detach teardown)
static bool CreateAndQueue(uintptr_t offset, void *hook, void **original) {
auto *target = reinterpret_cast<LPVOID>(offset);
if (MH_CreateHook(target, hook, original) != MH_OK)
return false;
// Queue only — a single MH_ApplyQueued below applies the whole batch in
// one thread-freeze.
if (MH_QueueEnableHook(target) != MH_OK)
return false;
return true;
}
static bool InstallHooks() {
if (MH_Initialize() != MH_OK)
return false;
InterlockedExchange(&g_mhInitialized, 1);
if (!CreateAndQueue(Offsets::FUN_INVALID_FUNCTION_PTR_CHECK,
reinterpret_cast<void *>(InvalidFunctionPtrCheck_h), nullptr))
return false;
// Four core init hooks — each runs glue logic (PrepareForReload,
// RunModuleRegistrations, RunGlueModuleRegistrations, EnableWrites,
// RetryClaims) tightly coupled to DllMain state, so a declarative
// HookAutoRegister wouldn't simplify them.
if (!CreateAndQueue(Offsets::FUN_FRAME_SCRIPT_INITIALIZE,
reinterpret_cast<void *>(FrameScript_Initialize_h),
reinterpret_cast<void **>(&FrameScript_Initialize_o)))
return false;
if (!CreateAndQueue(Offsets::FUN_LOAD_SCRIPT_FUNCTIONS,
reinterpret_cast<void *>(LoadScriptFunctions_h),
reinterpret_cast<void **>(&LoadScriptFunctions_o)))
return false;
if (!CreateAndQueue(Offsets::FUN_LOAD_GLUE_SCRIPT_FUNCTIONS,
reinterpret_cast<void *>(LoadGlueScriptFunctions_h),
reinterpret_cast<void **>(&LoadGlueScriptFunctions_o)))
return false;
if (!CreateAndQueue(Offsets::FUN_FRAME_REGISTER_EVENT,
reinterpret_cast<void *>(FrameRegisterEvent_h),
reinterpret_cast<void **>(&FrameRegisterEvent_o)))
return false;
// All feature hooks declared via `Game::HookAutoRegister` at file scope
// in their respective modules (create + queue-enable, no apply yet).
if (!Game::RunHookRegistrations())
return false;
// One thread-freeze that activates every queued hook at once.
return MH_ApplyQueued() == MH_OK;
}
// Runs InstallHooks exactly once. Returns 0 on success, 1 on failure. If a
// second caller arrives while the install is in flight, it blocks briefly so
// both callers observe the real result.
static DWORD EnsureInitialized() {
if (InterlockedCompareExchange(&g_initClaimed, 1, 0) == 0) {
g_initResult = InstallHooks() ? 0 : 1;
InterlockedExchange(&g_initDone, 1);
} else {
while (InterlockedCompareExchange(&g_initDone, 0, 0) == 0)
Sleep(1);
}
return static_cast<DWORD>(g_initResult);
}
static DWORD WINAPI InitWorker(LPVOID) {
EnsureInitialized();
return 0;
}
// VanillaFixes calls this on the game's MAIN thread after injection
// (InitAdditionalDLLs -> GetProcAddress(module, "Load")), outside the loader
// lock and with no timeout — the sanctioned VF extension point. Returns 0 on
// success; VF reports any non-zero result to the user. Exported undecorated
// as "Load" via src/ClassicAPI.def.
extern "C" DWORD Load() { return EnsureInitialized(); }
BOOL APIENTRY DllMain(HMODULE hModule, DWORD reason, LPVOID lpReserved) {
if (reason == DLL_PROCESS_ATTACH) {
DisableThreadLibraryCalls(hModule);
if (MH_Initialize() != MH_OK)
return FALSE;
auto *target = reinterpret_cast<LPVOID>(Offsets::FUN_INVALID_FUNCTION_PTR_CHECK);
if (MH_CreateHook(target, reinterpret_cast<LPVOID>(InvalidFunctionPtrCheck_h), nullptr) != MH_OK)
return FALSE;
if (MH_EnableHook(target) != MH_OK)
return FALSE;
// Four core init hooks stay here — each runs glue logic
// (PrepareForReload, RunModuleRegistrations,
// RunGlueModuleRegistrations, EnableWrites, RetryClaims)
// that's tightly coupled to DllMain state, so a declarative
// HookAutoRegister wouldn't simplify them.
HOOK_FUNCTION(Offsets::FUN_FRAME_SCRIPT_INITIALIZE, FrameScript_Initialize_h,
FrameScript_Initialize_o);
HOOK_FUNCTION(Offsets::FUN_LOAD_SCRIPT_FUNCTIONS, LoadScriptFunctions_h,
LoadScriptFunctions_o);
HOOK_FUNCTION(Offsets::FUN_LOAD_GLUE_SCRIPT_FUNCTIONS,
LoadGlueScriptFunctions_h, LoadGlueScriptFunctions_o);
HOOK_FUNCTION(Offsets::FUN_FRAME_REGISTER_EVENT, FrameRegisterEvent_h,
FrameRegisterEvent_o);
// All feature hooks declared via `Game::HookAutoRegister` at
// file scope in their respective modules.
if (!Game::RunHookRegistrations())
return FALSE;
// Install nothing here (loader lock — see the block comment above).
//
// Under VanillaFixes, `Load` is the ONLY install trigger. VfPatcher.dll
// is injected before any dlls.txt DLL, so its presence identifies VF as
// the loader; and the `Load` mechanism shipped in the same VF release
// (v1.4) as dlls.txt support itself, so any VF that loaded us will call
// it. Spawning the worker here would race the DllMains of the dlls.txt
// DLLs VF injects after us (see the block comment above).
if (GetModuleHandleW(L"VfPatcher.dll") == nullptr) {
// Any other injector: no `Load` caller exists, so install from a
// worker off the lock. The new thread cannot run its body until
// DllMain returns and the loader lock releases, and we never wait
// on it. DisableThreadLibraryCalls suppressed its THREAD_ATTACH.
HANDLE worker = CreateThread(nullptr, 0, InitWorker, nullptr, 0, nullptr);
if (worker != nullptr)
CloseHandle(worker);
}
} else if (reason == DLL_PROCESS_DETACH) {
// Clean /quit path. Flush before MH_Uninitialize — the cache's
// file I/O uses Win32 directly (no MinHook involvement), so
// either order works in practice, but flushing first lets us
// bail early if the hook teardown ever grows side effects.
// Hard process termination (task manager kill) bypasses this
// path; that's an inherent OS limitation, and the 5-minute
// backstop flush in Remember() covers the worst case there.
Player::NameCache::Flush();
MH_Uninitialize();
// Clean /quit path — only tear down if we actually initialized.
// Flush before MH_Uninitialize — the cache's file I/O uses Win32
// directly (no MinHook involvement), so either order works in
// practice, but flushing first lets us bail early if the hook
// teardown ever grows side effects. Hard process termination (task
// manager kill) bypasses this path; that's an inherent OS
// limitation, and the 5-minute backstop flush in Remember() covers
// the worst case there.
if (g_mhInitialized) {
Player::NameCache::Flush();
MH_Uninitialize();
}
}
return TRUE;
}
+53 -3
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) \
@@ -57,7 +64,8 @@ namespace Lua {
F(Resume, lua_resume, LUA_RESUME) \
F(Yield, lua_yield, LUA_YIELD) \
F(ArgError, luaL_argerror, LUAL_ARG_ERROR) \
F(SetN, luaL_setn, LUAL_SETN)
F(SetN, luaL_setn, LUAL_SETN) \
F(CheckStack, lua_checkstack, LUA_CHECK_STACK)
#define CLASSICAPI_BIND_LUA(Name, Typedef, Offset) \
const Typedef##_t Name = reinterpret_cast<Typedef##_t>(Offsets::Offset);
@@ -72,7 +80,7 @@ using RegisterFrameMethods_t = void(__fastcall *)(const FrameMethodEntry *table,
} // namespace
void *State() {
return *reinterpret_cast<void **>(static_cast<uintptr_t>(Offsets::VAR_LUA_STATE));
return Game::Read<void *>(Offsets::VAR_LUA_STATE);
}
namespace {
@@ -184,6 +192,33 @@ void SetGlobalNumber(void *L, const char *name, double value) {
RawSet(L, GLOBALS_INDEX);
}
bool PushGlobalFunction(void *L, const char *name) {
PushString(L, name);
GetTable(L, GLOBALS_INDEX);
if (Type(L, -1) == TYPE_FUNCTION)
return true;
SetTop(L, -2); // pop the non-function
return false;
}
void CallGlobal(void *L, const char *name) {
const int top = GetTop(L);
if (PushGlobalFunction(L, name))
Call(L, 0, 0);
SetTop(L, top);
}
bool CallGlobalString(void *L, const char *name, const char *arg) {
const int top = GetTop(L);
const bool called = PushGlobalFunction(L, name);
if (called) {
PushString(L, arg);
Call(L, 1, 0);
}
SetTop(L, top);
return called;
}
void PushLocalizedString(void *L, const char *globalName, const char *fallback) {
PushString(L, globalName);
GetTable(L, GLOBALS_INDEX);
@@ -260,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) {
@@ -281,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;
@@ -292,7 +337,12 @@ bool RunHookRegistrations() {
if (MH_CreateHook(targetPtr, node->hook,
reinterpret_cast<LPVOID *>(node->original)) != MH_OK)
return false;
if (MH_EnableHook(targetPtr) != MH_OK)
// Queue, don't enable. The caller (InstallHooks) applies every
// queued hook with a single MH_ApplyQueued, so all ~90 hooks share
// ONE thread-freeze instead of one freeze per hook. This is the
// difference that keeps install cheap on machines whose security
// stack intercepts SuspendThread / VirtualProtect per call.
if (MH_QueueEnableHook(targetPtr) != MH_OK)
return false;
}
return true;
+120 -8
View File
@@ -17,6 +17,59 @@
namespace Game {
// --- Typed engine-memory access ---------------------------------------------
//
// The canonical way to read/write an engine object field or a fixed-VA global,
// replacing the `*reinterpret_cast<const T *>(base + Offsets::OFF_X)` idiom
// (and its double-cast variant when `base` isn't a byte pointer). Offsets stay
// in Offsets.h per the single-source-of-truth rule — these only centralize the
// cast noise, they add no checking.
//
// Game::Read<int>(obj, Offsets::OFF_FIELD) field read
// Game::Ref<uint32_t>(obj, Offsets::OFF_FIELD) writable field lvalue
// Game::Ptr<float>(obj, Offsets::OFF_FIELD) pointer TO a field (arrays,
// out-params for engine calls)
// Game::Read<float>(Offsets::VAR_GLOBAL) fixed-VA global read
// Game::Ref<int>(Offsets::VAR_GLOBAL) fixed-VA global lvalue
template <typename T>
inline const T *Ptr(const void *base, uintptr_t offset) {
return reinterpret_cast<const T *>(reinterpret_cast<const uint8_t *>(base) + offset);
}
template <typename T>
inline T *Ptr(void *base, uintptr_t offset) {
return reinterpret_cast<T *>(reinterpret_cast<uint8_t *>(base) + offset);
}
template <typename T>
inline T Read(const void *base, uintptr_t offset) {
return *Ptr<T>(base, offset);
}
template <typename T>
inline T &Ref(void *base, uintptr_t offset) {
return *Ptr<T>(base, offset);
}
template <typename T>
inline T Read(uintptr_t address) {
return *reinterpret_cast<const T *>(address);
}
template <typename T>
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 *)();
@@ -70,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);
@@ -100,6 +156,7 @@ using lua_resume_t = int(__fastcall *)(void *L, int nargs);
using lua_yield_t = int(__fastcall *)(void *L, int nresults);
using luaL_argerror_t = void(__fastcall *)(void *L, int narg, const char *msg);
using luaL_setn_t = void(__fastcall *)(void *L, int t, int n);
using lua_checkstack_t = int(__fastcall *)(void *L, int size);
extern const lua_isnumber_t IsNumber;
extern const lua_isstring_t IsString;
@@ -124,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;
@@ -142,6 +200,7 @@ extern const lua_resume_t Resume;
extern const lua_yield_t Yield;
extern const luaL_argerror_t ArgError;
extern const luaL_setn_t SetN;
extern const lua_checkstack_t CheckStack;
// Returns the global `lua_State *` (read on demand from the engine's global).
// Callable outside a Lua callback, e.g. during LoadScriptFunctions setup.
@@ -242,6 +301,22 @@ void PushLocalizedString(void *L, const char *globalName, const char *fallback);
// using `PushLocalizedString` + manual `string.format` invocation.
void PushLocalizedFormatInt(void *L, const char *globalName,
const char *fallback, int n);
// Pushes `_G[name]` and returns true only if it resolved to a function
// (leaving it on the stack, ready to be called); otherwise pops it and
// returns false. The building block for the CallGlobal* helpers below and for
// any C module that dispatches to a Lua/FrameXML global by name (a pattern
// several modules otherwise open-code).
bool PushGlobalFunction(void *L, const char *name);
// `_G[name]()` — no args, no results; a no-op if the global isn't a function.
// Stack-neutral.
void CallGlobal(void *L, const char *name);
// `_G[name](arg)` — one string arg (NULL → nil), no results. Returns true iff
// the global was a function and got called, so callers can fall back to a
// native path when the FrameXML/addon global is absent. Stack-neutral.
bool CallGlobalString(void *L, const char *name, const char *arg);
} // namespace Lua
// Developer-console command system — the `~` console available when the
@@ -323,19 +398,24 @@ void RunGlueModuleRegistrations();
// Declarative MinHook registration. Each feature module declares a
// file-scope `static const Game::HookAutoRegister _hookreg{target,
// &hook_fn, reinterpret_cast<void**>(&original_fn)};` and DllMain's
// `RunHookRegistrations` walks the list once after `MH_Initialize`,
// installing each hook with `MH_CreateHook` + `MH_EnableHook`.
// &hook_fn, reinterpret_cast<void**>(&original_fn)};` and the installer
// (`InstallHooks` in DllMain.cpp) walks the list once after
// `MH_Initialize`, creating each hook and QUEUE-enabling it; a single
// `MH_ApplyQueued` then activates them all in one thread-freeze.
//
// The install runs OFF the loader lock (via the `Load` export or the
// fallback worker thread — see DllMain.cpp), never inside DllMain, so
// the thread-freeze can't deadlock or stall the loader.
//
// Same lifetime rules as ModuleAutoRegister: constructors chain onto
// a static-init list before DllMain runs, the linker keeps the OBJ
// because the constructor has side effects, and order across TUs is
// undefined but doesn't matter here (hooks are independent).
//
// Use only for feature hooks. The three core engine-init hooks in
// Use only for feature hooks. The four core engine-init hooks in
// DllMain (FrameScript_Initialize / LoadScriptFunctions /
// Frame::RegisterEvent) have inline logic that interleaves with the
// hook chain and stays in DllMain.
// LoadGlueScriptFunctions / Frame::RegisterEvent) have inline logic that
// interleaves with the hook chain and stays with the installer.
struct HookAutoRegister {
HookAutoRegister(uintptr_t target, void *hook, void **original);
uintptr_t target;
@@ -344,8 +424,40 @@ struct HookAutoRegister {
HookAutoRegister *next;
};
// Installs every registered hook. Returns `false` and stops on first
// failure — caller (DllMain) should propagate by returning FALSE.
// Creates and QUEUE-enables every registered hook (no `MH_ApplyQueued`
// here — the caller applies the whole batch at once). Returns `false`
// 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
+2266 -42
View File
File diff suppressed because it is too large Load Diff
+178 -83
View File
@@ -47,6 +47,12 @@
#include "Game.h"
#include "Offsets.h"
#include "addons/EngineIO.h"
#include "addons/FlavorBindings.h"
#include "addons/FlavorToc.h"
#include "addons/Registry.h"
#include "addons/Toc.h"
#include "addons/TocRewrite.h"
#include "bindings/Inject.h"
#include <cstdint>
@@ -60,6 +66,13 @@ namespace {
constexpr const char *kAddonName = "!!!ClassicAPI";
constexpr const char *kAddonTocFile = "!!!ClassicAPI.toc";
// Developer marker. When this file exists in the on-disk addon folder,
// the disk copy wins unconditionally (see `DiskHasDevMarker` /
// `DecideSource`). It is gitignored, excluded from the embed, and the
// release ships only the DLL — so it can only exist where a developer
// deliberately created it, never in a normal user's install.
constexpr const char *kDevMarkerFile = ".classicapi-dev";
// `Interface\AddOns\!!!ClassicAPI\` — the prefix the engine
// constructs for any of our addon's files. Comparing case-
// insensitively because the TOC parser uses `Interface\AddOns\%s\…`
@@ -104,30 +117,15 @@ const ClassicAPIFiles::File *LookupEmbedded(const char *suffix) {
return nullptr;
}
// Storm allocator — same one `FUN_FILE_READ` uses internally. Buffer
// allocated here is freed cleanly by the caller's standard `SMemFree`
// (`FUN_STORM_SMEM_FREE`). `__stdcall` per the function's `RET 0x10`
// (4 args × 4 bytes = 16) epilogue.
// __stdcall void *SMemAlloc(size_t size, const char *file, int line, int flags)
using SMemAlloc_t = void *(__stdcall *)(size_t size, const char *file, int line, int flags);
// Engine file I/O + Storm allocator — see addons/EngineIO.h for the shapes and
// the __stdcall / ESP-drift hazard. A buffer we SMemAlloc here is freed cleanly
// by the caller's standard SMemFree in turn.
using AddOns::EngineIO::FileReadFn;
using AddOns::EngineIO::SMemAllocFn;
using AddOns::EngineIO::SMemFreeFn;
// `FUN_FILE_READ` — same shape derived from the Octo decompile.
// Calling convention is `__stdcall` (callee cleans 28 bytes via
// `RET 0x1C`), not `__cdecl` — confirmed via the function epilogue.
// Getting this wrong silently corrupts the caller's stack frame.
// __stdcall int FileRead(int unused, const char *path, void **outBuf,
// size_t *outSize, size_t extraBytes,
// int flag1, int flag2)
using FileRead_t = int(__stdcall *)(int unused, const char *path, void **outBuf,
size_t *outSize, size_t extraBytes,
int flag1, int flag2);
FileRead_t FileRead_o = nullptr;
// Storm-allocator free. Used to release the disk-TOC buffer after we
// scrape its version line — same path the engine itself uses when
// `FileRead_o` succeeded and the caller's done with the result.
// __stdcall void SMemFree(void *buf, const char *file, int line, int flags)
using SMemFree_t = void(__stdcall *)(void *buf, const char *file, int line, int flags);
// The trampoline to the original FUN_FILE_READ (installed by the co-hook below).
FileReadFn FileRead_o = nullptr;
// Extracts the value of the `## Version: X` line from a TOC byte
// buffer. Writes into `out` (size `outSize`) and returns true on
@@ -135,38 +133,35 @@ using SMemFree_t = void(__stdcall *)(void *buf, const char *file, int line, int
// sensitive on the key. Returns false if no Version line is found.
bool ExtractTocVersion(const char *content, size_t size,
char *out, size_t outSize) {
static const char kKey[] = "## Version:";
constexpr size_t kKeyLen = sizeof(kKey) - 1;
for (size_t i = 0; i + kKeyLen <= size; ++i) {
const bool atLineStart = (i == 0) || content[i - 1] == '\n';
if (!atLineStart) continue;
if (std::memcmp(content + i, kKey, kKeyLen) != 0) continue;
const char *p = content + i + kKeyLen;
const char *end = content + size;
while (p < end && (*p == ' ' || *p == '\t')) ++p;
size_t j = 0;
while (p < end && *p != '\r' && *p != '\n' && j + 1 < outSize) {
out[j++] = *p++;
}
while (j > 0 && (out[j - 1] == ' ' || out[j - 1] == '\t')) --j;
out[j] = '\0';
return j > 0;
const char *v = nullptr;
size_t n = 0;
if (outSize == 0)
return false;
if (!AddOns::Toc::FindValue(content, size, "## Version:", &v, &n) || n == 0) {
out[0] = '\0';
return false;
}
if (outSize > 0) out[0] = '\0';
return false;
if (n >= outSize)
n = outSize - 1;
std::memcpy(out, v, n);
out[n] = '\0';
return true;
}
// Returns -1/0/+1 for `a < b` / `a == b` / `a > b`. "DEV" is a
// sentinel meaning "newer than any real release" — local dev builds
// always win against installed-on-disk versions. Otherwise the two
// strings are walked as dot-separated numeric semver components
// Returns -1/0/+1 for `a < b` / `a == b` / `a > b`. "DEV" is the
// local-build sentinel: two DEV builds are equal, but a DEV build sorts
// BELOW any real release. Dev-over-release precedence is signalled
// explicitly by the `.classicapi-dev` marker now — NOT by the toc
// version — so a stray `## Version: DEV` disk copy (repo clone / old
// bundle) never silently shadows a newer embedded release. Otherwise
// the two strings are walked as dot-separated numeric semver components
// (`1.2` < `1.10`).
int CompareVersions(const char *a, const char *b) {
const bool aDev = std::strcmp(a, "DEV") == 0;
const bool bDev = std::strcmp(b, "DEV") == 0;
if (aDev && bDev) return 0;
if (aDev) return 1;
if (bDev) return -1;
if (aDev) return -1;
if (bDev) return 1;
while (*a || *b) {
int va = 0, vb = 0;
while (*a >= '0' && *a <= '9') { va = va * 10 + (*a - '0'); ++a; }
@@ -203,16 +198,48 @@ void EnsureEmbeddedVersionExtracted() {
}
}
// True iff the on-disk addon folder contains the `.classicapi-dev`
// marker. Read via the ORIGINAL `FUN_FILE_READ` (bypassing our embed
// hook), so it reflects a real file on disk / in an MPQ — never an
// embedded copy. That makes it a clean "is this a developer working
// tree" signal: the marker is gitignored + excluded from the embed and
// the release ships only the DLL, so a normal user's install can't
// carry it.
bool DiskHasDevMarker() {
char fullPath[256];
std::snprintf(fullPath, sizeof(fullPath), "%s%s",
kAddonPathPrefix, kDevMarkerFile);
void *buf = nullptr;
size_t size = 0;
const int ok = FileRead_o(0, fullPath, &buf, &size, 1, 1, 0);
if (ok == 0 || buf == nullptr)
return false;
auto SMemFree = reinterpret_cast<SMemFreeFn>(Offsets::FUN_STORM_SMEM_FREE);
SMemFree(buf, __FILE__, __LINE__, 0);
return true;
}
// Decide whether disk or embedded should serve all reads for this
// addon. Called on the first FileRead_h call with a matching prefix.
// Reads the on-disk TOC via the original `FUN_FILE_READ` (bypassing
// our hook), parses its version, compares to the embedded version,
// and caches the winner. If disk has no TOC at all, embedded wins by
// default.
// The `.classicapi-dev` marker forces disk unconditionally (explicit
// developer override). Otherwise reads the on-disk TOC via the original
// `FUN_FILE_READ` (bypassing our hook), parses its version, compares to
// the embedded version, and caches the newer. If disk has no TOC at
// all, embedded wins by default.
void DecideSource() {
if (g_source != Source::Undecided) return;
EnsureEmbeddedVersionExtracted();
// Explicit developer override: disk wins regardless of version. This
// is how a dev keeps disk precedence when running local Lua against a
// *released* DLL (where the embedded version would otherwise be newer
// and win). Dev intent lives in this marker, decoupled from the toc
// version — so users never get silently shadowed by a stray copy.
if (DiskHasDevMarker()) {
g_source = Source::Disk;
return;
}
char fullPath[256];
std::snprintf(fullPath, sizeof(fullPath), "%s%s",
kAddonPathPrefix, kAddonTocFile);
@@ -232,7 +259,7 @@ void DecideSource() {
// Free the disk buffer we just borrowed — we only needed it for
// the version-line scrape. The actual content for the engine's
// TOC read comes through the regular hook path below.
auto SMemFree = reinterpret_cast<SMemFree_t>(Offsets::FUN_STORM_SMEM_FREE);
auto SMemFree = reinterpret_cast<SMemFreeFn>(Offsets::FUN_STORM_SMEM_FREE);
SMemFree(diskBuf, __FILE__, __LINE__, 0);
// Missing or unparseable disk version → assume it's older than
@@ -259,10 +286,33 @@ int __stdcall FileRead_h(int unused, const char *path, void **outBuf,
const char *suffix = StripAddonPrefix(path);
if (suffix == nullptr) {
// Path isn't under `Interface\AddOns\!!!ClassicAPI\` — straight
// pass-through, we don't care.
return FileRead_o(unused, path, outBuf, outSize, extraBytes,
flag1, flag2);
// Not our embedded addon. If this is a base-TOC read for a multi-flavor
// addon that ships only `<Name>_Turtle.toc` / `_ClassicAPI.toc`, serve
// that in place of the missing `<Name>.toc` so it registers and loads
// (see addons/FlavorToc.h).
int result;
if (AddOns::FlavorToc::TryHandle(unused, path, outBuf, outSize,
extraBytes, flag1, flag2, FileRead_o)) {
result = 1;
} else if (AddOns::FlavorBindings::TryHandle(unused, path, outBuf, outSize,
extraBytes, flag1, flag2,
FileRead_o)) {
// Flavor-specific addon keybindings: serve `Bindings_Turtle.xml` /
// `Bindings_ClassicAPI.xml` for a `…\AddOns\<Name>\Bindings.xml`
// read (see addons/FlavorBindings.h). The paired exists-check
// co-hook lives in that module.
result = 1;
} else {
result = FileRead_o(unused, path, outBuf, outSize, extraBytes,
flag1, flag2);
}
// Per-line TOC directives: evaluate `[Condition]` and expand
// `[Variable]` tokens on this addon TOC's file lines so the load
// pass sees clean paths (see addons/TocRewrite.h). No-op unless
// `path` is an addon `.toc` that actually contains directives.
if (result != 0)
AddOns::TocRewrite::Transform(path, outBuf, outSize);
return result;
}
DecideSource();
@@ -289,7 +339,7 @@ int __stdcall FileRead_h(int unused, const char *path, void **outBuf,
flag1, flag2);
}
auto SMemAlloc = reinterpret_cast<SMemAlloc_t>(
auto SMemAlloc = reinterpret_cast<SMemAllocFn>(
Offsets::FUN_STORM_SMEM_ALLOC);
const size_t totalSize = entry->size + extraBytes;
void *buf = SMemAlloc(totalSize, __FILE__, __LINE__, 0);
@@ -328,40 +378,80 @@ 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;
// 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).
void MoveEmbeddedToListHead() {
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) {
// `entry+0x2b` — DefaultState (`## DefaultState: enabled/disabled`).
// The enable resolver `FUN_ADDON_ENABLE_RESOLVE` falls back to this
// when the player has no per-character override. Belt-and-suspenders
// alongside the resolver co-hook below (which forces enabled anyway).
constexpr int OFF_ADDON_ENTRY_DEFAULT_STATE = 0x2b;
// Post-registration finalize for the embedded entry: move it to the
// head of the load-order list (so consumers that read our globals from
// their main chunk see them — see the block comment above the offsets),
// hide it from the addon list, and mark it default-enabled. Combined
// with the `FUN_ADDON_ENABLE_RESOLVE` co-hook, `!!!ClassicAPI` always
// loads and never appears as a toggleable entry.
void FinalizeEmbeddedEntry() {
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) {
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);
}
entry + Offsets::OFF_ADDON_ENTRY_NAME_PTR);
if (name == nullptr || std::strcmp(name, kAddonName) != 0)
return true; // keep walking
// 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) {
AddonInit_o(accountName);
auto TocParser = reinterpret_cast<TocParser_t>(Offsets::FUN_TOC_PARSER);
TocParser(kAddonName);
MoveEmbeddedToListHead();
FinalizeEmbeddedEntry();
}
// `FUN_ADDON_ENABLE_RESOLVE` — per-character enable resolver. We
// co-hook it to force-enable `!!!ClassicAPI` regardless of DefaultState
// or any stale WTF disable-override, so hiding it from the addon list
// can never leave it stuck off. Returns 2 ("enabled for all"); the
// load-pass gate only tests `!= 0`. Every other addon falls through to
// the original untouched. See the Offsets.h note for the ABI.
using AddonEnableResolve_t =
uint32_t(__fastcall *)(const char *name, const char *account, char resolveAll);
AddonEnableResolve_t AddonEnableResolve_o = nullptr;
uint32_t __fastcall AddonEnableResolve_h(const char *name, const char *account,
char resolveAll) {
if (name != nullptr && PathEqualsCI(name, kAddonName))
return 2;
return AddonEnableResolve_o(name, account, resolveAll);
}
const Game::HookAutoRegister _hookFileRead{
@@ -374,6 +464,11 @@ const Game::HookAutoRegister _hookAddonInit{
reinterpret_cast<void *>(&AddonInit_h),
reinterpret_cast<void **>(&AddonInit_o)};
const Game::HookAutoRegister _hookEnableResolve{
Offsets::FUN_ADDON_ENABLE_RESOLVE,
reinterpret_cast<void *>(&AddonEnableResolve_h),
reinterpret_cast<void **>(&AddonEnableResolve_o)};
} // namespace
} // namespace Addons::Embedded
+41
View File
@@ -0,0 +1,41 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
#pragma once
#include <cstddef>
// One home for the engine file-I/O and Storm-allocator function-pointer types
// the addon modules call. These are ABI-critical: FUN_FILE_READ /
// FUN_FILE_EXISTS / the Storm allocator are all `__stdcall` (callee cleans the
// stack), and declaring one `__cdecl` by mistake drifts ESP a few bytes per
// call and kills the process with no crash log. The shape lived in ~6 copies
// across the addon files; centralize it so the convention cannot drift.
namespace AddOns::EngineIO {
// FUN_FILE_READ (0x00648620) — __stdcall, callee cleans 28 bytes (RET 0x1C).
// arg0 is an optional archive handle (0 = merged VFS); outSize may be null.
using FileReadFn = int(__stdcall *)(int unused, const char *path, void **outBuf,
size_t *outSize, size_t extraBytes,
int flag1, int flag2);
// FUN_FILE_EXISTS (0x00648A30) — __stdcall, RET 8.
using FileExistsFn = int(__stdcall *)(const char *path, int mode);
// FUN_STORM_SMEM_ALLOC / FUN_STORM_SMEM_FREE — __stdcall, RET 0x10. Buffers
// FUN_FILE_READ hands out are freed with SMemFreeFn; a buffer we allocate with
// SMemAllocFn is freed cleanly by the engine's own SMemFree in turn.
using SMemAllocFn = void *(__stdcall *)(size_t size, const char *file, int line, int flags);
using SMemFreeFn = void(__stdcall *)(void *buf, const char *file, int line, int flags);
} // namespace AddOns::EngineIO
+130
View File
@@ -0,0 +1,130 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
#include "addons/EngineIO.h"
#include "addons/FlavorBindings.h"
#include "addons/Toc.h"
#include "Game.h"
#include "Offsets.h"
#include "turtle/Detect.h"
#include <cstddef>
#include <cstdint>
#include <cstring>
namespace AddOns::FlavorBindings {
namespace {
using AddOns::Toc::EqCI;
using AddOns::Toc::Lower;
constexpr size_t NPOS = static_cast<size_t>(-1);
// If `path` is an addon Bindings read (`…\AddOns\<Name>\Bindings.xml`, CI),
// return the length of the prefix up to the `Bindings.xml` filename (so a
// flavor filename can be appended in its place); else NPOS.
size_t AddonBindingsPrefix(const char *path) {
if (path == nullptr) return NPOS;
const size_t len = std::strlen(path);
const char *kFile = "bindings.xml"; // 12 chars, compared case-insensitively
const size_t flen = 12;
if (len < flen + 1) return NPOS;
for (size_t i = 0; i < flen; ++i)
if (Lower(path[len - flen + i]) != kFile[i]) return NPOS;
const size_t prefixLen = len - flen;
if (path[prefixLen - 1] != '\\' && path[prefixLen - 1] != '/')
return NPOS; // "Bindings.xml" must be a whole path segment
for (size_t i = 0; i + 8 <= len; ++i)
if (EqCI(path + i, 8, "\\AddOns\\")) return prefixLen;
return NPOS;
}
// Write `<prefix><flavorFile>` (e.g. `…\AddOns\Foo\Bindings_Turtle.xml`) into
// `out`. Returns false if it would not fit.
bool BuildFlavorPath(const char *path, size_t prefixLen, const char *flavorFile,
char *out, size_t outSize) {
const size_t flen = std::strlen(flavorFile);
if (prefixLen + flen + 1 > outSize) return false;
std::memcpy(out, path, prefixLen);
std::memcpy(out + prefixLen, flavorFile, flen + 1); // incl. NUL
return true;
}
// Fills `order` with the flavor filenames to probe, most specific first, and
// returns the count. `Bindings_Turtle.xml` only on a Turtle client.
int FlavorOrder(const char *(&order)[2]) {
int count = 0;
if (Turtle::Detected())
order[count++] = "Bindings_Turtle.xml";
order[count++] = "Bindings_ClassicAPI.xml";
return count;
}
// Engine file-exists probe FUN_00648a30 — __stdcall(path, mode) (RET 8).
using EngineIO::FileExistsFn;
FileExistsFn g_origExists = nullptr;
// Co-hook the exists-check so `Interface\AddOns\<Name>\Bindings.xml` reports as
// existing when the base file is absent but a flavor variant is present — so
// the loader (`FUN_004b6f70`, then our TryHandle redirect) runs for a
// flavor-only addon. Every non-Bindings path passes straight through.
int __stdcall FileExists_h(const char *path, int mode) {
const size_t prefixLen = AddonBindingsPrefix(path);
if (prefixLen == NPOS)
return g_origExists(path, mode);
if (g_origExists(path, mode) != 0)
return 1; // the plain Bindings.xml exists
const char *order[2];
const int count = FlavorOrder(order);
char cand[512];
for (int i = 0; i < count; ++i) {
if (BuildFlavorPath(path, prefixLen, order[i], cand, sizeof cand) &&
g_origExists(cand, mode) != 0)
return 1;
}
return 0;
}
const Game::HookAutoRegister _hookExists{
Offsets::FUN_FILE_EXISTS,
reinterpret_cast<void *>(&FileExists_h),
reinterpret_cast<void **>(&g_origExists)};
} // namespace
bool TryHandle(int unused, const char *path, void **outBuf, size_t *outSize,
size_t extraBytes, int flag1, int flag2, FileReadFn orig) {
const size_t prefixLen = AddonBindingsPrefix(path);
if (prefixLen == NPOS)
return false;
const char *order[2];
const int count = FlavorOrder(order);
char cand[512];
for (int i = 0; i < count; ++i) {
if (!BuildFlavorPath(path, prefixLen, order[i], cand, sizeof cand))
continue;
// Serve the flavor file in place of Bindings.xml (a selected flavor
// wins over a plain Bindings.xml). No recursion: `orig` is the original
// reader passed by the FUN_FILE_READ hook.
if (orig(unused, cand, outBuf, outSize, extraBytes, flag1, flag2) != 0)
return true;
}
return false; // no flavor variant — caller reads the base Bindings.xml
}
} // namespace AddOns::FlavorBindings
+55
View File
@@ -0,0 +1,55 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
#pragma once
#include <cstddef>
#include "addons/EngineIO.h"
namespace AddOns::FlavorBindings {
using EngineIO::FileReadFn;
// Flavor-specific addon Bindings selection — the Bindings.xml analog of
// AddOns::FlavorToc.
//
// A modern addon can ship a flavor-specific keybinding file
// (`Bindings_Turtle.xml` / `Bindings_ClassicAPI.xml`) instead of / alongside a
// plain `Bindings.xml`. The per-addon loader `FUN_0051f240` only ever builds
// `Interface\AddOns\<Name>\Bindings.xml`, gates it on an exists-check
// (`FUN_00648a30`), and — when that passes — loads it via `FUN_004b6f70`, which
// reads the file through `FUN_FILE_READ`. Two pieces make flavor files work:
//
// 1. This TryHandle, called from the FUN_FILE_READ hook: when `path` is an
// addon `…\AddOns\<Name>\Bindings.xml` read, serve a flavor variant in its
// place if one exists (a selected flavor wins over a plain `Bindings.xml`,
// matching FlavorToc). Probe order `Bindings_Turtle.xml` (only when
// Turtle::Detected) then `Bindings_ClassicAPI.xml`.
// 2. A co-hook on the exists-check `FUN_00648a30` (inside FlavorBindings.cpp):
// report `Bindings.xml` as existing when the base is absent but a flavor
// variant is present — otherwise the loader is never called for a
// flavor-only addon.
//
// Only `_Turtle` / `_ClassicAPI` are selected — never `_Vanilla` / `_Classic`
// (which target the 1.15 Classic Era build; its bindings reference handlers
// that build defines and this client does not load, so they would bind to
// missing functions). Both suffixes are ClassicAPI conventions, opt-in by the
// addon author, and mirror FlavorToc.
//
// Returns false when `path` is not an addon Bindings.xml read or no flavor
// variant exists — the caller then performs the normal base read.
bool TryHandle(int unused, const char *path, void **outBuf, size_t *outSize,
size_t extraBytes, int flag1, int flag2, FileReadFn orig);
} // namespace AddOns::FlavorBindings
+108
View File
@@ -0,0 +1,108 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
#include "addons/FlavorToc.h"
#include "turtle/Detect.h"
#include <cstring>
namespace AddOns::FlavorToc {
namespace {
// If `path` is `…\AddOns\<Name>\<Name>.toc` (case-insensitive), set `*prefixLen`
// to the byte length of the path up to and including the base `<Name>` filename
// (so a `_Vanilla.toc` suffix can be appended in place) and return true.
bool ParseBaseToc(const char *path, size_t *prefixLen) {
const size_t len = std::strlen(path);
if (len < 5 || _strnicmp(path + len - 4, ".toc", 4) != 0)
return false;
// Last separator -> "<Name>.toc" file part.
size_t lastSep = static_cast<size_t>(-1);
for (size_t i = len; i-- > 0;)
if (path[i] == '\\' || path[i] == '/') { lastSep = i; break; }
if (lastSep == static_cast<size_t>(-1))
return false;
const char *file = path + lastSep + 1; // "<Name>.toc"
const size_t nameLen = (len - (lastSep + 1)) - 4; // strip ".toc"
if (nameLen == 0)
return false;
// Previous separator -> "<Name>" folder segment; it must equal the base name.
size_t prevSep = static_cast<size_t>(-1);
for (size_t i = lastSep; i-- > 0;)
if (path[i] == '\\' || path[i] == '/') { prevSep = i; break; }
if (prevSep == static_cast<size_t>(-1))
return false;
const char *folder = path + prevSep + 1;
const size_t folderLen = lastSep - (prevSep + 1);
if (folderLen != nameLen || _strnicmp(folder, file, nameLen) != 0)
return false;
// Grandparent segment must be "AddOns" (so FrameXML/other .toc reads pass through).
size_t prev2Sep = static_cast<size_t>(-1);
for (size_t i = prevSep; i-- > 0;)
if (path[i] == '\\' || path[i] == '/') { prev2Sep = i; break; }
if (prev2Sep == static_cast<size_t>(-1))
return false;
const char *gp = path + prev2Sep + 1;
const size_t gpLen = prevSep - (prev2Sep + 1);
if (gpLen != 6 || _strnicmp(gp, "AddOns", 6) != 0)
return false;
*prefixLen = lastSep + 1 + nameLen; // "…\AddOns\<Name>\<Name>"
return true;
}
} // namespace
bool TryHandle(int unused, const char *path, void **outBuf, size_t *outSize,
size_t extraBytes, int flag1, int flag2, FileReadFn orig) {
if (path == nullptr)
return false;
size_t prefixLen = 0;
if (!ParseBaseToc(path, &prefixLen))
return false;
char cand[512];
if (prefixLen + 16 >= sizeof cand) // "_ClassicAPI.toc" + NUL is 16 (longest)
return false; // pathological path length — let base read run
std::memcpy(cand, path, prefixLen);
// Only flavors that describe THIS environment, most specific first:
// `_Turtle` — Turtle client/content, only when Turtle is detected;
// `_ClassicAPI` — 1.12 + ClassicAPI, ALWAYS (ClassicAPI is present by
// definition — it is the code doing this redirect).
// Deliberately NOT `_Vanilla`/`_Classic` (those target the 1.15 Classic Era
// client — a modern engine + full modern API — which this 1.12 +
// partial-backport client is not), nor `_Mainline`/`_Cata`/etc.
const char *order[2];
int count = 0;
if (Turtle::Detected())
order[count++] = "_Turtle.toc";
order[count++] = "_ClassicAPI.toc";
for (int i = 0; i < count; ++i) {
std::memcpy(cand + prefixLen, order[i], std::strlen(order[i]) + 1); // incl. NUL
if (orig(unused, cand, outBuf, outSize, extraBytes, flag1, flag2) != 0)
return true; // served the flavor TOC in place of the base
}
// No matching flavor TOC — fall through: the caller reads the base path,
// which chains through any other FUN_FILE_READ hooker before the engine.
return false;
}
} // namespace AddOns::FlavorToc
+60
View File
@@ -0,0 +1,60 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
#pragma once
#include <cstddef>
#include "addons/EngineIO.h"
namespace AddOns::FlavorToc {
using EngineIO::FileReadFn;
// Modern-client multi-flavor TOC selection, backported.
//
// A modern addon that targets several game flavors from one folder ships
// `<Name>_Vanilla.toc` / `_Mainline.toc` / `_Cata.toc` / … and often NO plain
// `<Name>.toc`. Vanilla 1.12 only ever opens `<Name>\<Name>.toc`, so such an
// addon never registers — it is invisible, not merely mis-loaded.
//
// Called from the FUN_FILE_READ hook. If `path` is an addon base-TOC read
// (`…\AddOns\<Name>\<Name>.toc`), probe the flavor TOCs that describe THIS
// environment and, if one exists, serve it via `orig` (as if it were the base
// TOC) and return true. Order, most specific first:
// `<Name>_Turtle.toc` — Turtle client/content, ONLY when Turtle is
// detected (Turtle::Detected); a ClassicAPI extension;
// `<Name>_ClassicAPI.toc` — 1.12 + ClassicAPI, ALWAYS (ClassicAPI is present
// by definition — it is the code doing this redirect).
// Both are ClassicAPI conventions, not retail flavors. We deliberately do NOT
// map to `_Vanilla`/`_Classic` (those target the 1.15 Classic Era client — a
// modern engine + full modern API — which a 1.12 + partial-backport client is
// not), nor `_Mainline`/`_Cata`/etc. A selected flavor wins over an existing
// base `<Name>.toc`. If neither flavor exists, TryHandle returns false and the
// caller does the normal base read — which chains through any other DLL that
// hooked FUN_FILE_READ before reaching the engine.
//
// The redirect fires at BOTH the registration scan (glue/char-select) and the
// in-world load pass, which must agree on the flavor. `Turtle::Detected()` is
// therefore consistent across both: it reads the in-world `TURTLE_WOW_VERSION`
// global AND Turtle's glue-time `TURTLE_*` GlueXML globals, so it returns true
// at the glue scan too (see turtle/Detect.h).
//
// Returns false when `path` is not a base-TOC read, or no vanilla flavor TOC
// exists — the caller then performs the normal base read. Because the redirect
// is at the read layer, the addon scan (FUN_0051C9B0), the load pass
// (FUN_006edb90) and GetAddOnMetadata all see the same selected TOC.
bool TryHandle(int unused, const char *path, void **outBuf, size_t *outSize,
size_t extraBytes, int flag1, int flag2, FileReadFn orig);
} // namespace AddOns::FlavorToc
+132
View File
@@ -0,0 +1,132 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
// `C_AddOns.GetAddOnLocalTable(name)` — the public, gated view of an addon's
// private namespace table (the `local _, addonTable = ...` second vararg the
// transpiler injects; see luasyntax/AddonNamespace.h).
//
// Retail added this in 11.0 alongside the `## AllowAddOnTableAccess` TOC
// directive: one addon can read another's local table ONLY when the target
// opts in. We mirror that gate:
// 1. the named addon is currently LOADED, and
// 2. its `.toc` declares `## AllowAddOnTableAccess: 1`.
// Either check failing returns nil. The internal preamble path (an addon
// receiving its OWN table) is a different, ungated function (`__addonns`) —
// an addon never needs permission for its own namespace.
//
// Directive spelling verified against the 1.15.8 Classic Era binary (both the
// `AllowAddOnTableAccess:` TOC key and the `GetAddOnLocalTable` name are
// present there). The vanilla 1.12 TOC parser drops unknown `##` directives,
// so we scan the `.toc` ourselves via `FUN_FILE_READ`.
#include "Game.h"
#include "Offsets.h"
#include "addons/EngineIO.h"
#include "addons/Toc.h"
#include "luasyntax/AddonNamespace.h"
#include <cstddef>
#include <cstdint>
#include <cstdio>
namespace AddOns::LocalTable {
namespace {
// IS_LOADED is a by-name hash lookup returning the loaded byte (`entry+0x18`);
// passing the name string works because the lookup hashes it. See Offsets.h.
using IsLoadedFn_t = uint8_t(__fastcall *)(const char *name);
// FUN_FILE_READ / the Storm free (see addons/EngineIO.h). Calling the raw
// FUN_FILE_READ address routes through Embedded's file-read hook, which forwards
// every non-`!!!ClassicAPI` path to the original untouched — what we want here.
using AddOns::EngineIO::FileReadFn;
using AddOns::EngineIO::SMemFreeFn;
// True iff the addon's `.toc` has a line `## AllowAddOnTableAccess: <nonzero>`.
// The value is read as a TOC boolean flag: parse the leading integer, true iff
// non-zero (so `1` opts in, `0`/absent do not) — matching WoW's numeric TOC
// flag semantics. Not cached: GetAddOnLocalTable is not a hot path.
bool TocAllowsTableAccess(const char *name) {
// A registered, loaded addon name never carries a path separator; reject
// any that does so the read stays bounded to the addon's own folder.
for (const char *p = name; *p; ++p)
if (*p == '\\' || *p == '/')
return false;
char path[300];
std::snprintf(path, sizeof path, "Interface\\AddOns\\%s\\%s.toc", name, name);
void *buf = nullptr;
size_t size = 0;
auto fileRead = reinterpret_cast<FileReadFn>(Offsets::FUN_FILE_READ);
if (fileRead(0, path, &buf, &size, 1, 1, 0) == 0 || buf == nullptr)
return false;
const char *v = nullptr;
size_t n = 0;
bool allowed = false;
if (AddOns::Toc::FindValue(static_cast<const char *>(buf), size,
"## AllowAddOnTableAccess:", &v, &n)) {
int value = 0;
bool anyDigit = false;
for (size_t k = 0; k < n && v[k] >= '0' && v[k] <= '9'; ++k) {
value = value * 10 + (v[k] - '0');
anyDigit = true;
}
allowed = anyDigit && value != 0;
}
auto smemFree = reinterpret_cast<SMemFreeFn>(Offsets::FUN_STORM_SMEM_FREE);
smemFree(buf, __FILE__, __LINE__, 0);
return allowed;
}
// `C_AddOns.GetAddOnLocalTable(name)` -> table, or nil.
//
// Name (string) input only — the local table is a by-name concept (one addon
// asking for another by name), and the namespace map is name-keyed. Numeric
// index is not accepted (returns nil); no addon references another's table by
// load order.
int __fastcall Script_GetAddOnLocalTable(void *L) {
const char *name = Game::Lua::ToString(L, 1);
if (name == nullptr || *name == '\0') {
Game::Lua::PushNil(L);
return 1;
}
auto isLoaded = reinterpret_cast<IsLoadedFn_t>(Offsets::FUN_ADDON_IS_LOADED);
if (isLoaded(name) == 0) { // not loaded (covers "does not exist")
Game::Lua::PushNil(L);
return 1;
}
if (!TocAllowsTableAccess(name)) { // opted out / no directive
Game::Lua::PushNil(L);
return 1;
}
LuaSyntax::PushAddonNamespace(L, name);
return 1;
}
void RegisterLuaFunctions() {
Game::Lua::RegisterTableFunction("C_AddOns", "GetAddOnLocalTable",
&Script_GetAddOnLocalTable);
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace
} // namespace AddOns::LocalTable
+50
View File
@@ -0,0 +1,50 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
#pragma once
#include "Offsets.h"
#include <cstdint>
#include <type_traits>
namespace Addons {
// Walk the engine's intrusive AddOn-registry linked list, calling `fn(entry)`
// for each `AddOnEntry *`. Head at `[VAR_ADDON_LIST_HEAD]`; the next-pointer
// field is at `entry + [VAR_ADDON_LIST_CTRL] + 4` (= `entry + 0x10`); a
// low-bit-1 sentinel or NULL terminates. This is the same traversal the
// engine's own load pass (`FUN_0051F600`) and scan tail loop use.
//
// `fn` may return `void` (always continue) or `bool` (return `false` to STOP
// early). Early stop is load-bearing for a visitor that mutates the list —
// e.g. re-linking an entry to the head invalidates the current next-pointer,
// so the walk must not follow it. Shared by `Addons::Embedded` (find + re-link
// the embedded entry) and `Addons::Rescan` (snapshot / diff / fix-up passes).
template <typename Fn> void ForEachEntry(Fn fn) {
const int linkOffset = *reinterpret_cast<const int *>(
static_cast<uintptr_t>(Offsets::VAR_ADDON_LIST_CTRL));
uintptr_t entry = *reinterpret_cast<const uintptr_t *>(
static_cast<uintptr_t>(Offsets::VAR_ADDON_LIST_HEAD));
while ((entry & 1) == 0 && entry != 0) {
if constexpr (std::is_void_v<decltype(fn(entry))>) {
fn(entry);
} else {
if (!fn(entry))
return;
}
entry = *reinterpret_cast<const uintptr_t *>(entry + linkOffset + 4);
}
}
} // namespace Addons
+523
View File
@@ -0,0 +1,523 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
// Retail-like `/reload`: pick up new addon folders and new files without
// restarting the client. Two independent engine limitations block that on
// a stock 1.12 client, each fixed here with the engine's own machinery:
//
// 1. FILE VISIBILITY. Every relative-path read resolves through a
// loose-file hash index built ONCE at boot (see Offsets.h,
// `FUN_VFS_INDEX_SUBTREE`); files created after boot — a new
// addon's TOC, a new .lua in an existing addon, a freshly written
// SavedVariables file — are invisible until restart. The boot
// indexer is dedup-safe and works on subtrees, so we re-run it per
// /reload on the only two subtrees whose contents can change what
// a /reload loads: `Interface\AddOns` and `WTF\Account`. New files
// in EXISTING addons need nothing more — the per-addon loader
// `FUN_0051F240` re-reads the TOC from disk on every load pass, so
// once the files are visible they load.
//
// 2. REGISTRY MEMBERSHIP. The addon registry is built once at login;
// new folders are never walked. We replay the login scan's disk
// walk verbatim (`FUN_ADDON_SCAN_DISK_DIRS` with the engine's own
// per-directory callback, which feeds the dedup-safe TOC parser),
// so new folders register as completely normal entries — then
// mirror the two registry structures the scan's OTHER passes would
// have filled: the reverse-LoadWith lists (scan tail loop) and the
// flat `GetNumAddOns` display array (`FUN_0051DA70` phase 2).
//
// 3. METADATA IMMUTABILITY. The parser's dedup guard makes a
// registered entry's `##` metadata read-once: an edited
// `## SavedVariables:` / `## Dependencies:` / any `##` line never
// re-parses. The engine's own complete per-entry destructor
// (`FUN_ADDON_ENTRY_DESTROY` — frees every owned allocation and
// self-unlinks from both the list and the hash; see Offsets.h)
// makes evict + re-register safe: destroy the entry so the dedup
// guard MISSES, re-feed the name to `FUN_TOC_PARSER`, and the
// entry rebuilds from the edited TOC exactly like a fresh
// registration. Gated on an actual `##` change (hash of the TOC's
// `##` lines only — file-reference edits load natively and never
// trigger the evict path). A TOC that no longer reads at all
// (folder deleted) evicts without re-registering — the deletion
// analog; a folder the scan still finds simply re-registers.
//
// Runs from the `ModuleAutoRegister` callback — LoadScriptFunctions
// post-hook, which fires inside FrameXML init (`FUN_0048FBF0`) BEFORE
// the AddOns.txt enable-state re-read, the load-progress count, and the
// addon load pass, on both login and every /reload. The engine then
// loads new entries natively: dep ordering, Bindings.xml, flavor TOCs,
// SavedVariables, ADDON_LOADED, the load-screen "Loading add-on %s".
// The window is also what makes eviction safe: the unload pass already
// ran (SavedVariables written from the OLD entry's SV lists, loaded
// bytes cleared), and the load pass hasn't — nothing holds entry state.
//
// Excluded from the evict path (everything else is fair game):
// - `!!!ClassicAPI` — embedded; `Addons::Embedded` owns its entry
// (head re-link + filter byte);
// - `## Secure:` / SMSG-managed entries — the parser cannot restore
// packet-delivered state (see OFF_ADDON_ENTRY_SECURE).
//
// NOTE the surgical discipline. A previous attempt re-invoked the
// login-only teardown+rescan (`FUN_ADDON_INIT`) mid-/reload and
// corrupted the registry (duplicate loads + Lua memory explosion on the
// NEXT login). This module never runs bulk teardown; the only entry
// mutation beyond what a login scan produces is the per-entry evict
// above, which is (a) the engine's own complete destructor, (b) always
// preceded by the reverse-LoadWith scrub (the one reference the dtor
// can't clean), and (c) always followed by the same fix-ups a new
// registration gets. New entries come from the same parser call
// `Addons::Embedded` has exercised every login.
//
// Thread note: the loose-file index has no lock, but the engine itself
// builds it lazily on first access and we mutate it only during the
// reload loading screen on the main thread — the same context in which
// the engine does all its own file work.
#include "Game.h"
#include "Offsets.h"
#include "addons/EngineIO.h"
#include "addons/Registry.h"
#include <algorithm>
#include <cstdint>
#include <cstdio>
#include <cstring>
#include <string>
#include <unordered_map>
#include <vector>
namespace Addons::Rescan {
namespace {
// ── Engine entry points (all verified by disassembly; see Offsets.h) ──
using FindOpen_t = void *(__stdcall *)(const char *dirPath);
using FindClose_t = void(__stdcall *)(void *findBlock);
using IndexSubtree_t = void(__fastcall *)(const char *basePath,
const char *relSubdir,
void *findHandle);
using ScanDiskDirs_t = int(__fastcall *)(const char *basePath,
const char *pattern, void *callback,
void *userParam, int includeHidden);
using ResolveByName_t = void *(__fastcall *)(const char *name);
using DescGrow_t = void(__thiscall *)(void *desc, uint32_t newCap);
using QuantumCalc_t = uint32_t(__thiscall *)(void *desc, uint32_t needed);
using Qsort_t = void(__cdecl *)(void *base, uint32_t num, uint32_t width,
void *compare);
using TocParser_t = void(__fastcall *)(const char *name);
using EntryDestroy_t = void(__stdcall *)(void *entry);
// The engine's ubiquitous growable-array descriptor.
struct Desc {
uint32_t cap;
uint32_t count;
uint32_t *data;
uint32_t quantum;
};
// Append one dword to a descriptor, mirroring the engine's inline
// grow-and-append (the scan tail loop and `FUN_0051DA70` phase 2 share
// it): round the needed cap up to the quantum — computed by the
// engine's own `FUN_DESC_QUANTUM_CALC` when the desc has none — grow
// via the site's grow instantiation (which reallocs `data` and writes
// `cap`), then `data[count++] = value`.
void DescAppend(Desc *desc, uint32_t value, DescGrow_t grow) {
uint32_t needed = desc->count + 1;
if (desc->cap < needed) {
uint32_t quantum = desc->quantum;
if (quantum == 0) {
auto calc =
reinterpret_cast<QuantumCalc_t>(Offsets::FUN_DESC_QUANTUM_CALC);
quantum = calc(desc, needed);
}
if (needed % quantum != 0)
needed += quantum - needed % quantum;
grow(desc, needed);
}
desc->data[desc->count++] = value;
}
// The registry linked-list walk lives in `addons/Registry.h`
// (`Addons::ForEachEntry`), shared with `Addons::Embedded`.
// By-name registry lookup via the engine's own hash resolver (returns
// the entry's RequiredDeps desc, so subtract its offset — same recovery
// `Addons::Info` uses).
uintptr_t ResolveEntryByName(const char *name) {
auto resolve =
reinterpret_cast<ResolveByName_t>(Offsets::FUN_ADDON_RESOLVE_REQ_DEPS);
const uintptr_t desc = reinterpret_cast<uintptr_t>(resolve(name));
return desc != 0 ? desc - Offsets::OFF_ADDON_REQDEPS_DESC : 0;
}
// ── Step 1: loose-file index refresh ──────────────────────────────────
// Register new on-disk files under `relSubdir` with the frozen index,
// exactly as the boot walk would have (the indexer skips every file
// already present). No-op when the directory doesn't exist —
// `FUN_VFS_FIND_OPEN` requires an existing directory and returns NULL.
void ReindexSubtree(const char *base, const char *relSubdir) {
char dir[260];
std::snprintf(dir, sizeof(dir), "%s\\%s", base, relSubdir);
auto findOpen = reinterpret_cast<FindOpen_t>(Offsets::FUN_VFS_FIND_OPEN);
auto findClose = reinterpret_cast<FindClose_t>(Offsets::FUN_VFS_FIND_CLOSE);
void *handle = findOpen(dir);
if (handle == nullptr)
return;
auto index =
reinterpret_cast<IndexSubtree_t>(Offsets::FUN_VFS_INDEX_SUBTREE);
index(base, relSubdir, handle);
findClose(handle);
}
void RefreshLooseFileIndex() {
// The boot walk's base choice (`FUN_00646EA0`): the recorded game
// dir if it opens as a directory, else ".". Index keys are relative
// to this, so it must match or the new keys would never be hit.
auto findOpen = reinterpret_cast<FindOpen_t>(Offsets::FUN_VFS_FIND_OPEN);
auto findClose = reinterpret_cast<FindClose_t>(Offsets::FUN_VFS_FIND_CLOSE);
const char *base =
reinterpret_cast<const char *>(Offsets::VAR_VFS_BASE_PATH);
void *probe = findOpen(base);
if (probe != nullptr)
findClose(probe);
else
base = ".";
// The only subtrees whose post-boot changes affect what a /reload
// loads: addon content, and the SavedVariables the unload pass
// wrote moments ago (a first-ever SV file is otherwise invisible —
// the stock client loses a freshly installed addon's settings on
// /reload for exactly this reason).
ReindexSubtree(base, "Interface\\AddOns");
ReindexSubtree(base, "WTF\\Account");
}
// ── Step 1.5: `##` metadata refresh (evict + re-register on change) ──
// The embedded addon's name — its entry is owned by `Addons::Embedded`
// (head re-link + filter byte) and never refreshed here.
constexpr const char *kEmbeddedAddon = "!!!ClassicAPI";
// name → FNV-1a hash of the addon's `##` lines, as last parsed.
// Per-process on purpose: the registry persists across /reload, and a
// stale hash after a full re-login scan only causes one harmless
// re-parse of already-current metadata.
std::unordered_map<std::string, uint32_t> g_metaHash;
// Read `<name>`'s base TOC exactly as the parser does — through the
// (hooked) `FUN_FILE_READ`, so flavor selection and TOC rewriting are
// reflected in what gets hashed. Caller frees `*buf` via Storm.
bool ReadToc(const char *name, void **buf, size_t *size) {
char path[260];
std::snprintf(path, sizeof(path), "Interface\\AddOns\\%s\\%s.toc", name,
name);
auto read = reinterpret_cast<AddOns::EngineIO::FileReadFn>(
static_cast<uintptr_t>(Offsets::FUN_FILE_READ));
*buf = nullptr;
*size = 0;
return read(0, path, buf, size, 1, 1, 0) != 0 && *buf != nullptr;
}
// FNV-1a over the TOC's `##` lines only. File-reference lines are
// excluded on purpose — the load pass re-reads those from disk every
// /reload anyway, so an edit there must not trigger the evict path.
// Line terminators are excluded (a CRLF↔LF conversion is not a
// metadata change); a per-line separator keeps adjacent lines from
// concatenating into the same hash. The BOM skip mirrors the parser.
uint32_t HashTocMetadata(const char *buf, size_t size) {
uint32_t h = 2166136261u;
size_t i = 0;
if (size >= 3 && static_cast<uint8_t>(buf[0]) == 0xEF &&
static_cast<uint8_t>(buf[1]) == 0xBB &&
static_cast<uint8_t>(buf[2]) == 0xBF)
i = 3;
while (i < size) {
size_t end = i;
while (end < size && buf[end] != '\n')
++end;
size_t stop = end;
while (stop > i && buf[stop - 1] == '\r')
--stop;
if (stop - i >= 2 && buf[i] == '#' && buf[i + 1] == '#') {
for (size_t k = i; k < stop; ++k) {
h ^= static_cast<uint8_t>(buf[k]);
h *= 16777619u;
}
h ^= '\n';
h *= 16777619u;
}
i = end + 1;
}
return h;
}
// True when the entry never takes the evict path: the embedded addon
// (owned by `Addons::Embedded`) and SMSG-managed secure entries (the
// parser cannot restore packet-delivered state).
bool IsRefreshExempt(uintptr_t entry, const char *name) {
return name == nullptr || std::strcmp(name, kEmbeddedAddon) == 0 ||
*reinterpret_cast<const uint8_t *>(
entry + Offsets::OFF_ADDON_ENTRY_SECURE) != 0;
}
// Read + hash `<name>`'s `##` lines. False when the TOC doesn't read.
bool TryHashToc(const char *name, uint32_t *outHash) {
void *buf = nullptr;
size_t size = 0;
if (!ReadToc(name, &buf, &size))
return false;
*outHash = HashTocMetadata(static_cast<const char *>(buf), size);
reinterpret_cast<AddOns::EngineIO::SMemFreeFn>(
static_cast<uintptr_t>(Offsets::FUN_STORM_SMEM_FREE))(
buf, __FILE__, __LINE__, 0);
return true;
}
// Remove every pointer to `victim` from the OTHER entries'
// reverse-LoadWith lists — the one reference `FUN_ADDON_ENTRY_DESTROY`
// cannot clean (it frees the victim's own list, not the victim's
// presence in others'). Without this, the loader's post-ADDON_LOADED
// loop dereferences freed memory.
void ScrubReverseLoadWith(uintptr_t victim) {
ForEachEntry([victim](uintptr_t entry) {
if (entry == victim)
return;
auto *desc = reinterpret_cast<Desc *>(
entry + Offsets::OFF_ADDON_REVLOADWITH_DESC);
uint32_t w = 0;
for (uint32_t r = 0; r < desc->count; ++r)
if (desc->data[r] != static_cast<uint32_t>(victim))
desc->data[w++] = desc->data[r];
desc->count = w;
});
}
// The refresh pass. Walks the registry comparing each entry's current
// `##` hash against the last-parsed one; on change (or an unreadable
// TOC — deleted folder), evicts via the engine's own destructor and,
// for changes, re-feeds the name to the parser. Re-registered entries
// are appended to `refreshed` so they join the same reverse-LoadWith /
// display-array fix-ups a new folder gets. Returns true when anything
// was actually evicted (the display array holds a freed name pointer
// until it's rebuilt).
//
// Victims are collected during the walk and processed after it — the
// destructor unlinks the entry the iterator stands on (its next-pointer
// is zeroed), so evicting mid-walk would silently truncate the walk.
// Names are copied for the same reason: the dtor frees the entry's
// name string. `g_metaHash` is committed only once the re-registration
// actually happened — updating it during the walk would absorb the edit
// forever if any later step failed, with no retry on the next /reload.
bool RefreshChangedMetadata(std::vector<uintptr_t> &refreshed) {
struct Victim {
std::string name;
uint32_t newHash;
bool reRegister; // false: TOC unreadable — evict only
};
std::vector<Victim> victims;
ForEachEntry([&victims](uintptr_t entry) {
const char *name = *reinterpret_cast<const char *const *>(
entry + Offsets::OFF_ADDON_ENTRY_NAME_PTR);
if (IsRefreshExempt(entry, name))
return;
uint32_t h = 0;
if (!TryHashToc(name, &h)) {
victims.push_back({name, 0, false});
return;
}
auto it = g_metaHash.find(name);
if (it == g_metaHash.end())
g_metaHash.emplace(name, h); // first sighting — seed only
else if (it->second != h)
victims.push_back({name, h, true});
});
bool anyEvicted = false;
auto destroy =
reinterpret_cast<EntryDestroy_t>(Offsets::FUN_ADDON_ENTRY_DESTROY);
auto parse = reinterpret_cast<TocParser_t>(Offsets::FUN_TOC_PARSER);
for (const Victim &v : victims) {
const uintptr_t entry = ResolveEntryByName(v.name.c_str());
if (entry == 0)
continue;
ScrubReverseLoadWith(entry);
destroy(reinterpret_cast<void *>(entry));
anyEvicted = true;
if (!v.reRegister) {
g_metaHash.erase(v.name);
continue;
}
// The parser appends the fresh entry at the list TAIL, so a
// refreshed addon's position in the load pass changes for this
// reload. Declared dependencies still force-load first; only
// undeclared load-order assumptions between unrelated addons
// could observe the difference.
parse(v.name.c_str());
const uintptr_t fresh = ResolveEntryByName(v.name.c_str());
if (fresh != 0) {
refreshed.push_back(fresh);
g_metaHash[v.name] = v.newHash;
} else {
// Evicted but the parse didn't restore it (TOC became
// unreadable between hash and parse) — treat as deleted so
// a reappearing folder re-seeds instead of diffing against
// a hash the registry never absorbed.
g_metaHash.erase(v.name);
}
}
return anyEvicted;
}
// ── Step 2: registry rescan + mirrored fix-ups ────────────────────────
// The scan tail loop's reverse-LoadWith build, restricted to pairs
// involving a newly registered entry. Login already linked old→old
// pairs; a pair is missing exactly when one side is new (the login
// walk skipped `## LoadWith:` names that didn't resolve, and new
// entries were never walked at all) — so appending just those pairs
// reproduces what a login scan would have built, with no duplicates in
// the engine's no-dedup lists.
void FixupReverseLoadWith(const std::vector<uintptr_t> &added) {
auto isNew = [&added](uintptr_t e) {
return std::find(added.begin(), added.end(), e) != added.end();
};
auto grow =
reinterpret_cast<DescGrow_t>(Offsets::FUN_ADDON_REVLOADWITH_GROW);
ForEachEntry([&](uintptr_t entry) {
const bool entryIsNew = isNew(entry);
const uint32_t count = *reinterpret_cast<const uint32_t *>(
entry + Offsets::OFF_ADDON_LOADWITH_COUNT);
auto names = *reinterpret_cast<const char *const *const *>(
entry + Offsets::OFF_ADDON_LOADWITH_ARRAY);
for (uint32_t i = 0; i < count; ++i) {
const uintptr_t target = ResolveEntryByName(names[i]);
if (target == 0 || (!entryIsNew && !isNew(target)))
continue;
DescAppend(reinterpret_cast<Desc *>(
target + Offsets::OFF_ADDON_REVLOADWITH_DESC),
static_cast<uint32_t>(entry), grow);
}
});
}
// `FUN_0051DA70` phase 2, mirrored (never call that function itself —
// its phase 1 consumes an SMSG_ADDON_INFO packet): rebuild the
// `GetNumAddOns`/`GetAddOnInfo(i)` name-pointer array from the linked
// list, skipping filtered entries (keeps `!!!ClassicAPI` hidden), then
// sort with the engine's own comparator to restore alphabetical order.
void RebuildDisplayArray() {
auto desc = reinterpret_cast<Desc *>(
static_cast<uintptr_t>(Offsets::VAR_ADDON_ARRAY_CAP));
auto grow = reinterpret_cast<DescGrow_t>(Offsets::FUN_ADDON_ARRAY_GROW);
desc->count = 0;
ForEachEntry([&](uintptr_t entry) {
if (*reinterpret_cast<const uint8_t *>(
entry + Offsets::OFF_ADDON_ENTRY_FILTER_OUT) != 0)
return;
DescAppend(desc,
*reinterpret_cast<const uint32_t *>(
entry + Offsets::OFF_ADDON_ENTRY_NAME_PTR),
grow);
});
auto qsort = reinterpret_cast<Qsort_t>(Offsets::FUN_CRT_QSORT);
qsort(desc->data, desc->count, 4,
reinterpret_cast<void *>(Offsets::FUN_ADDON_NAME_COMPARE));
}
void Run() {
// Registry populated (login scan ran) and loose index built — both
// always true by the first in-world LoadScriptFunctions, but these
// are the states the steps below mutate, so gate explicitly.
if (*reinterpret_cast<const uint8_t *>(
static_cast<uintptr_t>(Offsets::VAR_ADDON_INITIALIZED)) == 0 ||
*reinterpret_cast<const uint8_t *>(
static_cast<uintptr_t>(Offsets::VAR_VFS_INDEX_READY)) == 0)
return;
RefreshLooseFileIndex();
// Metadata refresh runs BEFORE the snapshot: a re-registered entry
// is alive by snapshot time, so it lands in `before` and can never
// double-count in `added` — even if the allocator reuses the old
// entry's address, `refreshed` tracks it independently of the diff.
std::vector<uintptr_t> refreshed;
const bool evicted = RefreshChangedMetadata(refreshed);
std::vector<uintptr_t> before;
ForEachEntry([&before](uintptr_t entry) { before.push_back(entry); });
// Replay login scan walk #2 verbatim: engine walker + engine
// callback + engine parser. The parser's dedup guard makes this a
// hash lookup per already-registered addon; new folders register
// as complete, normal entries (their TOC is readable now — and it
// goes through the FlavorToc/TocRewrite read hooks like any other).
//
// The dedup guard is why this replay can NEVER refresh or corrupt
// an existing entry's `##` metadata (verified in the parser's
// decompile): the guard is the function's FIRST block — hash
// lookup + name compare, early `return` on match BEFORE the TOC
// path is even built, so no file read and no entry write happen
// for a registered name. Both the Storm hash (`FUN_0064B3F0`,
// uppercases a–z and folds '/'→'\\' before hashing) and the
// compare (`SStrCmpI`) are case-insensitive, so a case-renamed
// folder still matches its existing entry rather than registering
// a duplicate.
auto scan =
reinterpret_cast<ScanDiskDirs_t>(Offsets::FUN_ADDON_SCAN_DISK_DIRS);
scan(reinterpret_cast<const char *>(Offsets::VAR_ADDON_PATH_PREFIX),
reinterpret_cast<const char *>(Offsets::VAR_ADDON_SCAN_PATTERN),
reinterpret_cast<void *>(Offsets::FUN_ADDON_DISK_DIR_CB),
/*userParam=*/nullptr, /*includeHidden=*/0);
std::vector<uintptr_t> added;
ForEachEntry([&](uintptr_t entry) {
if (std::find(before.begin(), before.end(), entry) == before.end())
added.push_back(entry);
});
// Seed the metadata map for entries this reload registered, so the
// next reload diffs a `##` edit against what was actually parsed
// (waiting for the next pass to seed would silently absorb an edit
// made between now and then).
for (uintptr_t entry : added) {
const char *name = *reinterpret_cast<const char *const *>(
entry + Offsets::OFF_ADDON_ENTRY_NAME_PTR);
uint32_t h = 0;
if (!IsRefreshExempt(entry, name) && TryHashToc(name, &h))
g_metaHash[name] = h;
}
if (added.empty() && refreshed.empty() && !evicted)
return;
// Refreshed entries need the same link fix-ups a new folder gets:
// their old reverse-LoadWith pairs died with the old entry (scrub +
// dtor), and the parser doesn't build reverse links.
std::vector<uintptr_t> linked = added;
linked.insert(linked.end(), refreshed.begin(), refreshed.end());
if (!linked.empty())
FixupReverseLoadWith(linked);
// Rebuild whenever membership OR an entry identity changed — after
// any eviction (including a pure deletion) the display array still
// holds the dead entry's freed name pointer.
RebuildDisplayArray();
}
const Game::ModuleAutoRegister _autoreg{&Run};
} // namespace
} // namespace Addons::Rescan
+270
View File
@@ -0,0 +1,270 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
// `## LoadSavedVariablesFirst: 1` — modern TOC directive, backported.
//
// Retail loads a flagged addon's SavedVariables BEFORE its Lua runs, so
// file-scope code (`local db = MyAddonDB`) sees restored config immediately.
// Vanilla 1.12 always does the opposite: the per-addon loader `FUN_0051f240`
// runs the addon's files (`FUN_ADDON_LOAD_FILES`), THEN loads SavedVariables,
// THEN fires `ADDON_LOADED`. So file-scope SV is always nil, and vanilla never
// parses the directive at all.
//
// We co-hook `FUN_ADDON_LOAD_FILES` (the per-addon file load, once per addon,
// in-world). For an addon whose TOC declares `## LoadSavedVariablesFirst`
// (nonzero), load its SavedVariables first — mirroring the engine's OWN path
// construction two steps later in `FUN_0051f240` (verified by disassembly):
// account : WTF\Account\<account>\SavedVariables\<Name>.lua
// per-char: WTF\Account\<account>\<realm>\<character>\SavedVariables\<Name>.lua,
// falling back to WTF\Account\<account>\<character>\SavedVariables\<Name>.lua
// (account = *VAR_ACCOUNT_NAME_PTR, realm = FUN_GET_REALM_NAME,
// character = FUN_GET_LOGIN_ACCOUNT_NAME) — then call the original so the
// files run with SV present. `FUN_LUA_LOAD_FILE` no-ops on a missing file, and
// `FUN_FILE_EXISTS` picks the same per-char variant the engine would.
//
// The engine STILL runs its own SavedVariables load at the normal step, right
// after the files. Left alone, that re-load would overwrite any value the
// addon wrote to its SavedVariable at file scope before ADDON_LOADED. So we
// also co-hook FUN_LUA_LOAD_FILE and suppress that one redundant re-load: each
// path we pre-load is remembered, and the engine's imminent load of that exact
// path is skipped once. This makes the behavior match true
// LoadSavedVariablesFirst — file-scope reads AND writes both survive. Our own
// early load calls the trampoline directly, so it is never self-suppressed.
// FUN_LUA_LOAD_FILE loads FILES only (not RunScript string execution), so the
// extra hook is a cold, addon-load-time path.
#include "Game.h"
#include "Offsets.h"
#include "addons/EngineIO.h"
#include "addons/Toc.h"
#include <cstddef>
#include <cstdint>
#include <cstdio>
#include <cstring>
#include <string>
#include <vector>
namespace AddOns::SavedVarsFirst {
namespace {
constexpr size_t NPOS = static_cast<size_t>(-1);
// Engine file I/O via the hooked FUN_FILE_READ (so a flavor TOC's flag is read —
// FlavorToc applies), the exists-check, and the Storm free. See addons/EngineIO.h.
using AddOns::EngineIO::FileExistsFn;
using AddOns::EngineIO::FileReadFn;
using AddOns::EngineIO::SMemFreeFn;
using LoadTocFilesFn = uint32_t(__fastcall *)(char *tocPath, int *a2, int *a3);
using LuaLoadFileFn = uint32_t(__fastcall *)(const char *path, void *a2, void *a3);
using NameFn = const char *(*)(); // no-arg readers (return in EAX)
LoadTocFilesFn g_orig = nullptr;
LuaLoadFileFn g_origLuaLoad = nullptr;
// Full SV paths we pre-loaded whose imminent engine re-load must be skipped
// once. Populated in LoadSavedVarsEarly, drained by LuaLoad_h. Only ever holds
// a handful of entries (one addon's SV files, briefly). Main-thread only.
std::vector<std::string> g_pendingSuppress;
using AddOns::Toc::EqCI;
using AddOns::Toc::Lower;
bool SamePathCI(const char *a, const char *b) {
for (; *a != '\0' && *b != '\0'; ++a, ++b)
if (Lower(*a) != Lower(*b)) return false;
return *a == *b;
}
// Extract "<Name>" from an addon base-TOC path `…\AddOns\<Name>\<Name>.toc`.
// Returns false for anything else (FrameXML.toc, nested paths, …) so the
// SavedVariables reorder only touches real addon loads.
bool AddonNameFromToc(const char *path, char *out, size_t outSize) {
const size_t len = std::strlen(path);
if (len < 4 || !(path[len - 4] == '.' && Lower(path[len - 3]) == 't' &&
Lower(path[len - 2]) == 'o' && Lower(path[len - 1]) == 'c'))
return false;
size_t lastSep = NPOS;
for (size_t i = len; i-- > 0;)
if (path[i] == '\\' || path[i] == '/') { lastSep = i; break; }
if (lastSep == NPOS) return false;
size_t prevSep = NPOS;
for (size_t i = lastSep; i-- > 0;)
if (path[i] == '\\' || path[i] == '/') { prevSep = i; break; }
if (prevSep == NPOS) return false;
size_t gpSep = NPOS;
for (size_t i = prevSep; i-- > 0;)
if (path[i] == '\\' || path[i] == '/') { gpSep = i; break; }
if (gpSep == NPOS) return false;
if (prevSep - (gpSep + 1) != 6 || !EqCI(path + gpSep + 1, 6, "AddOns"))
return false;
const size_t nameLen = lastSep - (prevSep + 1);
if (nameLen == 0 || nameLen + 1 > outSize) return false;
std::memcpy(out, path + prevSep + 1, nameLen);
out[nameLen] = '\0';
return true;
}
// True iff the leading integer of a TOC value is nonzero (WoW numeric flag).
bool NonzeroFlag(const char *v, size_t n) {
long val = 0;
bool digit = false;
for (size_t i = 0; i < n && v[i] >= '0' && v[i] <= '9'; ++i) {
val = val * 10 + (v[i] - '0');
digit = true;
}
return digit && val != 0;
}
// The SavedVariables directives that decide what LoadSavedVarsEarly does.
struct SvFlags {
bool first = false; // `## LoadSavedVariablesFirst:` is nonzero
bool account = false; // `## SavedVariables:` declares at least one variable
bool perChar = false; // `## SavedVariablesPerCharacter:` declares at least one
};
// Read the addon's TOC once (through the hooked FUN_FILE_READ, so a flavor
// TOC's directives are honored) and extract the three flags above.
SvFlags ReadSvFlags(const char *tocPath) {
SvFlags f;
auto FileRead =
reinterpret_cast<FileReadFn>(static_cast<uintptr_t>(Offsets::FUN_FILE_READ));
void *buf = nullptr;
size_t size = 0;
if (FileRead(0, tocPath, &buf, &size, 1, 1, 0) == 0 || buf == nullptr)
return f;
const char *b = static_cast<const char *>(buf);
const char *v = nullptr;
size_t n = 0;
if (AddOns::Toc::FindValue(b, size, "## LoadSavedVariablesFirst:", &v, &n))
f.first = NonzeroFlag(v, n);
// Gate each SV file on the DECLARATION, not mere file existence — mirroring
// the engine, which loads the account file iff `## SavedVariables` is
// declared and the per-char file iff `## SavedVariablesPerCharacter` is.
// (The trailing `:` keeps "## SavedVariables:" from matching the
// "## SavedVariablesPerCharacter:" line.) Otherwise a stale SV file from a
// scope the addon no longer declares would be executed early — Lua the
// engine never runs — and queued for a suppression the engine never drains.
f.account = AddOns::Toc::FindValue(b, size, "## SavedVariables:", &v, &n) && n > 0;
f.perChar =
AddOns::Toc::FindValue(b, size, "## SavedVariablesPerCharacter:", &v, &n) && n > 0;
reinterpret_cast<SMemFreeFn>(static_cast<uintptr_t>(Offsets::FUN_STORM_SMEM_FREE))(
buf, __FILE__, __LINE__, 0);
return f;
}
bool FileExists(const char *path) {
return reinterpret_cast<FileExistsFn>(
static_cast<uintptr_t>(Offsets::FUN_FILE_EXISTS))(path, 1) != 0;
}
// Load a SavedVariables file we found, then mark its path so the engine's own
// re-load of it (right after the addon's files) is suppressed — preserving any
// file-scope write. Calls the trampoline directly so this load is never itself
// suppressed by our LuaLoad_h hook.
void RunLuaFile(const char *path) {
if (g_origLuaLoad == nullptr)
return;
g_origLuaLoad(path, nullptr, nullptr);
g_pendingSuppress.emplace_back(path);
}
// Load the addon's SavedVariables early, mirroring FUN_0051f240's own paths.
// Each scope loads only when its directive is declared (`hasAccount` /
// `hasPerChar`) AND the file exists — exactly what the engine's own SV step
// would re-load, so every suppress entry we queue is later drained.
void LoadSavedVarsEarly(const char *addonName, bool hasAccount, bool hasPerChar) {
const char *account = *reinterpret_cast<const char *const *>(
static_cast<uintptr_t>(Offsets::VAR_ACCOUNT_NAME_PTR));
if (account == nullptr || *account == '\0')
return;
char path[260];
// Account-wide (`## SavedVariables`).
if (hasAccount) {
std::snprintf(path, sizeof path, "WTF\\Account\\%s\\SavedVariables\\%s.lua",
account, addonName);
if (FileExists(path))
RunLuaFile(path);
}
// Per-character (`## SavedVariablesPerCharacter`): prefer the realm-scoped
// file, else the realm-less one — the engine's own fallback order.
if (!hasPerChar)
return;
const char *character = reinterpret_cast<NameFn>(
static_cast<uintptr_t>(Offsets::FUN_GET_LOGIN_ACCOUNT_NAME))();
if (character == nullptr || *character == '\0')
return;
const char *realm = reinterpret_cast<NameFn>(
static_cast<uintptr_t>(Offsets::FUN_GET_REALM_NAME))();
if (realm != nullptr && *realm != '\0') {
std::snprintf(path, sizeof path,
"WTF\\Account\\%s\\%s\\%s\\SavedVariables\\%s.lua", account,
realm, character, addonName);
if (FileExists(path)) {
RunLuaFile(path);
return;
}
}
std::snprintf(path, sizeof path, "WTF\\Account\\%s\\%s\\SavedVariables\\%s.lua",
account, character, addonName);
if (FileExists(path))
RunLuaFile(path);
}
uint32_t __fastcall LoadTocFiles_h(char *tocPath, int *a2, int *a3) {
char name[128];
if (tocPath != nullptr && AddonNameFromToc(tocPath, name, sizeof name)) {
const SvFlags f = ReadSvFlags(tocPath);
if (f.first)
LoadSavedVarsEarly(name, f.account, f.perChar);
}
return g_orig(tocPath, a2, a3);
}
// Skip the engine's one redundant SavedVariables re-load per pre-loaded path.
// Every other file load passes straight through. Match is by full path
// (case-insensitive); a path is suppressed exactly once, so nothing else is
// affected. Our own early load bypasses this via the trampoline.
uint32_t __fastcall LuaLoad_h(const char *path, void *a2, void *a3) {
if (path != nullptr) {
for (size_t i = 0; i < g_pendingSuppress.size(); ++i) {
if (SamePathCI(g_pendingSuppress[i].c_str(), path)) {
g_pendingSuppress.erase(g_pendingSuppress.begin() +
static_cast<std::ptrdiff_t>(i));
return 0; // suppress — the addon's file-scope SV writes survive
}
}
}
return g_origLuaLoad(path, a2, a3);
}
const Game::HookAutoRegister _hook{
Offsets::FUN_ADDON_LOAD_FILES,
reinterpret_cast<void *>(&LoadTocFiles_h),
reinterpret_cast<void **>(&g_orig)};
const Game::HookAutoRegister _hookLua{
Offsets::FUN_LUA_LOAD_FILE,
reinterpret_cast<void *>(&LuaLoad_h),
reinterpret_cast<void **>(&g_origLuaLoad)};
} // namespace
} // namespace AddOns::SavedVarsFirst
+69
View File
@@ -0,0 +1,69 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
#pragma once
#include <cstddef>
#include <cstring>
namespace AddOns::Toc {
// Fold an ASCII letter to lower case (leaves every other byte unchanged).
inline char Lower(char c) {
return (c >= 'A' && c <= 'Z') ? static_cast<char>(c + 32) : c;
}
// Case-insensitive equality of the `n` bytes at `s` against the NUL-terminated
// `lit` (ASCII). True only when the lengths match too. Shared by the addon
// modules that scan `…\AddOns\<Name>\…` paths and TOC directive lines.
inline bool EqCI(const char *s, size_t n, const char *lit) {
for (size_t i = 0; i < n; ++i)
if (lit[i] == '\0' || Lower(s[i]) != Lower(lit[i]))
return false;
return lit[n] == '\0';
}
// Scans an in-memory TOC buffer for `directive` (the full line prefix, e.g.
// "## Version:") at a line start, case-insensitively, and returns the value
// that follows via out-params: [*valStart, *valStart + *valLen), trimmed of
// leading and trailing spaces/tabs and excluding the CR/LF/EOF terminator.
// Returns false (out-params untouched) when the directive is absent. Pure —
// reads no files; the caller owns the buffer (via FUN_FILE_READ, or an embedded
// copy). Shared by addons/LocalTable.cpp (AllowAddOnTableAccess) and
// addons/Embedded.cpp (Version) so the line-scan lives in one place.
inline bool FindValue(const char *buf, size_t size, const char *directive,
const char **valStart, size_t *valLen) {
const size_t dlen = std::strlen(directive);
if (dlen == 0)
return false;
for (size_t i = 0; i + dlen <= size; ++i) {
const bool atLineStart = (i == 0) || buf[i - 1] == '\n';
if (!atLineStart || _strnicmp(buf + i, directive, dlen) != 0)
continue;
const char *p = buf + i + dlen;
const char *end = buf + size;
while (p < end && (*p == ' ' || *p == '\t'))
++p;
const char *v = p;
while (p < end && *p != '\r' && *p != '\n')
++p;
while (p > v && (p[-1] == ' ' || p[-1] == '\t'))
--p;
*valStart = v;
*valLen = static_cast<size_t>(p - v);
return true;
}
return false;
}
} // namespace AddOns::Toc
+297
View File
@@ -0,0 +1,297 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
#include "addons/EngineIO.h"
#include "addons/TocRewrite.h"
#include "addons/Toc.h"
#include "Offsets.h"
#include <cstddef>
#include <cstdint>
#include <cstring>
#include <string>
namespace AddOns::TocRewrite {
namespace {
using AddOns::EngineIO::SMemAllocFn;
using AddOns::EngineIO::SMemFreeFn;
using AddOns::Toc::EqCI;
using AddOns::Toc::Lower;
bool IsSpace(char c) { return c == ' ' || c == '\t'; }
// The client locale code ("enUS", "frFR", …) — the exact string
// GetLocale() returns (Offsets.h VAR_LOCALE_NAME_TABLE derivation).
const char *ClientLocale() {
uint32_t idx = *reinterpret_cast<const uint32_t *>(
static_cast<uintptr_t>(Offsets::VAR_LOCALE_INDEX));
if (idx > 7) idx = 0; // slot 8 overreads the string pool — clamp
const char *const *table = reinterpret_cast<const char *const *>(
static_cast<uintptr_t>(Offsets::VAR_LOCALE_NAME_TABLE));
const char *s = table[idx];
return (s != nullptr) ? s : "enUS";
}
// True iff the comma-separated `list` (spaces allowed) contains `token` (CI).
bool ListContains(const char *list, size_t listLen, const char *token) {
size_t i = 0;
while (i < listLen) {
while (i < listLen && (IsSpace(list[i]) || list[i] == ',')) ++i;
const size_t start = i;
while (i < listLen && list[i] != ',') ++i;
size_t end = i; // [start, end) is one item, maybe with trailing space
while (end > start && IsSpace(list[end - 1])) --end;
if (end > start && EqCI(list + start, end - start, token)) return true;
}
return false;
}
// A single-word `[Token]` variable expansion, or nullptr if `head` is not
// a known variable. This 1.12 + ClassicAPI client is the Classic family,
// the Vanilla game type, and whatever the client text locale is.
const char *VariableValue(const char *head, size_t headLen) {
if (EqCI(head, headLen, "Family")) return "Classic";
if (EqCI(head, headLen, "Game")) return "Vanilla";
if (EqCI(head, headLen, "TextLocale")) return ClientLocale();
return nullptr;
}
// The client interface version the loadability check compares against
// (FUN_0051d7d0 -> 0x2BC0 = 11200). Read live so we mirror the engine's own
// value rather than hardcoding it.
using ClientIfaceVer_t = uint32_t(__cdecl *)();
uint32_t ClientInterfaceVersion() {
return reinterpret_cast<ClientIfaceVer_t>(
static_cast<uintptr_t>(Offsets::FUN_ADDON_CLIENT_INTERFACE_VERSION))();
}
// If `line` is a `## Interface:` directive whose value is a comma-list that
// contains the client version but does NOT lead with it, append a normalized
// `<prefix>: <clientVersion>` line to `out` and return true. Vanilla's parser
// reads only the first number, so without this a later 11200 is never seen and
// the addon is marked out of date. Returns false for any other line, and for
// an interface line that needs no change (single value, no client version, or
// client version already first) — the caller then copies the line verbatim.
bool TryRewriteInterfaceLine(const char *line, size_t len, std::string &out) {
size_t s = 0;
while (s < len && IsSpace(line[s])) ++s;
if (s + 1 >= len || line[s] != '#' || line[s + 1] != '#') return false;
size_t p = s + 2;
while (p < len && IsSpace(line[p])) ++p;
static const char kKey[] = "interface"; // matched case-insensitively
const size_t klen = 9;
if (p + klen > len) return false;
for (size_t i = 0; i < klen; ++i)
if (Lower(line[p + i]) != kKey[i]) return false;
p += klen;
while (p < len && IsSpace(line[p])) ++p;
if (p >= len || line[p] != ':') return false;
++p; // past ':'
const size_t prefixEnd = p; // "## Interface:" (author spacing preserved)
const uint32_t client = ClientInterfaceVersion();
long first = -1;
bool hasComma = false, hasClient = false;
size_t q = p;
while (q < len) {
while (q < len && (IsSpace(line[q]) || line[q] == ',')) {
if (line[q] == ',') hasComma = true;
++q;
}
long v = 0;
bool digit = false;
while (q < len && line[q] >= '0' && line[q] <= '9') {
v = v * 10 + (line[q] - '0');
++q;
digit = true;
}
if (digit) {
if (first < 0) first = v;
if (static_cast<uint32_t>(v) == client) hasClient = true;
} else if (q < len) {
++q; // skip a stray non-digit so the scan always advances
}
}
if (!hasComma || !hasClient || first == static_cast<long>(client))
return false; // single value / no client version / already first
out.append(line, prefixEnd);
out.push_back(' ');
out += std::to_string(client);
return true;
}
// Evaluate a `[keyword args]` condition — the return is whether the line should
// load. An unrecognized keyword returns false so the caller drops the line (a
// condition we cannot confirm must not load — fail safe).
bool EvalCondition(const char *head, size_t headLen, const char *args,
size_t argsLen) {
if (EqCI(head, headLen, "AllowLoadGameType"))
return ListContains(args, argsLen, "vanilla");
if (EqCI(head, headLen, "AllowLoadTextLocale"))
return ListContains(args, argsLen, ClientLocale());
if (EqCI(head, headLen, "AllowLoad"))
return ListContains(args, argsLen, "game"); // addon files: in-game only
return false;
}
// Rewrite one file-reference line into `out`: expand `[Variable]` tokens,
// evaluate and strip `[Condition]` directives. Returns true to KEEP the
// line (all conditions passed); on true `out` holds the cleaned,
// right-trimmed path.
bool ProcessFileLine(const char *line, size_t len, std::string &out) {
out.clear();
bool keep = true;
size_t i = 0;
while (i < len) {
const char c = line[i];
if (c != '[') {
out.push_back(c);
++i;
continue;
}
size_t close = i + 1;
while (close < len && line[close] != ']') ++close;
if (close >= len) { // unterminated '[' — copy the rest verbatim
out.append(line + i, len - i);
break;
}
const char *inner = line + i + 1;
const size_t innerLen = close - (i + 1);
size_t h = 0;
while (h < innerLen && !IsSpace(inner[h])) ++h; // [0,h) = head word
size_t a = h;
while (a < innerLen && IsSpace(inner[a])) ++a; // skip separating ws
const char *head = inner;
const size_t headLen = h;
const char *args = inner + a;
const size_t argsLen = innerLen - a;
if (argsLen == 0) {
// Single word: a variable to expand, else leave verbatim (an
// unknown token becomes a path the engine cannot open — the
// file just does not load).
const char *val = VariableValue(head, headLen);
if (val != nullptr) out.append(val);
else out.append(line + i, (close + 1) - i);
} else {
// Keyword + args: a condition. Strip it from the path (drop a
// space we already emitted before it so nothing dangles).
keep = keep && EvalCondition(head, headLen, args, argsLen);
if (!out.empty() && out.back() == ' ') out.pop_back();
}
i = close + 1;
}
while (!out.empty() && IsSpace(out.back())) out.pop_back();
return keep;
}
// Walk the whole TOC, preserving line terminators and any UTF-8 BOM.
// `#` comment and `##` metadata lines pass through verbatim; only file
// reference lines are rewritten. A gated-out line becomes a `#` comment
// so the load loop skips it (and a dumped TOC shows why it was dropped).
void RebuildToc(const char *src, size_t srcLen, std::string &out) {
out.clear();
out.reserve(srcLen + 32);
size_t i = 0;
if (srcLen >= 3 && static_cast<uint8_t>(src[0]) == 0xEF &&
static_cast<uint8_t>(src[1]) == 0xBB &&
static_cast<uint8_t>(src[2]) == 0xBF) {
out.append(src, 3);
i = 3;
}
std::string lineOut;
while (i < srcLen) {
const size_t lineStart = i;
while (i < srcLen && src[i] != '\r' && src[i] != '\n') ++i;
const char *line = src + lineStart;
const size_t len = i - lineStart;
const size_t termStart = i;
if (i < srcLen && src[i] == '\r') ++i;
if (i < srcLen && src[i] == '\n') ++i;
size_t s = 0;
while (s < len && IsSpace(line[s])) ++s;
if (s >= len) {
out.append(line, len); // blank
} else if (line[s] == '#') {
// Comment / `##` metadata — verbatim, except a multi-flavor
// `## Interface:` list that hides the client version.
if (!TryRewriteInterfaceLine(line, len, out))
out.append(line, len);
} else if (ProcessFileLine(line, len, lineOut)) {
out.append(lineOut);
} else {
out.push_back('#'); // gated out — comment it so the loader skips it
out.append(line, len);
}
out.append(src + termStart, i - termStart);
}
}
// True iff `path` is an addon `.toc` read (…\AddOns\…\*.toc). Gates the
// rewrite off every non-TOC read (a `.lua` with a `t[1]` subscript would
// otherwise be mangled) and off non-addon TOCs (FrameXML.toc, etc.).
bool IsAddonToc(const char *path) {
const size_t len = std::strlen(path);
if (len < 4) return false;
if (!(path[len - 4] == '.' && Lower(path[len - 3]) == 't' &&
Lower(path[len - 2]) == 'o' && Lower(path[len - 1]) == 'c'))
return false;
for (size_t i = 0; i + 8 <= len; ++i)
if (EqCI(path + i, 8, "\\addons\\")) return true;
return false;
}
} // namespace
void Transform(const char *path, void **outBuf, size_t *outSize) {
if (path == nullptr || outBuf == nullptr || *outBuf == nullptr) return;
if (!IsAddonToc(path)) return;
const char *content = static_cast<const char *>(*outBuf);
const size_t size = (outSize != nullptr && *outSize != 0)
? *outSize
: std::strlen(content);
if (size == 0) return;
// Fast path: no '[' (directives) and no ',' (a possible `## Interface:`
// list) means nothing to do — leave the engine buffer alone.
if (std::memchr(content, '[', size) == nullptr &&
std::memchr(content, ',', size) == nullptr)
return;
std::string rebuilt;
RebuildToc(content, size, rebuilt);
if (rebuilt.size() == size &&
std::memcmp(rebuilt.data(), content, size) == 0)
return; // brackets/commas were only in unaffected lines — nothing changed
auto SMemAlloc = reinterpret_cast<SMemAllocFn>(Offsets::FUN_STORM_SMEM_ALLOC);
auto SMemFree = reinterpret_cast<SMemFreeFn>(Offsets::FUN_STORM_SMEM_FREE);
const size_t newLen = rebuilt.size();
void *buf = SMemAlloc(newLen + 1, __FILE__, __LINE__, 0); // +1 NUL (extraBytes=1)
if (buf == nullptr) return; // OOM — keep the original
std::memcpy(buf, rebuilt.data(), newLen);
static_cast<char *>(buf)[newLen] = '\0';
SMemFree(*outBuf, __FILE__, __LINE__, 0);
*outBuf = buf;
if (outSize != nullptr) *outSize = newLen;
}
} // namespace AddOns::TocRewrite
+79
View File
@@ -0,0 +1,79 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
#pragma once
#include <cstddef>
namespace AddOns::TocRewrite {
// Modern-client per-line TOC directives, backported.
//
// A single-TOC multi-flavor addon gates individual file lines with
// conditions and expands path variables:
//
// [Family]\Init.lua # -> Classic\Init.lua
// Vanilla.lua [AllowLoadGameType vanilla]
// Mainline.lua [AllowLoadGameType mainline] # dropped on this client
// Localization\[TextLocale].lua # -> Localization\enUS.lua
// deDE.lua [AllowLoadTextLocale deDE]
//
// Vanilla 1.12's TOC parsers never understood any of this. The scan
// parser (FUN_0051C9B0) reads only `##` metadata; the load pass
// (FUN_006edb90) hands each non-`#` line to the per-file loader verbatim,
// where the trailing ` [...]` breaks the extension check and the file
// silently never loads. `[Variable]` tokens likewise become part of a
// path that does not exist.
//
// This closes the gap at the FUN_FILE_READ layer (alongside FlavorToc /
// Embedded): after an addon TOC is read, rewrite its file-reference lines
// so the engine's own load loop sees clean paths. `##` metadata and `#`
// comment lines are copied verbatim, with ONE exception (`## Interface:`,
// below); metadata-line conditions, added in retail 12.0.7, are out of
// scope. What the tokens resolve to on this 1.12 client:
//
// [AllowLoadGameType ...] keep the line iff the list contains `vanilla`
// [AllowLoadTextLocale ...] keep the line iff the list contains the
// client locale (GetLocale() code, e.g. enUS)
// [AllowLoad ...] addon files only load in-game, so `game`
// keeps and `glue` drops
// [Family] -> Classic
// [Game] -> Vanilla
// [TextLocale] -> the client locale code
//
// All conditions on a line must pass for it to load (AND). An unknown
// `[keyword args]` condition fails safe (drops the line); an unknown
// single-word `[Token]` is left verbatim (the engine then fails to find
// that path, so the file does not load).
//
// Multi-flavor `## Interface:` list. Retail lets a TOC list several
// interface versions (`## Interface: 120100, 50504, 11200`). Vanilla's
// parser is a plain atoi that reads only the FIRST number, so it never
// sees a later 11200 and marks the addon out of date. When the client
// version (FUN_ADDON_CLIENT_INTERFACE_VERSION, 11200) appears anywhere in
// the list but is not first, the `## Interface:` line is rewritten to that
// single value so the engine marks the addon compatible. A list WITHOUT
// the client version is left unchanged — it still parses to its first
// value and stays out of date, exactly as before.
//
// Called from the FUN_FILE_READ hook after the read succeeds. Does
// nothing unless `path` is an addon `.toc` read AND its content contains a
// `[` (directives) or a `,` (a possible `## Interface:` list) — the
// fast-path bail keeps every unaffected TOC zero-copy, and a rebuild that
// changes nothing is discarded by a content compare. When it does rewrite,
// it allocates a fresh Storm buffer, frees the original, and updates
// `*outBuf` / `*outSize` — the caller frees the replacement exactly as it
// would the engine's own read buffer.
void Transform(const char *path, void **outBuf, size_t *outSize);
} // namespace AddOns::TocRewrite
+19 -32
View File
@@ -55,8 +55,10 @@
#include "Game.h"
#include "Offsets.h"
#include "event/Custom.h"
#include "item/CGItem.h"
#include "item/ID.h"
#include "item/Location.h"
#include "item/Record.h"
#include "item/Swap.h"
#include "tick/WorldTick.h"
@@ -66,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"
@@ -97,30 +96,18 @@ Job g_job{};
// --- small CGItem / cache readers ---------------------------------------
using GetItemRecord_t = const uint8_t *(__thiscall *)(void *cache, uint32_t itemID,
const uint64_t *guid, void *callback,
void *userData, int unused);
const uint8_t *PeekItemRecord(uint32_t itemID) {
auto fn = reinterpret_cast<GetItemRecord_t>(Offsets::FUN_DBCACHE_ITEMSTATS_GET_RECORD);
auto *cache = reinterpret_cast<void *>(Offsets::VAR_ITEMDB_CACHE);
const uint64_t zeroGuid = 0;
return fn(cache, itemID, &zeroGuid, nullptr, nullptr, 0);
}
int CGItemCount(const uint8_t *item) {
auto *desc = *reinterpret_cast<const uint8_t *const *>(
item + Offsets::OFF_ITEM_DESCRIPTOR);
auto *desc = Item::ObjectFields(item);
if (desc == nullptr)
return 0;
return *reinterpret_cast<const int *>(desc + Offsets::OFF_DESCRIPTOR_STACK_COUNT);
return Game::Read<int>(desc, Offsets::OFF_DESCRIPTOR_STACK_COUNT);
}
uint64_t CGItemGuid(const uint8_t *item) {
auto *inst = *reinterpret_cast<const uint8_t *const *>(
item + Offsets::OFF_ITEM_INSTANCE_BLOCK);
auto *inst = Item::InstanceBlock(item);
if (inst == nullptr)
return 0;
return *reinterpret_cast<const uint64_t *>(inst + Offsets::OFF_INSTANCE_BLOCK_GUID);
return Game::Read<uint64_t>(inst, Offsets::OFF_INSTANCE_BLOCK_GUID);
}
struct SlotInfo {
@@ -145,18 +132,18 @@ SlotInfo ReadSlot(void *L, int bag, int slot) {
// --- auction-house globals ----------------------------------------------
uint64_t AuctioneerGuid() {
const uint32_t lo = *reinterpret_cast<const uint32_t *>(
const uint32_t lo = Game::Read<uint32_t>(
Offsets::VAR_AUCTION_AUCTIONEER_GUID_LO);
const uint32_t hi = *reinterpret_cast<const uint32_t *>(
const uint32_t hi = Game::Read<uint32_t>(
Offsets::VAR_AUCTION_AUCTIONEER_GUID_HI);
return (static_cast<uint64_t>(hi) << 32) | lo;
}
bool AHOpen() { return AuctioneerGuid() != 0; }
void SetSellGuid(uint64_t guid) {
*reinterpret_cast<uint32_t *>(Offsets::VAR_AUCTION_SELL_GUID_LO) =
Game::Ref<uint32_t>(Offsets::VAR_AUCTION_SELL_GUID_LO) =
static_cast<uint32_t>(guid);
*reinterpret_cast<uint32_t *>(Offsets::VAR_AUCTION_SELL_GUID_HI) =
Game::Ref<uint32_t>(Offsets::VAR_AUCTION_SELL_GUID_HI) =
static_cast<uint32_t>(guid >> 32);
}
@@ -188,11 +175,11 @@ bool BagIsGeneral(int bag) {
const int id = Item::ID::FromCGItem(bagItem);
if (id == 0)
return false;
const uint8_t *rec = PeekItemRecord(static_cast<uint32_t>(id));
const uint8_t *rec = Item::PeekRecord(static_cast<uint32_t>(id));
if (rec == nullptr)
return false;
return *reinterpret_cast<const uint32_t *>(
rec + Offsets::OFF_ITEMSTATS_BAG_FAMILY) == 0;
return Game::Read<uint32_t>(
rec, Offsets::OFF_ITEMSTATS_BAG_FAMILY) == 0;
}
bool FindFreeGeneralSlot(void *L, int *outBag, int *outSlot) {
@@ -216,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).
}
@@ -289,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;
@@ -410,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;
}
+237 -74
View File
@@ -17,18 +17,27 @@
// nameplate flags, etc.) are populated with vanilla-truthful
// defaults so consumers reading those keys get sensible values.
//
// Filter parsing here mirrors what most modern addons actually pass:
// "HELPFUL" / "HARMFUL" are honored, every other modern filter
// (`PLAYER` / `RAID` / `CANCELABLE` / `INCLUDE_NAME_PLATE_ONLY`) is
// accepted but no-ops — they'd require either source-GUID tracking
// (`PLAYER`), class-dispel-matrix infra (`RAID`), or systems
// vanilla doesn't have at all.
// Filter parsing (`ParseFilters`) tokenizes the modern AuraFilters string:
// tokens separated by `|` and/or whitespace, each optionally negated with a
// leading `!`. Whole-token matching, so `RAID_PLAYER_DISPELLABLE` is not
// mistaken for `PLAYER` and `!PLAYER` is a negation rather than a match.
// Honored: `HELPFUL` / `HARMFUL` (descriptor slot ranges), `PLAYER` /
// `!PLAYER` (caster == / != the local player, from the Aura::Source cache),
// `DISPELLABLE` / `!DISPELLABLE` (dispel type is / isn't one a
// dispel/purge/steal can remove — Spell.dbc Dispel ∈ Magic/Curse/Disease/
// Poison, matching the server's DISPEL_ALL_MASK), and `CROWD_CONTROL` /
// `!CROWD_CONTROL` (is / isn't a hard control effect, via the shared
// `Spell::CrowdControl` classifier that also backs C_LossOfControl). Every
// other modern token (`RAID`, `CANCELABLE`, `INCLUDE_NAME_PLATE_ONLY`, `MAW`,
// …) is accepted and ignored — they need a class-dispel matrix or systems
// vanilla has no data for.
#include "Data.h"
#include "Game.h"
#include "Offsets.h"
#include "ui/ColorData.h"
#include "unit/Identity.h"
#include <cstdint>
#include <cstring>
@@ -37,34 +46,90 @@ 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));
}
// Parses the filter string. Returns Helpful by default; Harmful if
// the string contains the substring "HARMFUL". Case-sensitive — the
// modern API documents the filter tokens as upper-case constants
// (`"HELPFUL"` etc.) and addons that pass them through case-changing
// transforms are already broken on modern too.
Data::Filter ParseFilter(const char *filter) {
// Resolves a token to its GUID for the out-of-range (no live CGUnit) path.
// Only valid to call AFTER `ResolveUnit(token)` returned null *without raising*:
// `ResolveUnit` runs the engine's token→GUID resolver first, so reaching a null
// return means the token was well-formed (the resolver never raised). This runs
// the same non-raising resolver and returns 0 for a token that maps to no
// rostered member — which the group-array push paths treat as "no auras".
uint64_t GuidForOutOfRange(const char *token) {
if (token == nullptr)
return 0;
return Unit::Identity::GuidForToken(token);
}
// Parsed AuraFilters string. `helpful`/`harmful` record which range tokens
// were present (neither → both, matching modern's default); `caster` carries
// the PLAYER / !PLAYER restriction.
struct ParsedFilter {
bool helpful = false;
bool harmful = false;
Data::CasterMode caster = Data::CasterMode::Any;
Data::DispelMode dispel = Data::DispelMode::Any;
Data::CcMode cc = Data::CcMode::Any;
Data::Match ToMatch() const { return Data::Match{caster, dispel, cc}; }
};
// Reduces the parsed range tokens to the single `Data::Filter` the indexed /
// by-id / by-name getters take: Harmful when HARMFUL was given, else Helpful
// (the modern default). HELPFUL wins ties are irrelevant here — an indexed
// getter reads one range.
Data::Filter RangeFilter(const ParsedFilter &pf) {
return pf.harmful && !pf.helpful ? Data::Filter::Harmful
: Data::Filter::Helpful;
}
// Tokenizes the filter string. Case-sensitive (modern documents the tokens as
// upper-case constants). `HELPFUL`/`HARMFUL` are positive range selectors;
// negating them is meaningless for our slot-based ranges, so a leading `!` is
// ignored on those. `PLAYER` honors negation (`!PLAYER` → NotPlayer). Any
// unrecognized token is accepted and skipped.
ParsedFilter ParseFilters(const char *filter) {
ParsedFilter out;
if (filter == nullptr)
return Data::Filter::Helpful;
if (strstr(filter, "HARMFUL") != nullptr)
return Data::Filter::Harmful;
return Data::Filter::Helpful;
}
return out;
for (const char *p = filter; *p != '\0';) {
while (*p == '|' || *p == ' ' || *p == '\t')
++p;
if (*p == '\0')
break;
bool negate = false;
if (*p == '!') {
negate = true;
++p;
}
char tok[64];
size_t n = 0;
while (*p != '\0' && *p != '|' && *p != ' ' && *p != '\t') {
if (n + 1 < sizeof(tok))
tok[n++] = *p;
++p;
}
tok[n] = '\0';
// The `PLAYER` filter token restricts results to auras the local player
// cast. No other modern token contains the substring "PLAYER", so a plain
// substring test is safe.
bool HasPlayerFilter(const char *filter) {
return filter != nullptr && strstr(filter, "PLAYER") != nullptr;
if (strcmp(tok, "HELPFUL") == 0)
out.helpful = true;
else if (strcmp(tok, "HARMFUL") == 0)
out.harmful = true;
else if (strcmp(tok, "PLAYER") == 0)
out.caster = negate ? Data::CasterMode::NotPlayer
: Data::CasterMode::PlayerOnly;
else if (strcmp(tok, "DISPELLABLE") == 0)
out.dispel = negate ? Data::DispelMode::NotDispellable
: Data::DispelMode::DispellableOnly;
else if (strcmp(tok, "CROWD_CONTROL") == 0)
out.cc = negate ? Data::CcMode::NotCrowdControl
: Data::CcMode::CrowdControlOnly;
// else: accepted and ignored (retail-only / unimplemented token).
}
return out;
}
const char *ArgUnit(void *L, int idx) {
@@ -89,18 +154,52 @@ const char *ArgOptString(void *L, int idx) {
// or nil if no such aura. Used by `GetAuraDataByIndex` and the
// filter-locked aliases.
int PushAuraByIndex(void *L, const char *unitToken, int index,
Data::Filter filter, bool playerOnly = false) {
Data::Filter filter, Data::Match match = {}) {
const uint8_t *unit = ResolveUnit(unitToken);
const int slot = Data::FindNthSlot(unit, index, filter, playerOnly);
if (slot >= 0) {
Data::Push(L, unit, slot);
return 1;
if (unit != nullptr) {
const int slot = Data::FindNthSlot(unit, index, filter, match);
if (slot >= 0) {
Data::Push(L, unit, slot);
return 1;
}
// Descriptor exhausted — an aura the engine dropped from the slot array
// (rogue stealth, nearby party range fluctuation) may still be live in
// the Aura::Source cache. Surface it after the descriptor entries.
if (Data::PushNthCacheFallback(L, unit, index, filter, match))
return 1;
} else {
// No live CGUnit — an out-of-range / cross-map groupmate. The server
// still sends their aura spell IDs via SMSG_PARTY_MEMBER_STATS; read
// them the same way the built-in UnitBuff/UnitDebuff do.
if (Data::PushNthGroupAura(L, GuidForOutOfRange(unitToken), index,
filter, match))
return 1;
}
Game::Lua::PushNil(L);
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;
}
// Descriptor exhausted — an aura the engine dropped from the slot array
// (rogue stealth, party range fluctuation) may still be live in the
// Aura::Source cache. Surface it after the descriptor entries.
if (Data::PushNthCacheFallback(L, unit, index, filter, playerOnly))
return 1;
Game::Lua::PushNil(L);
return 1;
}
@@ -113,8 +212,8 @@ int __fastcall Script_GetAuraDataByIndex(void *L) {
Game::Lua::PushNil(L);
return 1;
}
return PushAuraByIndex(L, unit, index, ParseFilter(filterStr),
HasPlayerFilter(filterStr));
const ParsedFilter pf = ParseFilters(filterStr);
return PushAuraByIndex(L, unit, index, RangeFilter(pf), pf.ToMatch());
}
int __fastcall Script_GetBuffDataByIndex(void *L) {
@@ -137,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);
@@ -146,19 +289,23 @@ int __fastcall Script_GetUnitAuraBySpellID(void *L) {
return 1;
}
const uint8_t *unit = ResolveUnit(unitToken);
Data::Filter f;
const Data::Filter *fp = nullptr;
if (filterStr != nullptr) {
f = ParseFilter(filterStr);
fp = &f;
}
const int slot = Data::FindSlotBySpellID(unit, static_cast<uint32_t>(spellID),
fp, HasPlayerFilter(filterStr));
if (slot < 0) {
Game::Lua::PushNil(L);
const ParsedFilter pf = ParseFilters(filterStr);
Data::Filter f = RangeFilter(pf);
const Data::Filter *fp = (pf.helpful || pf.harmful) ? &f : nullptr;
if (unit != nullptr) {
const int slot = Data::FindSlotBySpellID(
unit, static_cast<uint32_t>(spellID), fp, pf.ToMatch());
if (slot >= 0) {
Data::Push(L, unit, slot);
return 1;
}
} else if (Data::PushGroupAuraBySpellID(L, GuidForOutOfRange(unitToken),
static_cast<uint32_t>(spellID), fp,
pf.ToMatch())) {
// Out-of-range groupmate — read from the group-member aura array.
return 1;
}
Data::Push(L, unit, slot);
Game::Lua::PushNil(L);
return 1;
}
@@ -171,19 +318,21 @@ int __fastcall Script_GetAuraDataBySpellName(void *L) {
return 1;
}
const uint8_t *unit = ResolveUnit(unitToken);
Data::Filter f;
const Data::Filter *fp = nullptr;
if (filterStr != nullptr) {
f = ParseFilter(filterStr);
fp = &f;
}
const int slot = Data::FindSlotBySpellName(unit, spellName, fp,
HasPlayerFilter(filterStr));
if (slot < 0) {
Game::Lua::PushNil(L);
const ParsedFilter pf = ParseFilters(filterStr);
Data::Filter f = RangeFilter(pf);
const Data::Filter *fp = (pf.helpful || pf.harmful) ? &f : nullptr;
if (unit != nullptr) {
const int slot = Data::FindSlotBySpellName(unit, spellName, fp, pf.ToMatch());
if (slot >= 0) {
Data::Push(L, unit, slot);
return 1;
}
} else if (Data::PushGroupAuraBySpellName(L, GuidForOutOfRange(unitToken),
spellName, fp, pf.ToMatch())) {
// Out-of-range groupmate — read from the group-member aura array.
return 1;
}
Data::Push(L, unit, slot);
Game::Lua::PushNil(L);
return 1;
}
@@ -207,7 +356,7 @@ int __fastcall Script_GetPlayerAuraBySpellID(void *L) {
// sequential keys starting from `nextKey`. Updates `nextKey` so a
// follow-up call can append to the same outer table.
void AppendRangeToArray(void *L, const uint8_t *unit, int outerIdx,
Data::Filter filter, int &nextKey, bool playerOnly) {
Data::Filter filter, int &nextKey, Data::Match match) {
const int start = (filter == Data::Filter::Harmful)
? Offsets::UNIT_AURA_BUFF_COUNT
: 0;
@@ -217,14 +366,15 @@ void AppendRangeToArray(void *L, const uint8_t *unit, int outerIdx,
for (int slot = start; slot < end; ++slot) {
if (!Data::IsSlotPopulated(unit, slot))
continue;
if (playerOnly && !Data::IsPlayerCast(unit, slot))
if (!Data::MatchesAura(match, Data::IsPlayerCast(unit, slot),
Data::ReadSpellID(unit, slot)))
continue;
Game::Lua::PushNumber(L, static_cast<double>(nextKey++));
Data::Push(L, unit, slot);
Game::Lua::SetTable(L, outerIdx);
}
// Append auras the descriptor dropped but Aura::Source still has live.
Data::AppendCacheFallbacks(L, unit, filter, playerOnly, outerIdx, nextKey);
Data::AppendCacheFallbacks(L, unit, filter, match, outerIdx, nextKey);
}
int __fastcall Script_GetUnitAuras(void *L) {
@@ -234,22 +384,29 @@ int __fastcall Script_GetUnitAuras(void *L) {
Game::Lua::SetTop(L, 0);
Game::Lua::NewTable(L);
if (unit == nullptr)
return 1;
// Range tokens are independent of PLAYER: an explicit HELPFUL/HARMFUL
// selects that range, neither selects both. So `"PLAYER"` alone returns
// both ranges restricted to player-cast auras (matching retail).
const bool playerOnly = HasPlayerFilter(filterStr);
const bool hasHelpful = filterStr != nullptr && strstr(filterStr, "HELPFUL") != nullptr;
const bool hasHarmful = filterStr != nullptr && strstr(filterStr, "HARMFUL") != nullptr;
const bool both = !hasHelpful && !hasHarmful;
const ParsedFilter pf = ParseFilters(filterStr);
const bool both = !pf.helpful && !pf.harmful;
int nextKey = 1;
if (hasHelpful || both)
AppendRangeToArray(L, unit, 1, Data::Filter::Helpful, nextKey, playerOnly);
if (hasHarmful || both)
AppendRangeToArray(L, unit, 1, Data::Filter::Harmful, nextKey, playerOnly);
if (unit != nullptr) {
if (pf.helpful || both)
AppendRangeToArray(L, unit, 1, Data::Filter::Helpful, nextKey, pf.ToMatch());
if (pf.harmful || both)
AppendRangeToArray(L, unit, 1, Data::Filter::Harmful, nextKey, pf.ToMatch());
} else {
// No live CGUnit — out-of-range / cross-map groupmate. Enumerate the
// group-member aura array (spell IDs the server still transmits), the
// same source the built-in UnitBuff/UnitDebuff read out of range.
const uint64_t guid = GuidForOutOfRange(unitToken);
if (pf.helpful || both)
Data::AppendGroupAuras(L, guid, Data::Filter::Helpful, pf.ToMatch(), 1, nextKey);
if (pf.harmful || both)
Data::AppendGroupAuras(L, guid, Data::Filter::Harmful, pf.ToMatch(), 1, nextKey);
}
return 1;
}
@@ -334,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",
+68 -65
View File
@@ -21,7 +21,9 @@
#include "Offsets.h"
#include "dbc/Lookup.h"
#include "net/PacketReader.h"
#include "net/SendObserver.h"
#include "spell/Mod.h"
#include "time/Clock.h"
#include "unit/Identity.h"
#include <cstdint>
@@ -31,12 +33,7 @@ namespace Aura::ComboDuration {
namespace {
using Net::CDataStore;
uint32_t NowMs() {
using TickCount_t = uint32_t(__fastcall *)();
return reinterpret_cast<TickCount_t>(
static_cast<uintptr_t>(Offsets::FUN_OS_TICKCOUNT_MS))();
}
using Time::Clock::NowMs;
// ---- Combo-point snapshot (NetClient send co-hook) ------------------------
@@ -56,28 +53,52 @@ uint8_t CurrentComboPoints() {
return *(info + Offsets::OFF_CGPLAYER_COMBO_POINTS);
}
// One cast in flight at a time is the practical case (a second finisher
// can't be cast until the server consumes the first one's combo points),
// so a single slot with a spellID match and a short TTL suffices.
// Keyed by spellID, because a finisher's own SMSG_SPELL_GO is not reliably
// the next one to arrive after its cast. A talent that procs an aura on the
// finisher (Turtle's rogue Taste for Blood casts spell 52529 on the player)
// lands its SPELL_GO first, and every player-cast aura spell reaches
// TryComboScaledMs. A single slot -- or a lookup that cleared the slot on a
// spellID mismatch -- therefore lost the snapshot to the proc, and the
// finisher fell back to its 0-CP base duration. Only the matching entry is
// ever consumed; a miss leaves every other capture intact.
struct Capture {
uint32_t spellId;
uint8_t comboPoints;
uint32_t capturedMs;
};
Capture g_capture{0, 0, 0};
constexpr int kCaptureCount = 8;
Capture g_captures[kCaptureCount];
int g_captureCursor = 0;
// Worst-case send -> SMSG_SPELL_GO roundtrip. Past this the snapshot is
// stale (the cast failed / was rejected) and must not scale a later cast.
constexpr uint32_t kCaptureTtlMs = 2000;
void RememberCapture(uint32_t spellId, uint8_t comboPoints, uint32_t nowMs) {
for (auto &c : g_captures) {
if (c.spellId == spellId) { // re-cast before the first resolved
c.comboPoints = comboPoints;
c.capturedMs = nowMs;
return;
}
}
g_captures[g_captureCursor] = {spellId, comboPoints, nowMs};
g_captureCursor = (g_captureCursor + 1) % kCaptureCount;
}
int ConsumeCapturedCP(uint32_t spellId) {
const Capture c = g_capture;
g_capture = {0, 0, 0};
if (c.spellId != spellId || c.comboPoints == 0)
return 0;
if (NowMs() - c.capturedMs >= kCaptureTtlMs)
return 0;
return c.comboPoints;
for (auto &c : g_captures) {
if (c.spellId != spellId)
continue;
const Capture got = c;
c = {0, 0, 0};
if (got.comboPoints == 0)
return 0;
if (NowMs() - got.capturedMs >= kCaptureTtlMs)
return 0;
return got.comboPoints;
}
return 0;
}
// Co-hook on the NetClient send — the one funnel every outgoing CMSG
@@ -86,28 +107,17 @@ int ConsumeCapturedCP(uint32_t spellId) {
// re-entry, but still build and send CMSG_CAST_SPELL through the engine).
// Snapshot the combo points at the moment the cast request leaves: the
// server consumes them before SMSG_SPELL_GO arrives, so reading at
// SpellGo time is too late.
using NetSend_t = void(__fastcall *)(void *conn, void *edx,
CDataStore *packet);
NetSend_t g_origNetSend = nullptr;
void __fastcall NetSend_h(void *conn, void *edx, CDataStore *packet) {
if (packet != nullptr) {
const uint32_t saved = packet->m_read;
const uint32_t opcode = Net::Read<uint32_t>(packet);
if (opcode == Offsets::OP_CMSG_CAST_SPELL) {
const uint32_t spellId = Net::Read<uint32_t>(packet);
if (spellId != 0)
g_capture = {spellId, CurrentComboPoints(), NowMs()};
}
packet->m_read = saved;
}
g_origNetSend(conn, edx, packet);
// SpellGo time is too late. We watch sends through the shared
// `Net::SendObserver` rather than owning the funnel hook ourselves.
void OnSend(uint32_t opcode, Net::CDataStore *packet) {
if (opcode != Offsets::OP_CMSG_CAST_SPELL)
return;
const uint32_t spellId = Net::Read<uint32_t>(packet);
if (spellId != 0)
RememberCapture(spellId, CurrentComboPoints(), NowMs());
}
const Game::HookAutoRegister _sendHook{
Offsets::FUN_NET_SEND, reinterpret_cast<void *>(&NetSend_h),
reinterpret_cast<void **>(&g_origNetSend)};
const Net::SendObserver::AutoSubscribe _sendSub{&OnSend};
// ---- Duration data (SpellDuration.dbc + Lua overrides) --------------------
@@ -132,30 +142,19 @@ const Override *FindOverride(uint32_t spellId) {
return nullptr;
}
// Known values for spells whose duration row DANGLES — a non-zero
// DurationIndex pointing at a SpellDuration row the client doesn't have.
// Consulted ONLY when that exact condition holds, so the gate is the bug
// itself, not a server fingerprint: on a stock client every one of these
// resolves to a valid row and this table is never reached, and if the
// broken client data is ever fixed the DBC row takes over automatically.
//
// Turtle's reworked Rip is the known case: all six ranks point at
// SpellDuration row 87, which Turtle added on the SERVER only
// ({8000, 0, 18000} — live server data shows 10/12/14/16/18s at 1..5 CP,
// i.e. 8s + 2s per combo point); their client patch never shipped the
// row (the client table jumps 86 -> 105). Verified the only dangling
// duration index in the entire client Spell.dbc.
constexpr Override kDanglingRowKnown[] = {
{1079, 8000, 18000}, {9492, 8000, 18000}, {9493, 8000, 18000},
{9752, 8000, 18000}, {9894, 8000, 18000}, {9896, 8000, 18000},
};
// Resolver chain for dangling-duration-row spells (client DBC missing the
// row). The KNOWN VALUES themselves are server-custom data and live in
// their own module (e.g. `src/turtle/ComboDuration.cpp` for Turtle's
// reworked Rip); this stock module only owns the mechanism and the
// dangling-condition gate. Chained at static-init by `AutoDanglingResolver`.
AutoDanglingResolver *g_danglingResolvers = nullptr;
const Override *FindDanglingRowKnown(uint32_t spellId) {
for (const auto &k : kDanglingRowKnown) {
if (k.spellId == spellId)
return &k;
}
return nullptr;
bool ResolveDangling(uint32_t spellId, int32_t *baseMs, int32_t *maxMs) {
for (AutoDanglingResolver *r = g_danglingResolvers; r != nullptr;
r = r->next)
if (r->fn(spellId, baseMs, maxMs))
return true;
return false;
}
// `C_UnitAuras.RegisterComboDuration(spellID, baseSeconds, maxSeconds)`
@@ -203,6 +202,11 @@ const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace
AutoDanglingResolver::AutoDanglingResolver(DanglingResolver f)
: fn(f), next(g_danglingResolvers) {
g_danglingResolvers = this;
}
uint32_t TryComboScaledMs(const uint8_t *spellRecord, uint32_t spellId) {
if (spellRecord == nullptr || spellId == 0)
return 0;
@@ -227,12 +231,11 @@ uint32_t TryComboScaledMs(const uint8_t *spellRecord, uint32_t spellId) {
maxMs = *reinterpret_cast<const int32_t *>(row + 0xC);
} else if (idx != 0) {
// Dangling duration index — the client data is broken for this
// spell. Fall back to the built-in known values (Turtle Rip).
const Override *k = FindDanglingRowKnown(spellId);
if (k == nullptr)
// spell. Defer to a registered resolver (the known values are
// server-custom data owned by a platform module, e.g.
// src/turtle for Turtle's reworked Rip).
if (!ResolveDangling(spellId, &baseMs, &maxMs))
return 0;
baseMs = k->baseMs;
maxMs = k->maxMs;
} else {
return 0; // no duration row at all
}
+18
View File
@@ -47,4 +47,22 @@ namespace Aura::ComboDuration {
// duration path".
uint32_t TryComboScaledMs(const uint8_t *spellRecord, uint32_t spellId);
// Extension point for finishers whose SpellDuration row DANGLES — a
// non-zero DurationIndex pointing at a row the client DBC doesn't have.
// A resolver returns true and fills *baseMs/*maxMs if it knows the spell,
// false otherwise. Consulted by `TryComboScaledMs` ONLY in that dangling
// condition (after any Lua-registered override), so the activation gate is
// the client-data bug itself, not a server fingerprint — the deliberate
// design choice (a realm-sniffing approach was rejected). Server-custom
// data (e.g. Turtle's reworked Rip) registers its values here instead of
// living in this stock module: declare a file-scope
// `static const Aura::ComboDuration::AutoDanglingResolver`.
using DanglingResolver = bool (*)(uint32_t spellId, int32_t *baseMs,
int32_t *maxMs);
struct AutoDanglingResolver {
explicit AutoDanglingResolver(DanglingResolver fn);
DanglingResolver fn;
AutoDanglingResolver *next;
};
} // namespace Aura::ComboDuration
+572 -110
View File
@@ -17,7 +17,11 @@
#include "Offsets.h"
#include "aura/Source.h"
#include "dbc/Lookup.h"
#include "group/MemberStats.h"
#include "guid/Guid.h"
#include "spell/CrowdControl.h"
#include "spell/IsSelfBuff.h"
#include "time/Clock.h"
#include "unit/Identity.h"
#include <cstdint>
@@ -27,13 +31,6 @@ namespace Aura::Data {
namespace {
// Spell.dbc record field offsets used locally. `spell/Info.cpp` has
// the same set of locals for `Script_GetSpellInfo`; promoting them
// to `Offsets.h` is a future refactor — for now we mirror its
// convention so both files stay in sync.
constexpr int OFF_SPELL_ICON_ID = 0x1D4;
constexpr int OFF_SPELLICON_PATH = 0x04;
const uint8_t *Descriptor(const uint8_t *unit) {
if (unit == nullptr)
return nullptr;
@@ -71,22 +68,18 @@ const char *SpellIconPath(const uint8_t *spellRecord) {
if (spellRecord == nullptr)
return nullptr;
const int iconID = *reinterpret_cast<const int *>(
spellRecord + OFF_SPELL_ICON_ID);
spellRecord + Offsets::OFF_SPELL_RECORD_ICON_ID);
if (iconID <= 0)
return nullptr;
return DBC::StringField(Offsets::VAR_SPELL_ICON_RECORDS,
Offsets::VAR_SPELL_ICON_COUNT,
static_cast<uint32_t>(iconID),
OFF_SPELLICON_PATH);
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
@@ -139,6 +132,20 @@ double PlayerBuffExpirationSeconds(const uint8_t *entry) {
const uint32_t expirationMs = expirationTable[slotCode];
if (expirationMs == 0)
return 0.0;
// Report "no timer" once the stored expiration has elapsed — this is not a
// heuristic, it's exactly what the engine's own GetPlayerBuffTimeLeft does.
// Script_GetPlayerBuffTimeLeft (0x004e4930) → FUN_004e4450 reads this same
// expiration table and returns:
// if ((int)(now - expiration[slot]) >= 0) return 0; // elapsed -> 0
// return expiration[slot] - now; // else remaining
// Permanent auras (paladin auras, passives, racials) carry an infinite
// Spell.dbc duration (SpellDuration base -1); the server stores the fixed
// cast-time in this slot for them, so it's always "already elapsed" and the
// engine shows no countdown. `Time::Clock::Reached` takes the signed diff, so
// it also absorbs the ~24.86-day GetTickCount wrap (both operands wrap
// together).
if (Time::Clock::Reached(expirationMs))
return 0.0; // expiration reached/passed → permanent or no timer
return static_cast<double>(expirationMs) * 0.001;
}
@@ -192,6 +199,13 @@ double SpellBaseDurationSeconds(uint32_t spellID, int unitLevel) {
// Reads the player's level from UNIT_FIELD_LEVEL. Returns 0 when
// unresolved (pre-login, no descriptor) — caller should treat that
// as "skip level scaling".
// True if the engine-tick expiration `expMs` has already reached/passed now.
// Same signed-wrap-safe compare `PlayerBuffExpirationSeconds` uses (absorbs
// the ~24.86-day GetTickCount wrap). An already-elapsed cached expiration is
// not meaningful — the caster is kept regardless, but the time is reported as
// unknown rather than as a negative remaining.
bool ExpirationElapsed(uint32_t expMs) { return Time::Clock::Reached(expMs); }
int PlayerLevel(const uint8_t *player) {
auto *desc = Descriptor(player);
if (desc == nullptr)
@@ -200,6 +214,35 @@ int PlayerLevel(const uint8_t *player) {
desc + Offsets::OFF_UNIT_FIELD_LEVEL);
}
// Caster + timing attributed to ONE aura instance — the aura `spellID` sitting
// in absolute descriptor `slot` on `guid` (`Aura::Source::SLOT_UNBOUND` when
// the caller has no slot: the out-of-range group array, the cache fallback).
// The slot is what tells two casters' copies of one spell apart; without it
// they are indistinguishable from a spell ID alone.
//
// The caster is the observed one from the Aura::Source cache, else — when we
// never saw the cast — the unit itself if the spell is a self-only-target buff
// (self-buffs can't be cross-cast), else 0. Single source of truth for both
// display (sourceGUID/sourceUnit) and the PLAYER caster filter, so a self-buff
// shows a source AND matches HELPFUL|PLAYER instead of the two paths
// disagreeing.
struct Attribution {
uint64_t caster;
uint32_t expirationMs;
uint32_t durationMs;
};
Attribution Attribute(uint64_t guid, uint32_t spellID, int slot) {
Attribution a = {0, 0, 0};
if (guid == 0 || spellID == 0)
return a;
Aura::Source::Get(guid, spellID, slot, &a.caster, &a.expirationMs,
&a.durationMs);
if (a.caster == 0 && Spell::IsSelfBuff::IsSelfBuff(spellID))
a.caster = guid; // self-only-target aura → cast by the unit itself
return a;
}
} // namespace
uint32_t ReadSpellID(const uint8_t *unit, int slot) {
@@ -233,16 +276,68 @@ bool IsPlayerCast(const uint8_t *unit, int slot) {
const uint32_t spellID = ReadSpellID(unit, slot);
if (spellID == 0)
return false;
uint64_t casterGuid = 0;
uint32_t expMs = 0;
uint32_t durMs = 0;
if (!Aura::Source::Get(UnitGuid(unit), spellID, &casterGuid, &expMs, &durMs))
const uint64_t player = Unit::Identity::PlayerGuid();
return player != 0 && Attribute(UnitGuid(unit), spellID, slot).caster == player;
}
// Applies the PLAYER / !PLAYER caster restriction. `isPlayerCast` is the
// per-aura "was this cast by the local player" answer (false on a cache miss).
bool CasterMatches(CasterMode caster, bool isPlayerCast) {
switch (caster) {
case CasterMode::PlayerOnly: return isPlayerCast;
case CasterMode::NotPlayer: return !isPlayerCast;
default: return true; // Any
}
}
bool IsDispellable(uint32_t spellID) {
const uint8_t *rec = SpellRecord(spellID);
if (rec == nullptr)
return false;
return casterGuid != 0 && casterGuid == Unit::Identity::PlayerGuid();
const uint32_t dispelType = *reinterpret_cast<const uint32_t *>(
rec + Offsets::OFF_SPELL_DISPEL_TYPE);
// Magic(1) / Curse(2) / Disease(3) / Poison(4) — the server's
// DISPEL_ALL_MASK. None(0) / Stealth(5) / Invisibility(6) / Enrage(9)
// etc. are not dispellable by any mechanism.
return dispelType >= 1 && dispelType <= 4;
}
bool DispelMatches(DispelMode dispel, uint32_t spellID) {
switch (dispel) {
case DispelMode::DispellableOnly: return IsDispellable(spellID);
case DispelMode::NotDispellable: return !IsDispellable(spellID);
default: return true; // Any
}
}
bool CcMatches(CcMode cc, uint32_t spellID) {
switch (cc) {
case CcMode::CrowdControlOnly:
return Spell::CrowdControl::IsCrowdControl(spellID);
case CcMode::NotCrowdControl:
return !Spell::CrowdControl::IsCrowdControl(spellID);
default:
return true; // Any
}
}
bool MatchesAura(const Match &match, bool isPlayerCast, uint32_t spellID) {
return CasterMatches(match.caster, isPlayerCast) &&
DispelMatches(match.dispel, spellID) &&
CcMatches(match.cc, spellID);
}
// Group-array analog of IsPlayerCast: the member has no descriptor, so there is
// no slot to attribute by and the cache is consulted by (guid, spellID) alone.
// A miss counts as "not the player" (same as IsPlayerCast).
bool GroupIsPlayerCast(uint64_t guid, uint32_t spellID) {
const uint64_t player = Unit::Identity::PlayerGuid();
return player != 0 &&
Attribute(guid, spellID, Aura::Source::SLOT_UNBOUND).caster == player;
}
int FindNthSlot(const uint8_t *unit, int oneBasedIndex, Filter filter,
bool playerOnly) {
Match match) {
if (unit == nullptr || oneBasedIndex < 1)
return -1;
const int start = (filter == Filter::Harmful)
@@ -255,7 +350,7 @@ int FindNthSlot(const uint8_t *unit, int oneBasedIndex, Filter filter,
for (int slot = start; slot < end; ++slot) {
if (!IsSlotPopulated(unit, slot))
continue;
if (playerOnly && !IsPlayerCast(unit, slot))
if (!MatchesAura(match, IsPlayerCast(unit, slot), ReadSpellID(unit, slot)))
continue;
if (++matches == oneBasedIndex)
return slot;
@@ -264,7 +359,7 @@ int FindNthSlot(const uint8_t *unit, int oneBasedIndex, Filter filter,
}
int FindSlotBySpellID(const uint8_t *unit, uint32_t spellID,
const Filter *filter, bool playerOnly) {
const Filter *filter, Match match) {
if (unit == nullptr || spellID == 0)
return -1;
const int start = (filter != nullptr && *filter == Filter::Harmful)
@@ -278,7 +373,7 @@ int FindSlotBySpellID(const uint8_t *unit, uint32_t spellID,
continue;
if (ReadSpellID(unit, slot) != spellID)
continue;
if (playerOnly && !IsPlayerCast(unit, slot))
if (!MatchesAura(match, IsPlayerCast(unit, slot), ReadSpellID(unit, slot)))
continue;
return slot;
}
@@ -286,7 +381,7 @@ int FindSlotBySpellID(const uint8_t *unit, uint32_t spellID,
}
int FindSlotBySpellName(const uint8_t *unit, const char *spellName,
const Filter *filter, bool playerOnly) {
const Filter *filter, Match match) {
if (unit == nullptr || spellName == nullptr || *spellName == '\0')
return -1;
const int start = (filter != nullptr && *filter == Filter::Harmful)
@@ -301,7 +396,7 @@ int FindSlotBySpellName(const uint8_t *unit, const char *spellName,
const char *name = LocalizedSpellName(SpellRecord(ReadSpellID(unit, slot)));
if (name == nullptr || std::strcmp(name, spellName) != 0)
continue;
if (playerOnly && !IsPlayerCast(unit, slot))
if (!MatchesAura(match, IsPlayerCast(unit, slot), ReadSpellID(unit, slot)))
continue;
return slot;
}
@@ -350,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);
@@ -403,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);
@@ -419,56 +541,127 @@ static void BuildTable(void *L, uint32_t spellID, int applications,
// yields nil, so the table doesn't need an explicit entry.
}
void Push(void *L, const uint8_t *unit, int slot) {
const uint32_t spellID = ReadSpellID(unit, slot);
const bool isHelpful = slot < Offsets::UNIT_AURA_BUFF_COUNT;
// 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);
// Timing fields. Vanilla doesn't broadcast cast time / duration for ANY
// unit's auras except the local player's — for everyone else (target,
// party, raid, mouseover) `expirationTime` stays 0 unless the
// `Aura::Source` cache observed the cast. For the player we read the
// engine's `VAR_PLAYER_BUFF_TABLE` (same data `GetPlayerBuffTimeLeft`
// returns), which gives a real expiration timestamp.
//
// `duration` comes from Spell.dbc → SpellDuration.dbc with level scaling
// (the *base* value); talent / glyph duration-extension modifiers are
// already baked into the expiration timestamp on the caster's side, so
// `expirationTime - GetTime()` reflects true remaining time even when the
// base `duration` doesn't include the talent boost.
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
// (stacks, level, is-this-the-player), the others pass their known-or-default
// values.
//
// Timing rules (identical across paths): `duration` is the Spell.dbc →
// SpellDuration.dbc base scaled by `unitLevel` (0 skips scaling); talent/glyph
// duration modifiers are baked into the caster-side expiration, so
// `expirationTime - GetTime()` is the true remaining time even when `duration`
// omits the boost. `expirationTime` for the local player comes from the engine
// buff table (gated on `isPlayer`); for everyone else it, the caster, and the
// applied (caster-modified) duration come from the `Aura::Source` SMSG_SPELL_GO
// cache when it observed the cast — a miss leaves the modern-truthful defaults
// (expiration 0, no sourceUnit/GUID). `slot` is the absolute descriptor slot
// the aura occupies, which is what attributes it to the right caster when two
// of them hold the same spell on the unit; `Aura::Source::SLOT_UNBOUND` for the
// paths with no descriptor to read one from.
//
// `known` short-circuits the by-(guid,spell,slot) resolve: the cache-fallback
// paths already hold the exact entry's attribution (Enumerate copied it out),
// so they pass it straight through. Re-resolving there would call `Get` a second
// time and, for an unbound entry sharing its spell with another caster's entry,
// return a miss — dropping a caster Enumerate already knew. Null for the
// descriptor / group-array paths, which have no per-entry attribution in hand.
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,
Emit emit = Emit::Table) {
double duration = 0.0;
double expirationTime = 0.0;
uint64_t casterGuid = 0;
const uint8_t *player = LocalPlayer();
if (spellID != 0) {
const int unitLevel = (unit != nullptr) ? PlayerLevel(unit) : 0;
if (spellID != 0)
duration = SpellBaseDurationSeconds(spellID, unitLevel);
}
if (unit != nullptr && unit == player) {
if (isPlayer && spellID != 0) {
const uint8_t *entry = FindPlayerBuffEntry(spellID);
if (entry != nullptr)
expirationTime = PlayerBuffExpirationSeconds(entry);
}
// Caster + non-player expiration from the SMSG_SPELL_GO cache, captured at
// cast time (the descriptor has neither). The player-buff table above is
// authoritative for the player's own expiration; the cache fills it for
// everyone else. Prefer the applied (caster-modified) duration so it stays
// consistent with `expirationTime` — otherwise a talented DoT (Improved
// Shadow Word: Pain) would show remaining time exceeding the base.
if (spellID != 0) {
uint64_t c = 0;
uint32_t expMs = 0;
uint32_t durMs = 0;
if (Aura::Source::Get(UnitGuid(unit), spellID, &c, &expMs, &durMs)) {
if (expirationTime == 0.0 && expMs != 0)
expirationTime = static_cast<double>(expMs) * 0.001;
if (durMs != 0)
duration = static_cast<double>(durMs) * 0.001;
casterGuid = c;
}
if (spellID != 0 && guid != 0) {
// Caster (sourceGUID/sourceUnit) and timing resolved together, so a
// self-buff both shows a source and matches HELPFUL|PLAYER instead of
// the two paths disagreeing.
Attribution a = (known != nullptr) ? *known : Attribute(guid, spellID, slot);
// Self-buff inference for a pre-resolved entry whose caster the cache
// never learned (idempotent on the Attribute() path, which already
// applied it — the caster is nonzero there when it matched).
if (a.caster == 0 && Spell::IsSelfBuff::IsSelfBuff(spellID))
a.caster = guid;
// A cached expiration that already elapsed while the aura is still
// present (non-player casters get an underestimated base duration)
// is not meaningful — report unknown (0) rather than a negative
// remaining time.
if (expirationTime == 0.0 && a.expirationMs != 0 &&
!ExpirationElapsed(a.expirationMs))
expirationTime = static_cast<double>(a.expirationMs) * 0.001;
if (a.durationMs != 0)
duration = static_cast<double>(a.durationMs) * 0.001;
casterGuid = a.caster;
}
if (emit == Emit::Positional)
PushPositional(L, spellID, applications, isHelpful, duration,
expirationTime, casterGuid);
else
BuildTable(L, spellID, applications, isHelpful, duration, expirationTime,
casterGuid);
}
BuildTable(L, spellID, ReadStacks(unit, slot), isHelpful, duration,
expirationTime, casterGuid);
// Attribution held directly by an `Aura::Source` cache entry (from `Enumerate`),
// for the cache-fallback push paths to hand to `PushEnriched` as `known`.
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, 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,
/*known*/ nullptr, emit);
}
namespace {
@@ -478,18 +671,17 @@ 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) {
double duration = 0.0;
if (c.durationMs != 0) {
duration = static_cast<double>(c.durationMs) * 0.001;
} else {
const int unitLevel = (unit != nullptr) ? PlayerLevel(unit) : 0;
duration = SpellBaseDurationSeconds(c.spellId, unitLevel);
}
const double expirationTime =
(c.expirationMs != 0) ? static_cast<double>(c.expirationMs) * 0.001 : 0.0;
BuildTable(L, c.spellId, 1, isHelpful, duration, expirationTime,
c.casterGuid);
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
// with another caster's would otherwise resolve to a miss and drop the
// caster. Stacks aren't in SMSG_SPELL_GO, so `applications` is 1.
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, emit);
}
// Reconciles the `Aura::Source` cache against `unit`'s descriptor: when the
@@ -506,15 +698,9 @@ void ReconcileCache(const uint8_t *unit) {
if (unit == nullptr)
return;
uint32_t present[Offsets::UNIT_AURA_TOTAL];
int n = 0;
for (int slot = 0; slot < Offsets::UNIT_AURA_TOTAL; ++slot) {
const uint32_t id = ReadSpellID(unit, slot);
if (id != 0)
present[n++] = id;
}
if (n == 0)
return;
Aura::Source::EvictAbsent(UnitGuid(unit), present, n);
for (int slot = 0; slot < Offsets::UNIT_AURA_TOTAL; ++slot)
present[slot] = ReadSpellID(unit, slot);
Aura::Source::EvictAbsent(UnitGuid(unit), present);
}
// True if `unit`'s descriptor currently exposes any visible aura in either
@@ -536,8 +722,8 @@ bool DescriptorHasVisibleAura(const uint8_t *unit) {
return false;
}
// Counts populated descriptor slots matching `filter` (and `playerOnly`).
int CountSlots(const uint8_t *unit, Filter filter, bool playerOnly) {
// Counts populated descriptor slots matching `filter` (and `caster`).
int CountSlots(const uint8_t *unit, Filter filter, Match match) {
if (unit == nullptr)
return 0;
const int start = (filter == Filter::Harmful)
@@ -549,7 +735,7 @@ int CountSlots(const uint8_t *unit, Filter filter, bool playerOnly) {
for (int slot = start; slot < end; ++slot) {
if (!IsSlotPopulated(unit, slot))
continue;
if (playerOnly && !IsPlayerCast(unit, slot))
if (!MatchesAura(match, IsPlayerCast(unit, slot), ReadSpellID(unit, slot)))
continue;
++n;
}
@@ -559,21 +745,21 @@ int CountSlots(const uint8_t *unit, Filter filter, bool playerOnly) {
constexpr int kFallbackMax = Offsets::UNIT_AURA_TOTAL;
// Whether cache entry `c` should be surfaced as a fallback for `unit` under
// `filter`/`playerOnly`: the spell must be a user-visible aura, it must not
// `filter`/`caster`: the spell must be a user-visible aura, it must not
// already be in a populated descriptor slot (no double-listing the same live
// aura) and, when player-filtered, must have been cast by us. Superseded /
// dispelled auras are kept out of the cache by `Aura::Source`'s removal
// eviction, not filtered here.
bool FallbackEligible(const uint8_t *unit, const Aura::Source::CachedAura &c,
Filter filter, bool playerOnly) {
Filter filter, Match match) {
if (!IsVisible(SpellRecord(c.spellId)))
return false; // hidden/internal aura (e.g. SPELL_ATTR_HIDDEN_CLIENTSIDE):
// the descriptor path drops these via IsSlotPopulated, so
// the cache path must apply the same engine predicate or
// they leak through as tooltip-less phantom buffs.
if (FindSlotBySpellID(unit, c.spellId, &filter, false) >= 0)
if (FindSlotBySpellID(unit, c.spellId, &filter, Match{}) >= 0)
return false; // still in the descriptor — returned via the slot path
if (playerOnly && c.casterGuid != Unit::Identity::PlayerGuid())
if (!MatchesAura(match, c.casterGuid == Unit::Identity::PlayerGuid(), c.spellId))
return false;
return true;
}
@@ -581,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, bool playerOnly) {
Filter filter, Match match, Emit emit) {
if (unit == nullptr)
return false;
ReconcileCache(unit); // drop entries the (synced) descriptor contradicts
@@ -590,7 +776,7 @@ bool PushNthCacheFallback(void *L, const uint8_t *unit, int oneBasedIndex,
// The descriptor held `descCount` matches; the caller already found fewer
// than `oneBasedIndex` there, so the cache supplies index
// `oneBasedIndex - descCount` (1-based) of the entries it didn't.
const int target = oneBasedIndex - CountSlots(unit, filter, playerOnly);
const int target = oneBasedIndex - CountSlots(unit, filter, match);
if (target < 1)
return false;
const uint64_t guid = UnitGuid(unit);
@@ -601,10 +787,10 @@ bool PushNthCacheFallback(void *L, const uint8_t *unit, int oneBasedIndex,
guid, filter == Filter::Harmful, buf, kFallbackMax);
int seen = 0;
for (int i = 0; i < n; ++i) {
if (!FallbackEligible(unit, buf[i], filter, playerOnly))
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;
}
}
@@ -612,7 +798,7 @@ bool PushNthCacheFallback(void *L, const uint8_t *unit, int oneBasedIndex,
}
void AppendCacheFallbacks(void *L, const uint8_t *unit, Filter filter,
bool playerOnly, int outerIdx, int &nextKey) {
Match match, int outerIdx, int &nextKey) {
if (unit == nullptr)
return;
ReconcileCache(unit); // drop entries the (synced) descriptor contradicts
@@ -625,7 +811,7 @@ void AppendCacheFallbacks(void *L, const uint8_t *unit, Filter filter,
const int n = Aura::Source::Enumerate(
guid, filter == Filter::Harmful, buf, kFallbackMax);
for (int i = 0; i < n; ++i) {
if (!FallbackEligible(unit, buf[i], filter, playerOnly))
if (!FallbackEligible(unit, buf[i], filter, match))
continue;
Game::Lua::PushNumber(L, static_cast<double>(nextKey++));
PushFromCache(L, unit, buf[i], filter == Filter::Helpful);
@@ -633,4 +819,280 @@ void AppendCacheFallbacks(void *L, const uint8_t *unit, Filter filter,
}
}
// ── Out-of-range groupmate path ────────────────────────────────────────────
namespace {
// Absolute slot range [start, end) for a filter, matching the descriptor
// convention (helpful 0..31, harmful 32..47).
void GroupRange(Filter filter, int &start, int &end) {
start = (filter == Filter::Harmful) ? Offsets::UNIT_AURA_BUFF_COUNT : 0;
end = (filter == Filter::Harmful) ? Offsets::UNIT_AURA_TOTAL
: Offsets::UNIT_AURA_BUFF_COUNT;
}
// Spell ID at group-array slot (0..47), 0 if `arr` null / OOB / empty.
uint32_t GroupSpellID(const uint16_t *arr, int slot) {
if (arr == nullptr || slot < 0 || slot >= Offsets::UNIT_AURA_TOTAL)
return 0;
return arr[slot];
}
// Whether the group-array slot holds a surfacable aura for this query. Mirrors
// the engine's per-slot test in Script_UnitBuff/UnitDebuff — a non-zero spell
// ID with a real Spell.dbc record that passes the visibility predicate (the
// server only transmits *applied* auras, so there is no flag nibble to check).
// `caster` additionally requires the `Aura::Source` cache to attribute the
// cast to the local player (a miss excludes it — we can't confirm the caster
// from the group array alone).
bool GroupSlotEligible(uint64_t guid, const uint16_t *arr, int slot,
Match match) {
const uint32_t id = GroupSpellID(arr, slot);
if (id == 0 || !IsVisible(SpellRecord(id)))
return false;
if (!MatchesAura(match, GroupIsPlayerCast(guid, id), id))
return false;
return true;
}
// The groupmate's level, for Spell.dbc duration scaling (0 = skip). Cheap
// roster lookup; only hit on the out-of-range path.
int GroupMemberLevel(uint64_t guid) {
return Group::MemberStats::Lookup(guid).level;
}
// True if the group array exposes ANY visible aura (either kind). When it
// does, the server's out-of-range aura data is present and authoritative, so
// we do NOT supplement from the Aura::Source cache — mirroring how the
// descriptor path treats a populated descriptor (DescriptorHasVisibleAura).
// Only when the array is entirely empty do we fall back to the cache: the
// server resends out-of-range stats as deltas only, so a member seen in range
// and then moved away again leaves an empty party-stats aura array (the array
// is cleared when they become visible and never re-sent), yet their auras
// still sit in the cache from when we observed them in range.
bool GroupArrayHasVisibleAura(const uint16_t *arr) {
for (int slot = 0; slot < Offsets::UNIT_AURA_TOTAL; ++slot) {
const uint32_t id = GroupSpellID(arr, slot);
if (id != 0 && IsVisible(SpellRecord(id)))
return true;
}
return false;
}
// Whether cache entry `c` may supplement the (empty) group array: a visible
// aura, not already present in the array, and — when player-filtered — cast by
// us. See GroupArrayHasVisibleAura for when this path runs.
bool GroupFallbackEligible(const uint16_t *arr, const Aura::Source::CachedAura &c,
Match match) {
if (!IsVisible(SpellRecord(c.spellId)))
return false;
for (int slot = 0; slot < Offsets::UNIT_AURA_TOTAL; ++slot)
if (GroupSpellID(arr, slot) == c.spellId)
return false;
if (!MatchesAura(match, c.casterGuid == Unit::Identity::PlayerGuid(), c.spellId))
return false;
return true;
}
// 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,
Emit emit = Emit::Table) {
Aura::Source::CachedAura buf[kFallbackMax];
const int n =
Aura::Source::Enumerate(guid, filter == Filter::Harmful, buf, kFallbackMax);
int seen = 0;
for (int i = 0; i < n; ++i) {
if (!GroupFallbackEligible(arr, buf[i], match))
continue;
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, emit);
return true;
}
}
return false;
}
// Append every cache-fallback aura of `filter`'s kind into the array table.
void AppendGroupCacheFallbacks(void *L, uint64_t guid, const uint16_t *arr,
Filter filter, Match match, int outerIdx,
int &nextKey) {
Aura::Source::CachedAura buf[kFallbackMax];
const int n =
Aura::Source::Enumerate(guid, filter == Filter::Harmful, buf, kFallbackMax);
const int level = GroupMemberLevel(guid);
for (int i = 0; i < n; ++i) {
if (!GroupFallbackEligible(arr, buf[i], match))
continue;
Game::Lua::PushNumber(L, static_cast<double>(nextKey++));
const Attribution a = CachedAttribution(buf[i]);
PushEnriched(L, guid, buf[i].spellId, filter == Filter::Helpful, 1, level,
false, buf[i].slot, &a);
Game::Lua::SetTable(L, outerIdx);
}
}
// Single cache-fallback aura matching `spellID` (when nonzero) or `spellName`
// (when non-null), of the given kind. Backs the by-ID / by-name group queries.
bool PushGroupCacheFallbackMatch(void *L, uint64_t guid, const uint16_t *arr,
bool harmful, uint32_t spellID,
const char *spellName, Match match) {
Aura::Source::CachedAura buf[kFallbackMax];
const int n = Aura::Source::Enumerate(guid, harmful, buf, kFallbackMax);
for (int i = 0; i < n; ++i) {
if (spellID != 0 && buf[i].spellId != spellID)
continue;
if (spellName != nullptr) {
const char *nm = LocalizedSpellName(SpellRecord(buf[i].spellId));
if (nm == nullptr || std::strcmp(nm, spellName) != 0)
continue;
}
if (!GroupFallbackEligible(arr, buf[i], match))
continue;
const Attribution a = CachedAttribution(buf[i]);
PushEnriched(L, guid, buf[i].spellId, !harmful, 1, GroupMemberLevel(guid),
false, buf[i].slot, &a);
return true;
}
return false;
}
} // namespace
bool PushNthGroupAura(void *L, uint64_t guid, int oneBasedIndex, Filter filter,
Match match, Emit emit) {
if (guid == 0 || oneBasedIndex < 1)
return false;
const uint16_t *arr = Group::MemberStats::AuraArray(guid);
if (arr == nullptr)
return false;
Aura::Source::ObserveGroupAuras(guid, arr);
int start, end;
GroupRange(filter, start, end);
int matches = 0;
for (int slot = start; slot < end; ++slot) {
if (!GroupSlotEligible(guid, arr, slot, match))
continue;
if (++matches == oneBasedIndex) {
PushEnriched(L, guid, GroupSpellID(arr, slot),
filter == Filter::Helpful, 1, GroupMemberLevel(guid),
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, emit);
return false;
}
bool PushGroupAuraBySpellID(void *L, uint64_t guid, uint32_t spellID,
const Filter *filter, Match match) {
if (guid == 0 || spellID == 0)
return false;
const uint16_t *arr = Group::MemberStats::AuraArray(guid);
if (arr == nullptr)
return false;
Aura::Source::ObserveGroupAuras(guid, arr);
const int start = (filter != nullptr && *filter == Filter::Harmful)
? Offsets::UNIT_AURA_BUFF_COUNT
: 0;
const int end = (filter != nullptr && *filter == Filter::Helpful)
? Offsets::UNIT_AURA_BUFF_COUNT
: Offsets::UNIT_AURA_TOTAL;
for (int slot = start; slot < end; ++slot) {
if (GroupSpellID(arr, slot) != spellID)
continue;
if (!GroupSlotEligible(guid, arr, slot, match))
continue;
PushEnriched(L, guid, spellID, slot < Offsets::UNIT_AURA_BUFF_COUNT, 1,
GroupMemberLevel(guid), false, Aura::Source::SLOT_UNBOUND);
return true;
}
if (!GroupArrayHasVisibleAura(arr)) {
const bool both = (filter == nullptr);
if ((both || *filter == Filter::Helpful) &&
PushGroupCacheFallbackMatch(L, guid, arr, false, spellID, nullptr,
match))
return true;
if ((both || *filter == Filter::Harmful) &&
PushGroupCacheFallbackMatch(L, guid, arr, true, spellID, nullptr,
match))
return true;
}
return false;
}
bool PushGroupAuraBySpellName(void *L, uint64_t guid, const char *spellName,
const Filter *filter, Match match) {
if (guid == 0 || spellName == nullptr || *spellName == '\0')
return false;
const uint16_t *arr = Group::MemberStats::AuraArray(guid);
if (arr == nullptr)
return false;
Aura::Source::ObserveGroupAuras(guid, arr);
const int start = (filter != nullptr && *filter == Filter::Harmful)
? Offsets::UNIT_AURA_BUFF_COUNT
: 0;
const int end = (filter != nullptr && *filter == Filter::Helpful)
? Offsets::UNIT_AURA_BUFF_COUNT
: Offsets::UNIT_AURA_TOTAL;
for (int slot = start; slot < end; ++slot) {
const uint32_t id = GroupSpellID(arr, slot);
if (id == 0)
continue;
const char *name = LocalizedSpellName(SpellRecord(id));
if (name == nullptr || std::strcmp(name, spellName) != 0)
continue;
if (!GroupSlotEligible(guid, arr, slot, match))
continue;
PushEnriched(L, guid, id, slot < Offsets::UNIT_AURA_BUFF_COUNT, 1,
GroupMemberLevel(guid), false, Aura::Source::SLOT_UNBOUND);
return true;
}
if (!GroupArrayHasVisibleAura(arr)) {
const bool both = (filter == nullptr);
if ((both || *filter == Filter::Helpful) &&
PushGroupCacheFallbackMatch(L, guid, arr, false, 0, spellName,
match))
return true;
if ((both || *filter == Filter::Harmful) &&
PushGroupCacheFallbackMatch(L, guid, arr, true, 0, spellName,
match))
return true;
}
return false;
}
void AppendGroupAuras(void *L, uint64_t guid, Filter filter, Match match,
int outerIdx, int &nextKey) {
if (guid == 0)
return;
const uint16_t *arr = Group::MemberStats::AuraArray(guid);
if (arr == nullptr)
return;
Aura::Source::ObserveGroupAuras(guid, arr);
int start, end;
GroupRange(filter, start, end);
const int level = GroupMemberLevel(guid);
for (int slot = start; slot < end; ++slot) {
if (!GroupSlotEligible(guid, arr, slot, match))
continue;
Game::Lua::PushNumber(L, static_cast<double>(nextKey++));
PushEnriched(L, guid, GroupSpellID(arr, slot),
filter == Filter::Helpful, 1, level, false,
Aura::Source::SLOT_UNBOUND);
Game::Lua::SetTable(L, outerIdx);
}
// Empty array (server delta-resend gap): supplement from the cache.
if (!GroupArrayHasVisibleAura(arr))
AppendGroupCacheFallbacks(L, guid, arr, filter, match, outerIdx,
nextKey);
}
} // namespace Aura::Data
+106 -8
View File
@@ -33,6 +33,45 @@ 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
// (`!PLAYER`). Caster attribution comes from the `Aura::Source` cache; a
// cache miss counts as "not the player" (so `PlayerOnly` excludes it and
// `NotPlayer` includes it — matching `IsPlayerCast`'s miss semantics).
enum class CasterMode { Any, PlayerOnly, NotPlayer };
// Restriction from the `DISPELLABLE` / `!DISPELLABLE` aura filter tokens.
// `Any` = no restriction; `DispellableOnly` = only auras that can be
// dispelled / purged / stolen by SOME mechanism (dispel type Magic, Curse,
// Disease, or Poison — the server's DISPEL_ALL_MASK); `NotDispellable` = the
// complement. "Regardless of whether the local player can dispel it" —
// matching the modern AuraFilters semantics.
enum class DispelMode { Any, DispellableOnly, NotDispellable };
// Restriction from the `CROWD_CONTROL` / `!CROWD_CONTROL` aura filter tokens.
// `Any` = no restriction; `CrowdControlOnly` = only auras that are a crowd-
// control effect (stun / fear / silence / root / etc., per
// `Spell::CrowdControl`); `NotCrowdControl` = the complement.
enum class CcMode { Any, CrowdControlOnly, NotCrowdControl };
// A parsed aura-filter's per-aura predicates (beyond the helpful/harmful
// range, which is the separate `Filter`). Extensible: new orthogonal filter
// dimensions add a field here rather than another function parameter.
struct Match {
CasterMode caster = CasterMode::Any;
DispelMode dispel = DispelMode::Any;
CcMode cc = CcMode::Any;
};
// Reads the spell ID at the given absolute slot (0..47). Returns 0
// if the slot is empty or the unit pointer is null.
uint32_t ReadSpellID(const uint8_t *unit, int slot);
@@ -50,27 +89,41 @@ bool IsSlotPopulated(const uint8_t *unit, int slot);
// UNIT_AURA_TOTAL-1 for Harmful. When `playerOnly` is true, only auras
// the local player cast (per `IsPlayerCast`) are counted/returned.
int FindNthSlot(const uint8_t *unit, int oneBasedIndex, Filter filter,
bool playerOnly = false);
Match match = {});
// Finds the absolute slot of a populated aura with the given spell
// ID, optionally restricted to one filter range. `filter` of
// nullptr searches both ranges (helpful first, then harmful).
// `playerOnly` restricts to player-cast auras. Returns -1 if not found.
int FindSlotBySpellID(const uint8_t *unit, uint32_t spellID,
const Filter *filter, bool playerOnly = false);
const Filter *filter, Match match = {});
// Finds the absolute slot of a populated aura whose locale-resolved
// spell name exactly matches `spellName`. Case-sensitive. Otherwise
// same semantics as `FindSlotBySpellID`. Returns -1 if not found
// or if `spellName` is null/empty.
int FindSlotBySpellName(const uint8_t *unit, const char *spellName,
const Filter *filter, bool playerOnly = false);
const Filter *filter, Match match = {});
// True iff the aura at `slot` was cast by the local player, per the
// `Aura::Source` cache. False on a cache miss (caster unknown) — so a
// PLAYER-filtered query excludes auras whose cast we didn't observe.
bool IsPlayerCast(const uint8_t *unit, int slot);
// Applies a `CasterMode` to a per-aura "was cast by the local player" answer.
// `Any` → always true; `PlayerOnly` → the answer; `NotPlayer` → its negation.
bool CasterMatches(CasterMode caster, bool isPlayerCast);
// True iff the spell's dispel type is one a dispel/purge/steal can remove —
// Magic, Curse, Disease, or Poison (`Spell.dbc` Dispel ∈ 1..4). None,
// Stealth, Invisibility, Enrage, etc. are not dispellable. Verified against
// the server's DISPEL_ALL_MASK.
bool IsDispellable(uint32_t spellID);
// Applies a full `Match` (caster + dispel predicates) to one aura, given the
// per-aura "was cast by the local player" answer and its spell ID.
bool MatchesAura(const Match &match, bool isPlayerCast, uint32_t spellID);
// Display stack count for the given slot (engine stores stacks-1,
// we add 1). Returns 0 if the unit is null.
int ReadStacks(const uint8_t *unit, int slot);
@@ -109,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.
@@ -120,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, bool playerOnly = false);
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`
@@ -130,6 +186,48 @@ bool PushNthCacheFallback(void *L, const uint8_t *unit, int oneBasedIndex,
// for `GetUnitAuras`. Skips entries already present in a populated descriptor
// slot so it never double-lists.
void AppendCacheFallbacks(void *L, const uint8_t *unit, Filter filter,
bool playerOnly, int outerIdx, int &nextKey);
Match match, int outerIdx, int &nextKey);
// ── Out-of-range groupmate path ────────────────────────────────────────────
//
// When a party/raid member has no live CGUnit at all (different map, far out
// of range) the token resolves to a GUID but no object, so there is no
// descriptor to read. The server still transmits that member's current aura
// spell IDs via SMSG_PARTY_MEMBER_STATS, which the client keeps in the
// group-member stats structs (`Group::MemberStats::AuraArray`). These functions
// enumerate that array — exactly what the built-in `UnitBuff`/`UnitDebuff` do
// out of range — and build the same AuraData table.
//
// Spell IDs only come off the wire: `applications` is 1 (stacks aren't sent)
// and IDs are u16-truncated (custom IDs > 65535 are wrong). Any caster / real
// expirationTime we observed for the member via SMSG_SPELL_GO is still merged
// in from the `Aura::Source` cache. `guid` of 0 (not a rostered member) yields
// no results. These are only meaningful when the unit has NO descriptor —
// callers use them in the `unit == nullptr` branch, so there is no descriptor
// to dedup against.
// Pushes AuraData for the `oneBasedIndex`-th group-array aura on `guid`
// 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 = {}, 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
// on a hit (table pushed), false otherwise (nothing pushed).
bool PushGroupAuraBySpellID(void *L, uint64_t guid, uint32_t spellID,
const Filter *filter, Match match = {});
// As `PushGroupAuraBySpellID` but matched by locale-resolved spell name
// (case-sensitive exact match), mirroring `FindSlotBySpellName`.
bool PushGroupAuraBySpellName(void *L, uint64_t guid, const char *spellName,
const Filter *filter, Match match = {});
// Appends every group-array aura on `guid` matching `filter` into the array
// table at `outerIdx`, continuing from `nextKey` (updated in place). The
// bulk-enumeration analog of `PushNthGroupAura`, for `GetUnitAuras`.
void AppendGroupAuras(void *L, uint64_t guid, Filter filter, Match match,
int outerIdx, int &nextKey);
} // namespace Aura::Data
+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
+1513 -132
View File
File diff suppressed because it is too large Load Diff
+172 -21
View File
@@ -19,7 +19,15 @@
// the client ever sees the caster + a server-authoritative duration is the
// `SMSG_SPELL_GO` packet at cast time. We parse it (the same packet
// nampower parses for its `AURA_CAST_ON_*` events) and remember, per
// `(targetGuid, spellId)`, who cast it and when it should expire.
// `(targetGuid, spellId, casterGuid)`, who cast it and when it should expire.
//
// The caster belongs in that key because it belongs in the server's: two
// same-class raiders' Corruption on one boss are two auras in two descriptor
// slots, and a `(target, spell)` cache holds one entry for both — the second
// cast overwrites the first caster's timing, and either copy falling off
// evicts the survivor's entry too. The descriptor stores only spell IDs, so
// each entry additionally records the slot its aura was seated in
// (`OnAuraAdded`), which is what lets a per-slot query name the right instance.
//
// `Aura::Data::Push` consults this to fill `sourceUnit` (caster, resolved
// to a unit token) and `expirationTime` for non-player units. The cache is
@@ -31,36 +39,164 @@
namespace Aura::Source {
// Looks up the cached caster + timing for the aura `spellId` currently on
// the unit identified by `unitGuid`. Returns true and fills the out params
// on a hit. `*outExpirationMs` is an absolute `GetTickCount`-epoch timestamp
// (0 = unknown / infinite-duration aura); `*outDurationMs` is the applied
// duration including the caster's modifiers (talents etc.; 0 = none) — use
// it for the `duration` field so it stays consistent with `expirationTime`;
// `*outCaster` is the caster's 64-bit GUID (never 0 on a hit). Returns false
// on a miss or for zero inputs.
bool Get(uint64_t unitGuid, uint32_t spellId, uint64_t *outCaster,
// `slot` value for a query with no descriptor slot to go on (out-of-range
// group array, cache fallback) and for an entry not yet seated in one.
constexpr int SLOT_UNBOUND = -1;
// Looks up the cached caster + timing for the aura `spellId` occupying
// absolute descriptor `slot` on the unit identified by `unitGuid`. Returns
// true and fills the out params on a hit. `*outExpirationMs` is an absolute
// `GetTickCount`-epoch timestamp (0 = unknown / infinite-duration aura);
// `*outDurationMs` is the applied duration including the caster's modifiers
// (talents etc.; 0 = none) — use it for the `duration` field so it stays
// consistent with `expirationTime`; `*outCaster` is the caster's 64-bit GUID
// (0 when the aura was seen applied but its cast never observed). Returns
// false on a miss or for zero inputs.
//
// Pass `SLOT_UNBOUND` when the caller has no slot. Resolution is by slot
// first, then by spell ID when the unit carries only one entry for it; two
// casters' entries with no slot binding resolve to a miss rather than to a
// coin flip between them.
bool Get(uint64_t unitGuid, uint32_t spellId, int slot, uint64_t *outCaster,
uint32_t *outExpirationMs, uint32_t *outDurationMs);
// Refreshes `casterGuid`'s aura on `unitGuid` matching the same selector the
// duration rules use — SpellFamilyName + family-flag overlap + optional icon
// (0 = any) — to a full duration from now. Returns the spellID refreshed, or 0
// if nothing matched. For a mechanic whose duration edit reaches the client
// too late to be attributed to a cast packet, so no rule can express it.
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,
DURATION_MOD_REDUCE = 1,
DURATION_MOD_SET = 2,
DURATION_MOD_REMOVE = 3,
};
// Register a server duration-modifier rule from C++. The trigger is matched by
// exact `triggerSpellId`; the affected aura by SpellFamilyName + a family-flag
// overlap (+ optional `affectedIcon`, 0 = any). `valueMs` is the reduce/set
// amount in milliseconds (ignored by refresh/remove). Used by src/turtle
// modules for the server's built-in mods (the family/school-matched variant is
// still Lua-registerable via C_UnitAuras.RegisterAuraDurationModifierByTrigger).
// Returns false on bad input or a full table.
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;
uint64_t casterGuid; // 0 = caster unknown (application hook, no SpellGo)
uint32_t expirationMs; // 0 = infinite / unknown
uint32_t durationMs; // applied duration (incl. caster mods); 0 = none
int slot; // descriptor slot the aura was seated in; feed it
// back to `Get` to reach this exact entry again
};
// Evicts every cached entry for `unitGuid` whose spellId is NOT present in
// `presentSpellIds[0..count)`. Used to reconcile the cache against a unit's
// authoritative descriptor when it is back in view (a populated aura array
// means the unit is fully synced): an entry the descriptor no longer lists
// was removed while we couldn't observe it — e.g. a buff the owner cancelled
// while out of our range, whose `OnAuraRemoved` we never received — so drop
// it before the descriptor-drop fallback resurfaces it as a phantom. The
// caller must only invoke this when the descriptor is populated (count > 0);
// an empty array can't distinguish "out of range" from "genuinely buffless",
// so reconciling then would wrongly wipe still-valid out-of-range entries.
void EvictAbsent(uint64_t unitGuid, const uint32_t *presentSpellIds, int count);
// Evicts every cached entry for `unitGuid` the unit's descriptor contradicts.
// `slotSpellIds` is the raw `UNIT_FIELD_AURA` array — exactly
// `Offsets::UNIT_AURA_TOTAL` spell IDs indexed by absolute slot, 0 for empty.
// Used to reconcile the cache against a unit's authoritative descriptor when
// it is back in view (a populated aura array means the unit is fully synced):
// an entry the descriptor no longer backs was removed while we couldn't
// observe it — e.g. a buff the owner cancelled while out of our range, whose
// `OnAuraRemoved` we never received — so drop it before the descriptor-drop
// fallback resurfaces it as a phantom.
//
// An entry seated in a slot is checked against THAT slot, so one caster's
// copy going away retires its own entry even while another caster keeps the
// spell ID present on the unit. Entries never seated are checked against the
// whole array. An all-empty array is ignored: it can't distinguish "out of
// range" from "genuinely buffless", and reconciling then would wrongly wipe
// still-valid out-of-range entries.
void EvictAbsent(uint64_t unitGuid, const uint32_t *slotSpellIds);
// Fills `out` with up to `maxOut` cached, non-expired auras on `unitGuid`
// whose helpful/harmful classification matches `harmful`. Returns the count
@@ -74,4 +210,19 @@ void EvictAbsent(uint64_t unitGuid, const uint32_t *presentSpellIds, int count);
// the aura is still active server-side: the cache survives descriptor clears.
int Enumerate(uint64_t unitGuid, bool harmful, CachedAura *out, int maxOut);
// Feeds the cache from an out-of-range group member's aura spell-ID array
// (`Group::MemberStats::AuraArray` — 48 slots, buffs 0..BUFF_COUNT-1, debuffs
// after). Such a member has no CGUnit and never yields SMSG_SPELL_GO, so the
// SpellGo path never learns their auras' timing — but SMSG_PARTY_MEMBER_STATS
// carries the spell IDs and the server sends it promptly when an aura is
// added/removed. So a spell ID that newly *appears* here did so ~one server
// tick + latency after the real application: we stamp a best-effort
// `now + base duration` expiration (base only — no remote caster mods, no
// caster recorded). Auras already present the first time we see a member have
// unknown age and are NOT stamped; only genuine absent->present transitions
// are. Idempotent within a frame; call it before enumerating the array so a
// just-appeared aura carries its guess on the same poll. The Store guard keeps
// any real SpellGo timing we already hold, so this never downgrades better data.
void ObserveGroupAuras(uint64_t guid, const uint16_t *auraArray);
} // namespace Aura::Source
+16 -1
View File
@@ -60,6 +60,8 @@
#include "event/Custom.h"
#include "tick/WorldTick.h"
#include <cstdint>
namespace Bag::UpdateDelayed {
namespace {
@@ -127,8 +129,21 @@ unsigned __fastcall KeyringDescChange_h(void *bagDesc, void *edx,
void OnWorldTick() {
if (!g_pending)
return;
// Bag changes aren't meaningful until the player is actually in the world.
// During any loading screen (initial login, zone/instance transition) the
// engine re-populates bag slots as objects stream in (setting g_pending)
// while still behind the screen; the per-frame world tick runs there too,
// so firing BAG_UPDATE_DELAYED would make addons rescan bags repeatedly
// mid-load for no real change. Hold the pending flag — do NOT clear it —
// until the engine's in-world flag is set; the first in-world frame then
// emits a single DELAYED covering the settled inventory (matching retail's
// one BAG_UPDATE_DELAYED per load). VAR_IN_WORLD clears on every transition,
// so this re-suppresses on later zone changes too, not just initial login.
if (*reinterpret_cast<const volatile uint8_t *>(
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).
+38
View File
@@ -0,0 +1,38 @@
// 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
// Tiny ASCII string helpers. Header-only — no locale, no allocation, safe from
// any context (hook callbacks, DBC walks). Use these instead of hand-rolling
// yet another case-insensitive compare.
namespace Ascii {
// Fold a single ASCII char to lower case (non-letters unchanged).
inline char ToLower(char c) {
return (c >= 'A' && c <= 'Z') ? static_cast<char>(c - 'A' + 'a') : c;
}
// Case-insensitive full-string equality over ASCII. Two null pointers compare
// equal; a null vs non-null does not.
inline bool EqualCI(const char *a, const char *b) {
if (a == nullptr || b == nullptr)
return a == b;
for (; *a && *b; ++a, ++b)
if (ToLower(*a) != ToLower(*b))
return false;
return *a == *b;
}
} // namespace Ascii
+145
View File
@@ -0,0 +1,145 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
// `collectgarbage(opt [, arg])` — accept Lua 5.1's string options on the
// 5.0 collector. 5.0's collectgarbage takes only an optional NUMBER (a KB
// threshold for the next cycle) and errors on every 5.1 option (verified
// in-game: `collectgarbage("count")` → "bad argument #1 (number expected,
// got string)"). Shim dispatch:
//
// "count" → KB of Lua memory in use — the engine's own gcinfo()
// value, the same number 5.1's "count" reports.
// "collect" → original collectgarbage(0): threshold 0 forces a full
// cycle, the canonical 5.0 "collect now". Returns 0.
// "step" → no-op returning true ("cycle finished"): the 5.0 GC
// cannot step incrementally, and true is the loop-safe
// sentinel (`repeat until collectgarbage("step")` exits
// immediately instead of spinning forever).
// "stop" / "restart" / "setpause" / "setstepmul"
// → accepted no-ops returning 0 — the 5.0 GC has no toggle
// or tuning; callers just lose the optimization.
// number / no argument
// → forwarded to the original untouched (5.0 threshold
// semantics preserved for vanilla-era callers).
//
// The originals (`collectgarbage`, `gcinfo`) are function objects stashed
// in the state's registry at registration, BEFORE the global is
// overwritten — no raw engine address needed, and each /reload re-stashes
// the freshly re-registered engine original (module callbacks run after
// the engine's own registration pass). Registered on BOTH states.
#include "Game.h"
#include <cstring>
namespace BaseLib::CollectGarbage {
namespace {
constexpr const char *kOrigCollect = "__classicapi_orig_collectgarbage";
constexpr const char *kOrigGcInfo = "__classicapi_orig_gcinfo";
// Pushes registry[key]; true when it holds a function (left on the
// stack), false otherwise (stack unchanged).
bool PushStashed(void *L, const char *key) {
Game::Lua::PushString(L, key);
Game::Lua::RawGet(L, Game::Lua::REGISTRY_INDEX);
if (Game::Lua::Type(L, -1) == Game::Lua::TYPE_FUNCTION)
return true;
Game::Lua::SetTop(L, -2);
return false;
}
int __fastcall Script_collectgarbage(void *L) {
if (Game::Lua::Type(L, 1) == Game::Lua::TYPE_STRING) {
const char *opt = Game::Lua::ToString(L, 1);
if (std::strcmp(opt, "count") == 0) {
if (PushStashed(L, kOrigGcInfo)) {
Game::Lua::Call(L, 0, 1); // gcinfo's first return = KB in use
return 1;
}
Game::Lua::PushNumber(L, 0.0); // no gcinfo on this state
return 1;
}
if (std::strcmp(opt, "collect") == 0) {
if (PushStashed(L, kOrigCollect)) {
Game::Lua::PushNumber(L, 0.0); // threshold 0 = collect now
Game::Lua::Call(L, 1, 0);
}
Game::Lua::PushNumber(L, 0.0); // 5.1 returns 0 for "collect"
return 1;
}
if (std::strcmp(opt, "step") == 0) {
Game::Lua::PushBool(L, true); // "cycle finished" — see header
return 1;
}
if (std::strcmp(opt, "stop") == 0 || std::strcmp(opt, "restart") == 0 ||
std::strcmp(opt, "setpause") == 0 ||
std::strcmp(opt, "setstepmul") == 0) {
Game::Lua::PushNumber(L, 0.0);
return 1;
}
Game::Lua::Error(L, "bad argument #1 to 'collectgarbage' (invalid option '%s')",
opt);
return 0; // unreachable
}
// Number or no argument: original 5.0 threshold behavior, forwarded.
const int n = Game::Lua::GetTop(L);
if (!PushStashed(L, kOrigCollect))
return 0; // no original captured — inert
for (int idx = 1; idx <= n; ++idx)
Game::Lua::PushValue(L, idx);
Game::Lua::Call(L, n, Game::Lua::MULTRET);
return Game::Lua::GetTop(L) - n;
}
// Stashes the function at `_G[name]` under registry[key]; true when a
// function was actually stashed.
bool Stash(void *L, const char *name, const char *key) {
Game::Lua::PushString(L, name);
Game::Lua::GetTable(L, Game::Lua::GLOBALS_INDEX); // [fn?]
if (Game::Lua::Type(L, -1) != Game::Lua::TYPE_FUNCTION) {
Game::Lua::SetTop(L, -2);
return false;
}
Game::Lua::PushString(L, key); // [fn, key]
Game::Lua::Insert(L, -2); // [key, fn]
Game::Lua::RawSet(L, Game::Lua::REGISTRY_INDEX);
return true;
}
void DoRegister(bool glue) {
void *L = Game::Lua::State();
if (L == nullptr)
return;
// Stash BEFORE overwriting; if the engine original is somehow absent,
// leave the global untouched rather than installing a dead shim.
if (!Stash(L, "collectgarbage", kOrigCollect))
return;
Stash(L, "gcinfo", kOrigGcInfo); // optional; "count" degrades to 0
if (glue)
Game::Lua::RegisterGlueFunction("collectgarbage", &Script_collectgarbage);
else
Game::Lua::RegisterGlobalFunction("collectgarbage", &Script_collectgarbage);
}
void RegisterInGame() { DoRegister(false); }
void RegisterGlue() { DoRegister(true); }
const Game::ModuleAutoRegister _autoreg{&RegisterInGame};
const Game::GlueModuleAutoRegister _autoregGlue{&RegisterGlue};
} // namespace
} // namespace BaseLib::CollectGarbage
+40 -5
View File
@@ -14,16 +14,21 @@
// Lua 5.1 math-library additions that 1.12's Lua 5.0 is missing:
//
// - `math.fmod(x, y)` — floating-point remainder of `x / y`.
// - `math.modf(x)` — split into integral + fractional parts.
// - `math.huge` — positive infinity constant.
//
// This is a pure rename: Lua 5.0 ships the exact same C function under the
// name `math.mod`; 5.1 renamed the Lua-facing entry to `math.fmod`. We
// register `math.fmod` as a direct alias of the engine's `math.mod` C
// function (`FUN_LUA_MATH_FMOD`) — no wrapper. `math.mod` stays available too
// (we don't touch it), so code written for either name works.
// `math.fmod` is a pure rename: Lua 5.0 ships the exact same C function under
// the name `math.mod`; 5.1 renamed the Lua-facing entry to `math.fmod`. We
// register it as a direct alias of the engine's `math.mod` C function
// (`FUN_LUA_MATH_FMOD`) — no wrapper. `math.mod` stays available too, so code
// written for either name works. `modf` and `huge` are genuinely new in 5.1
// (not in 1.12's mathlib table), so we implement/define them here.
#include "Game.h"
#include "Offsets.h"
#include <cmath>
namespace BaseLib::MathLib {
namespace {
@@ -33,8 +38,38 @@ namespace {
const auto Script_math_fmod =
reinterpret_cast<Game::Lua::CFunction>(Offsets::FUN_LUA_MATH_FMOD);
// `math.modf(x)` — returns `(integral, fractional)`, the integral part
// truncated toward zero and the fractional part carrying `x`'s sign
// (`modf(3.7) → 3, 0.7`; `modf(-3.7) → -3, -0.7`). Matches C `modf` / Lua 5.1.
int __fastcall Script_math_modf(void *L) {
if (!Game::Lua::IsNumber(L, 1)) {
Game::Lua::Error(L, "Usage: math.modf(x)");
return 0; // unreachable
}
double integral = 0.0;
const double fractional = std::modf(Game::Lua::ToNumber(L, 1), &integral);
Game::Lua::PushNumber(L, integral);
Game::Lua::PushNumber(L, fractional);
return 2;
}
void RegisterFns() {
Game::Lua::RegisterTableFunction("math", "fmod", Script_math_fmod);
Game::Lua::RegisterTableFunction("math", "modf", &Script_math_modf);
// `math.huge` — a numeric constant, not a function. Fetch the (now
// guaranteed-present) `math` table and set the field directly.
void *L = Game::Lua::State();
if (L != nullptr) {
Game::Lua::PushString(L, "math");
Game::Lua::GetTable(L, Game::Lua::GLOBALS_INDEX); // [math]
if (Game::Lua::Type(L, -1) == 5 /* LUA_TTABLE */) {
Game::Lua::SetFieldNumber(L, "huge", HUGE_VAL); // math.huge = inf; [math]
Game::Lua::SetTop(L, -2); // pop [math]
} else {
Game::Lua::SetTop(L, -2); // pop whatever GetTable returned
}
}
}
// Both states are Lua 5.0 and equally missing this; RegisterTableFunction
+83
View File
@@ -0,0 +1,83 @@
// 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/>.
// `Mixin(object, ...)` and `CreateFromMixins(...)` — the FrameXML table-mixin
// primitives. Modern WoW provides these as engine C functions (in TableUtil);
// we do the same rather than leaving them in the !!!ClassicAPI addon so they
// exist whenever the DLL is injected, even if the addon is disabled. The
// composite `CreateAndInitFromMixin` (which calls a Lua `:Init` method) stays
// Lua-side in the addon, mirroring retail's Mixin.lua.
//
// Behaviour matches the canonical Lua implementation exactly:
// Mixin(object, ...) -- shallow-copy every key/value from each table
// argument onto `object`; returns `object`.
// CreateFromMixins(...) -- Mixin({}, ...); returns the new table.
// Non-table extra arguments are skipped (the Lua form guards nil the same way).
#include "Game.h"
namespace BaseLib::Mixin {
namespace {
// Shallow-copy every pair from the table at `srcIdx` onto the table at
// `dstIdx` (both absolute, positive stack indices). Stack-balanced.
void MixinInto(void *L, int dstIdx, int srcIdx) {
Game::Lua::PushNil(L);
while (Game::Lua::Next(L, srcIdx) != 0) {
// stack top: ..., key (-2), value (-1). Copy both so the originals
// survive for the next Next() iteration.
Game::Lua::PushValue(L, -2); // key copy
Game::Lua::PushValue(L, -2); // value copy
Game::Lua::SetTable(L, dstIdx); // dst[key] = value; pops the two copies
Game::Lua::SetTop(L, -2); // pop value, leave key for Next()
}
}
int __fastcall Script_Mixin(void *L) {
const int top = Game::Lua::GetTop(L);
if (top < 1 || Game::Lua::Type(L, 1) != Game::Lua::TYPE_TABLE) {
Game::Lua::Error(L, "Usage: Mixin(object, ...)");
return 0; // unreachable
}
for (int i = 2; i <= top; ++i)
if (Game::Lua::Type(L, i) == Game::Lua::TYPE_TABLE)
MixinInto(L, 1, i);
Game::Lua::PushValue(L, 1); // return object
return 1;
}
int __fastcall Script_CreateFromMixins(void *L) {
const int top = Game::Lua::GetTop(L);
Game::Lua::NewTable(L); // new object at index top + 1
const int objIdx = top + 1;
for (int i = 1; i <= top; ++i)
if (Game::Lua::Type(L, i) == Game::Lua::TYPE_TABLE)
MixinInto(L, objIdx, i);
Game::Lua::SetTop(L, objIdx); // the new table is the sole result
return 1;
}
void RegisterFns() {
Game::Lua::RegisterGlobalFunction("Mixin", &Script_Mixin);
Game::Lua::RegisterGlobalFunction("CreateFromMixins", &Script_CreateFromMixins);
}
// Pure table helpers — register on both the in-game and glue states so they're
// always present (matching the "always available" intent).
const Game::ModuleAutoRegister _autoreg{&RegisterFns};
const Game::GlueModuleAutoRegister _autoregGlue{&RegisterFns};
} // namespace
} // namespace BaseLib::Mixin
+322 -10
View File
@@ -11,28 +11,36 @@
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
// Lua 5.1 string-library additions that 1.12's Lua 5.0 is missing:
// String helpers that 1.12's Lua 5.0 is missing:
//
// - `string.match(s, pattern [, init])` — first-match extraction.
// - `string.gmatch(s, pattern)` — match iterator.
// - `string.match(s, pattern [, init])` — first-match extraction (5.1).
// - `string.gmatch(s, pattern)` — match iterator (5.1).
// - `string.gsub` TABLE replacement — 5.1 form (see the shim below).
// - `strsplit(sep, str [, pieces])` — WoW global; split on any char.
// - `strjoin(delimiter, ...)` — WoW global; join varargs.
// - `strtrim(str [, chars])` — WoW global; trim a char set.
// - `strreplace(str, find, replace)` — plain-text replace (see below).
// - `string.reverse(s)` / `strrev(s)` — 5.1 string function, missing in 5.0.
//
// Both are the same underlying pattern engine as functions 5.0 already ships:
// The two pattern `string.*` ones reuse machinery 5.0 already ships:
// - `match` is `find` returning captures / the whole match instead of the
// start/end indices — so we call the engine's `str_find` and transform.
// - `gmatch` is exactly 5.0's `string.gfind` (renamed in 5.1); we register
// it as a direct alias of the engine's `gfind` C function.
// `strsplit` is a hand-rolled port of 3.3.5's `strsplit` (see below).
//
// NOTE: only the `string.foo(s, ...)` call form works — NOT the
// `("x"):foo(...)` method sugar. WoW's Lua VM strips type-metatables for
// every value except tables/userdata (both `lua_setmetatable` and the VM's
// `luaT_gettmbyobj` hard-return "no metamethod" for strings), so strings have
// no `__index`, and there's no client-side way to add one short of hooking a
// hot VM-core function. This is the long-standing vanilla constraint.
// NOTE: both call forms work — `string.foo(s, ...)` AND the `("x"):foo(...)`
// method sugar. Method calls resolve through this same `string` table via
// LuaSyntax::StringMethods (the Lua 5.1 string-metatable backport), so
// everything registered here is automatically reachable as a method, on
// both states.
#include "Game.h"
#include "Offsets.h"
#include <cstddef>
#include <cstring>
#include <string>
namespace BaseLib::StringLib {
@@ -83,9 +91,313 @@ int __fastcall Script_string_match(void *L) {
const auto Script_string_gmatch =
reinterpret_cast<Game::Lua::CFunction>(Offsets::FUN_LUA_STR_GFIND);
// `strsplit(sep, str [, pieces])` — the WoW global (also aliased as
// `string.split`), split `str` on ANY character in `sep` and return the
// pieces as multiple values. `pieces > 0` caps the result count, with the
// unsplit remainder as the final piece; `0` / omitted = unlimited. Ported
// from 3.3.5's `strsplit` (FUN_00816a60) with one deliberate change: we do
// NOT `lua_settop(L, 0)` up front. Popping the args would leave the source
// string unreferenced, so a GC step triggered by a `pushlstring` could free
// the very buffer we're still scanning. Keeping the args on the stack keeps
// the source GC-rooted; Lua returns the top N values (our pieces) regardless
// of the args sitting below them. Each push is guarded by `lua_checkstack`
// (this pushes an unbounded number of results), erroring like 3.3.5 on
// genuine stack exhaustion.
int __fastcall Script_strsplit(void *L) {
const char *sep = Game::Lua::ToString(L, 1);
const char *str = Game::Lua::ToString(L, 2);
if (sep == nullptr || str == nullptr) {
Game::Lua::Error(L, "Usage: strsplit(\"separators\", str [, pieces])");
return 0; // unreachable
}
const int pieces =
Game::Lua::IsNumber(L, 3) ? static_cast<int>(Game::Lua::ToNumber(L, 3)) : 0;
const char *segStart = str;
int count = 0;
// Only scan for separators while unlimited (pieces == 0) or the cap
// hasn't been reached (pieces > 1). pieces == 1 (or <= 0 but non-zero)
// yields the whole string as a single piece — matches 3.3.5.
if (pieces == 0 || pieces > 1) {
for (const char *p = str; *p != '\0'; ++p) {
bool isSep = false;
for (const char *s = sep; *s != '\0'; ++s) {
if (*p == *s) {
isSep = true;
break;
}
}
if (!isSep)
continue;
++count;
if (Game::Lua::CheckStack(L, count) == 0) {
Game::Lua::Error(L, "strsplit(): Stack overflow");
return 0; // unreachable
}
Game::Lua::PushLString(L, segStart,
static_cast<unsigned int>(p - segStart));
segStart = p + 1;
if (count == pieces - 1)
break; // cap hit; remainder becomes the final piece
}
}
if (Game::Lua::CheckStack(L, count + 1) == 0) {
Game::Lua::Error(L, "strsplit(): Stack overflow");
return 0; // unreachable
}
Game::Lua::PushString(L, segStart); // final piece (remainder to end)
return count + 1;
}
// `strjoin(delimiter, ...)` — the inverse of `strsplit`: concatenate the
// vararg pieces with `delimiter` between each. Standard WoW global (FrameXML
// defines it in Lua; there's no C original to mirror). A nil / non-coercible
// piece is treated as an empty string rather than erroring, so the delimiter
// placement stays predictable. `strjoin(",")` with no pieces returns "".
int __fastcall Script_strjoin(void *L) {
const char *delim = Game::Lua::ToString(L, 1);
if (delim == nullptr) {
Game::Lua::Error(L, "Usage: strjoin(delimiter, ...)");
return 0; // unreachable
}
const unsigned delimLen = Game::Lua::StrLen(L, 1);
const int top = Game::Lua::GetTop(L);
std::string out; // no Error() past this point — a longjmp would leak it
for (int i = 2; i <= top; ++i) {
if (i > 2)
out.append(delim, delimLen);
const char *piece = Game::Lua::ToString(L, i);
if (piece != nullptr)
out.append(piece, Game::Lua::StrLen(L, i));
}
Game::Lua::PushLString(L, out.data(), static_cast<unsigned int>(out.size()));
return 1;
}
// `strtrim(str [, chars])` — remove any of the characters in `chars` from
// both ends of `str` (default: whitespace). `chars` is a literal set of
// characters, not a pattern. Standard WoW global. Two-ended scan — no
// allocation, embedded NULs preserved (bounds come from the Lua length).
int __fastcall Script_strtrim(void *L) {
const char *str = Game::Lua::ToString(L, 1);
if (str == nullptr) {
Game::Lua::Error(L, "Usage: strtrim(str [, chars])");
return 0; // unreachable
}
const unsigned len = Game::Lua::StrLen(L, 1);
const char *chars = Game::Lua::IsString(L, 2) ? Game::Lua::ToString(L, 2)
: " \t\n\v\f\r";
bool trim[256] = {false};
for (const char *c = chars; *c != '\0'; ++c)
trim[static_cast<unsigned char>(*c)] = true;
unsigned start = 0, end = len;
while (start < end && trim[static_cast<unsigned char>(str[start])])
++start;
while (end > start && trim[static_cast<unsigned char>(str[end - 1])])
--end;
Game::Lua::PushLString(L, str + start, end - start);
return 1;
}
// `strreplace(str, find, replace)` — replace every occurrence of the literal
// substring `find` with `replace`; returns `(result, count)`. NOT a stock WoW
// global and NOT pattern-based (that's `gsub`) — a plain-text replace that
// needs no magic-character escaping. Empty `find` returns `str` unchanged
// (count 0) rather than looping forever.
int __fastcall Script_strreplace(void *L) {
const char *str = Game::Lua::ToString(L, 1);
const char *find = Game::Lua::ToString(L, 2);
const char *repl = Game::Lua::ToString(L, 3);
if (str == nullptr || find == nullptr || repl == nullptr) {
Game::Lua::Error(L, "Usage: strreplace(str, find, replace)");
return 0; // unreachable
}
const unsigned strLen = Game::Lua::StrLen(L, 1);
const unsigned findLen = Game::Lua::StrLen(L, 2);
const unsigned replLen = Game::Lua::StrLen(L, 3);
if (findLen == 0) { // nothing to match — return input unchanged
Game::Lua::PushLString(L, str, strLen);
Game::Lua::PushNumber(L, 0.0);
return 2;
}
std::string out; // no Error() past this point
int count = 0;
unsigned i = 0;
while (i < strLen) {
if (i + findLen <= strLen && std::memcmp(str + i, find, findLen) == 0) {
out.append(repl, replLen);
i += findLen;
++count;
} else {
out.push_back(str[i]);
++i;
}
}
Game::Lua::PushLString(L, out.data(), static_cast<unsigned int>(out.size()));
Game::Lua::PushNumber(L, static_cast<double>(count));
return 2;
}
// `string.reverse(s)` / `strrev(s)` — return `s` with its bytes reversed.
// Lua 5.1 added `string.reverse`; 5.0's strlib lacks it. Byte-wise (not
// UTF-8 aware), matching stock Lua; embedded NULs preserved (bounds from the
// Lua length).
int __fastcall Script_string_reverse(void *L) {
const char *s = Game::Lua::ToString(L, 1);
if (s == nullptr) {
Game::Lua::Error(L, "Usage: string.reverse(s)");
return 0; // unreachable
}
const unsigned len = Game::Lua::StrLen(L, 1);
std::string out(len, '\0'); // no Error() past this point
for (unsigned i = 0; i < len; ++i)
out[i] = s[len - 1 - i];
Game::Lua::PushLString(L, out.data(), len);
return 1;
}
// --- string.gsub table-replacement upgrade (Lua 5.1) ------------------------
//
// 5.1 allows a TABLE as gsub's replacement: each match looks up the first
// capture (the whole match when the pattern declares no captures) in the
// table; a nil/false value keeps the original match; string/number values
// substitute. This engine's gsub (FUN_LUA_STR_GSUB) is stock 5.0 —
// string/function only ("string or function expected" otherwise), and its
// add_value appends NOTHING when a replacement function returns a
// non-string (match → empty; verified by decode, see Offsets.h). So a
// plain table→closure adapter would EAT unmatched tokens where 5.1 keeps
// them, and the closure can't reconstruct the whole match from capture
// args alone.
//
// The shim therefore rewrites the pattern with one OUTER capture —
// `pat` → `(pat)`, keeping a leading `^` and an unescaped trailing `$`
// outside (both are anchors only at their positional ends; inside the
// parens they'd turn literal) — so the adapter closure always receives
// the whole match as arg 1, the pattern's own first capture as arg 2, and
// can hand arg 1 back to reproduce 5.1's keep-match for missing keys.
// Patterns containing a `%1`..`%9` back-reference can't be wrapped (the
// outer capture would renumber what `%N` refers to); those fall back to
// an unwrapped adapter whose missing-key behavior is the engine's own
// (match → empty) — the one documented divergence, and the combination
// (table replacement + pattern back-references) is essentially absent
// from real code. String/function replacements tail-call the engine gsub
// untouched.
// True when the pattern contains a `%1`..`%9` back-reference. Escape-aware:
// the char after any `%` is consumed, so `%%1` (literal percent, then '1')
// doesn't count.
bool PatternHasBackref(const char *p, unsigned len) {
for (unsigned i = 0; i + 1 < len; ++i) {
if (p[i] != '%')
continue;
const char c = p[i + 1];
if (c >= '1' && c <= '9')
return true;
++i; // skip the escaped char
}
return false;
}
// Adapter for the wrapped-pattern path: arg 1 = whole match, arg 2 = the
// pattern's own first capture (when it declared any). Upvalue 1 = the
// replacement table. Missing/false value → return the whole match (5.1's
// keep-match).
int __fastcall GsubTableLookupWrapped(void *L) {
const int nargs = Game::Lua::GetTop(L);
Game::Lua::PushValue(L, nargs >= 2 ? 2 : 1); // the 5.1 lookup key
Game::Lua::GetTable(L, Game::Lua::UpvalueIndex(1)); // tbl[key]
const int t = Game::Lua::Type(L, -1);
if (t == Game::Lua::TYPE_NIL ||
(t == Game::Lua::TYPE_BOOLEAN && Game::Lua::ToBoolean(L, -1) == 0))
Game::Lua::PushValue(L, 1); // keep the original match
return 1; // top value is the result
}
// Adapter for back-reference patterns (unwrapped): arg 1 is already the
// 5.1 key. A missing key returns nil → the engine appends nothing.
int __fastcall GsubTableLookupPlain(void *L) {
Game::Lua::PushValue(L, 1);
Game::Lua::GetTable(L, Game::Lua::UpvalueIndex(1));
return 1;
}
int __fastcall Script_string_gsub(void *L) {
const auto engineGsub =
reinterpret_cast<Game::Lua::CFunction>(Offsets::FUN_LUA_STR_GSUB);
if (Game::Lua::Type(L, 3) != Game::Lua::TYPE_TABLE)
return engineGsub(L); // string/function/error paths untouched
const char *pat = Game::Lua::ToString(L, 2);
if (pat == nullptr)
return engineGsub(L); // let the engine raise its own arg error
const unsigned patLen = Game::Lua::StrLen(L, 2);
Game::Lua::SetTop(L, 4); // normalize to (s, pat, tbl, n) — gsub reads ≤4
if (!PatternHasBackref(pat, patLen)) {
// Build `(body)` with the anchors outside the capture.
unsigned b = 0, e = patLen;
std::string wrapped;
wrapped.reserve(patLen + 3);
if (e > b && pat[0] == '^') {
wrapped += '^';
b = 1;
}
bool anchoredBack = false;
if (e > b && pat[e - 1] == '$') {
// `$` is an anchor only when not %-escaped: an even number of
// `%` immediately before it means the `$` itself is unescaped.
unsigned pc = 0;
for (int idx = static_cast<int>(e) - 2;
idx >= static_cast<int>(b) && pat[idx] == '%'; --idx)
++pc;
anchoredBack = (pc % 2) == 0;
if (anchoredBack)
--e;
}
wrapped += '(';
wrapped.append(pat + b, e - b);
wrapped += ')';
if (anchoredBack)
wrapped += '$';
Game::Lua::PushLString(L, wrapped.data(),
static_cast<unsigned int>(wrapped.size())); // 5
Game::Lua::PushValue(L, 3); // 6: tbl
Game::Lua::PushCClosure(L, &GsubTableLookupWrapped, 1); // 6: closure
Game::Lua::Insert(L, 2); // closure→2: s, closure, pat, tbl, n, wrapped
Game::Lua::Insert(L, 2); // wrapped→2: s, wrapped, closure, pat, tbl, n
Game::Lua::Remove(L, 4); // drop the original pattern
Game::Lua::Remove(L, 4); // drop the table → (s, wrapped, closure, n)
} else {
Game::Lua::PushValue(L, 3); // 5: tbl
Game::Lua::PushCClosure(L, &GsubTableLookupPlain, 1); // 5: closure
Game::Lua::Insert(L, 3); // → s, pat, closure, tbl, n
Game::Lua::Remove(L, 4); // → s, pat, closure, n
}
return engineGsub(L);
}
void RegisterFns() {
Game::Lua::RegisterTableFunction("string", "match", &Script_string_match);
Game::Lua::RegisterTableFunction("string", "gmatch", Script_string_gmatch);
Game::Lua::RegisterTableFunction("string", "gsub", &Script_string_gsub);
Game::Lua::RegisterTableFunction("string", "reverse", &Script_string_reverse);
Game::Lua::RegisterTableFunction("string", "split", &Script_strsplit);
// The bare `gsub` global (1.12 exposes the string functions as globals
// too) must match `string.gsub`, so addons calling either form get the
// table-replacement upgrade.
Game::Lua::RegisterGlobalFunction("gsub", &Script_string_gsub);
Game::Lua::RegisterGlobalFunction("strsplit", &Script_strsplit);
Game::Lua::RegisterGlobalFunction("strjoin", &Script_strjoin);
Game::Lua::RegisterGlobalFunction("strtrim", &Script_strtrim);
Game::Lua::RegisterGlobalFunction("strreplace", &Script_strreplace);
Game::Lua::RegisterGlobalFunction("strrev", &Script_string_reverse);
}
// Both states are Lua 5.0 and equally missing these; RegisterTableFunction
+86
View File
@@ -0,0 +1,86 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
// `unpack(list [, i [, j]])` — upgrade 1.12's Lua 5.0 `unpack` to the 5.1
// signature. 5.0's unpack takes NO range arguments and silently ignores
// extras, so 5.1 code calling `unpack(t, 2)` got the WHOLE table back —
// wrong values with no error, the worst failure mode (verified in-game:
// `unpack({10,20,30}, 2)` returned 10, 20, 30). This replacement is a
// port of 5.1's `luaB_unpack` (lbaselib.c): `i` defaults to 1 and `j` to
// the table length via luaL_getn — which reads a `t.n` field first, so
// the transpiler's `unpack(arg)` vararg expansion keeps its
// embedded-nil-aware count exactly as before.
//
// Registered on BOTH states (language-level helper, both are 5.0).
#include "Game.h"
#include "Offsets.h"
namespace BaseLib::Unpack {
namespace {
// Engine auxlib/API entries (see Offsets.h for derivations).
using LuaLGetN_t = int(__fastcall *)(void *L, int idx);
using RawGetI_t = void(__fastcall *)(void *L, int idx, int n);
// Range argument at `idx`: absent/nil → `fallback`; a number (or numeric
// string, matching luaL_optint's coercion) → its value; anything else
// errors like 5.1's luaL_optint.
int OptRange(void *L, int idx, int fallback) {
if (Game::Lua::Type(L, idx) <= Game::Lua::TYPE_NIL) // none or nil
return fallback;
if (!Game::Lua::IsNumber(L, idx)) {
Game::Lua::Error(L, "bad argument #%d to 'unpack' (number expected)", idx);
return 0; // unreachable
}
return static_cast<int>(Game::Lua::ToNumber(L, idx));
}
int __fastcall Script_unpack(void *L) {
if (Game::Lua::Type(L, 1) != Game::Lua::TYPE_TABLE) {
Game::Lua::Error(L, "bad argument #1 to 'unpack' (table expected)");
return 0; // unreachable
}
const int i = OptRange(L, 2, 1);
const int e = OptRange(
L, 3, reinterpret_cast<LuaLGetN_t>(Offsets::LUAL_GETN)(L, 1));
if (i > e)
return 0;
// `n <= 0` guards the int overflow of `e - i + 1` (i <= e holds here),
// mirroring 5.1's luaB_unpack.
const int n = e - i + 1;
if (n <= 0 || Game::Lua::CheckStack(L, n) == 0) {
Game::Lua::Error(L, "too many results to unpack");
return 0; // unreachable
}
const auto rawGetI = reinterpret_cast<RawGetI_t>(Offsets::LUA_RAWGETI);
for (int k = i; k <= e; ++k)
rawGetI(L, 1, k);
return n;
}
void RegisterInGame() {
Game::Lua::RegisterGlobalFunction("unpack", &Script_unpack);
}
void RegisterGlue() {
Game::Lua::RegisterGlueFunction("unpack", &Script_unpack);
}
const Game::ModuleAutoRegister _autoreg{&RegisterInGame};
const Game::GlueModuleAutoRegister _autoregGlue{&RegisterGlue};
} // namespace
} // namespace BaseLib::Unpack
+620
View File
@@ -0,0 +1,620 @@
// 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/>.
// Backports the direct-action and override binding APIs introduced in 2.0.
// Normal bindings are stored by vanilla's SetBinding implementation using the
// later-client SPELL/ITEM/MACRO/CLICK command strings. The resolved-command
// hook executes those four command families; every native binding command is
// passed through unchanged.
//
// Override bindings live in a separate owner-scoped layer in front of the
// native key lookup. They are deliberately never written to the saved binding
// table, so clearing an owner immediately reveals the next override or the
// unchanged native binding underneath it.
#include "Game.h"
#include "Offsets.h"
#include "macro/Execute.h"
#include <algorithm>
#include <cstddef>
#include <cstdint>
#include <cstring>
#include <string>
#include <unordered_map>
#include <vector>
namespace Bindings::Api {
namespace {
constexpr const char kSpellPrefix[] = "SPELL ";
constexpr const char kItemPrefix[] = "ITEM ";
constexpr const char kMacroPrefix[] = "MACRO ";
constexpr const char kClickPrefix[] = "CLICK ";
constexpr const char kDefaultMouseButton[] = "LeftButton";
using ScriptSetBinding_t = int(__fastcall *)(void *L);
using KeyDispatch_t = int(__thiscall *)(void *mgr, const char *key, int isDown);
using CommandExecute_t = int(__thiscall *)(void *mgr, const char *cmd, int isDown);
KeyDispatch_t KeyDispatch_o = nullptr;
CommandExecute_t CommandExecute_o = nullptr;
enum class CommandType {
None,
Spell,
Item,
Macro,
Click,
};
struct ParsedCommand {
CommandType type = CommandType::None;
const char *argument = nullptr;
};
struct OverrideEntry {
const void *owner = nullptr;
std::string key;
std::string command;
bool priority = false;
uint64_t sequence = 0;
};
std::vector<OverrideEntry> g_overrides;
std::unordered_map<std::string, std::string> g_activeOverrides;
uint64_t g_nextSequence = 0;
// Normalizes a binding key to the engine's own canonical form so a stored
// override matches the string FUN_BINDING_KEY_DISPATCH builds at keypress. The
// engine's key-string builder (FUN_004b6630) always emits modifiers in the
// fixed order ALT-CTRL-SHIFT- ahead of the base key, regardless of the order
// the caller passed them; vanilla SetBinding never reorders, so the override
// layer does it here. Both the store and the lookup run through this, so they
// stay consistent while also matching the engine's keypress form.
std::string CanonicalKey(const char *key) {
std::string result = key ? key : "";
for (char &character : result) {
if (character >= 'a' && character <= 'z')
character = static_cast<char>(character - 'a' + 'A');
}
// Strip the three modifier prefixes in whatever order/repetition they
// appear, then re-emit them in the engine's fixed order. Prefix-stripping
// (rather than splitting on '-') keeps the minus base key correct, e.g.
// "SHIFT--" -> modifier SHIFT + base "-".
bool alt = false, ctrl = false, shift = false;
size_t base = 0;
for (bool matched = true; matched;) {
matched = false;
if (result.compare(base, 4, "ALT-") == 0) {
alt = true;
base += 4;
matched = true;
} else if (result.compare(base, 5, "CTRL-") == 0) {
ctrl = true;
base += 5;
matched = true;
} else if (result.compare(base, 6, "SHIFT-") == 0) {
shift = true;
base += 6;
matched = true;
}
}
if (!alt && !ctrl && !shift)
return result;
std::string canonical;
if (alt)
canonical += "ALT-";
if (ctrl)
canonical += "CTRL-";
if (shift)
canonical += "SHIFT-";
canonical += result.substr(base);
return canonical;
}
std::string MakeCommand(const char *prefix, const char *arg) {
std::string cmd = prefix;
cmd += arg;
return cmd;
}
std::string MakeClickCommand(const char *btnName, const char *mouseBtn) {
std::string cmd = MakeCommand(kClickPrefix, btnName);
if (mouseBtn != nullptr) {
cmd += ':';
cmd += mouseBtn;
}
return cmd;
}
ParsedCommand ParseCommand(const char *cmd) {
if (cmd == nullptr)
return {};
struct PrefixEntry {
const char *prefix;
size_t length;
CommandType type;
};
static constexpr PrefixEntry prefixes[] = {
{kSpellPrefix, sizeof(kSpellPrefix) - 1, CommandType::Spell},
{kItemPrefix, sizeof(kItemPrefix) - 1, CommandType::Item},
{kMacroPrefix, sizeof(kMacroPrefix) - 1, CommandType::Macro},
{kClickPrefix, sizeof(kClickPrefix) - 1, CommandType::Click},
};
for (const PrefixEntry &entry : prefixes) {
if (std::strncmp(cmd, entry.prefix, entry.length) == 0)
return {entry.type, cmd + entry.length};
}
return {};
}
bool CallGlobalString(void *L, const char *func, const char *arg) {
const int top = Game::Lua::GetTop(L);
if (!Game::Lua::PushGlobalFunction(L, func)) {
Game::Lua::SetTop(L, top);
return false;
}
Game::Lua::PushString(L, arg);
const bool ok = Game::Lua::PCall(L, 1, 0, 0) == 0;
Game::Lua::SetTop(L, top);
return ok;
}
bool CallTableString(void *L, const char *table, const char *func, const char *arg) {
const int top = Game::Lua::GetTop(L);
Game::Lua::PushString(L, table);
Game::Lua::GetTable(L, Game::Lua::GLOBALS_INDEX);
if (Game::Lua::Type(L, -1) != Game::Lua::TYPE_TABLE) {
Game::Lua::SetTop(L, top);
return false;
}
const int tableIndex = Game::Lua::GetTop(L);
Game::Lua::PushString(L, func);
Game::Lua::GetTable(L, tableIndex);
if (Game::Lua::Type(L, -1) != Game::Lua::TYPE_FUNCTION) {
Game::Lua::SetTop(L, top);
return false;
}
Game::Lua::PushString(L, arg);
const bool ok = Game::Lua::PCall(L, 1, 0, 0) == 0;
Game::Lua::SetTop(L, top);
return ok;
}
void InvokeButtonClick(void *L, const char *btnName, size_t btnNameLength, const char *mouseBtn) {
const int top = Game::Lua::GetTop(L);
Game::Lua::PushLString(L, btnName, static_cast<unsigned int>(btnNameLength));
Game::Lua::GetTable(L, Game::Lua::GLOBALS_INDEX);
if (Game::Lua::Type(L, -1) != Game::Lua::TYPE_TABLE) {
Game::Lua::SetTop(L, top);
return;
}
const int frameIndex = Game::Lua::GetTop(L);
Game::Lua::PushString(L, "Click");
Game::Lua::GetTable(L, frameIndex);
if (Game::Lua::Type(L, -1) == Game::Lua::TYPE_FUNCTION) {
Game::Lua::PushValue(L, frameIndex);
Game::Lua::PushString(L, mouseBtn);
Game::Lua::PCall(L, 2, 0, 0);
}
Game::Lua::SetTop(L, top);
}
void ExecuteClick(void *L, const char *arg) {
const char *separator = std::strrchr(arg, ':');
if (separator == arg || (separator != nullptr && separator[1] == '\0'))
return;
const char *mouseBtn = kDefaultMouseButton;
size_t btnNameLength = std::strlen(arg);
if (separator != nullptr) {
btnNameLength = static_cast<size_t>(separator - arg);
mouseBtn = separator + 1;
}
if (btnNameLength == 0)
return;
InvokeButtonClick(L, arg, btnNameLength, mouseBtn);
}
// Returns true for every recognized direct-action command, including malformed
// or stale commands. Vanilla cannot execute these namespaces, so a bad command
// is consumed as a no-op instead of falling through to an unrelated handler.
bool ExecuteSpecialCommand(const char *cmd, int isDown) {
const ParsedCommand parsed = ParseCommand(cmd);
if (parsed.type == CommandType::None)
return false;
if (!isDown || parsed.argument == nullptr || *parsed.argument == '\0')
return true;
void *L = Game::Lua::State();
if (L == nullptr)
return true;
switch (parsed.type) {
case CommandType::Spell:
CallGlobalString(L, "CastSpellByName", parsed.argument);
break;
case CommandType::Item:
CallTableString(L, "C_Item", "UseItemByName", parsed.argument);
break;
case CommandType::Macro:
Macro::Execute::Saved(L, parsed.argument);
break;
case CommandType::Click:
ExecuteClick(L, parsed.argument);
break;
default:
break;
}
return true;
}
const OverrideEntry *FindEffectiveOverride(const std::string &key) {
const OverrideEntry *best = nullptr;
for (const OverrideEntry &entry : g_overrides) {
if (entry.key != key)
continue;
if (best == nullptr || entry.priority > best->priority ||
(entry.priority == best->priority && entry.sequence > best->sequence))
best = &entry;
}
return best;
}
void SetOverride(const void *owner, bool priority, const std::string &key, const std::string &cmd) {
auto existing =
std::find_if(g_overrides.begin(), g_overrides.end(),
[&](const OverrideEntry &entry) {
return entry.owner == owner && entry.key == key;
});
if (existing == g_overrides.end()) {
g_overrides.push_back(
{owner, key, cmd, priority, ++g_nextSequence});
return;
}
existing->command = cmd;
existing->priority = priority;
existing->sequence = ++g_nextSequence;
}
void RemoveOverride(const void *owner, const std::string &key) {
g_overrides.erase(std::remove_if(g_overrides.begin(), g_overrides.end(),
[&](const OverrideEntry &entry) {
return entry.owner == owner &&
entry.key == key;
}),
g_overrides.end());
}
int ExecuteCommand(void *mgr, const char *cmd, int isDown) {
if (ExecuteSpecialCommand(cmd, isDown))
return 1;
return CommandExecute_o(mgr, cmd, isDown);
}
int __fastcall CommandExecute_h(void *mgr, void *, const char *cmd, int isDown) {
return ExecuteCommand(mgr, cmd, isDown);
}
// Runs an override-resolved command with the same execution context the engine
// wraps around a native keypress. FUN_BINDING_KEY_DISPATCH saves the frame-
// script exec-context, zeroes it for the nested command, marks the manager as
// mid-command (`+0xD8`), then restores the context and floors the nesting
// depth. Native-table bindings already run inside this (the engine sets it up
// before calling FUN_BINDING_COMMAND_EXECUTE, which we co-hook); overrides
// bypass that path, so we mirror it here. Without it, a native Bindings.xml
// command dispatched through an override would run without the context its
// handler expects. Special SPELL/ITEM/MACRO/CLICK commands run harmlessly
// inside the same wrapper.
int DispatchOverrideCommand(void *mgr, const char *cmd, int isDown) {
volatile uint32_t *context =
reinterpret_cast<volatile uint32_t *>(Offsets::VAR_FRAMESCRIPT_EXEC_CONTEXT);
volatile uint32_t *depth = reinterpret_cast<volatile uint32_t *>(
Offsets::VAR_FRAMESCRIPT_EXEC_CONTEXT_DEPTH);
volatile uint32_t *executing =
Game::Ptr<volatile uint32_t>(mgr, Offsets::OFF_BINDING_MANAGER_EXECUTING);
const uint32_t savedContext = *context;
*depth = *depth + 1;
if (*depth != 0)
*context = 0;
*executing = 1;
const int result = ExecuteCommand(mgr, cmd, isDown);
*executing = 0;
if (*depth != 0)
*context = savedContext;
if (static_cast<int32_t>(*depth) - 1 > 0)
*depth = *depth - 1;
else
*depth = 0;
return result;
}
int __fastcall KeyDispatch_h(void *mgr, void *, const char *key, int isDown) {
const std::string canonicalKey = CanonicalKey(key);
// Keep a key-down paired with its override command even if that override is
// cleared while the command executes. The newly revealed native binding
// must not receive only the key-up half of the same physical press.
if (!isDown) {
auto active = g_activeOverrides.find(canonicalKey);
if (active != g_activeOverrides.end()) {
DispatchOverrideCommand(mgr, active->second.c_str(), 0);
g_activeOverrides.erase(active);
return 1;
}
}
const OverrideEntry *entry = FindEffectiveOverride(canonicalKey);
if (entry == nullptr)
return KeyDispatch_o(mgr, key, isDown);
if (isDown)
g_activeOverrides[canonicalKey] = entry->command;
return DispatchOverrideCommand(mgr, entry->command.c_str(), isDown);
}
int SetBindingCommand(void *L, const char *prefix, const char *usage) {
if (!Game::Lua::IsString(L, 1) || !Game::Lua::IsString(L, 2)) {
Game::Lua::Error(L, usage);
return 0;
}
const std::string key = Game::Lua::ToString(L, 1);
const std::string command = MakeCommand(prefix, Game::Lua::ToString(L, 2));
// Preserve the stock SetBinding return value, binding-set selection,
// key normalization, UPDATE_BINDINGS event, and SaveBindings behavior.
Game::Lua::SetTop(L, 0);
Game::Lua::PushString(L, key.c_str());
Game::Lua::PushString(L, command.c_str());
auto setBinding =
reinterpret_cast<ScriptSetBinding_t>(Offsets::FUN_SCRIPT_SET_BINDING);
return setBinding(L);
}
const char *OptionalMouseButton(void *L, int index, const char *usage) {
if (Game::Lua::GetTop(L) < index || Game::Lua::Type(L, index) == Game::Lua::TYPE_NIL)
return nullptr;
if (!Game::Lua::IsString(L, index)) {
Game::Lua::Error(L, usage);
return nullptr;
}
return Game::Lua::ToString(L, index);
}
int __fastcall Script_SetBindingSpell(void *L) {
return SetBindingCommand(L, kSpellPrefix, "Usage: SetBindingSpell(key, spell)");
}
int __fastcall Script_SetBindingItem(void *L) {
return SetBindingCommand(L, kItemPrefix, "Usage: SetBindingItem(key, item)");
}
int __fastcall Script_SetBindingMacro(void *L) {
return SetBindingCommand(L, kMacroPrefix, "Usage: SetBindingMacro(key, macro)");
}
int __fastcall Script_SetBindingClick(void *L) {
constexpr const char *usage = "Usage: SetBindingClick(key, buttonName [, mouseButton])";
if (!Game::Lua::IsString(L, 1) || !Game::Lua::IsString(L, 2)) {
Game::Lua::Error(L, usage);
return 0;
}
const std::string key = Game::Lua::ToString(L, 1);
const char *buttonName = Game::Lua::ToString(L, 2);
const char *mouseButton = OptionalMouseButton(L, 3, usage);
const std::string command = MakeClickCommand(buttonName, mouseButton);
Game::Lua::SetTop(L, 0);
Game::Lua::PushString(L, key.c_str());
Game::Lua::PushString(L, command.c_str());
auto setBinding =
reinterpret_cast<ScriptSetBinding_t>(Offsets::FUN_SCRIPT_SET_BINDING);
return setBinding(L);
}
bool ReadOverrideHeader(void *L, const char *usage, const void **owner,
bool *priority, std::string *key) {
*owner = Game::Lua::ResolveObject(L, 1);
if (*owner == nullptr || Game::Lua::Type(L, 2) != Game::Lua::TYPE_BOOLEAN ||
!Game::Lua::IsString(L, 3)) {
Game::Lua::Error(L, usage);
return false;
}
*priority = Game::Lua::ToBoolean(L, 2) != 0;
*key = CanonicalKey(Game::Lua::ToString(L, 3));
return true;
}
int SetOverrideBindingCommand(void *L, const char *prefix, const char *usage) {
const void *owner = nullptr;
bool priority = false;
std::string key;
if (!ReadOverrideHeader(L, usage, &owner, &priority, &key))
return 0;
if (!Game::Lua::IsString(L, 4)) {
Game::Lua::Error(L, usage);
return 0;
}
SetOverride(owner, priority, key,
MakeCommand(prefix, Game::Lua::ToString(L, 4)));
return 0;
}
int __fastcall Script_SetOverrideBinding(void *L) {
constexpr const char *usage = "Usage: SetOverrideBinding(owner, isPriority, key [, command])";
const void *owner = nullptr;
bool priority = false;
std::string key;
if (!ReadOverrideHeader(L, usage, &owner, &priority, &key))
return 0;
if (Game::Lua::GetTop(L) < 4 ||
Game::Lua::Type(L, 4) == Game::Lua::TYPE_NIL) {
RemoveOverride(owner, key);
return 0;
}
if (!Game::Lua::IsString(L, 4)) {
Game::Lua::Error(L, usage);
return 0;
}
SetOverride(owner, priority, key, Game::Lua::ToString(L, 4));
return 0;
}
int __fastcall Script_SetOverrideBindingSpell(void *L) {
return SetOverrideBindingCommand(
L, kSpellPrefix,
"Usage: SetOverrideBindingSpell(owner, isPriority, key, spell)");
}
int __fastcall Script_SetOverrideBindingItem(void *L) {
return SetOverrideBindingCommand(
L, kItemPrefix,
"Usage: SetOverrideBindingItem(owner, isPriority, key, item)");
}
int __fastcall Script_SetOverrideBindingMacro(void *L) {
return SetOverrideBindingCommand(
L, kMacroPrefix,
"Usage: SetOverrideBindingMacro(owner, isPriority, key, macro)");
}
int __fastcall Script_SetOverrideBindingClick(void *L) {
constexpr const char *usage =
"Usage: SetOverrideBindingClick(owner, "
"isPriority, key, buttonName [, mouseButton])";
const void *owner = nullptr;
bool priority = false;
std::string key;
if (!ReadOverrideHeader(L, usage, &owner, &priority, &key))
return 0;
if (!Game::Lua::IsString(L, 4)) {
Game::Lua::Error(L, usage);
return 0;
}
const char *buttonName = Game::Lua::ToString(L, 4);
const char *mouseButton = OptionalMouseButton(L, 5, usage);
SetOverride(owner, priority, key,
MakeClickCommand(buttonName, mouseButton));
return 0;
}
int __fastcall Script_ClearOverrideBindings(void *L) {
const void *owner = Game::Lua::ResolveObject(L, 1);
if (owner == nullptr) {
Game::Lua::Error(L, "Usage: ClearOverrideBindings(owner)");
return 0;
}
g_overrides.erase(std::remove_if(g_overrides.begin(), g_overrides.end(),
[&](const OverrideEntry &entry) {
return entry.owner == owner;
}),
g_overrides.end());
return 0;
}
void RegisterLuaFunctions() {
// A fresh FrameScript state makes every owner pointer from the previous
// state stale. Permanent bindings remain in vanilla's binding table.
g_overrides.clear();
g_activeOverrides.clear();
g_nextSequence = 0;
Game::Lua::RegisterGlobalFunction("SetBindingSpell", &Script_SetBindingSpell);
Game::Lua::RegisterGlobalFunction("SetBindingItem", &Script_SetBindingItem);
Game::Lua::RegisterGlobalFunction("SetBindingMacro", &Script_SetBindingMacro);
Game::Lua::RegisterGlobalFunction("SetBindingClick", &Script_SetBindingClick);
Game::Lua::RegisterGlobalFunction("SetOverrideBinding",
&Script_SetOverrideBinding);
Game::Lua::RegisterGlobalFunction("SetOverrideBindingSpell",
&Script_SetOverrideBindingSpell);
Game::Lua::RegisterGlobalFunction("SetOverrideBindingItem",
&Script_SetOverrideBindingItem);
Game::Lua::RegisterGlobalFunction("SetOverrideBindingMacro",
&Script_SetOverrideBindingMacro);
Game::Lua::RegisterGlobalFunction("SetOverrideBindingClick",
&Script_SetOverrideBindingClick);
Game::Lua::RegisterGlobalFunction("ClearOverrideBindings",
&Script_ClearOverrideBindings);
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
const Game::HookAutoRegister _hookKeyDispatch{
Offsets::FUN_BINDING_KEY_DISPATCH, reinterpret_cast<void *>(&KeyDispatch_h),
reinterpret_cast<void **>(&KeyDispatch_o)};
const Game::HookAutoRegister _hookCommandExecute{
Offsets::FUN_BINDING_COMMAND_EXECUTE,
reinterpret_cast<void *>(&CommandExecute_h),
reinterpret_cast<void **>(&CommandExecute_o)};
} // namespace
} // namespace Bindings::Api
+1 -3
View File
@@ -13,8 +13,6 @@
TargetUnit("focus");
</Binding>
<Binding name="AOELOOT">
if not C_Loot.IsScanInProgress() then
C_Loot.ScanNearbyLoot()
end
C_Loot.LootAllCorpses()
</Binding>
</Bindings>
+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);
+17 -49
View File
@@ -18,29 +18,19 @@
// against an internal cache to identify the player. With this we
// just read the GUID directly.
//
// Implementation: the SMSG_MESSAGECHAT handler at FUN_0049D560 reads
// the senderGUID from the packet (offsets `[ebp-0x10]` / `[ebp-0xC]`
// in that function's stack frame) and forwards it as args 9-10 to
// the chat dispatcher at FUN_0049A870. The dispatcher in turn fires
// the appropriate CHAT_MSG_* event during its execution. We hook the
// dispatcher's entry, stash the GUID into a file-scope global, call
// the original (which fires the event synchronously), then clear the
// global on return. Inside the addon's OnEvent, `GetCurrentChatGUID`
// reads the global.
//
// Why not hook the SMSG handler directly: the GUID isn't in argument
// registers/stack at the handler's entry — it's read mid-function
// into locals. Hooking the dispatcher (which receives the GUID as
// args) is structurally simpler. The trade-off: the dispatcher is
// also called for synthetic chat (system notifications, arena team
// events, etc.) with GUID = 0; for those, `GetCurrentChatGUID()`
// returns nil — matches the modern API's behavior of nil GUID for
// non-player-sourced chat.
// The GUID is published for the duration of a chat dispatch by a
// `DispatchScope` (see the header), which Chat::Dispatch — the single
// owner of the FUN_0049A870 chat-dispatch hook — constructs around the
// original call. The engine fires the CHAT_MSG_* event synchronously
// inside that call, so `GetCurrentChatGUID` reads the right GUID from
// the addon's OnEvent. The dispatcher is also called for synthetic chat
// (system notifications, arena team events, etc.) with GUID = 0; for
// those `GetCurrentChatGUID()` returns nil — matching the modern API's
// behavior of a nil GUID for non-player-sourced chat.
#include "CurrentGUID.h"
#include "Game.h"
#include "Offsets.h"
#include "guid/Guid.h"
#include <cstdint>
@@ -69,39 +59,17 @@ void RegisterLuaFunctions() {
} // namespace
ChatDispatch_t ChatDispatch_o = nullptr;
DispatchScope::DispatchScope(uint32_t guidLo, uint32_t guidHi)
: prevLo_(g_currentGuidLo), prevHi_(g_currentGuidHi) {
g_currentGuidLo = guidLo;
g_currentGuidHi = guidHi;
}
void __fastcall ChatDispatch_h(
const char *sender, int chatType,
int arg3, int arg4, int arg5, int arg6, int arg7,
int targetGuidLo, int targetGuidHi, int arg10,
int senderGuidLo, int senderGuidHi)
{
// Save before original — the engine's chat event fires DURING
// origCall, and the addon's OnEvent runs synchronously inside
// that window. The GUID must be readable at that point.
const uint32_t prevLo = g_currentGuidLo;
const uint32_t prevHi = g_currentGuidHi;
g_currentGuidLo = static_cast<uint32_t>(senderGuidLo);
g_currentGuidHi = static_cast<uint32_t>(senderGuidHi);
ChatDispatch_o(sender, chatType, arg3, arg4, arg5, arg6, arg7,
targetGuidLo, targetGuidHi, arg10,
senderGuidLo, senderGuidHi);
// Restore prior value rather than clearing to 0, so nested
// dispatch (one chat event firing during another's OnEvent —
// e.g. an addon calling SendChatMessage from its handler) leaves
// the outer context's GUID intact when the inner returns.
g_currentGuidLo = prevLo;
g_currentGuidHi = prevHi;
DispatchScope::~DispatchScope() {
g_currentGuidLo = prevLo_;
g_currentGuidHi = prevHi_;
}
static const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
static const Game::HookAutoRegister _hookreg{
Offsets::FUN_CHAT_DISPATCH,
reinterpret_cast<void *>(&ChatDispatch_h),
reinterpret_cast<void **>(&ChatDispatch_o)};
} // namespace Chat::CurrentGUID
+21 -48
View File
@@ -13,55 +13,28 @@
#pragma once
#include <cstdint>
namespace Chat::CurrentGUID {
// Hook for FUN_CHAT_DISPATCH (0x0049A870). Saves the senderGUID into
// a file-scope global for the duration of the original call, then
// clears it on return. Inside that window the engine fires the
// appropriate CHAT_MSG_* event, so `GetCurrentChatGUID()` called from
// the addon's OnEvent sees the GUID for the message that triggered it.
//
// 12-arg __fastcall signature. The function has a stack-probe
// prologue (`mov eax, 0x1F0C; call _chkstk`) — its frame is ~8KB.
// Verified by disassembling 0x0049A870 in 1.12.1: stack-arg access
// reaches `[ebp+0x2C]` (= param 12) and the function exits with
// `ret 0x28` (cleans 40 bytes = 10 stack args + 2 register args).
//
// ECX = arg1 (sender name string)
// EDX = arg2 (chat type byte)
// [esp+ 4] = arg3
// [esp+ 8] = arg4
// [esp+ c] = arg5
// [esp+10] = arg6
// [esp+14] = arg7
// [esp+18] = arg8 — target GUID lo (for whisper/channel target)
// [esp+1c] = arg9 — target GUID hi
// [esp+20] = arg10
// [esp+24] = arg11 — sender GUID lo ← captured
// [esp+28] = arg12 — sender GUID hi ← captured
//
// Identified args 11/12 as senderGUID by tracing the AFK auto-reply
// path at 0x0049AB39: reads params 11/12, OR's them to check for
// non-zero, pushes them onto the packet stream via FUN_00418370
// (the "write u64" helper). That's the GUID the AFK whisper-back is
// addressed to — i.e. the original message sender.
//
// An earlier 10-arg signature derived from Ghidra (on the wrong
// binary) had `ret 0x20` cleanup. The 8-byte cleanup mismatch with
// the engine's `ret 0x28`-shaped call left 8 bytes of stack
// uncleaned per chat-dispatch invocation, corrupting the engine's
// stack frame and crashing on a later indirect call. See the
// commit message for the full diagnosis.
using ChatDispatch_t = void(__fastcall *)(
const char *sender, int chatType,
int arg3, int arg4, int arg5, int arg6, int arg7,
int targetGuidLo, int targetGuidHi, int arg10,
int senderGuidLo, int senderGuidHi);
extern ChatDispatch_t ChatDispatch_o;
void __fastcall ChatDispatch_h(
const char *sender, int chatType,
int arg3, int arg4, int arg5, int arg6, int arg7,
int targetGuidLo, int targetGuidHi, int arg10,
int senderGuidLo, int senderGuidHi);
// RAII scope that publishes the sender GUID of the chat message currently being
// dispatched, for `GetCurrentChatGUID()` to read. Chat::Dispatch constructs one
// around the original chat-dispatch call: the engine fires the CHAT_MSG_* event
// synchronously inside that call, so an addon's OnEvent sees the GUID of the
// message that triggered it. Restores the prior value on destruction rather than
// clearing to 0, so nested dispatch (a chat event firing during another's
// OnEvent — e.g. an addon calling SendChatMessage from its handler) leaves the
// outer context's GUID intact when the inner returns.
class DispatchScope {
public:
DispatchScope(uint32_t guidLo, uint32_t guidHi);
~DispatchScope();
DispatchScope(const DispatchScope &) = delete;
DispatchScope &operator=(const DispatchScope &) = delete;
private:
uint32_t prevLo_;
uint32_t prevHi_;
};
} // namespace Chat::CurrentGUID
+105
View File
@@ -0,0 +1,105 @@
// 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/>.
// Single owner of the chat-dispatch hook (FUN_0049A870).
//
// The engine routes every server-delivered chat message — say/yell/whisper/
// channel/emote and synthetic system notifications — through this one function.
// (Addon `print`/`AddMessage` does NOT pass through it.) Several independent
// concerns need to observe or transform a dispatched message, but MinHook allows
// only ONE hook per target, so this module owns the hook and orchestrates them,
// mirroring Net::PacketDispatch (one hook, fanned out to independent concerns):
//
// - Chat::IconFilter::Sanitize — strip player-injected `|T` icon spoofs from
// the message before the engine builds the chat line AND the speech bubble
// (both come from the same buffer, so one sanitize covers both).
// - Chat::RaidMarkers::Substitute — expand `{rt1}`/`{skull}`/… raid-target
// tokens into inline marker icons. Runs AFTER Sanitize so a player gets the
// fixed marker icons but still can't inject an arbitrary `|T` path.
// - Chat::CurrentGUID::DispatchScope — publish the sender GUID for the
// synchronous CHAT_MSG_* event so `GetCurrentChatGUID()` can read it.
//
// Signature — `__fastcall`, `RET 0x28`:
// ECX = arg1 — the MESSAGE text (verified from the call site in FUN_0049D560:
// `MOV ECX,[EBP-0x10]` = the raw/processed message. An older
// note mislabeled this "sender name".) Copied into one buffer
// fed to both the chat frame and the bubble spawn FUN_00608AC0.
// EDX = arg2 — chat type byte
// [esp+04] = arg3 [esp+08] = arg4 [esp+0c] = arg5
// [esp+10] = arg6 [esp+14] = arg7
// [esp+18] = arg8 — target GUID lo (whisper/channel target)
// [esp+1c] = arg9 — target GUID hi
// [esp+20] = arg10
// [esp+24] = arg11 — sender GUID lo ← published
// [esp+28] = arg12 — sender GUID hi ← published
//
// The `RET 0x28` cleanup (40 bytes = 10 stack args) MUST match the declared
// signature exactly; an earlier 10-arg / `ret 0x20` version left 8 bytes
// uncleaned per call and crashed the engine on a later indirect call.
#include "CurrentGUID.h"
#include "IconFilter.h"
#include "RaidMarkers.h"
#include "Game.h"
#include "Offsets.h"
#include <cstdint>
namespace Chat::Dispatch {
namespace {
using ChatDispatch_t = void(__fastcall *)(
const char *message, int chatType,
int arg3, int arg4, int arg5, int arg6, int arg7,
int targetGuidLo, int targetGuidHi, int arg10,
int senderGuidLo, int senderGuidHi);
ChatDispatch_t g_original = nullptr;
void __fastcall ChatDispatch_h(
const char *message, int chatType,
int arg3, int arg4, int arg5, int arg6, int arg7,
int targetGuidLo, int targetGuidHi, int arg10,
int senderGuidLo, int senderGuidHi)
{
// Strip player-injected `|T` icon escapes (covers chat line + speech bubble;
// both build from this message). The 0x800 buffer matches the engine's own
// message-buffer cap.
char iconBuf[0x800];
const char *msg = Chat::IconFilter::Sanitize(message, iconBuf, sizeof iconBuf);
// Expand raid-target tokens ({rt1}/{skull}/…) into inline marker icons —
// after the sanitize, so only these fixed, safe escapes are added.
char markerBuf[0x800];
msg = Chat::RaidMarkers::Substitute(msg, markerBuf, sizeof markerBuf);
// Publish the sender GUID for the CHAT_MSG_* event the original fires
// synchronously; restored when this scope ends (after the original returns).
Chat::CurrentGUID::DispatchScope guid(static_cast<uint32_t>(senderGuidLo),
static_cast<uint32_t>(senderGuidHi));
g_original(msg, chatType, arg3, arg4, arg5, arg6, arg7,
targetGuidLo, targetGuidHi, arg10,
senderGuidLo, senderGuidHi);
}
static const Game::HookAutoRegister _hookreg{
Offsets::FUN_CHAT_DISPATCH,
reinterpret_cast<void *>(&ChatDispatch_h),
reinterpret_cast<void **>(&g_original)};
} // namespace
} // namespace Chat::Dispatch
+85
View File
@@ -0,0 +1,85 @@
// 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/>.
// Inline-texture (`|T`) chat anti-spoof.
//
// Vanilla neutralizes player-*typed* escapes in chat (a typed `|cff…|r`,
// `|Hitem…|h` or `|T…|t` shows as raw text) — only *trusted* text renders them
// (addon `AddMessage`, shift-clicked links). Our Text::InlineTexture backport
// breaks that parity for `|T` alone: it detects the neutralized `||T` form and
// draws the icon anyway, so a player could `/say` `|Tpath|t` and have a real
// icon appear in everyone's chat AND in the speech bubble over their head.
//
// This restores vanilla's behavior for `|T` by defanging it in the chat path,
// while leaving `|c`/`|r`/`|H`/`|h` exactly as vanilla (already raw for typed
// input). It CANNOT be done in the text emitter: addon `|T` and player `|T`
// reach the emitter as byte-identical `||T` (vanilla doubles the unrecognized
// `|T` for both), so the only place they're distinguishable is the source —
// server-delivered chat flows through the chat dispatcher, addon `print` does
// not. Chat::Dispatch owns that hook and calls this on the message.
//
// Defang = replace the `|` of each `|T` with a space, breaking the pipe→T
// adjacency the inline-texture detector keys on (`|T` and `||T` both contain the
// `|T` substring, so this catches the doubled form too). The path text stays
// readable, the closer `|t` is inert without an opener, and every other escape
// is untouched.
#include "IconFilter.h"
namespace Chat::IconFilter {
namespace {
// True if `s` contains an inline-texture opener (`|T`) anywhere.
bool HasIconEscape(const char *s) {
for (; *s != '\0'; ++s)
if (s[0] == '|' && s[1] == 'T')
return true;
return false;
}
} // namespace
const char *Sanitize(const char *msg, char *buf, size_t bufSize) {
if (msg == nullptr || !HasIconEscape(msg))
return msg; // common case: forward untouched, no copy
// NEVER write `msg` in place: several of the chat handler's ~50 callers pass
// read-only `.rdata` literals (system notifications), so an in-place edit
// would fault. We copy only when a `|T` is actually present.
//
// Defanging must not leave a LONE `|` followed by a non-escape char — that's
// an invalid escape, which the chat frame tolerates but the speech-bubble
// text setter chokes on, producing an empty bubble (verified in-game). By the
// time chat reaches us the escape is usually the doubled `||T` (vanilla's own
// neutralization of typed escapes), where `||` is a VALID literal-pipe escape.
// So: keep the pipe(s) and blank the `T` when the pipe completes a `||`; blank
// BOTH chars for a lone `|T`. Either way no dangling `|` remains, and no `|T`
// opener survives for the inline-texture detector to fire on.
size_t j = 0;
for (size_t i = 0; msg[i] != '\0' && j + 1 < bufSize;) {
if (msg[i] == '|' && msg[i + 1] == 'T') {
const bool doubledPipe = (i > 0 && msg[i - 1] == '|');
buf[j++] = doubledPipe ? '|' : ' '; // keep pipe only if it completes `||`
if (j + 1 < bufSize)
buf[j++] = ' '; // blank the `T`
i += 2; // consumed `|T`
} else {
buf[j++] = msg[i++];
}
}
buf[j] = '\0';
return buf;
}
} // namespace Chat::IconFilter
+28
View File
@@ -0,0 +1,28 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
#pragma once
#include <cstddef>
namespace Chat::IconFilter {
// Strips player-injected inline-texture (`|T…|t`) escapes from a chat message.
// Returns `msg` unchanged when it contains no `|T` (the common case — no copy),
// otherwise a sanitized copy written into `buf`. Called by Chat::Dispatch on the
// message the engine's chat handler is about to display. See IconFilter.cpp for
// the anti-spoof rationale and why sanitizing here (not in the text emitter) is
// the only place player-typed and addon `|T` are distinguishable.
const char *Sanitize(const char *msg, char *buf, size_t bufSize);
} // namespace Chat::IconFilter
+154
View File
@@ -0,0 +1,154 @@
// 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/>.
// Raid-target marker chat substitutions ({rt1}..{rt8} / {star} / {skull} / ...).
//
// Modern WoW lets players type `{skull}` (etc.) in chat and everyone sees the
// raid-target icon. Vanilla 1.12 has no such substitution. We backport it onto
// the inline-texture (`|T…|t`) renderer: each recognized token is rewritten into
// a `|T` escape that crops the shared raid-target sprite sheet
// `Interface\TargetingFrame\UI-RaidTargetingIcons` to the matching 64×64 cell.
//
// Runs in Chat::Dispatch AFTER Chat::IconFilter::Sanitize. Order is deliberate:
// sanitize first defangs any player-typed raw `|T` spoof, then we add ONLY the
// fixed, known-safe marker escapes — so players get the markers without a path to
// inject arbitrary textures.
//
// Icon→texcoord mapping verified against the client's own UnitPopup.lua
// (RAID_TARGET_1..8): the sheet is 256×256 with 64-pixel cells, marker N at
// column (N-1)%4, row (N-1)/4. So RT1(star) is the top-left cell and RT8(skull)
// is row 1, column 3. The inline renderer's OpenGL v-flip already draws this sheet
// right-side-up (it's the sheet that flip was verified against), so top<bottom
// pixel coords render correctly.
#include "RaidMarkers.h"
#include <cstdio>
#include <cstring>
namespace Chat::RaidMarkers {
namespace {
struct Token {
const char *name; // lowercase alias, no braces
int marker; // raid-target index 1..8
};
// Aliases per the modern client, all case-insensitive. `{coin}` is a colloquial
// alias for `{circle}` (RT2) and `{x}` for `{cross}` (RT7) — both shown in the
// in-game substitution help.
constexpr Token kTokens[] = {
{"rt1", 1}, {"star", 1},
{"rt2", 2}, {"circle", 2}, {"coin", 2},
{"rt3", 3}, {"diamond", 3},
{"rt4", 4}, {"triangle", 4},
{"rt5", 5}, {"moon", 5},
{"rt6", 6}, {"square", 6},
{"rt7", 7}, {"cross", 7}, {"x", 7},
{"rt8", 8}, {"skull", 8},
};
char LowerAscii(char c) { return (c >= 'A' && c <= 'Z') ? static_cast<char>(c + 32) : c; }
// Case-insensitive match of the n-char token body [s, s+n) against a lowercase
// literal — true only when `lit` is exactly n chars long.
bool EqCI(const char *s, size_t n, const char *lit) {
for (size_t i = 0; i < n; ++i)
if (lit[i] == '\0' || LowerAscii(s[i]) != lit[i])
return false;
return lit[n] == '\0';
}
// Raid-target index (1..8) for the token body [name, name+n), or 0 if unknown.
int MatchMarker(const char *name, size_t n) {
for (const Token &t : kTokens)
if (EqCI(name, n, t.name))
return t.marker;
return 0;
}
bool HasBrace(const char *s) {
for (; *s != '\0'; ++s)
if (*s == '{')
return true;
return false;
}
} // namespace
const char *Substitute(const char *msg, char *buf, size_t bufSize) {
if (msg == nullptr || !HasBrace(msg))
return msg; // common case: forward untouched, no copy
// A token inflates ~28x (a 3-char `{rtN}` -> ~85-byte `|T` markup), so a
// marker-dense message can exceed `buf`. Never DROP message content to fit:
// only expand a token when the expansion AND the whole remaining input (worst
// case copied 1:1 as literal text) still fit. Past that point later tokens
// stay literal `{rtN}`, so the full message text is always preserved — a DLL
// user never sees a shorter line than a non-DLL user, just fewer icons.
const size_t msgLen = std::strlen(msg);
size_t j = 0;
for (size_t i = 0; msg[i] != '\0' && j + 1 < bufSize;) {
if (msg[i] == '{') {
// Scan the token body to the closing brace (bounded — longest alias
// is 8 chars, so a `{` with no nearby `}` is just literal text).
size_t k = i + 1;
while (msg[k] != '\0' && msg[k] != '}' && k - i <= 12)
++k;
if (msg[k] == '}') {
const int marker = MatchMarker(msg + i + 1, k - (i + 1));
if (marker != 0) {
const int col = (marker - 1) % 4;
const int row = (marker - 1) / 4;
const int l = col * 64, r = l + 64, t = row * 64, b = t + 64;
// Emit the pipe-DOUBLED form `||T…||t`. The inline emitter
// renders both `|T` and `||T`, but the vanilla speech-bubble
// text builder (FUN_004b1600) TRUNCATES at a lone `|T` while
// PRESERVING `||` — so the doubled form is what survives into
// the bubble's FontString (which pfUI's bubble reskin copies
// and renders). Anti-spoof Sanitize runs before this, so these
// are the only `||T` openers a player can produce.
char markup[128];
// `:0:0` = auto-size. The inline emitter resolves height/width
// to the target line's font pixel height at draw time, so a
// marker tracks the chat font size (and grows in speech bubbles)
// exactly like retail's ICON_LIST `…Icon_%d:0`. The rest of the
// payload crops the 256x256 sheet to the cell (l,r,t,b).
const int m = std::snprintf(
markup, sizeof markup,
"||TInterface\\TargetingFrame\\UI-RaidTargetingIcons:0:0:0:0:256:256:"
"%d:%d:%d:%d||t",
l, r, t, b);
// Reserve room for the literal tail after this token
// (msg[k+1..]) so the rest of the message always fits.
const size_t tail = msgLen - (k + 1);
if (m > 0 && j + static_cast<size_t>(m) + tail + 1 <= bufSize) {
std::memcpy(buf + j, markup, static_cast<size_t>(m));
j += static_cast<size_t>(m);
i = k + 1; // consume through the closing `}`
continue;
}
// No room for the expansion — fall through and copy the
// literal `{` so the message is never left truncated mid-token.
}
}
}
buf[j++] = msg[i++];
}
buf[j] = '\0';
return buf;
}
} // namespace Chat::RaidMarkers
+30
View File
@@ -0,0 +1,30 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
#pragma once
#include <cstddef>
namespace Chat::RaidMarkers {
// Expands raid-target marker tokens in a chat message into inline raid-target
// icon textures — the modern `{rt1}`..`{rt8}` / `{star}` / `{circle}` / `{skull}`
// (etc.) chat substitutions, backported onto the inline-texture renderer. Returns
// `msg` unchanged when it contains no `{` (the common case — no copy), otherwise a
// rewritten copy in `buf`. Called by Chat::Dispatch AFTER the `|T` anti-spoof
// sanitize, so a player can use the fixed, safe marker icons but still can't
// inject an arbitrary texture path. See RaidMarkers.cpp for the token table and
// the icon→texcoord mapping.
const char *Substitute(const char *msg, char *buf, size_t bufSize);
} // namespace Chat::RaidMarkers
+82
View File
@@ -0,0 +1,82 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
// `C_ChatBubbles.GetAllChatBubbles([includeForbidden])` — returns an
// array of the currently-active chat-bubble Frame objects, matching
// modern WoW's 7.2.5 signature. Addons use it to recolor / translate /
// reposition speech bubbles by reading the bubble's FontString region.
//
// Vanilla 1.12 chat bubbles are `CGChatBubbleFrame`s — full
// `CSimpleFrame`s created purely in C++ (never via `CreateFrame`), so
// they have no name and no Lua wrapper until something pushes them. This
// is exactly the default-nameplate case: we push each via the shared
// `UI::FrameObject::Push`, which delegates to the engine's own
// `FrameScript_Object::ScriptRegister` to lazily build the canonical
// wrapper. The returned frames respond to real frame methods — the
// FontString holding the spoken text (bubble + 0x31c, created with the
// bubble as its parent) shows up in `bubble:GetRegions()`, so the modern
// "iterate GetRegions, find the FontString, read GetText()" idiom works
// unchanged. This is the pre-7.2.5 "walk WorldFrame children" pattern the
// wiki describes, but handed the exact set instead of the whole world.
//
// The engine keeps active bubbles on an intrusive Storm `TSList` whose
// head is `VAR_CHAT_BUBBLE_LIST_HEAD` (`0x00B6F038`); each node's forward
// link sits at `+0x318` and the list terminator carries the low-bit
// sentinel. We mirror the engine's own walk in the per-frame update
// `FUN_004b1060` / teardown `FUN_004b0f80` verbatim. Bubbles whose owner
// unit has despawned are pruned on the engine's next update tick, so a
// just-orphaned bubble can linger one frame — harmless, and addons that
// care filter on `bubble:IsShown()` (a real method here) anyway.
//
// `includeForbidden` is accepted and ignored: vanilla has no forbidden
// frames, so every bubble is returned regardless.
#include "Game.h"
#include "ui/FrameObject.h"
#include <cstdint>
namespace ChatBubble::Info {
namespace {
// Storm TSList of live CGChatBubbleFrames. Head pointer, per-node
// forward link, and the end-of-list low-bit sentinel — see the engine
// walks at 0x004b1060 / 0x004b0f80.
constexpr uintptr_t kListHead = 0x00B6F038;
constexpr int kOffNextLink = 0x318;
} // namespace
static int __fastcall Script_GetAllChatBubbles(void *L) {
Game::Lua::NewTable(L);
uintptr_t node = *reinterpret_cast<const uintptr_t *>(kListHead);
int nextIndex = 1;
while (node != 0 && (node & 1) == 0) {
Game::Lua::PushNumber(L, static_cast<double>(nextIndex++));
UI::FrameObject::Push(L, reinterpret_cast<void *>(node));
Game::Lua::SetTable(L, -3);
node = *reinterpret_cast<const uintptr_t *>(node + kOffNextLink);
}
return 1;
}
static void RegisterLuaFunctions() {
Game::Lua::RegisterTableFunction("C_ChatBubbles", "GetAllChatBubbles",
&Script_GetAllChatBubbles);
}
static const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace ChatBubble::Info
+8 -30
View File
@@ -15,6 +15,7 @@
#include "Game.h"
#include "Offsets.h"
#include "dbc/Names.h"
#include <cstdint>
@@ -22,21 +23,11 @@ namespace Classes::Info {
namespace {
const uint8_t *const *Records() {
return *reinterpret_cast<const uint8_t *const *const *>(
static_cast<uintptr_t>(Offsets::VAR_CHRCLASSES_RECORDS));
}
int Count() {
return *reinterpret_cast<const int *>(
static_cast<uintptr_t>(Offsets::VAR_CHRCLASSES_COUNT));
}
int LocaleIndex() {
return *reinterpret_cast<const int *>(
static_cast<uintptr_t>(Offsets::VAR_LOCALE_INDEX));
}
// `FillLocalizedClassList(table [, isFemale]) → table` — fills the
// passed-in table with `[classToken] = localizedName` pairs for every
// class in `ChrClasses.dbc`, then returns the same table for chaining.
@@ -58,15 +49,7 @@ int __fastcall Script_FillLocalizedClassList(void *L) {
return 0;
}
auto *records = Records();
if (records == nullptr) {
// DBC not loaded yet — return the table unchanged. Matches
// modern behavior of "fill what's available, don't crash".
Game::Lua::SetTop(L, 1);
return 1;
}
const int count = Count();
const int locale = LocaleIndex();
// Drop the optional `isFemale` arg (and any further junk) so the
// table sits alone at idx 1. Each loop iteration pushes key+value
@@ -74,19 +57,14 @@ int __fastcall Script_FillLocalizedClassList(void *L) {
// [table] before the next iteration.
Game::Lua::SetTop(L, 1);
for (int i = 1; i <= count; i++) {
const uint8_t *record = records[i];
if (record == nullptr)
// DBC::* are null-safe (empty result if ChrClasses.dbc isn't loaded)
// and skip empty strings, so a sparse/absent class id just falls
// through — same "fill what's available, don't crash" behavior.
const char *token = DBC::ClassToken(static_cast<uint32_t>(i));
if (token == nullptr)
continue;
const char *token = *reinterpret_cast<const char *const *>(
record + Offsets::OFF_CHRCLASSES_FILENAME);
if (token == nullptr || *token == '\0')
continue;
const char *const *names = reinterpret_cast<const char *const *>(
record + Offsets::OFF_CHRCLASSES_NAMES);
const char *name = names[locale];
if (name == nullptr || *name == '\0')
const char *name = DBC::ClassName(static_cast<uint32_t>(i));
if (name == nullptr)
continue;
Game::Lua::PushString(L, token);
+170
View File
@@ -0,0 +1,170 @@
// 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/>.
// `length = CopyToClipboard(text [, removeMarkup])` — copy `text` to the
// Windows clipboard and return the number of bytes copied. Vanilla has no
// clipboard access at all (Blizzard never exposed one), so this is a pure
// ClassicAPI addition backed by the Win32 clipboard API.
//
// - `text` the string to copy (numbers are coerced, like Lua does).
// - `removeMarkup` optional; when truthy, strip WoW UI escape sequences
// (|cAARRGGBB..|r color, |H..|h..|h hyperlinks keeping the
// visible text, |T..|t textures, |n newline, || → |) before
// copying, so the clipboard gets plain text.
// - returns byte length of the copied (possibly stripped) string —
// consistent with Lua's `#s`. 0 on an empty string or if
// the clipboard couldn't be opened.
//
// WoW's in-memory strings are UTF-8 here (same assumption the credential and
// TTS modules make), so we convert to UTF-16 and set CF_UNICODETEXT; Windows
// synthesizes CF_TEXT for legacy consumers.
#include "Game.h"
#include <string>
#include <windows.h>
namespace Clipboard::Copy {
namespace {
// Strip WoW UI escape sequences, leaving readable text. See header comment for
// the set handled; unknown `|x` sequences are left verbatim so real text that
// merely contains a pipe survives.
std::string StripMarkup(const char *s, unsigned len) {
std::string out;
out.reserve(len);
unsigned i = 0;
while (i < len) {
const char c = s[i];
if (c != '|') {
out.push_back(c);
++i;
continue;
}
const char n = (i + 1 < len) ? s[i + 1] : '\0';
switch (n) {
case '|': // escaped pipe
out.push_back('|');
i += 2;
break;
case 'n': // newline token
out.push_back('\n');
i += 2;
break;
case 'r': // color reset
i += 2;
break;
case 'c': // color open: |c + up to 8 hex digits
i += 2;
for (int k = 0; k < 8 && i < len; ++k) {
const char h = s[i];
const bool isHex = (h >= '0' && h <= '9') || (h >= 'a' && h <= 'f') ||
(h >= 'A' && h <= 'F');
if (!isHex)
break;
++i;
}
break;
case 'h': // hyperlink boundary (text separator or closer) — drop
i += 2;
break;
case 'H': // hyperlink data opener — skip through to the next |h
i += 2;
while (i < len && !(s[i] == '|' && i + 1 < len && s[i + 1] == 'h'))
++i;
break; // the |h is dropped by the 'h' case on the next iteration
case 't': // stray texture closer — drop
i += 2;
break;
case 'T': // texture opener — skip through to the next |t
i += 2;
while (i < len && !(s[i] == '|' && i + 1 < len && s[i + 1] == 't'))
++i;
break; // the |t is dropped by the 't' case on the next iteration
default: // unknown sequence — keep the pipe literally
out.push_back('|');
++i;
break;
}
}
return out;
}
// Place a UTF-8 byte range on the clipboard as CF_UNICODETEXT. Returns true on
// success. Does not free the allocation on success — ownership passes to the
// clipboard.
bool SetClipboardUtf8(const char *data, int byteLen) {
if (!OpenClipboard(nullptr))
return false;
bool ok = false;
if (EmptyClipboard()) {
// Empty string still writes a valid (empty) clipboard entry.
const int wlen =
byteLen > 0 ? MultiByteToWideChar(CP_UTF8, 0, data, byteLen, nullptr, 0) : 0;
HGLOBAL h = GlobalAlloc(GMEM_MOVEABLE, (static_cast<SIZE_T>(wlen) + 1) * sizeof(wchar_t));
if (h != nullptr) {
auto *dst = static_cast<wchar_t *>(GlobalLock(h));
if (dst != nullptr) {
if (wlen > 0)
MultiByteToWideChar(CP_UTF8, 0, data, byteLen, dst, wlen);
dst[wlen] = L'\0';
GlobalUnlock(h);
if (SetClipboardData(CF_UNICODETEXT, h) != nullptr)
ok = true; // clipboard owns h now
}
if (!ok)
GlobalFree(h);
}
}
CloseClipboard();
return ok;
}
int __fastcall Script_CopyToClipboard(void *L) {
if (!Game::Lua::IsString(L, 1) && !Game::Lua::IsNumber(L, 1)) {
Game::Lua::Error(L, "Usage: CopyToClipboard(text [, removeMarkup])");
return 0; // unreachable
}
const char *text = Game::Lua::ToString(L, 1);
const unsigned len = Game::Lua::StrLen(L, 1);
const bool removeMarkup = Game::Lua::ToBoolean(L, 2) != 0;
int copiedLen;
if (removeMarkup) {
const std::string stripped = StripMarkup(text, len);
copiedLen = static_cast<int>(stripped.size());
SetClipboardUtf8(stripped.data(), copiedLen);
} else {
copiedLen = static_cast<int>(len);
SetClipboardUtf8(text, copiedLen);
}
Game::Lua::PushNumber(L, static_cast<double>(copiedLen));
return 1;
}
void RegisterFns() {
Game::Lua::RegisterGlobalFunction("CopyToClipboard", &Script_CopyToClipboard);
}
const Game::ModuleAutoRegister _autoreg{&RegisterFns};
const Game::GlueModuleAutoRegister _autoregGlue{&RegisterFns};
} // namespace
} // namespace Clipboard::Copy
+292
View File
@@ -0,0 +1,292 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
// `C_ColorUtil` — the modern color-space + text-color-code helpers, backported
// to 1.12. Every one is pure math / string formatting (no engine state), so
// this is a plain reimplementation rather than a hook.
//
// Conventions verified in-game against a retail-family client that ships
// C_ColorUtil natively:
// - HSV/HSL hue is in DEGREES, [0, 360); saturation / value / lightness are
// [0, 1]. Achromatic (gray) inputs yield hue = -1 (a sentinel, not 0).
// - GenerateTextColorCode(color) returns the BARE 8-hex string "AARRGGBB"
// (e.g. red -> "ffff0000"), alpha forced to ff (the arg type is colorRGB,
// no alpha channel).
// - WrapTextInColorCode(text, code) returns "|c" .. code .. text .. "|r".
// - WrapTextInColor(text, color) = WrapTextInColorCode(text,
// GenerateTextColorCode(color)) -> "|cffff0000Hi|r".
//
// The color arg is a table read by raw r/g/b fields (ColorMixin-shaped); an
// optional a field is honored (default 1.0) though retail's colorRGB type
// omits it — for the documented no-alpha case both give ff.
#include "Game.h"
#include <cmath>
#include <cstdio>
#include <string>
namespace Color::Util {
namespace {
double Clamp01(double v) { return v < 0.0 ? 0.0 : (v > 1.0 ? 1.0 : v); }
int ToByte(double v) {
const double c = Clamp01(v);
return static_cast<int>(c * 255.0 + 0.5); // round to nearest
}
double Max3(double a, double b, double c) {
double m = a > b ? a : b;
return m > c ? m : c;
}
double Min3(double a, double b, double c) {
double m = a < b ? a : b;
return m < c ? m : c;
}
double MinLuma(double l) { return l < 1.0 - l ? l : 1.0 - l; }
// --- Pure conversions --------------------------------------------------
void RgbToHsv(double r, double g, double b, double &h, double &s, double &v) {
const double mx = Max3(r, g, b), mn = Min3(r, g, b), d = mx - mn;
v = mx;
s = (mx <= 0.0) ? 0.0 : d / mx;
if (d <= 0.0) { // achromatic
h = -1.0;
return;
}
if (mx == r)
h = 60.0 * std::fmod((g - b) / d, 6.0);
else if (mx == g)
h = 60.0 * ((b - r) / d + 2.0);
else
h = 60.0 * ((r - g) / d + 4.0);
if (h < 0.0)
h += 360.0;
}
void HsvToRgb(double h, double s, double v, double &r, double &g, double &b) {
if (s <= 0.0 || h < 0.0) { // achromatic (incl. the -1 sentinel)
r = g = b = v;
return;
}
h = std::fmod(h, 360.0);
if (h < 0.0)
h += 360.0;
const double hh = h / 60.0;
const int i = static_cast<int>(std::floor(hh));
const double f = hh - i;
const double p = v * (1.0 - s);
const double q = v * (1.0 - s * f);
const double t = v * (1.0 - s * (1.0 - f));
switch (i % 6) {
case 0: r = v; g = t; b = p; break;
case 1: r = q; g = v; b = p; break;
case 2: r = p; g = v; b = t; break;
case 3: r = p; g = q; b = v; break;
case 4: r = t; g = p; b = v; break;
default: r = v; g = p; b = q; break;
}
}
void HsvToHsl(double h, double sv, double v, double &ho, double &sl, double &l) {
ho = h; // hue (incl. -1) passes through unchanged
l = v * (1.0 - sv / 2.0);
sl = (l <= 0.0 || l >= 1.0) ? 0.0 : (v - l) / MinLuma(l);
}
void HslToHsv(double h, double sl, double l, double &ho, double &sv, double &v) {
ho = h;
v = l + sl * MinLuma(l);
sv = (v <= 0.0) ? 0.0 : 2.0 * (1.0 - l / v);
}
void HslToRgb(double h, double s, double l, double &r, double &g, double &b) {
if (s <= 0.0 || h < 0.0) { // achromatic
r = g = b = l;
return;
}
h = std::fmod(h, 360.0);
if (h < 0.0)
h += 360.0;
const double c = (1.0 - std::fabs(2.0 * l - 1.0)) * s;
const double hh = h / 60.0;
const double x = c * (1.0 - std::fabs(std::fmod(hh, 2.0) - 1.0));
const double m = l - c / 2.0;
double r1, g1, b1;
switch (static_cast<int>(std::floor(hh)) % 6) {
case 0: r1 = c; g1 = x; b1 = 0.0; break;
case 1: r1 = x; g1 = c; b1 = 0.0; break;
case 2: r1 = 0.0; g1 = c; b1 = x; break;
case 3: r1 = 0.0; g1 = x; b1 = c; break;
case 4: r1 = x; g1 = 0.0; b1 = c; break;
default: r1 = c; g1 = 0.0; b1 = x; break;
}
r = r1 + m;
g = g1 + m;
b = b1 + m;
}
// --- Lua glue ----------------------------------------------------------
// Read arg `n` as a number, or raise the usage error.
double Arg(void *L, int n, const char *usage) {
if (!Game::Lua::IsNumber(L, n)) {
Game::Lua::Error(L, "%s", usage);
return 0.0; // unreachable
}
return Game::Lua::ToNumber(L, n);
}
// Read a numeric field from the color table at (absolute, positive) index
// `idx`. Raw access — ColorMixin stores r/g/b/a as direct keys.
double ColorField(void *L, int idx, const char *key, double dflt) {
Game::Lua::PushString(L, key);
Game::Lua::RawGet(L, idx);
const double v = Game::Lua::IsNumber(L, -1) ? Game::Lua::ToNumber(L, -1) : dflt;
Game::Lua::SetTop(L, -2); // pop the value
return v;
}
// "AARRGGBB" from a color table at `idx`, alpha default ff.
std::string HexFromColor(void *L, int idx) {
const int a = ToByte(ColorField(L, idx, "a", 1.0));
const int r = ToByte(ColorField(L, idx, "r", 0.0));
const int g = ToByte(ColorField(L, idx, "g", 0.0));
const int b = ToByte(ColorField(L, idx, "b", 0.0));
char buf[9];
std::snprintf(buf, sizeof(buf), "%02x%02x%02x%02x", a, r, g, b);
return std::string(buf, 8);
}
int __fastcall Script_ConvertRGBToHSV(void *L) {
const double r = Arg(L, 1, "Usage: C_ColorUtil.ConvertRGBToHSV(r, g, b)");
const double g = Arg(L, 2, "Usage: C_ColorUtil.ConvertRGBToHSV(r, g, b)");
const double b = Arg(L, 3, "Usage: C_ColorUtil.ConvertRGBToHSV(r, g, b)");
double h, s, v;
RgbToHsv(r, g, b, h, s, v);
Game::Lua::PushNumber(L, h);
Game::Lua::PushNumber(L, s);
Game::Lua::PushNumber(L, v);
return 3;
}
int __fastcall Script_ConvertHSVToRGB(void *L) {
const double h = Arg(L, 1, "Usage: C_ColorUtil.ConvertHSVToRGB(h, s, v)");
const double s = Arg(L, 2, "Usage: C_ColorUtil.ConvertHSVToRGB(h, s, v)");
const double v = Arg(L, 3, "Usage: C_ColorUtil.ConvertHSVToRGB(h, s, v)");
double r, g, b;
HsvToRgb(h, s, v, r, g, b);
Game::Lua::PushNumber(L, r);
Game::Lua::PushNumber(L, g);
Game::Lua::PushNumber(L, b);
return 3;
}
int __fastcall Script_ConvertHSVToHSL(void *L) {
const double h = Arg(L, 1, "Usage: C_ColorUtil.ConvertHSVToHSL(h, s, v)");
const double s = Arg(L, 2, "Usage: C_ColorUtil.ConvertHSVToHSL(h, s, v)");
const double v = Arg(L, 3, "Usage: C_ColorUtil.ConvertHSVToHSL(h, s, v)");
double ho, sl, l;
HsvToHsl(h, s, v, ho, sl, l);
Game::Lua::PushNumber(L, ho);
Game::Lua::PushNumber(L, sl);
Game::Lua::PushNumber(L, l);
return 3;
}
int __fastcall Script_ConvertHSLToHSV(void *L) {
const double h = Arg(L, 1, "Usage: C_ColorUtil.ConvertHSLToHSV(h, s, l)");
const double s = Arg(L, 2, "Usage: C_ColorUtil.ConvertHSLToHSV(h, s, l)");
const double l = Arg(L, 3, "Usage: C_ColorUtil.ConvertHSLToHSV(h, s, l)");
double ho, sv, v;
HslToHsv(h, s, l, ho, sv, v);
Game::Lua::PushNumber(L, ho);
Game::Lua::PushNumber(L, sv);
Game::Lua::PushNumber(L, v);
return 3;
}
int __fastcall Script_ConvertHSLToRGB(void *L) {
const double h = Arg(L, 1, "Usage: C_ColorUtil.ConvertHSLToRGB(h, s, l)");
const double s = Arg(L, 2, "Usage: C_ColorUtil.ConvertHSLToRGB(h, s, l)");
const double l = Arg(L, 3, "Usage: C_ColorUtil.ConvertHSLToRGB(h, s, l)");
double r, g, b;
HslToRgb(h, s, l, r, g, b);
Game::Lua::PushNumber(L, r);
Game::Lua::PushNumber(L, g);
Game::Lua::PushNumber(L, b);
return 3;
}
int __fastcall Script_GenerateTextColorCode(void *L) {
if (Game::Lua::Type(L, 1) != Game::Lua::TYPE_TABLE) {
Game::Lua::Error(L, "Usage: C_ColorUtil.GenerateTextColorCode(color)");
return 0; // unreachable
}
const std::string hex = HexFromColor(L, 1);
Game::Lua::PushString(L, hex.c_str());
return 1;
}
int __fastcall Script_WrapTextInColor(void *L) {
const char *text = Game::Lua::ToString(L, 1);
if (text == nullptr || Game::Lua::Type(L, 2) != Game::Lua::TYPE_TABLE) {
Game::Lua::Error(L, "Usage: C_ColorUtil.WrapTextInColor(text, color)");
return 0; // unreachable
}
std::string out = "|c" + HexFromColor(L, 2) + text + "|r";
Game::Lua::PushString(L, out.c_str());
return 1;
}
int __fastcall Script_WrapTextInColorCode(void *L) {
const char *text = Game::Lua::ToString(L, 1);
const char *code = Game::Lua::ToString(L, 2);
if (text == nullptr || code == nullptr) {
Game::Lua::Error(L, "Usage: C_ColorUtil.WrapTextInColorCode(text, colorCode)");
return 0; // unreachable
}
std::string out = std::string("|c") + code + text + "|r";
Game::Lua::PushString(L, out.c_str());
return 1;
}
void RegisterFns() {
Game::Lua::RegisterTableFunction("C_ColorUtil", "ConvertRGBToHSV",
&Script_ConvertRGBToHSV);
Game::Lua::RegisterTableFunction("C_ColorUtil", "ConvertHSVToRGB",
&Script_ConvertHSVToRGB);
Game::Lua::RegisterTableFunction("C_ColorUtil", "ConvertHSVToHSL",
&Script_ConvertHSVToHSL);
Game::Lua::RegisterTableFunction("C_ColorUtil", "ConvertHSLToHSV",
&Script_ConvertHSLToHSV);
Game::Lua::RegisterTableFunction("C_ColorUtil", "ConvertHSLToRGB",
&Script_ConvertHSLToRGB);
Game::Lua::RegisterTableFunction("C_ColorUtil", "GenerateTextColorCode",
&Script_GenerateTextColorCode);
Game::Lua::RegisterTableFunction("C_ColorUtil", "WrapTextInColor",
&Script_WrapTextInColor);
Game::Lua::RegisterTableFunction("C_ColorUtil", "WrapTextInColorCode",
&Script_WrapTextInColorCode);
}
// Pure helpers — useful on both the in-game and glue states.
const Game::ModuleAutoRegister _autoreg{&RegisterFns};
const Game::GlueModuleAutoRegister _autoregGlue{&RegisterFns};
} // namespace
} // namespace Color::Util

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