Turtle's Bestial Wrath gives the hunter's pet Scent of Blood for 18s, but
C_UnitAuras reported 8s and then dropped the timer entirely while the buff
was still up.
Server-side (tortoise-wow spell_hunter_bestial_wrath) Bestial Wrath neither
applies the aura nor carries its duration on the wire: effect 0 is
TRIGGER_SPELL(Scent of Blood, 52995), and the script then stretches the
holder that trigger just created via SetAuraMaxDuration/SetAuraDuration.
Both are plain field writes, and 1.12's only duration packet is scoped to an
aura-bearer that is a PLAYER, which a pet is not -- so the client never hears
the 18s and keeps the 8s base it computed when the aura landed. Exactly the
class of edit Aura::Source's duration rules exist to mirror; two things had
to change before one could express it.
The affected-aura selector demanded a SpellFamilyFlags overlap, and every
Turtle custom spell carries flags of 0 (308 in the hunter family alone), so
no mask could ever name this aura. It now takes a family-flag overlap and/or
a SpellIconID, at least one of the two -- an icon is shared across a spell's
whole rank ladder, so it stays as rank-proof as a flag, and icon 2245 picks
out exactly the four Scent of Blood records and nothing else. Registrars
reject the mask-0 + icon-0 rule that would match a whole class family, and
RefreshDurationByFamily takes the same invariant so the two entry points
cannot disagree about what they match.
A trigger can also CREATE the aura it edits, which the immediate-apply model
could not see. The trigger's SMSG_SPELL_GO arrives before the triggered
cast's, so editing at trigger time either matches nothing or is overwritten
moments later when the triggered cast stores its own base duration -- the
edit is lost either way. Idempotent ops now ARM the target as well, and the
next matching store re-applies the edit, from the cast path or the
application path, whichever the aura arrives on. Only refresh and set arm:
re-running those lands on the same value, while reduce would shave twice and
remove would kill an aura that was legitimately re-applied, so Conflagrate
behaves exactly as before.
The rule reads Bestial Wrath's own duration from the client's spell data
instead of hardcoding the server's 18s, so a rebalance that patches the DBC
carries over on its own.
Fixesbrues-code/pfUI#50
C_UnitAuras.GetAuraSlots / GetAuraDataBySlot / UnitAuraBySlot: enumerate a
unit's auras once into opaque slot ids and fetch each by id, so a full scan
walks the aura array once instead of once per index (the by-index getters
re-walk from slot 0 on every call). Slot ids cover live descriptor slots,
the stealth / range cache fallback, and the out-of-range group array, so
the slot path keeps every source the by-index path had.
GetAuraSlots also takes a table as a 5th argument (ClassicAPI extension)
and fills it in place, returning `continuationToken, n`. Lua 5.0 allocates
an `arg` table for EVERY vararg call (luaD_precall 0x006F6050 ->
adjust_varargs 0x006F6200 -> luaH_new, verified in Ghidra), so a Lua helper
receiving the retail multi-return costs one table per batch. The fill form
keeps per-frame scans allocation-free; AuraUtil.ForEachAura / FindAura use
it with a per-nesting-depth buffer pool instead of retail's vararg helpers.
The descriptor walkers (FindNthSlot, FindSlotBySpellID/Name, GetUnitAuras)
now evaluate IsPlayerCast only when the filter restricts on caster. It
scans the Aura::Source cache (~1000 entries in a raid) and was paid for
every populated slot on every call, PLAYER token or not.
Cache-fallback eligibility is centralized (EligibleFallbacks /
EligibleGroupFallbacks) so the by-index, bulk, and by-slot paths share one
ordering, which is the ordinal an opaque cache slot id encodes.
Recasting a channel while already channeling it (e.g. Fishing while
fishing) left the cast bar frozen: PollPlayer detected a new channel by
spellID only, but a re-channel keeps channelSpellID == g_chanSpell and
the continuous channel never lets +0x228 (and thus g_channel.spellID)
return to 0 in between, so neither CHANNEL_STOP nor CHANNEL_START fired.
Also flag a same-spell re-channel when the channel was re-stamped
(channelStartMs advanced) AND a fresh CMSG_CAST_SPELL is pending for the
spell (g_pendingGuid). Both are required so neither false-fire trap
triggers: the two-stamp-per-start sequence (SMSG_SPELL_START then
MSG_CHANNEL_START) is absorbed because the first CHANNEL_START already
consumed g_pendingGuid, and a rejected recast (SPELL_IN_PROGRESS) never
re-stamps so channelStartMs is unchanged.
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.
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.
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.
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.
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.
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.
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).
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.
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
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.
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.
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.
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.
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.
* 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).
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.
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
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".
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).
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.
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.
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.
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.
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.
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.
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.
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.
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).
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.
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.
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.
Fixesbrues-code/pfUI#43
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.
Fixesbrues-code/pfUI#45
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.
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).
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
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
* 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>
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
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.
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.
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.
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).
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.
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.
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.
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.
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.
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.
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
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.
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.
* 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.
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.
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
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.
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.
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.
{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.
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.
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.
* 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.
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.
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.
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.
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
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).
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".
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.
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.
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.
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.
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.
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).
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.
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.
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.
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.
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.
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.
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.
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.
* 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>
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.
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).
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.
* 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.
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.
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
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.
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.
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.
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.
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.
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.
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.
* 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>
* 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.
* 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>
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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).
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
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.
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.
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.
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.
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).
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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).
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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).
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.
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.
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.
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).
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).
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.
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.
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).
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.
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".
`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.
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.
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.
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.
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.
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.
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.
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.
* 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.
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.
"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.
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.
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.
* 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.
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).
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.
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.
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.
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.
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).
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.
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.
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.
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).
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.
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().
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".
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.
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.
IsFirstLoadThisSession is glue-only -> GlueXML-calls section only.
IsOnGlueScreen is registered on both states -> main in-game table AND the
GlueXML-calls section.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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).
Backport of retail's Rectangle helper (sides, stretch/move/adjust,
contains/intersects, center, width/height accessors), built on
CreateFromMixins. Registered in the TOC.
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).
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.
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.
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.
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.
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).
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.
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.
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.
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.
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.
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.
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.
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.
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.
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).
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.
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.
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.
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
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.
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
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.
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.
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.
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.
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().
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.
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.
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.
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.
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.
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.
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.
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.
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.
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
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").
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).
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).
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).
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.
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.
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.
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.
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').
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.
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.
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.
- 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.
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.
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.
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.
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).
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.
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.
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.
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.
AutoReserve silently drops any reservation past MAX_RESERVED, and the
codebase crossed 32 file-scope reservations, so a shifting subset of custom
events never claimed a table slot -> Lookup returned -1 and Fire no-op'd.
Adding the three AUCTION_MULTISELL_* events moved the overflow onto
ITEM_DATA_LOAD_RESULT / GET_ITEM_INFO_RECEIVED, which stopped firing.
Raise the cap to 64 (well above the ~45 total incl. the lazy TTS events) and
add a comment warning to keep it above the AutoReserve count. Verified via
_classicapi_DumpAllEvents: all custom events now claim slots, item events
included.
Returns the 1-based log index of the collapsible header (zone / category)
a questID sits under, or nil if the quest isn't in the log. The log is laid
out header-then-its-quests, so it locates the quest's entry and walks back to
the nearest preceding header row (+8 non-NULL). Pairs with GetQuestLogTitle
to fetch the category name.
Verified in-game: quest 60142 -> header index 1 -> GetQuestLogTitle "Dun
Morogh" with isHeader true.
The inverse of GetQuestIDForLogIndex: returns the 1-based quest-log index of
a questID, or nil if the quest isn't in the log. Walks VAR_QUEST_LOG_ENTRIES,
skips header rows via the +8 header-pointer gate, and returns the first
matching entry's index. The index spans the full entry list (headers
included), so it feeds straight into GetQuestLogTitle / the other index-based
quest-log accessors.
Verified in-game: index->ID->index and ID->index->ID both round-trip.
Implement C_Item.GetItemInfo(itemInfo) in C, returning the full modern
tuple instead of the Lua alias to the vanilla global (which only returned
1.12's short set). Sourced entirely from the cached ItemStats_C record plus
the class/subclass/inventory-type DBC name lookups item/Info.cpp already
does for GetItemInfoInstant — no new duplication.
Returns 18 values: itemName, itemLink, itemQuality, itemLevel, itemMinLevel,
itemType, itemSubType, itemStackCount, itemEquipLoc, itemTexture, sellPrice,
classID, subclassID, bindType, expansionID (0), setID (nil if none),
isCraftingReagent (false; no such flag in 1.12), itemDescription. itemTexture
is the icon path (no fileID system in 1.12). On a cache miss it warms the
cache and returns nil, landing on the GET_ITEM_INFO_RECEIVED retry — same
async contract as the stock global.
Drops the `C_Item.GetItemInfo = GetItemInfo` shim from ItemUtil.lua so the
native implementation isn't clobbered at addon load.
Verified in-game against Rancher's Trousers (nil setID) and Blackened Defias
Boots (setID 161) — all 18 fields correct.
The new-item diff only tracked bag contents, so an item moving INTO a bag
from anywhere out of scope looked identical to a fresh loot. Unequipping a
piece of gear (equipment -> bag) flagged it new; same for pulling an item
out of the bank.
Widen the "already owned" baseline to the player's whole inventory — bags,
worn equipment (paperdoll + equipped bags), and the bank (main + bank bags)
— while still only flagging bag items. An item whose GUID was already owned
somewhere is a move, not an acquisition, so it isn't flagged. Bank GUIDs are
read straight from the invMgr array (the gate-bypass C_Item.GetItemCount
already uses; the array is server-populated at login), so no bank window is
needed. Cap raised 256 -> 512 to hold bags + equipment + a full bank.
Post numStacks auctions of `quantity` each from one source stack in a
single call (retail multi-sell style). Vanilla's CMSG_AUCTION_SELL_ITEM
(opcode 0x256, verified from Script_StartAuction 0x004CE770) has no count
field like WotLK's, so the server posts the whole item object a GUID points
at; posting a partial quantity requires the client to split it into a real
bag item first. Both the split and the post are server-authoritative and
async, so this runs a WorldTick state machine:
Idle -> (source > quantity) split `quantity` into a reused free general
bag slot via Item::Swap::MoveCount (CMSG_SPLIT_ITEM)
-> SplitWait until the stack materializes
-> point the sell-slot globals at it and call the engine's own
Script_StartAuction (reusing all its per-stack validation)
-> PostWait until the server pulls it from the slot -> next stack.
The final exact stack posts the source object directly, no split.
At most one reused free slot is needed (each post frees it for the next
split); a whole-stack post needs none. Per-step ~10s timeout aborts
gracefully. Progress via retail event names AUCTION_MULTISELL_START /
_UPDATE(done,total) / _FAILURE. Immediate rejections return (false, reason).
New offsets: FUN_SCRIPT_START_AUCTION, VAR_AUCTION_AUCTIONEER_GUID_LO/HI,
VAR_AUCTION_DURATION_TABLE ({120,480,1440} minutes = 2h/8h/24h).
v1 limits: single source stack (no cross-bag aggregation), general-purpose
bags only for the temp split, one job at a time.
Backport math.fmod to 1.12's Lua 5.0. Pure 5.0->5.1 rename: 5.0 ships the
identical C function as math.mod (mathlib entry, FUN 0x007FAFD0 —
checknumber(1/2) -> fmod -> pushnumber); 5.1 renamed the Lua-facing entry
to math.fmod. Registered as a direct alias of that function pointer on both
the in-game and glue states (BaseLib::MathLib), no wrapper. math.mod stays
available, so code written for either name works.
Backport the Lua 5.1 pattern helpers to 1.12's Lua 5.0 (BaseLib::StringLib),
both reusing pattern machinery 5.0 already ships:
- string.match(s, pattern [, init]) calls the engine's str_find
(0x007FC3B0) and reshapes the result — captures, or the whole match when
the pattern has none, or nil. match and find are the same operation in
lstrlib.c; find returns start/end, match returns the captures.
- string.gmatch is 5.0's string.gfind verbatim (renamed in 5.1), registered
as a direct alias of the engine gfind (0x007FCFA0) — no wrapper.
Registered on both the in-game and glue Lua states. Only the
string.foo(s, ...) form works, not ("x"):foo(...): WoW's VM strips
type-metatables for non-table/userdata values (both lua_setmetatable and
luaT_gettmbyobj hard-return no-metamethod for strings), so string method
sugar can't be enabled client-side without hooking a hot VM core function.
Documented as a known limitation.
Drops the Util/String.lua polyfill (was just string.gmatch = string.gfind),
now redundant, and its .toc entry.
Backport Lua 5.1's select() to 1.12's Lua 5.0 (BaseLib::Select). Two
forms: select('#', ...) returns the vararg count; select(n, ...) returns
the args from position n on (negative n counts from the end). Ported from
luaB_select (lbaselib.c) — the numeric form pushes nothing and just
returns top-n, handing back the slice already on the stack. Registered on
both the in-game and glue Lua states (both are 5.0 and equally missing it).
Docs: gather the scattered Lua base-library restorations (select,
table.wipe, coroutine.*) into a single "Lua" section in API.md, and
collapse the separate Coroutine/Table rows in the README table into one
Lua row. No prose lost — the coroutine and table.wipe entries moved
verbatim under the new section (coroutine.* as level-4 subsections).
Backport the modern structured-table accessor. Vanilla's global
GetContainerItemInfo (Script_GetContainerItemInfo, 0x004F9670) returns
only a flat texture/itemCount/locked/quality/readable tuple; assemble the
full ContainerItemInfo table from the same descriptor + item-cache sources
plus the fields 1.12 never surfaced (hyperlink, hasNoValue, itemID,
isBound, itemName).
Live per-instance fields (stackCount, isLocked, isBound, itemID, hyperlink)
are always present; cache-derived fields (iconFileID, quality, hasLoot,
hasNoValue, itemName) are omitted/nil until the item's static data lands.
Reuses Item::StatAccum::FetchRecord and Item::Link for the cache peek and
link/name build (no new duplication); gathers all data into locals before
building the table so the engine link/name builders never interleave with
the half-built table on the Lua stack.
readable mirrors the engine branch at 0x004F97BD (static page text OR the
per-instance readable-text id at descriptor +0x9C, added as
OFF_DESCRIPTOR_READABLE_TEXT_ID). hasLoot is a best-effort static LOOTABLE
approximation (vanilla can't track post-loot emptiness client-side).
GetSpellBonusDamage(school) reads the player's per-school spell damage
directly from the CGPlayer sub-struct at [player+0xE68]+0xFD4 (POS) / +0xFF0
(NEG), net = POS-NEG. That field isn't in the broadcast descriptor (+0x110
reads zero — the vanilla server never sends it there), but the client keeps a
fully-computed copy at +0xE68 with gear/enchants/buffs/talents/sets baked in.
Same source nampower's GetSpellPower reads; verified 613 generic / 647 Fire on
a geared mage. 1-based school, matching 3.3.5's Script_GetSpellBonusDamage.
GetSpellBonusHealing() has no such field (vanilla lacks a healing-done field
entirely), so it's derived to mirror the server's SpellBaseHealingBonusDone:
- flat healing: SPELL_AURA_MOD_HEALING_DONE (135) off gear equip-effects,
item enchants, random suffixes, and active buffs
- Spirit conversion: SPELL_AURA_MOD_SPELL_HEALING_OF_STAT_PERCENT (175) x
total Spirit / 100 (Priest Spiritual Guidance)
- Armor conversion: SPELL_AURA_MOD_SPELL_HEALING_OF_ARMOR_PERCENT (199) x
armor / 100 (Turtle Ironclad)
Percents are read straight from Spell.dbc, so it's generic across any
aura-175/199 talent/buff including Turtle customs, and gated on SPELL_ATTR_
PASSIVE for the known-spell-bitmap walk. Spirit/Armor come from UNIT_FIELD_
STAT4 (+0x250) / UNIT_FIELD_RESISTANCES[0] (+0x254).
Item flat healing routes through the shared Item::StatAccum engine rather than
re-implementing the item spell/suffix/enchant walk: extract a reusable
ApplyEnchant() (from ApplyRandomSuffix) plus a Value() reader, and read
ITEM_MOD_SPELL_HEALING_DONE_SHORT.
The healing sum is expensive (StatAccum over 19 items + buff table + the 27k
known-spell bitmap), so it's cached and recomputed only when a player stat
input changes. New Player::StatSignal (a pull-based epoch) is bumped from the
aura hooks (player-guarded), the equipment observer, and the spell learn/
unlearn writers (0x4B25B0 / 0x4B2C50) -- so buffs, gear, and talent respecs
all invalidate instantly. A coarse world-tick recompute remains only as a
backstop against a missed signal.
Wire a Windows VERSIONINFO resource to the existing CLASSICAPI_TAG so the
DLL's file/product version (Explorer -> Properties -> Details, and
GetFileVersionInfo) matches the CLASSIC_API_VERSION Lua global. Both derive
from the vMAJOR.MINOR.PATCH tag; an untagged dev build gets the 9999.99.99
"newer than everything" sentinel.
Enables the RC language, keeps the tag's major/minor/patch components that
were previously discarded after the integer math, and configure_file's a
version.rc into the build. The resource lands in .rsrc, untouched by the
.rdata/.pdata section merges.
The PlayerLocation-taking C_PlayerInfo accessors. Resolve the location to a
unit token - unit locations use their token, GUID locations via
Unit::Identity::TokenFromGUID (while the GUID has a live token) - then:
- GetName / GetSex / IsConnected tail-call the engine's Script_UnitName /
Script_UnitSex / Script_UnitIsConnected (the GetFactionInfoByID
forward-the-return pattern), reusing all vanilla unit logic. GetSex must go
through the engine because the raw UNIT_FIELD_BYTES_0 gender byte is 0/1
while the API reports 2/3. IsConnected defaults to the local player when
called with no location.
- GetRace / GetClass read UNIT_FIELD_BYTES_0 for the numeric ids vanilla's
UnitRace / UnitClass don't return (login-time mirror globals for "player",
else the descriptor bytes); GetClass resolves className / classFile from
ChrClasses.dbc like C_CreatureInfo.GetClassInfo. classID/raceID round-trip
with C_CreatureInfo.
Only the unit and guid location kinds resolve on 1.12; the chat/who/BG/
community/voice kinds return nil. New file - player/Info.cpp is the separate
NameCache/GetPlayerInfoByGUID module. Adds the three Script_Unit* offsets
from raw_globals.txt.
Backport the modern PlayerLocation parameter object (Blizzard_ObjectAPI) - a
lightweight descriptor wrapping one way to identify a player (GUID, unit
token, chat line, /who index, battlefield-score index, ...). Pure table
bookkeeping like the existing ItemLocation mixin, so ported addon code can
construct and inspect it unchanged.
The GUID / unit / chatLine / whoIndex / battlefieldScore kinds map to real
1.12 concepts; community / voice / Battle.net are kept as harmless data
holders so the mixin matches retail exactly. The only two methods that reach
into systems 1.12 lacks - IsValid (Club) and IsBattleNetGUID (account info) -
are guarded so they degrade gracefully instead of erroring on the missing
namespaces.
Classic Era addition (present in the 1.15.x client, absent in 5.4.8) — a
plain occupancy check for a bag slot. Occupancy sibling of
GetContainerItemID: resolves (bagIndex, slotIndex) the same way and returns
whether the CGItem is non-null. Presence only, so it reports true for a
just-looted item even before its static data caches. Returns false for
empty slots and out-of-range indices.
Sum of free slots across the player's general-purpose bags (backpack +
equipped bags 0..4 with BagFamily 0), matching FrameXML's implementation.
Reuses the module's existing ResolveBagInfo (slot count + family) and
CountFreeSlotsInBag, so it's just their sum with the family == 0 filter.
Same sparse-BagFamily caveat as GetContainerNumFreeSlots: vanilla server
data leaves most bags' family field at 0, so specialty bags retail would
exclude get counted here - consistent with the per-bag values it sums.
Nine modules each rolled their own ResolvePlayer / PlayerInvMgr helper on the
throwing FUN_RESOLVE_UNIT_TOKEN("player"). Delete them all and route through
the canonical non-throwing Unit::Identity::PlayerObject(), plus a new
Unit::Identity::PlayerInventoryManager() (PlayerObject + inventory-manager
offset, mirroring the existing PlayerDescriptor accessor).
Verified equivalent by disassembly: FUN_RESOLVE_UNIT_TOKEN is TokenToGUID ->
FUN_OBJECT_RESOLVE_BY_GUID, the same object-manager lookup PlayerObject()
performs, differing only in a type-mask the player object passes either way.
Callers therefore get the identical CGPlayer (and inventory manager) but
non-throwing - null pre-world instead of a raised Lua error. That also makes
item/Location's equipment resolver safe to call outside a Lua callback (e.g.
the C_NewItems WorldTick bag scan), where the token resolver could have
raised.
Migrated: item/Count, item/AverageLevel, item/Swap, item/Location,
player/CanUseItem, spell/Cancel, equipmentset/Data, equipmentset/Locations,
merchant/Frame. debug/Probe keeps its own (deliberately standalone).
Backport the modern C_NewItems system - IsNewItem / RemoveNewItem /
ClearAll - flagging items freshly added to bags as "new", cleared once
acknowledged. Vanilla has none of the machinery (no DBC, no server data),
so it is pure client-side bookkeeping, done entirely in C++ like retail.
Newness is keyed on each item's instance GUID (read off the CGItem), so a
flag survives the item being moved between slots - impossible for a Lua
addon, which can only track by (bag, slot). Bags 0..4 only; equipment and
bank are out of scope, matching retail.
The feed is event-driven, not polled: it subscribes to Bag::UpdateDelayed
(which co-hooks the engine's three BAG_UPDATE fire sites), so a bag rescan
runs only on frames where a bag actually changed. A one-time baseline taken
~1.5s after login (re-armed on character switch) keeps items owned at login
from being flagged. BAG_NEW_ITEMS_UPDATED fires when the set changes.
Consolidations along the way:
- New Bag::UpdateDelayed::AutoSubscribe exposes the authoritative
"a bag changed this frame" signal as a C++ subscription.
- New Item::Location::ContainerInventory factors the CGContainer -> its
inventory (vtable +0x10) dispatch hand-rolled in item/Count,
item/AverageLevel, and equipmentset/Locations; all now share it.
- New Item::Location::EquippedBagInventory (equipped bag -> inventory) plus
the FUN_GET_EQUIPPED_BAG_GUID / OFF_CONTAINER_GET_INVENTORY offsets.
- NewItems resolves the player via the shared Unit::Identity::PlayerObject.
Raw CreatureType id (CreatureType.dbc row: 1=Beast … 11=Totem), the
counterpart to the family additions:
UnitCreatureTypeID(unit) -> id, nil if none
C_CreatureInfo.GetCreatureTypeInfo(id) -> { id, name }
C_CreatureInfo.GetCreatureTypeIDs() -> ascending id array (1..11)
UnitCreatureTypeID calls the engine's own type resolver at 0x00605570
(Script_UnitCreatureType's inner helper) directly — it handles all three
engine paths (display-override, creature-cache +0x18, player-race
ChrRaces+0x24), so it matches UnitCreatureType for every unit kind
(players resolve to Humanoid). The C_ lookups read CreatureType.dbc
(records 0x00C0DE2C, count 0x00C0DE30, name +0x04).
Also documented that 0x00605570 is really the creature-type resolver
(new canonical alias FUN_UNIT_CREATURE_TYPE); the existing
FUN_UNIT_PET_MINION_CLASS name is a historical label — Pet.cpp's
"!= 1" test works because it is effectively "is this a Beast (type 1)":
hunter pets are Beasts, warlock minions are Demons (3). Verified
in-game: target -> 7 Humanoid, IDs[8] -> 8 Critter, round-trips.
The by-raceID form of vanilla's UnitFactionGroup, returning
{ name (localized), groupTag (english "Alliance"/"Horde") } for a race
with no unit required. Mirrors the player branch of Script_UnitFactionGroup
(0x00516630) exactly: ChrRaces[race]+0x08 -> FactionTemplate id ->
FactionTemplate[id]+0x0C group mask -> scan FactionGroup.dbc for the row
whose bit the mask sets and whose localized name is non-empty (the
non-empty test is how the engine skips the always-set "Player" bit and
the nameless "Monster" row to land on Alliance/Horde). nil for
non-numeric / non-positive / unknown race or a race with no named group.
Every field offset ground-truthed against ChrRaces/FactionTemplate/
FactionGroup .dbc files. FactionGroup is stored contiguously (base +
i*0x30, direct field reads), so it's iterated by hand rather than via
DBC::Record. Its records-base/count globals (0x00C0DD5C / 0x00C0DD60)
are taken from the disassembly, which is authoritative over DBCs.md's
standard-shape tabulation for this table (0x00C0DD64 / 0x00C0DD68) --
verified in-game: race 1 -> Alliance, race 2 -> Horde.
Retail backports over CreatureFamily.dbc (records 0x00C0DE7C, count
0x00C0DE80 — the globals Script_UnitCreatureFamily already indexes), on
the existing C_CreatureInfo table:
GetCreatureFamilyInfo(id) -> { id, name, iconFile }
GetCreatureFamilyIDs() -> ascending array of populated family ids
Localized name at record +0x20 (via LocalizedField), icon at +0x44 (via
StringField). GetCreatureFamilyIDs skips the sparse unused rows
(10/13/14/22/...); each element round-trips with GetCreatureFamilyInfo.
Faithful deviation: retail's iconFile is a numeric fileID, but vanilla's
DBC stores the icon as a texture-path string, so iconFile is a string
(usable directly with SetTexture) and "" for families with no icon
(warlock pets). Verified in-game: 27 -> Wind Serpent + icon path, 15 ->
Felhunter + "", IDs array sparse, unused id 10 -> nil.
Extension exposing the numeric CreatureFamily id (CreatureFamily.dbc
row) for a unit, the value addons want for stable checks — stock
UnitCreatureFamily only returns the locale-dependent display name.
It's the same id the engine's Script_UnitCreatureFamily (0x0051A310)
resolves internally: its family helper FUN_006055e0 returns
[unit+0xB30] ? [row+0x1C] : 0, then maps it through CreatureFamily.dbc
for the name. We stop at the id. Resolves the token via the same
FUN_RESOLVE_UNIT_TOKEN, returns nil for family 0 (players, non-beast
NPCs) and unsynced/unresolvable units — matching UnitCreatureFamily.
Shares UnitSubName's +0xB30 creature-cache read path. Verified in-game:
target (cat) -> 2, targetpet -> 2.
MODIFIER_STATE_CHANGED never fired for a modifier released while WoW was
in the background: WM_KEY* messages are only delivered to the focused
window, so the WH_GETMESSAGE hook never saw the release and the cached
bit stayed stuck down (also leaving IsLeft/RightShiftKeyDown etc. wrong).
Add a WH_CALLWNDPROC thread hook to catch WM_ACTIVATEAPP (sent, not
posted, so invisible to WH_GETMESSAGE). On app activation, resync the
cached 6-bit mask from GetAsyncKeyState (global physical key state,
unaffected by focus), firing MODIFIER_STATE_CHANGED for any bit that
drifted while WoW was away. Both the keyboard path and the resync now
funnel through a shared SetBit helper so the "only transitions fire"
invariant holds regardless of source.
Caveat (documented): the engine's native IsShiftKeyDown/IsControlKeyDown/
IsAltKeyDown bottom out at synchronous GetKeyState and share the blind
spot; they are not reconciled here — only ClassicAPI's L/R functions and
the event reflect the corrected state.
Retail backports over ChrRaces.dbc / ChrClasses.dbc, on the existing
C_CreatureInfo table. By-id lookups (no unit token needed, unlike
UnitRace/UnitClass) returning the localized name plus the non-localized
file token that round-trips with the unit APIs:
GetRaceInfo(raceID) -> { raceName, clientFileString, raceID }
GetClassInfo(classID) -> { className, classFile, classID }
Reuses the already-verified DBC offsets (OFF_CHRRACES_* / OFF_CHRCLASSES_*),
StringField for the token and LocalizedField for the name. nil for
non-numeric / non-positive / unknown ids. Verified in-game: race 4 ->
Night Elf/NightElf, class 1 -> Warrior/WARRIOR, unknown id -> nil.
Taxi areaID resolution now tries the node's LOCATION part first
("Orgrimmar" in "Orgrimmar, Durotar") -- accepted only when it's a
mapped area whose WorldMapArea rect contains the node -- then the zone
suffix, then the geometric resolver. Capitals thus pin on their own city
map (Orgrimmar 1637, Undercity 1497) instead of the surrounding region,
while normal nodes (location is an unmapped subzone) still resolve to the
outdoor zone. Map::Area::PercentInZone is now containment-checked so a
candidate zone is taken only if it actually holds the point.
Add C_Map.GetMapAreaIDs() -> { [mapName] = areaID } from WorldMapArea:
the missing bridge (vanilla has no GetCurrentMapAreaID) for resolving a
browsed city/instance map -- which GetMapZones() doesn't enumerate -- to
its zone id. Keys on the WorldMapArea name (matches GetMapInfo()'s dir
for nearly all maps; a few misspelled Blizzard folders like "Ogrimmar"
differ, documented).
Verified in-game: Orgrimmar/Undercity flight masters pin on their city
maps; GetMapAreaIDs()["Orgrimmar"] = 1637.
Zone assignment (Map::Area::ZonePercent, shared by GetTaxiNodesForMap
and GetAreaTriggerInfo) chose among overlapping WorldMapArea bounding
boxes by smallest rect, mis-assigning border points. Now resolve by the
zone whose DRAWN LANDMASS the point sits on -- WorldMapOverlay hit rects,
the runtime analog of an ADT area-grid lookup -- tie-broken by how deep
inside an overlay the point is (a node deep in its own zone's overlay
beats one clipping a neighbor's edge). Falls back to most-interior
bounding box when on no overlay. Fixes Ratchet, Un'Goro, Nijel's Point.
For taxi specifically, resolve areaID from the node's own name suffix
("..., Western Plaguelands" -> AreaTable, exact then word-prefix) --
authoritative, and right at borders geometry can't separate (Chillwind
Camp -> WPL, not the Alterac box it sits in). New Map::Area::PercentInZone
projects the position into the named zone; geometric resolver remains the
fallback and the area-trigger path.
Add a `reachable` field: a flight path leads to the node (in-degree > 0).
false marks departure-only nodes reachable only by other means -- druid
Teleport (Nighthaven, Moonglade) or a boat dock (Stormwind Harbor) -- so
a consumer can hide them from a "where can I fly" map without a hardcoded
druid guess. BuildTaxiGraph tracks endpoint + destination sets in one pass.
Verified in-game: Nijel's->Desolace(405), Marshal's->Un'Goro(490),
Ratchet->Barrens(17), Chillwind->WPL(28).
Previously no-arg meant the currently-viewed continent; make it return
every flight master on every continent instead -- a one-call flight DB.
A numeric mapID still filters to that continent. Each entry already
carries its own mapID, and the mount + TaxiPath-endpoint filters apply
uniformly. Verified: no-arg 97, GetTaxiNodesForMap(0) 48.
GetTaxiNodesForMap(mapID) reads TaxiNodes.dbc: real flight masters on a
continent, faction-tagged, discovered or not -- the static DB pfQuest's
pfDB.meta.flight wants. Non-flight rows are filtered out: a node must
have a flight mount (horde @+0x38 / alliance @+0x3C -- drops transports/
boats/zeppelins) AND be a TaxiPath.dbc endpoint (drops Northshire Abbey,
spurious duplicate hub rows, standalone markers with no connecting
path), plus a guard for Blizzard's lone "Generic, World target"
placeholder. Returns retail nodeID/name/faction/position (continent
0-1) + ext mapID/x/y/z and derived areaID/mapX/mapY (zone-relative).
GetAllTaxiNodes([uiMapID]) wraps the open flight master's live node
list (VAR_TAXI_NODE_ARRAY stride 0x20: slot+0 = TaxiNodes record ptr,
+0x04/+0x08 = flight-map x/y; count VAR_TAXI_NODE_COUNT; type via
FUN_004dc4e0): slotIndex (TakeTaxiNode), name, nodeID, state
(Enum.FlightPathState). Empty when no taxi map is open.
Registers Enum.FlightPathFaction / Enum.FlightPathState (retail values).
Extracts the zone-percent projection into shared Map::Area::ZonePercent
+ adds ContinentPercent; refactors AreaTriggers onto ZonePercent.
Verified: EK 48 real masters (Northshire/EPL/transports gone), Stormwind
Alliance / Undercity Horde, continent position matches projection; live
24 nodes with Stormwind state=Current.
GetExploredMapTextures(areaID) backports the retail (9.0-era) call:
only overlays the local player has discovered. GetUnexploredMapTextures
is the ClassicAPI-extension complement (the reveal-over-fog pieces); its
inverse, C_Map.GetMapOverlays, still returns all overlays.
Refactor the overlay walk into a shared Map::Overlays::PushZoneOverlays
with a Filter {All, Explored, Unexplored}; GetMapOverlays is now the
All wrapper. "Explored" is computed per-areaID from the player's
explored-areas bitfield ([player+0xE68]+0xE6C, indexed by AreaTable
AreaBit +0x0C, gated by +0x28) -- mirrors the engine's discovered-
overlay rebuild FUN_004a67a0 -- so the areaID arg works for any zone,
not just the current view. Also add a fileDataIDs field (ordered tile
paths, retail's field name) to every overlay entry.
New offsets: OFF_AREATABLE_AREA_BIT, OFF_AREATABLE_EXPLORE_GATE,
OFF_PLAYER_EXPLORED_BITS. Verified: Elwynn explored(10) + unexplored(2)
== all(12).
Surface AreaTable.dbc's localized name column. GetAreaInfo(areaID) is
the retail backport -- localized name for one zone/subzone id, nil for
bad input / no row. GetAreas() is an extension: the full { [areaID] =
name } map (empty-name rows omitted, round-trips with GetAreaInfo), the
piece name databases need for name->id / reverse search. Same "surface
the hidden DBC" pattern as GetAreaTriggers / GetMapOverlays.
AreaTable.dbc is a client DBC (always loaded, synchronous, localized),
so no WDB cold-cache concerns. Uses existing VAR_AREATABLE_* /
OFF_AREATABLE_NAMES wiring; no new offsets. Verified: (12) Elwynn
Forest, (9) Northshire Valley, GetAreas()[12] Elwynn Forest.
GetSkillLineRank(skillLineID) returns the local player's curRank,
maxRank, modifier for a SkillLine.dbc id, or nil if unlearned -- keyed
by id directly instead of forcing addons to map id -> localized name and
string-match GetSkillLineInfo's by-name, by-position walk. Resolves the
id to the player's skill slot via FUN_SKILL_LINE_TO_SLOT, then reads the
CGPlayer +0xE68 skill table (the storage Player::CanUseItem's
require-skill gate already uses). GetSkillLineName(skillLineID) is the
id -> localized name half (pure SkillLine.dbc read, learned or not).
Both live in the existing Spell::SkillLine module; no new offsets.
Verified: Enchanting(333) 171/225/15, Tailoring(197) 261/300/0,
name(182)=Herbalism, unlearned line -> nil.
Each overlay now carries hitRectTop/Left/Bottom/Right (WorldMapOverlay.dbc
+0x34..+0x40) -- the tight clickable landmass bounds in world-map canvas
px (~1002x668), the subzone-center source map addons scrape into their
zone tables. Verified: areaID 12 overlay 8 (NorthshireValley, areaID 9)
-> hitRect 164/400/388/617.
Also stop dropping texture-less rows: a row with an areaID + hit rect but
no texture now appears with textureName == "", zero size, and an empty
tiles table (ipairs-safe), instead of being skipped. No-op on stock/Octo
data (every overlay has a texture) but matches the schema and the "every
overlay of a zone" contract.
Each overlay table now carries areaID (primary AreaTable id revealed,
first nonzero) and areaIDs (the full nonzero list -- WorldMapOverlay.dbc
areaID[4]; 52 of 707 overlays span 2-3 subzones). Lets consumers map an
overlay to its subzone(s) / exploration state. Verified: overlay 1 of
areaID 85 -> areaID 157.
Backport the retail C_Map.GetMapWorldSize (9.0): return a zone's world
size in yards as width, height, read from the WorldMapArea.dbc loc rect
(width = locLeft - locRight east-west, height = locTop - locBottom
north-south). Takes an AreaTable areaID, or defaults to the world map's
current view like GetMapOverlays. Verified: (85) -> 4518.75, 3012.5.
Extract the two WorldMapArea row resolvers (areaID -> row, current view
-> row) out of map/Overlays.cpp into a shared Map::Area helper; both
GetMapOverlays and GetMapWorldSize key off the same rows, so the two
ways of naming a zone now live in one place.
Expose AreaTrigger.dbc (517 static trigger volumes: subzone-entry /
exploration / teleport), which the client loads but never surfaces to
Lua -- the reason map addons ship hand-scraped trigger tables. Same
"read the hidden DBC" pattern as C_Map.GetMapOverlays.
Each trigger returns both the authoritative raw geometry (mapID, world
x/y/z, radius, isBox + box length/width/height/yaw) and a derived
consumable form (areaID + mapX/mapY as a 0..100 zone-relative percent)
via the WorldMapArea rect, picking the smallest zone whose rect
contains the point. GetAreaTriggerInfo(id) returns one trigger;
GetAreaTriggers([mapID]) enumerates all, optionally filtered to a map.
Adds the AreaTrigger.dbc record offsets and the WorldMapArea mapID /
loc-rect offsets to Offsets.h.
Add the API.md entry (input shapes, equipment-then-bags search, no-op
conditions, direct-cursor-primitive note) plus its TOC link, and the
README Item-row index entry.
Replace the auto-slot path's Script_PickupContainerItem stack-fake
(SetTop + PushNumber x2 + call) with the shared
Item::Cursor::PickupBagItem, which drives the engine's cursor primitive
directly -- the same one C_Item.PickupItem uses. The ClearCursor at the
top still runs first, so PickupBagItem's empty-cursor guard is satisfied;
bail if the pickup didn't take (locked item / busy cursor) rather than
auto-equipping nothing. Drops the now-unused ScriptFn_t typedef.
FUN_SCRIPT_PICKUP_CONTAINER_ITEM now has no callers -- the Script-pickup
stack-fake is gone from the codebase. Explicit-dstSlot path (atomic
FUN_INVENTORY_SWAP) is unchanged. Verified in-game (auto-slot equip by
name and by id).
C_Item.PickupItem(itemInfo) picks the matching item up onto the cursor.
itemInfo is an itemID / "item:NNN" / link / name (same input family as
UseItemByName / EquipItemByName): resolved via Item::Arg::Resolve and
found by Item::Location::FindByArg across equipment (1..19, checked first)
and bags (0..4) using the shared MatchesArg predicate (itemID, or the
decorated name for a name arg). An equipped match is lifted off the
paperdoll; a bag match off the container. Verified in-game (bag + equipped,
by id and by name).
Drives the engine's cursor-pickup primitives DIRECTLY rather than the Lua
Script_Pickup* state-machine wrappers (no faking the Lua stack), via a new
shared Item::Cursor:
- PickupEquipmentSlot -> FUN_004C7300 (self-contained; own guards)
- PickupBagItem -> FUN_00494B60, replicating the two guards the
Script wrapper applies (empty cursor + unlocked item) and the
post-pickup source lock. The empty-cursor check reuses a new shared
Cursor::Info::HasItem() (the same cursor-GUID globals GetCursorInfo
reads), rather than duplicating it.
Supporting consolidation in Item::Location:
- ResolveBagSlotDetail exposes the container object + linear slot the
cursor primitive needs; ResolveBagSlot now delegates to it.
- FindByArg (equipment-then-bags, MatchesArg) reuses FindByArgInBags for
the bag half; FindByItemID is now its itemID-only specialization.
C_EquipmentSet.UseEquipmentSet was considered but already uses the atomic
FUN_INVENTORY_SWAP (no cursor). EquipItemByName's auto-slot path still uses
the Script wrapper; migrating it to Item::Cursor::PickupBagItem is a
follow-up.
Returns true if an item is a consumable: item class Consumable (0), or
inventory type INVTYPE_AMMO (24) / INVTYPE_THROWN (25). Registered as both
the namespaced C_Item form and the bare global, like IsUsableItem.
Definition established by decoding 3.3.5's Script_IsConsumableItem
(0x005173E0) then verifying the surviving contract on a modern client:
3.3.5 walked the item's on-use spells and returned true for any with a
real effect (which would include on-use trinkets), but that heuristic did
NOT survive -- an on-use trinket with a numeric Use: effect (Talisman of
Ephemeral Power) returns false on modern. It's a plain class/ammo check.
Verified in-game on our client, matching modern exactly: potion / bandage
/ Faintly Glowing Skull (all class 0) and Rough Arrow (Ammo) -> true;
Hearthstone, Talisman, plain armor -> false.
itemID / "item:NNN" / link input (names -> false, no vanilla name->ID
map); uncached -> false with no async load, same contract as
C_Item.IsEquippableItem. Documented in API.md (+ TOC) and the README
Item-row index.
C_Item.IsItemInRange(item, targetUnit) resolves the item to its on-use
spell (Item::Spell::OnUseSpellIDForItemID) and runs the same geometric
range test as C_Spell.IsSpellInRange: true / false in-or-out of range,
nil when the check doesn't apply (no on-use spell, a rangeless one, or an
unresolvable item/unit). Matches retail, which also returns nil for items
with no range restriction. item accepts itemID / "item:NNN" / link (names
aren't resolvable in vanilla -> nil). Range-only, ignoring LoS/faction,
like IsSpellInRange.
The range predicate SpellIDHasRange was about to become its fourth copy,
so extract the shared core into Spell::Range (SpellIDHasRange +
PlayerVsUnit, returning 1/0/-1). C_Spell.IsSpellInRange and
C_Item.IsItemInRange are now thin Lua wrappers over PlayerVsUnit, and
C_Spell.SpellHasRange / SpellHasRange call the shared SpellIDHasRange --
range logic single-sourced.
Follow-up to the random-suffix work (aa0279a, e8a9003, 2a218a0). API.md
still described the old base-name behavior in five places; update them:
- GameTooltip:GetItem() example: name is now the decorated name that
agrees with the returned link ("Superior Cloak of the Eagle").
- C_Item.GetItemName: split the two forms -- location returns the
decorated name (live instance, via the engine name builder), ByID
returns the base name (an itemID has no instance/suffix).
- C_Item.IsEquippedItem / EquipItemByName / UseItemByName: name matching
is against the decorated name (a suffixed item matches its full name,
not the base), noting the one shared predicate behind all three.
Left the C_Item.GetItemData return-table `name` row as base m_name[0] on
purpose: that table is a static itemID-keyed record dump (same contract
as GetItemInfoInstant, which doesn't decorate either).
IsEquippedItem(name) and the bag find-by-name (FindByArgInBags, behind
UseItemByName/EquipItemByName/UseAtCursor) compared the caller's string
against the base ItemStats m_name[0], so a random-suffix item ("Foo of
the Owl") only matched when queried by its base name ("Foo"). The modern
client does the opposite -- verified in-game on both the modern client
and ours:
IsEquippedItem("Iridium Chain") -> false
IsEquippedItem("Iridium Chain of the Owl") -> true
IsEquippedItem("iridium chain of the owl") -> true (case-insensitive)
IsEquippedItem(12022) -> true (itemID)
Match the decorated display name instead, via the engine name builder
(Item::Link::NameFromCGItem), which falls back to the base name for
unsuffixed items so plain items still match.
The match predicate was duplicated inline in both call sites -- which is
why this took two edits. Consolidate it into a single
Item::Location::MatchesArg(cgItem, resolvedArg): itemID-compare when
arg.itemID > 0, else case-insensitive decorated-name compare. Both
IsEquippedItem and FindByArgInBags now delegate to it, so the full chain
is single-sourced (Item::Arg parses input -> MatchesArg decides the match
-> NameFromCGItem / ID::FromCGItem supply the comparands). Also drop a
now-unused <string.h> from Equipment.cpp and fix the stale m_name[0] doc
on FindByArgInBags.
GameTooltip:GetItem() resolved the CGItem and built the suffixed link via
Item::Link::FromCGItem, but returned the base ItemStats name beside it --
so name and link disagreed ("Iridium Chain" vs "[Iridium Chain of the
Owl]"). The old comment claimed the engine didn't expose the dressed name
as a separate string; it does, via the same builder the link uses.
Extract that builder into a shared Item::Link::NameFromCGItem helper
(next to FromCGItem, wrapping FUN_ITEM_BUILD_INSTANCE_NAME) and use it in
both GetItem and C_Item.GetItemName so the returned name matches the link
and modern behavior. Verified in-game: GetItem() now returns "Iridium
Chain of the Owl" alongside its matching link.
Left the base-name reads that are correct as-is: itemID-only paths (mail,
cursor-by-id, GetItemInfo, equipment-set missing items) have no instance,
and the by-name *matching* in IsEquippedItem / bag lookups compares
against a caller string (a separate semantics question).
The location form of C_Item.GetItemName points at a real item instance,
so its name should carry random-suffix decoration ("... of the Owl") the
way modern WoW does -- but we were reading m_name[0] off the itemID-keyed
ItemStats_C cache, which only holds the base name.
Resolve the location to its CGItem and build the name via the engine's
own per-instance name builder (FUN_005D8BC0): it reads the suffix ID off
the descriptor (+0x98) and formats base + ItemRandomProperties localized
suffix through the engine's ITEM_SUFFIX_TEMPLATE. This is the same
builder FUN_GAMETOOLTIP_BUILD_ITEM_LINK wraps in brackets, so the output
now matches GetItemLink's bracketed name and modern C_Item.GetItemName
exactly (verified in-game: "Iridium Chain of the Owl"). Falls back to the
base ItemStats name (and warms the cache) when the instance build yields
nothing.
GetItemNameByID is unchanged -- an itemID has no instance, so base name
only is correct.
FUN_TOKEN_TO_GUID (0x00515970) is a pure parser: it returns 0 for any
input it doesn't recognize and never raises -- verified from the
disassembly (plain stack frame, no assert/SEH prologue; every
unknown-input path is xor eax,eax; ret). Both call sites had mirrored
the engine's token grammar by hand to pre-screen input, on the mistaken
belief this function throws (it was conflated with its sibling
FUN_RESOLVE_UNIT_TOKEN 0x00515940, which does throw).
- item/Use.cpp UseItemByName: delete LooksLikeToken / MatchBase /
SkipTargetSuffixes / HasPrefix; just call FUN_TOKEN_TO_GUID and let it
return 0 for non-tokens.
- unit/Flags.cpp UnitIsInMyGuild: delete LooksLikeKnownToken / HasPrefix
/ AllDigits. Resolve the input to a GUID via FUN_TOKEN_TO_GUID, then
resolve GUID -> object via the non-throwing FUN_OBJECT_RESOLVE_BY_GUID
(unit|player mask) for the fast guild-key compare, and GUID -> name via
the NameCache for the roster fallback. This avoids the throwing
resolver entirely, and -- because the token->GUID step goes through
our TokenExtensions hook -- now supports focus / nameplateN, which the
old grammar mirror silently treated as literal names. Matches what
3.3.5's Script_UnitIsInMyGuild does (resolve to a GUID first).
No grammar to keep in sync with the engine anymore. Behavior otherwise
unchanged (self, guildmate/other-guild target, NPC, OOR raid member,
literal name, empty slot all verified case-by-case).
UseItemByName resolved the unit token (arg 2) AFTER FindByArgInBags,
which stomps the Lua stack (clears it and pushes bag-slot numbers for
PackBagSlot). So ResolveUnitGuid read a leftover slot number instead of
the token: IsString failed, the GUID came back 0, and the engine fell
back to its default target. That default is the player's current target
for a targeted-use item, so "target" appeared to work while "focus",
"party1", "mouseover", etc. silently did nothing useful.
Resolve the target GUID before the bag walk (as the sibling UseAtUnit
already does). ResolveUnitGuid only reads slot 2 and resolves a C
string -- no stack stomp -- and leaves arg.name (slot 1) intact for
FindByArgInBags; the GUID is a plain value by the time the stack is
blown away.
ClassicAPI extension (retail has no such global): true if the player
has clear LoS to unit, false if world geometry blocks it, nil when the
check can't apply (absent/unresolvable token, or non-unit object).
Traces a ray between the two units through the client's world-collision
core CWorld::Intersect (0x672170, verified in-binary: __fastcall(start,
end, unused, hitOut, tOut, flags), tail-calls the real intersect
FUN_0069BFF0). Flags 0x100111 = terrain + WMO (0x100171 also tests M2
doodads but crashes in some dungeons; matches vanilla server LoS which
ignores doodads). Endpoints are raised by each unit's collision-box
height (CMovement+0xB4) so foot-to-foot rays don't false-block on
terrain, with a two-pass level/look-up trace for height mismatches and
a 150yd guard against a long-segment crash.
Mirrors UnitXP_SP3's proven implementation (same VA, flags, guard, and
two-pass logic) but native, so it works with or without that addon --
we call the intersect rather than hooking it, and our flag isn't one
UnitXP_SP3's detour acts on. Gated on object type (unit/player) so it
never reads CMovement off a GameObject.
Adds FUN_CWORLD_INTERSECT, OFF_CMOVEMENT_COLLISION_BOX_HEIGHT,
OFF_CGOBJECT_TYPE_ID, OBJECT_TYPE_UNIT/PLAYER (reuses the existing
OFF_UNIT_MOVEMENT_INFO_PTR = 0x118), plus API.md and README entries.
Returns true/false for in/out of range, nil when the check does not
apply (rangeless spell, or unresolvable spell/unit). spellIdentifier
accepts a spellID, link, or spellbook name; targetUnit is a unit token.
Backed by the engine's own geometric range core FUN_006E47B0 -- the
same test the action-button range coloring uses (Script_IsActionInRange
to FUN_004E56F0 to FUN_006E4440 to FUN_006E47B0). It derives the
spell min/max range via FUN_006E3480 (which folds in the target's
bounding radius AND applies the range SpellMod op 5, so range-extending
talents/items like Hawk Eye and Nature's Reach count) then compares
center-to-center distance, so the boundary matches the client exactly,
melee and min-range spells included.
We call the pure core rather than FUN_006E4440: that wrapper also gates
on target type and folds range-not-applicable into the same truthy
return as in-range, so it cannot yield a clean true/false/nil. The core
is range-only, matching retail (ignores line of sight; does not reject
wrong-faction targets, documented). Positions are guarded via
Unit::Position::Read before the engine call, which dereferences the
GetPosition result without a null check.
Adds Offsets::FUN_SPELL_RANGE_CHECK (0x006E47B0) with derivation notes,
plus API.md and README entries.
table.wipe nil'd every key via lua_next + rawset(nil) but left the
Lua 5.0 length counter untouched. In 5.0 a table's table.insert/getn
length lives out of band from its keys (a t.n field, else a weak
sizes[t] entry in the registry's LUA_SIZES table), so it survived the
clear. After a wipe, getn stayed at its old value and subsequent
table.insert appended past the wiped slots -- t[1] stayed nil. The
3.3.5 (Lua 5.1) twipe this was ported from has no getn legacy, which
is why the ported logic looked complete.
Fix: call luaL_setn(L, 1, 0) after the clear loop. Bound the engine's
luaL_setn (FUN_006f4ea0, verified against 5.0.2; located via
table.insert FUN_007fb6b0) as Game::Lua::SetN.
Repro before: table.insert(t,1); table.insert(t,2); table.wipe(t);
table.insert(t,3) -> getn 3, t[1] nil, t[3]=3.
After: getn 2, t[1]=3, t[2]=4. Verified in-game.
Previously g_lastButton persisted as the last click forever. Modern
WoW returns the button only while a mouse handler is running and nil
otherwise (verified in 3.3.5: GetMouseButtonClicked pushes a
frame-manager field [mgr+0x1234] that is set before a mouse handler
dispatches and cleared after, so it is nil when null).
We cannot bracket the handler the same way: WoW dispatches the
frame-script mouse handlers deferred from the WH_GETMESSAGE pump, so
eviction is time-based via the existing per-frame WorldTick. The
button is still captured on each WM button message; EvictTick clears
it after a couple of quiet ticks with nothing held. A held button
keeps it (OnDragStart still reads it) and it survives the whole frame
its click lands in, so multiple addons hooking one OnClick all see it,
avoiding the multi-reader hole a consume-on-read latch would have.
Verified in-game: value matches the GLOBAL_MOUSE_DOWN button arg inside
a handler, and reads nil once clicks settle.
Returns the button name of the most recent mouse-button message
("LeftButton"/.../"Button5"), or nil before any click. Rides the
existing WH_GETMESSAGE stream in GlobalMouse that already backs
GLOBAL_MOUSE_DOWN/UP and IsMouseButtonDown, so no engine hook is
needed: g_lastButton is captured on every press/release, and the
Script fn pushes it (null tail-jumps to pushnil for the "no click
yet" case).
The hook runs at message-dequeue, synchronously before the engine
dispatches the click to the Lua handler, so the value reflects the
button of the handler currently running (and is readable from nested
helpers where arg1 isn't in scope). Not cleared between events -- no
"handler ended" signal in the 1.12 stream -- so outside a mouse
handler it returns the last-clicked button rather than nil; documented.
Move the persistent name cache from the per-account
WTF\Account\<acct>\<realm>\ClassicAPI_NameCache.txt to a shared
WTF\ClassicAPI\<realmList>\<realm>\ClassicAPI_NameCache.txt, so alts on
different accounts of the same server pool into one file instead of each
rebuilding their own.
Keyed by the realmlist address (unambiguous server identity — can't collide
across unrelated private servers, the same reason CredStore scopes on it)
plus the realm name. Since this path isn't under Account\, WoW doesn't
create it: EnsureParentDirs makes the tree before saving. On first load with
no shared file yet, a pre-existing per-account cache is migrated in (load
old file, mark dirty so the next save lands in the shared location); the old
file is left untouched.
The PersistentNameCacheEnabled / NameCacheScanEnabled toggles stay
per-account in WTF\Account\<acct>\ClassicAPI.txt — each account opts in
independently; only the cache contents are shared.
Backport retail's UnitDistanceSquared(unit) -> (distanceSquared,
checkedPosition): the squared world distance from the player to the unit,
plus a flag for whether both positions were readable. Squared because
almost all distance logic is a threshold compare or nearest-unit sort,
neither of which needs the sqrt — which is why retail never shipped a plain
UnitDistance.
Added to the existing Unit::Range module alongside UnitInRange (both now
share one PlayerToUnitDistSq compute off the CGObject::GetPosition vtable
virtual). Center-to-center, self-contained (reads the position in C — no
SuperWoW dependency, and leaves SuperWoW's own UnitPosition untouched).
Verified in-game to match SuperWoW UnitPosition math to float precision.
On a position miss returns (0, false); no self-quirk, so
UnitDistanceSquared("player") is a legitimate (0, true).
Expose the target-level gate for ranked beneficial auras — e.g. Divine
Spirit Rank 1 (14752) can only be applied to a target level >= 20.
- C_Spell.GetSpellLevelInfo(spellID) -> spellLevel, baseLevel, maxLevel
(raw Spell.dbc level fields; nil for an unknown spell).
- GetSpellRequiredTargetLevel(spellID) (global, plus C_Spell mirror) ->
the minimum castable target level (0 = no restriction; nil if unknown).
The computed helper mirrors the server's SpellMgr::SelectAuraRankForLevel:
returns spellLevel - 10 only when the spell is a ranked positive aura
(spellLevel > 10, not passive, has a Rank string, and has an APPLY_AURA
effect on a friendly/party/raid target or an APPLY_AREA_AURA_PARTY effect),
else 0. All fields read directly from the client's Spell.dbc.
The explicit MinTargetLevel / MaxTargetLevel fields the server also checks
are server-only columns (absent from the 1.12 client DBC, whose tail is
SpellFamilyName / SpellFamilyFlags / MaxAffectedTargets), so the spellLevel
rule is the only client-readable target-level mechanism.
C_UnitAuras' descriptor-drop fallback could surface a "phantom" aura that
was actually gone — enumerated with a spellId but SetUnitAura rendering an
empty tooltip. Two cases, both fixed:
- Owner cancels a buff (e.g. a flask) while dead: the server keeps
death-persistent auras through death without changing any field, so death
itself fires no OnAuraRemoved. Any OnAuraRemoved we do receive for one is
genuine (cancel/dispel/expiry), so always evict the cache entry — including
death-persistent auras on dead units. Fixes the phantom for the caster and
nearby observers (both receive the removal packet).
- A buff cancelled while its owner is out of an observer's range sends no
packet, so the stale entry lingers. Reconcile on return: when a unit's
descriptor holds >=1 raw aura it's in view and fully synced, so it's
authoritative — evict any cached entry whose spell isn't in the array
(Aura::Data::ReconcileCache -> Aura::Source::EvictAbsent, run at the top of
the fallback path). Reads raw slots so a flag-hidden aura still counts as
present; skips an empty array (can't tell out-of-range from buffless).
The only residual is a buff cancelled while the owner is still out of range,
which vanilla can't report until they return.
Block/dodge/parry chance and crit/hit are native vanilla percentages, so
their compare-tooltip delta read as a bare number ("-5 Block"). Attach the
percent via the locale-aware PERCENTAGE_STRING global so it renders
"-5% Block" without touching any _SHORT label (correct in every locale).
IsPercentStat marks the percent keys; FormatPercent substitutes the value
into PERCENTAGE_STRING's first conversion slot by hand (never feeds the
locale format to snprintf) with a plain "%" fallback. Flat stats (Defense,
Attack Power, Block Value) stay bare; dropped the now-redundant trailing
" %" from the English fallback labels.
Player-minion classification for a unit, all owner-based:
- UnitIsMinion(unit): owned by a player (pet/guardian/totem). Primary
check is the owner GUID, since summoned guardians ("X's Minion") carry
an owner but not UNIT_FLAG_PLAYER_CONTROLLED; the flag is a fallback.
- UnitIsPet(unit): a player's controllable pet specifically -- owned by a
player AND the engine's creature-family pet/minion classifier
FUN_00605570 == 1, the same signal driving the UNITNAME_TITLE_PET vs
_MINION tooltip label. The GUID can't be used (pets and guardians share
the 0xF14 prefix) nor can PLAYER_CONTROLLED (a Minion can have it).
ClassicAPI extension.
- UnitIsOtherPlayersPet(unit): owner is a player other than you; matches
5.4.8's owner-based Script_UnitIsOtherPlayersPet.
Owner GUID read from CHARMEDBY (+0x10, verified via Script_UnitIsCharmed)
and CREATEDBY (+0x20, verified via the unit title builder FUN_0052FD30).
Verified in-game: Salvatory's Pet is a pet; Jimmyf's/Methl's Minion is a
minion but not a pet.
The proficiency/level/class/skill/spell/rep gate that answers "can this
player equip or use this item" -- the tooltip's red-requirement logic,
distinct from IsEquippableItem (fits a slot) and IsUsableItem (on-use
castable). Mirrors the requirement checks FUN_0052B650 inlines:
1. proficiency - VAR_PROFICIENCY_TABLE[m_class] & (1<<m_subClass)
2. RequiredLevel
3. AllowableClass / AllowableRace
4. RequiredSkill - effective rank via FUN_SKILL_LINE_TO_SLOT
5. RequiredSpell - spell-knowledge bitmap (crafting specializations
like Armorsmith, proficiency spells)
6. RequiredHonorRank / RequiredCityRank - PvP honor rank + medal mask
7. RequiredReputation - current standing vs the reaction-min band
Verified in-game: mail boots false (proficiency), Riding mount false
without skill 150, Plans: Lionheart Helm false until Armorsmith learned.
Residual gap: a ranked RequiredSpell known only at a different rank.
Adds OFF_CGPLAYER_HONOR_RANK / OFF_CGPLAYER_PVP_MEDALS.
Compare-tooltip stat deltas now resolve their display label through
_G[key] (the WoW GlobalString), falling back to the built-in English
only when undefined. LabelFor tries _G[key], then _G[key.."_SHORT"],
then the English fallback: primary stats / resistances / CURRENTLY_EQUIPPED
resolve directly, while the rating/regen tokens GetItemStats keys on
(ITEM_MOD_CRIT_MELEE_RATING, ...) — whose own global is a "%s"
description — map to their compact, translatable _SHORT sibling for
display. StatAccum keys are left untouched (the GetItemStats contract,
confirmed against a TBC dump: ratings key on the non-_SHORT token).
The format guard now rejects only real printf conversions (%d/%s/%1$s),
letting a literal "%" unit through.
enUS.lua gains the ITEM_MOD_*_SHORT, ITEM_DELTA_DESCRIPTION and format
keys (native RESISTANCE*/CURRENTLY_EQUIPPED left to the client). The
non-US locales (deDE/esES/frFR/ruRU/zhCN/zhTW) are auto-filled from
Blizzard Classic (1.15) per-locale GlobalStrings; only the custom
BINDING_NAME_AOELOOT stays commented as an enUS fallback.
Fixes#8
Item analog of IsUsableSpell, returning (usable, noMana) for an item's
on-use ability -- not equippability. Gates on the item having an ON_USE
spell, its use requirements (RequiredLevel, AllowableClass/Race), then
the on-use spell's castability via the engine helper FUN_SPELL_IS_USABLE
(0x006E3D60), which sets noMana on power shortfall.
Shares the on-use spell-slot walk with C_Item.GetItemSpell: promoted
Item::Spell::FindOnUseSpellIDInRecord to the header so both use it
instead of re-deriving the trigger scan.
Bring C_LootHistory closer to the MoP contract and make a UI keying on it
robust across the ring saturating.
- GetItem now returns rollID, itemLink, numPlayers, isDone, winnerIdx,
timestamp (was itemLink-first, with the winner as a name string). rollID
is a stable monotonic ID assigned at creation, so addons can key expanded
/ highlighted rows on it instead of the volatile ring index. winnerIdx is
the winner 1-based player index for GetPlayerInfo, matching MoP; the winner
is inserted into the roster if their own roll packet never arrived, so it
always resolves. timestamp is a ClassicAPI extension (GetTime()-comparable
seconds).
- LOOT_HISTORY_FULL_UPDATE is now actually dispatched (was reserved-only):
on a new item appearing (roll open) and on ring eviction, fired before the
granular ROLL_CHANGED / ROLL_COMPLETE.
- Add C_LootHistory.Clear() (ClassicAPI extension) to wipe history and fire
FULL_UPDATE.
- Factor roll/winner roster insertion into a shared FindOrAddPlayer helper.
Docs updated for the new signature, Clear, and the FULL_UPDATE semantics.
Our DLL adds the modern HookScript widget method; older pfUI forks branch
on `if button.HookScript` and take a modern path they weren't written for
on pfActionBar buttons, breaking them. While the actionbar module builds,
wrap CreateFrame to shadow HookScript with a falsy instance field (nil
doesn't work — it's a metatable method, so the lookup falls through) so
those forks use their vanilla SetScript path. The maintained "brues" fork
is detected via the TOC X-Website tag and left alone; the wrapper is
removed once the actionbar module finishes loading.
Make profession link button position responsive to title text changes and justification. The button now repositions whenever the title's text or visibility changes, using the title's string width and justification mode to calculate the correct anchor point. Also add support for AdvancedTradeSkillWindow and AdvancedTradeSkillWindow2 addons with appropriate link callbacks.
Closes#7
The ClassTrainer detail template's decorative track pieces
($parentTop/$parentBottom) stayed visible when the slider was hidden,
leaving an empty scrollbar groove on recipes that fit. Toggle them with
the slider in UpdateDetailScrollbar (no-ops on Turtle's plain template).
Extend each reagent button's hit rect rightward across the name plate
(capped short of the next column so hovers don't bleed across), so the
item tooltip shows anywhere on the element instead of only over the icon.
Rework the trade-skill link viewer's detail pane to match the native
1.12 TradeSkillFrame:
- Detail is now a real ScrollFrame (ClassTrainer templates on stock,
plain on Turtle) whose scroll child holds the product header + reagent
grid, so the whole pane pixel-scrolls and clips partial reagent rows
instead of the old fixed 2-row faux-scroll that clipped many-reagent
recipes. Slider is managed explicitly (shown only when content
overflows; re-asserted a frame after show so the engine's layout pass
can't leave a dead scrollbar on Turtle's roomy pane).
- Reagent buttons built on demand (any count) inside the scroll child.
- Add the recipe/detail separator (stock only) using the top-quarter rod
of UI-ClassTrainer-HorizontalBar, stretched to width.
- Stock list/detail scrolls use the ClassTrainer scroll templates for the
decorative UI-ClassTrainer-ScrollBar track; Turtle keeps plain ones.
FUN_ADDON_GET_BY_INDEX returns a name pointer, not the AddOnEntry
record, so reading the OptionalDeps offsets (+0x3C/+0x40) directly off
it landed in addon-name string memory and dereferenced garbage as an
array base -> ACCESS_VIOLATION (pfUI's addon list iterates by index and
hit it immediately). Route both numeric and string args through the
by-name hash resolver FUN_ADDON_RESOLVE_REQ_DEPS to reach the real
record, exactly as stock Script_GetAddOnDependencies does for both.
Backport the optional-deps getter vanilla never exposed (it ships
GetAddOnDependencies for the required array only). Resolves the arg
the same way stock Script_GetAddOnDependencies does -- numeric via
FUN_ADDON_GET_BY_INDEX, string via the by-name hash lookup 0x0051E350
(returns record+0x48; subtract to recover the base) -- then walks the
OptionalDeps descriptor at record+0x38 (count +0x3C, data +0x40),
verified against the TOC parser FUN_0051C9B0. Arg/error behavior
matches stock: out-of-range index or a non-string/number arg errors;
an unknown name returns nothing.
Backport of the MoP C_LootHistory namespace. Vanilla 1.12 has no
loot-history store, but it receives the live group-loot roll traffic,
which the stock UI only turns into chat. This reconstructs the history
client-side (like Aura::Source): four co-hooks on the loot-roll packet
handlers accumulate a ring (128) of rolled items with per-player
Need/Greed/Pass results and the winner, exposed through the namespace.
Handlers, from the opcode dispatch FUN_005E6010:
- START_ROLL 0x2A1 FUN_0061B310 (__fastcall(packet), RET 0)
- ALL_PASSED 0x29E FUN_0061B640 (msg=EDX, RET 0)
- ROLL_WON 0x29F FUN_0061B9E0 (ctx=ECX, msg=EDX, +stack, RET 4)
- ROLL 0x2A2 FUN_0061C0B0 (msg=ECX, ctx=EDX, +stack, RET 4)
The per-handler ABI (message register, stack arg, RET) is load-bearing:
matching it exactly is what keeps the co-hooks from corrupting ESP.
Names and class come from the engine NameCache (FUN_0055F080, null-
callback read of a loaded entry), captured at roll time — so they
resolve even for a roller who has left the group by the time their roll
arrives. The item's random suffix is only in SMSG_LOOT_START_ROLL (the
result packets send 0), so it's read off the store-head node right after
that handler runs, giving a correct item:id:0:suffix:unique link.
Surface: C_LootHistory.GetNumItems / GetItem / GetPlayerInfo (name,
class token, rollType, roll, isWinner, isMe) + LOOT_HISTORY_ROLL_CHANGED
/ _ROLL_COMPLETE events (_FULL_UPDATE reserved). The MoP master-loot
functions (SetExpiration / GiveMasterLoot / CanMasterLoot) are omitted —
they need server support 1.12 lacks. Documented in docs/API.md + README.
Leaving a battleground (incl. the /afk teleport out while dead) bulk-
clears the player's aura descriptor WITHOUT firing per-aura
OnAuraRemoved, so Aura::Source entries for auras that are now gone
lingered and Aura::Data's descriptor-empty fallback kept surfacing them.
The tell from the report: a real buff (filling a descriptor slot)
temporarily masks the phantoms, removing it brings them back.
Can't evict on descriptor-empty — that's the exact state the fallback
must survive (rogue stealth, party-range drops). The clean distinguisher
is a map transition: a BG/instance/continent change moves the Map.dbc
ID, while stealth/range don't. OnWorldTick now polls VAR_CURRENT_MAP_ID
and flushes the whole cache on any change (no new hook). Every other
unit from the old map has despawned, and the player's real auras re-sync
from the descriptor / PLAYER_BUFF tables on entering the new world, so a
full flush loses nothing legitimate.
Reported via pfUI issue #20.
Vanilla has RegisterEvent / UnregisterEvent as frame methods but not the
query IsEventRegistered (added in a later expansion). There's no engine
Script_* to delegate to, so we replicate the membership check already
embedded in Frame::RegisterEvent (FUN_00702140): find the event by name
in the live event table (SStrCmpI), then walk that entry's subscriber
chain (head@+0xC, next@+4, frame@+8, low-bit/null sentinel) for this
frame. self is resolved via Game::Lua::ResolveObject, yielding the same
CFrameScriptObject* the chain stores.
Registered on the Frame method registry (VAR_FRAME_METHOD_REGISTRY,
alongside RegisterEvent) so it resolves on any frame. Pure read, no new
hooks. Returns a boolean, matching the 3.3.5-era contract; verified
in-game (true for a registered event, false otherwise). Documented in
docs/API.md and README.md.
The link viewer mirrored Turtle's HD 768x512 two-column TradeSkillFrame,
whose TW-TradeSkill art doesn't exist in the base 1.12.1 client — an
earlier stretch fallback smeared the vanilla art's internal borders.
Split every layout value that differs by client into an LO table gated on
TURTLE_WOW_VERSION. On stock clients the viewer now mirrors the native
Blizzard_TradeSkillUI geometry: 384x512, single column (list on top,
detail below), built from the vanilla ClassTrainer + UI-TradeSkill
quadrant art at native size, 8 visible rows, and the Exit button placed
exactly where the base TradeSkillCancelButton sits. Turtle keeps its
768-wide two-column layout unchanged.
Backport of the WotLK "link this profession to chat" button. A
petition-icon button sits right of the window title; clicking inserts
the window's |Htrade:...|h link (C_TradeSkillUI.GetTradeSkillListLink /
GetCraftListLink) into the chat edit box, opening it if hidden. Other
players click the link to open the recipe viewer.
Both windows are LoadOnDemand addons on this client, so creation is
deferred via EventUtil.ContinueOnAddOnLoaded (fires immediately if
already loaded). Uses the native GossipFrame\PetitionGossipIcon since
the 3.3.5 UI-TradeSkill-LinkButton art doesn't exist in 1.12. Adds the
LINK_TRADESKILL_TOOLTIP locale string for the hover text.
C_Spell.UnitCastingInfo/UnitChannelInfo returned negative startTimeMs
and endTimeMs for users whose engine ms tick has crossed 2^31. The tick
(FUN_OS_TICKCOUNT_MS) has two backends: GetTickCount, or a scaled rdtsc.
The rdtsc value survives warm reboots (only a full power-off resets the
CPU TSC), so it can sit well past 2^31 even right after a "restart" --
which is why rebooting didn't clear the negatives. We stored the tick in
a signed int and pushed it straight to a double, so the high bit read as
the sign.
Script_GetTime (0x00515EA0) computes (double)(tick & 0xFFFFFFFF) * 0.001,
i.e. it masks the tick to the low 32 bits UNSIGNED. New PushMs helper
reinterprets our stored tick as uint32 before the double conversion,
reproducing GetTime()'s exact value so startTimeMs/endTimeMs stay
consistent with GetTime()*1000 across the whole range (both wrap together
at the ~49.7-day 2^32 boundary). Carrying full 64-bit precision would be
wrong -- it would diverge from GetTime() past 2^32 and break addons'
(now-start)/(end-start) math.
Routes all four timestamp pushes (cast + channel, local and remote)
through PushMs. The aura expirationTime path already handled the tick as
unsigned, so no change there.
The old IsStealthed read descriptor +0x17C & 0x02, which is actually
UNIT_FIELD_AURALEVELS (per-slot caster-level bytes) - it only worked by
coincidence on the original test char and read false at level 60
(0x3C & 0x02 == 0), so Prowl/Stealth reported not-stealthed.
_classicapi_DiffAll on a Prowl toggle from a Cat-Form baseline showed
the real signal: UNIT_FIELD_BYTES_1 (+0x210) byte 3 flips 0x00->0x02
(the sneak/anim-tier flag the client render path reads to draw a
stealthed unit semi-transparent); everything else that changed was
aura-array slot metadata. IsStealthed now reads that broadcast bit
directly - verified in-game for Rogue Stealth, Druid Prowl, and
Shadowmeld, with no mount false-positive.
Also refactored the shared buff-slot scan into FindPlayerBuffWithEffect
(Dismount still uses it for the mount aura) and dropped the bogus
OFF_PLAYER_FIELD_VIS_BYTES / PLAYER_VIS_BIT_STEALTH constants.
A custom patch-*.mpq that upscales the WorldMap overlay art broke the tile
resolution: the resolver read the actual BLP dimensions but did its grid and
texcoord math against a hardcoded 256px cell, so HD tiles rendered sheared
and half-clipped.
- Grid resolution detects the full-cell file size S (256*scale) and uses it
as the partial-tile threshold instead of a literal 256, so HD edge tiles
aren't mistaken for full ones.
- Texcoords now use content/nextPOT(content) — the engine's own WorldMapFrame
fraction, which is scale-invariant (an HD file is POT*scale, same fraction)
and needs no file-size guessing. Fixes overlays whose stock file was a 128
cell (content <=128): the patch doubled them to 256, which the earlier
S>=256 floor read as un-upscaled and halved their texcoord (the clipped
dark patches).
Draw sizes/positions stay in logical map pixels; stock data is unchanged
(its files already are next-POT). File reads now only drive grid resolution
and the rare DBC-rounded sliver case.
Each recipe row appends " [N]" (min over reagents of floor(onHand/required),
via GetItemCount) like Blizzard's list. The frame registers
BAG_UPDATE_DELAYED and, while shown, re-runs Refresh (row counts) and
re-renders the selected recipe's detail pane (reagent have/need) so the
numbers track the reader's bags live.
- SetHitRectInsets(2,82,2,73) so the mouse/drag region matches the visible
parchment (decoded from the corner BLPs' alpha: opaque art spans frame
x[2..686], y[2..439]) instead of the full 768x512 texture rect.
- Bottom quadrants use the CraftFrame's TW-TradeSkill-BotLeft2/BotRight2
(the flat button-bar bottom) and add an Exit button positioned like
CraftCancelButton.
- Quad helper uses the bare SetPoint(point) one-arg form (enabled by the
Script_SetPoint co-hook in src/frame/Modern.cpp).
The detail pane and subclass dropdown now resolve crafted items/reagents
through Item:CreateFromItemID():ContinueOnItemLoad() — each uncached item
re-renders only its own pane/dropdown when its data arrives, guarded so a
load completing after a newer selection can't clobber it. Replaces the
global GET_ITEM_INFO_RECEIVED watcher that re-derived everything on a 0.3s
debounce whenever any item loaded anywhere.
The 'All' subclass view no longer reapplies the filter (and resets scroll)
as items trickle in; have/need counts still use GetItemCount and the
subclass name still uses the synchronous GetItemInfoInstant.
Clicking a recipe row now shows a Blizzard-styled detail pane: the crafted
item (or enchant) with its icon on the Class-Trainer detail-header bar, the
spell description for enchants, and the reagent grid (icon + name plate +
have/need counts) matching the CraftFrame layout. The first recipe is
auto-selected on open; selecting a row tints the row highlight to the
recipe's difficulty colour and whitens its text.
Rows are now real ClassTrainerSkillButtonTemplate buttons (the template the
CraftFrame/TradeSkillFrame use) so name rendering and spacing match exactly.
Reagent + product data (createdItem, numMade, reagents) is folded into the
existing C_TradeSkillUI.GetTradeSkillListRecipes decoder rather than adding a
new function.
The real GetTradeSkillReagentInfo family is indexed by the player's own open
window, so it can't describe a linked recipe from someone else's profession;
the reagents are read straight from Spell.dbc instead.
Stuck auras (pfUI issue #20 part 2): a no-duration debuff (Ghost/Spirit
in battlegrounds) stayed visible after the unit resurrected. It can only
come from the Aura::Source cache fallback — the descriptor path re-reads
the live aura array each call.
The aura is cached with expirationMs == 0 (infinite), so OnWorldTick
never expires it, and its real removal came via a bulk object drop
(death/leave-visibility, then resurrect) rather than a per-aura removal,
so OnAuraRemoved never fired to evict it. The infinite entry lingered and
the fallback resurrected it on the now-alive unit.
The fallback only exists for rogue stealth and party range fluctuation,
which both clear the unit's entire aura array at once. Gate both fallback
entry points on DescriptorHasVisibleAura: if the descriptor exposes any
live aura it's authoritative, so a cache-only aura is genuinely gone.
Stealth/range recovery is unaffected — those produce a wholesale-empty
aura array, so the fallback still fires there exactly as before.
Add visibility check to `FallbackEligible` to prevent hidden/internal auras (e.g. SPELL_ATTR_HIDDEN_CLIENTSIDE) from leaking through as phantom buffs. The descriptor path already drops these via `IsSlotPopulated`; the cache fallback must apply the same engine predicate to avoid surfacing invisible auras.
Synthesize the modern C_LossOfControl surface for the local player, which
vanilla lacks entirely (a Mists-era aggregation layer):
- SCHOOL_INTERRUPT: co-hook the SMSG_SPELL_COOLDOWN handler (FUN_006e9460,
same __stdcall(opCode*, CDataStore*) shape/registrar as Spell::Cast's
failure/channel hooks). Turtle's Player::ProhibitSpellSchool sends the
lockout as ONE cooldown packet listing every spell of the interrupted
school at one uniform duration -- a shape nothing else emits (verified
against the server source) -- so a player-GUID batch of >=2 same-school
spells with equal duration authoritatively yields the locked school
(1<<Spell.dbc School) + duration. The interrupter isn't in the packet, so
spellID=0 / displayText="Interrupted".
- CC types (STUN/FEAR/ROOT/CONFUSE/CHARM/POSSESS/SILENCE/PACIFY/
PACIFYSILENCE/DISARM): scan the player's debuffs, classify each by its
EffectApplyAuraName (values authoritative from the server's SPELL_AURA
table). Timing is best-effort from the Aura::Source cache (nil when the
applying cast wasn't observed -- the fields are nullable).
- LOSS_OF_CONTROL_ADDED / LOSS_OF_CONTROL_UPDATE: a WorldTick poll-and-diff
of the active set fires ADDED (with the 1-based eventIndex) per new effect
and one coalesced UPDATE on any change. Chosen over event-driven aura hooks
because effect EXPIRY has no engine packet (a tick is needed regardless),
and the WorldTick tail keeps Lua handlers out of the mid-packet contexts
the state updates in. Scan is bounded (16 debuff slots) and idle-cheap.
Placeholders with no vanilla equivalent: priority=0, displayType=2,
auraInstanceID=nil; iconTexture is the icon path (no FileDataIDs in vanilla).
Verified in-game: Polymorph -> CONFUSE, correct name/icon/duration, nil once
worn off; a counterspelled heal -> SCHOOL_INTERRUPT (lockoutSchool + 10s) +
SILENCE (4s), with two ADDED at cast and UPDATE as each expires.
Both resolve by name through the engine's own template lookup
(FUN_006ee6f0) -- the same path inherits= uses -- so they're
case-insensitive and cover Blizzard FrameXML plus addon templates.
GetTemplateInfo returns { type, width, height, keyValues, inherits } or
nil if the template doesn't exist. type is the element tag; width/height
are the RAW values from a <Size> element declared directly on the
template (0 if none / inherited) -- read straight from the attributes
rather than via the engine's Size parser (FUN_006f1eb0), which applies
UI-scale conversion we don't want here. inherits is the inherits=
attribute or nil. keyValues is always empty (vanilla's XML schema has no
<KeyValues> element) and sourceLocation is omitted (a 10.2 feature;
vanilla records no per-node file/line) -- both parity-safe.
DoesTemplateExist returns a bool.
Verified in-game: DoesTemplateExist true/false as expected; GetTemplate-
Info nil for a missing name; StatFrameTemplate -> 104x13; FriendsFrame-
TabTemplate -> inherits "CharacterFrameTabButtonTemplate".
Backport the modern C_XMLUtil.GetTemplates() -- enumerate every
registered virtual XML template as { name, type }.
The engine keeps virtual templates (the <Frame virtual="true"> etc. that
inherits= targets) in a Storm hash table. The XML parser (FUN_006ede10)
registers each via FUN_006ee500 and the inherits resolver reads the same
table via FUN_006ee6f0, so it's the authoritative template store. The
new module walks it: each node carries the template name (+0x14) and its
parsed definition node (+0x18), whose element tag string (+0x8) is the
frame type ("Frame" / "Button" / ...).
Covers both Blizzard FrameXML and addon templates; rebuilt on /reload.
Fonts are a separate registry and correctly excluded (matching retail).
Returns an empty table before the first template registers (mask ==
0xFFFFFFFF). Pure read, no hooks. Verified in-game: 212 templates,
entry 1 = { name="AtlasLootDefaultFrameButtonTemplate", type="Button" }.
UnitChannelInfo showed a stale "Ritual of Summoning" countdown after
helping a warlock summon: g_channel only cleared at its computed endMs
or when a timed cast superseded it, so a ritual that ends early (when the
summon fills) lingered, and a following instant cast -- which supersedes
neither cast timing nor the channel -- couldn't dislodge it.
OnWorldTick now polls the player's UNIT_FIELD_CHANNEL_SPELL (+0x228) and
clears g_channel when it drops to 0, gated by a confirm latch so the
field's ~1-tick start lag can't wipe a freshly stamped channel. Both
channel-stamp sites route through a new StampChannel helper that re-arms
the latch. Also fixes early-ended channels generally (interrupt / cancel
/ LoS break), which previously self-expired only at endMs.
Cleanup: factor the player-object/descriptor read into shared
Unit::Identity::PlayerObject() / PlayerDescriptor(), now used by
State.cpp, Cast.cpp's poll, and ComboDuration. PlayerObject() uses the
object-manager GUID resolve (FUN_OBJECT_RESOLVE_BY_GUID) -- the fastest
player-object path, non-throwing (null pre-world) -- replacing the
duplicated resolves (State.cpp's token resolver, ComboDuration's
hand-rolled GUID plumbing).
Every overlay of a zone regardless of exploration, replacing the
hand-measured tables map-reveal addons ship (pfUI pfMapOverlayData).
The engine's GetNumMapOverlays/GetMapOverlayInfo only expose
DISCOVERED overlays -- the exploration gate is a server-fed ID list at
0x00B6E630 -- so unexplored areas were previously undrawable without
per-server data. Reads WorldMapOverlay.dbc directly instead, scoped by
the zone's WorldMapArea row (never by folder contents).
No argument = the world map's currently viewed zone (same
continent/zone resolution GetMapInfo performs, globals 0x0084506C /
0x00845070 / 0x00B6E668); numeric argument = AreaTable areaID, the
same identity C_Map.GetBestMapForUnit returns.
Tile grids are resolved in the DLL rather than left to consumers,
because Octo's shipped data breaks the canonical ceil(w/256) layout
three ways (mirroring the InkLab map generator's empirically-derived
disambiguation):
- sliver columns the DBC rect rounds away (Icepoint's Kaneq'nuun has a
real 8px third column; DBC w=512 implies 2 -- naive layout shears
the overlay into misplaced strips, the one visibly broken overlay);
- foreign tiles appended to an overlay's number sequence (Kaneqnuun
ships 12 tiles, the true grid is 3x2 = 6);
- same-rect higher-resolution re-exports (Stonetalon's WindshearCrag:
4 real tiles for a 1-tile rect, kept and scaled).
The true grid comes from the actual BLP header dimensions (width @
+0x0C, height @ +0x10, BLP1 and BLP2 alike) read through the engine's
own VFS reader, since only a row's last tile may be narrower than 256.
A sweep of all 707 overlays found 108 art/DBC mismatches; Kaneq'nuun
is the only shear-class one, matching the single visible artifact.
Resolution is cached per overlay per session.
Each overlay returns the DBC fields plus a ready-to-draw `tiles`
array (file / width / height / texCoordX / texCoordY / offsetX /
offsetY) so consumers need no grid math at all. WorldMapArea.dbc and
WorldMapOverlay.dbc schemas verified against fresh extractions and
the engine's own Script_GetMapOverlayInfo (0x004A8A00).
ClassicAPI advertises WOW_PROJECT_ID = WOW_PROJECT_CLASSIC, which
steers modern addon ports onto their Classic Era code paths -- where
they also assume Era's C-level frame behaviors. Supply the missing
ones (surfaced by AI_VoiceOver's Era path, but generic to every port):
- region:SetPoint("RIGHT") -- the one-arg form. Vanilla's parser
accepts every other modern shape but raises the usage error when
arg 3 is fully omitted (LUA_TNONE fails its type validation); a
co-hook on Script_SetPoint (0x007A2540, the ONE implementation in
the Region base table) normalizes it to (point, 0, 0) -- vanilla's
parent-relative form, exactly the modern semantic.
- region:SetSize / region:GetSize -- registered on the REGION base
method registry (ctx 0x00CF54B4), so they resolve on frames,
buttons, textures, fontstrings -- including engine-created textures
(GetNormalTexture():SetSize(...)).
- frame:SetShown -- Show/Hide are per-branch script functions with
their own object typechecks, so it's registered separately on
Frame / Texture / FontString with matching delegates.
- frame:SetResizeBounds -- modern rename of SetMinResize/SetMaxResize.
- frame:HookScript -- absent in vanilla entirely. Chains the old
handler (invoked vanilla-style, reading the this/event/argN
globals), then invokes the hook handler with modern positional args
((frame, event, arg1..arg9) for OnEvent, (frame, arg1..arg9)
otherwise) via a C closure over (handler, old, isOnEvent).
All methods delegate to the engine's own Script_* implementations by
reshaping the Lua stack -- no re-derived setters, so UI-scale
conversion, object resolution, and type checking stay engine code.
Method-registry contexts beyond GameTooltip's (Region / Frame /
Texture / FontString) are now in Offsets.h per the
docs/BlizzardScriptAPI.md catalogue.
Verified in-game: GameTooltip:GetSize() returns real dimensions,
CreateFrame + SetSize(50,50) reads back 50, and AI_VoiceOver's Era
path runs through its entire frame construction (SetPoint one-arg,
SetSize, SetShown, SetResizeBounds, 14 HookScript sites) where it
previously errored on the first call.
Rupture at 4 combo points reported duration=6 (the SpellDuration.dbc
base) instead of the real 14s: finisher durations are computed
server-side at cast time and 1.12 never transmits them for debuffs the
player puts on other units. Verified with a whole-protocol sniff --
SMSG_UPDATE_AURA_DURATION (0x137) is self-scoped (slot+duration for the
player's own aura array), 0x2DD has no client handler and never
arrives, and nothing else carries the value. (pfUI issue #17.)
Mirror the server's computation instead (new Aura::ComboDuration):
- Snapshot combo points ([player+0xE68]+0x1029, the field behind
Script_GetComboPoints) the instant CMSG_CAST_SPELL leaves, via a
co-hook on the NetClient send funnel (FUN_NET_SEND 0x005379A0). The
server consumes CP before SMSG_SPELL_GO returns, so reading at
SpellGo time is too late; the send funnel also covers
nampower-queued casts, which bypass any Spell_C_CastSpell co-hook
via trampoline re-entry.
- Compute base + (max - base) * CP / 5 from the spell's own
SpellDuration.dbc row ((v)mangos CalculateSpellDuration), then apply
the player's duration SpellMods, matching server order. DBC-driven
and spell-agnostic: finishers are exactly the rows with base != max
(Rupture 6/16s, Kidney Shot 1/6s, r1 0/5s, Slice and Dice 6/21s;
Expose Armor 30/30 self-disables). Consumed by Aura::Source's
SpellGo hook; 0 falls through to the existing base path.
- Turtle's reworked Rip (8s + 2s per CP) has NO client data: all six
ranks' DurationIndex points at SpellDuration row 87, which Turtle
added server-side only -- the client table jumps 86 -> 105. Built-in
values cover it, gated on the dangling-row condition itself (the
only dangling cross-reference in the entire client Spell.dbc, per a
full DurationIndex/CastingTimeIndex/RangeIndex sweep) rather than on
realm detection: stock clients resolve Rip's index normally, and if
Turtle ships the missing row the DBC takes over automatically.
- C_UnitAuras.RegisterComboDuration(spellID, baseSec, maxSec) as an
escape hatch for other servers with the same class of client-data
gap; a registered override beats DBC and built-ins.
Verified in-game: Rupture at 4 CP now reads duration=14.
Turtle's core doesn't broadcast SMSG_SPELL_FAILURE or
SMSG_SPELL_FAILED_OTHER on interrupt (verified by logging both
handlers through live tests), so the SPELL_FAILED_OTHER co-hook never
fired and interrupted enemy casts (Kick / Earth Shock / Counterspell)
lingered as ghost bars. What actually stops the cast visual on Turtle
is SuperWoW calling CGUnit_C::ClearCastingSpell (0x0060D040) directly.
Co-hook that choke point instead: every unit-stopped-casting path
funnels through it (failure packets, SPELL_GO completion, movement,
death, animation events, local cancel), gated on the unit's live
current-cast field at +0xC8C, so eviction is independent of which
signal the server sends. Both failure-packet handlers stay co-hooked
as backstops for servers that do broadcast them. Verified in-game:
all three interrupts evict; non-interrupting damage doesn't
false-evict.
Also confirmed via a whole-protocol sniff on the NetClient message
dispatch (FUN_NET_MESSAGE_DISPATCH = 0x00537AA0, documented for
future use): SMSG_SPELL_DELAYED goes only to the affected caster --
observers receive no packet of any kind for pushback, so remote bars
can't stretch. Documented as an inherent 1.12 protocol limitation.
Fires (no payload) when any equipped item's durability changes -
combat damage, death, repairs - via a co-hook on the engine's
inventory-alerts recompute (FUN_004C7EE0, the routine behind vanilla's
UPDATE_INVENTORY_ALERTS). The engine calls it whenever an owned item's
fields change (the item post-update handler FUN_005D9A90 routes every
item values-update through it) plus equip/enter-world paths, so it is
the engine's own durability-may-have-changed signal. Post-original we
diff a per-slot {itemGuid, durability} snapshot and fire once per
recompute with a real change - a death dinging all armor produces one
event, not nineteen.
The snapshot seeds silently on the first recompute of the process and
is deliberately NOT re-seeded per enter-world: death/release/graveyard
teleports re-run the setup, and a per-setup reset swallowed the
death's own durability loss (verified in-game; repairs fired, deaths
didn't until this was fixed).
Dead end, kept documented at OFF_DESC_ITEM_DURABILITY: bank-1 (ITEM
field) descriptor observers register without error but are never
invoked - item values-updates demonstrably arrive and player-bank
observers fire in the same session; registration arithmetic fully
verified (bank base FUN_00465690(1)=0x18, bank walk item 0->1->2).
Prime suspect is the registrar's internal wrapper lookup
(FUN_00464890, the observer registry, distinct from the object
manager) silently no-op'ing for item objects.
Also in Player::Equipment: resolve the player via the non-throwing
GUID resolver (FUN_00468460, what the engine's own bag observer uses)
instead of FUN_RESOLVE_UNIT_TOKEN, which raises a Lua error when the
token doesn't resolve - unsafe inside SMSG_UPDATE_OBJECT processing
where the player can be mid-create.
The quest's objective count — the length GetQuestDetails(questID)
.requirements would have — read straight from the cache block with no
Lua tables or strings materialized. nil when the quest isn't cached
(same contract as GetQuestDetails).
Exists because materializing full detail tables in a login-path loop
is expensive at warm cache: GetQuestDetails copies description /
objectives / completion text into Lua strings (~5 KB per quest).
TurtleGuide's guide-load OIDX validation did exactly that for every
tagged step — fine on the first (cold-cache) session, but once the
quest cache was warm from questcache.wdb every subsequent login
exhausted vanilla's fixed Lua memory pool and crashed the client in
lmemPool.cpp:53. With this accessor the same validation costs a few
dozen byte reads regardless of cache temperature.
The earlier investigation tried to hook a firer of
UNIT_INVENTORY_CHANGED; none of the three known 0xBA fire sites
trigger on equip because equipment descriptor writes are silent — the
engine registers descriptor-field observers (the __AUCMirrorHandler__
system: registrar 0x00467E70, dispatcher 0x00465570 with per-node
mirror + memcmp change detection) for the bag/backpack/bank/keyring
GUID ranges but never for equipment 0x4A8..0x538.
So register our own: co-hook the engine's inventory observer setup
(FUN_004F8CC0, once per enter-world) and, post-original, register one
observer per equipment GUID field through the engine's registrar —
same bank/size/GUID shape as the engine's bag observers, engine-
managed lifetime, no polling, no hot-function hook. The dispatcher
invokes us only on a real change.
Callback ABI verified from the dispatcher call site at 0x004655DC and
the engine callback FUN_004F8DB0's RET 0x10:
__fastcall(fieldOffset /*ecx*/, size /*edx*/, guidLo, guidHi,
oldValue*, userArg) -> ret 0x10
equipmentSlot is the 1-based Lua inventory slot (1 head .. 19 tabard);
hasCurrent (1/nil) is read from the player invMgr GUID array — the
same post-change truth the engine's bag observer reads.
Vanilla has two disjoint questgiver paths. Gossip NPCs send
SMSG_GOSSIP_MESSAGE into the gossip arrays this namespace reads; but
quest-only NPCs send SMSG_QUESTGIVER_QUEST_LIST (the QUEST_GREETING
event / QuestFrameGreetingPanel), whose data lands in entirely
separate storage C_GossipInfo never read — every getter returned
empty for those NPCs. GetText was also ungated: the gossip text
buffer persists after close, so it served the previous gossip NPC's
greeting.
- Gate all gossip reads on the live gossip NPC GUID (0x00BC3F58,
cleared by the close worker at 0x004E1FA0 before GOSSIP_CLOSED
fires). Stale text/options/quests are no longer visible, and
selectors can't send a CMSG from stale slots.
- Serve the quest-greeting session when one is live instead: the
questgiver GUID (0x00BE0810) must match the greeting-fill GUID
(0x00C4D740) so leftovers can't leak when a quest session starts
from a gossip-menu click. GetText returns the greeting text;
Get{Available,Active}Quests / GetNum* read the greeting arrays
(32 x 0x4C entries {questID, level, title}) with isComplete
recovered from the packet handler's scratch status array
(status 4 = turn-in-ready); Select{Available,Active}Quest route to
the engine's greeting selectors (0x005012A0 / 0x00501320).
- Neither session live: empty tables, 0 counts, empty text.
GetOptions/GetNumOptions stay gossip-only — greetings have no
options. Retail keeps the two APIs disjoint; merging is deliberate so
modern-addon ports work on greeting-path NPCs (common on 1.12
servers).
Introduce QUEST_REMOVED (fires with questID) and document it in API.md and README. Rename Quest::Accepted → Quest::LogEvents and update the quest-log rebuild hook to compute pre/post deltas for both QUEST_ACCEPTED (questLogIndex, questID) and QUEST_REMOVED (questID). Add suppression rules to avoid firing on login/resync bulk changes. Update Offsets.h commentary to reflect the new LogEvents module and its responsibilities.
Mind Control is a 3 s cast on a spell flagged CHANNELED with a 60 s
duration. HandleSpellStart branched on the channel attribute alone, so
at cast start it stamped g_channel with the 60 s duration and killed
the client hook's correct 3 s cast stamp — the bar showed a premature
60 s countdown (pfUI #9). Same story remotely: an observed MC stored a
60 s channel entry instead of the 3 s cast.
- Treat a channeled spell with castTime > 0 as its CAST phase in
HandleSpellStart (player and remote paths); instant channels keep
the immediate stamp.
- Co-hook the MSG_CHANNEL_START handler (0x006E7550, layout from
nampower's SpellChannelStartHandlerHook: spellId u32, duration u32).
The server sends it to the caster the moment the channel begins —
after MC's cast completes — with the server-authoritative duration.
Stamp g_channel there, before the original fires
SPELLCAST_CHANNEL_START, so pollers see fresh data. For instant
channels it re-stamps what the SpellStart path just wrote.
Verified: MC shows 3 s cast then 60 s channel; Mind Flay-style instant
channels unaffected.
tinycbor's grow-and-retry contract: on buffer overflow the encoder
switches to counting mode and encoding must CONTINUE for
cbor_encoder_get_extra_bytes_needed() to be accurate — a nested
container's tally only reaches the parent via close_container. The old
code returned false on the first CborErrorOutOfMemory, so a mid-
container overflow could report extra == 0 and be misread as a genuine
"value not representable" failure (returning nil), or undercount and
churn through multiple resize passes.
Add EncOk() (NoError or OutOfMemory = keep going) around every encode
call; the retry loop now distinguishes real failures (return nil) from
overflow (single complete-tally resize, retry).
Remote casts cached from SMSG_SPELL_START were expired only by their
computed end time — an interrupted remote cast lingered as a ghost bar
for every UnitCastingInfo consumer (pfUI #11). The interrupt signal
does exist at the packet layer: the server broadcasts
SMSG_SPELL_FAILED_OTHER to observers whenever a started cast aborts
(interrupt, death, movement, fizzle).
Co-hook its handler (0x006E8E40, same __stdcall shape as
FUN_SPELL_DELAYED; address + layout mirrored from nampower's
SpellFailedOtherHandlerHook — body is plain casterGuid(u64),
spellId(u32)) and:
- evict the matching g_remoteCasts entry (guid + spellID), so remote
cast/channel bars drop the same tick the cast dies;
- clear the player's own g_cast on a guid match — covers the
server-stamped chained recast that the WorldTick VAR==0 poll can't
clear (self-receipt is server-dependent; the endMs self-expiry
backstop remains if it never fires).
Update the stale 'no per-unit interrupt signal in 1.12' notes in
Cast.cpp and docs/API.md.
Re-add the C_VoiceChat / C_TTSSettings SAPI module removed in 6f543ec,
gated so it coexists with the standalone VanillaTTS DLL: when
VanillaTTS.dll is loaded in the process, the module stands down
entirely — no Lua functions, no ttsVoice/ttsSpeed/ttsVolume cvars, no
VOICE_CHAT_TTS_* event reservations, no SAPI init. Without the gate,
each DLL would claim its own event-table slot for the same event names
(listeners bind to one slot while the other DLL fires the other) and
the cvar registrar would replace the first DLL's clamp callbacks.
The check (GetModuleHandleA at RegisterLuaFunctions time) is
load-order-independent — VanillaFixes loads every DLL at process
attach, long before FUN_LOAD_SCRIPT_FUNCTIONS fires. The event
AutoReserves move from file scope into the gated registration as
function-local statics so a stand-down leaves no reservations behind.
Restore the ole32 link, the README VoiceChat/API rows + event table,
and the docs/API.md VoiceChat section (with a coexistence note).
Drop legacy third shopping tooltip handling from GameTooltip_ShowCompareItem. Unpack only two shoppingTooltips, remove unused item3/shoppingTooltip3 variables and commented code, and simplify anchor/owner logic for the two-compare layout. GAP/backdrop spacing calculation is preserved.
GameTooltip:GetItem() returned the previous item after SetUnitAura /
SetEquipmentSet (and any tooltip that shows no item). The engine's per-tooltip
clear FUN_00530050 zeroes the unit / gameobject / itemID / spell identity fields
but leaves the item GUID (+0x380/+0x384) intact -- the field CGItem tooltips
(SetBagItem/SetInventoryItem) set and that Item::TooltipItem::CurrentID resolves
when the itemID field is 0. So a stale GUID lingered across a switch to non-item
content and GetItem/HasItem/IsEquippedItem resolved the old item.
Co-hook FUN_00530050 and zero +0x380/+0x384 after the original. All 14
Set*/builder paths route through this clear (verified via xrefs; the aura
builder FUN_0052f880 and the item builder FUN_0052b650 both call it), and the
item builder calls it before writing the GUID -- so every fresh tooltip resets
the item GUID and only a real item build re-sets it. The hook runs no Lua, just
zeroes 8 bytes; the CurrentID unit/GO/spell gating is now belt-and-suspenders.
Verified in-game: bag/equipped items still resolve; SetUnitAura / SetEquipmentSet
no longer leak the previous item.
The initial fire used FUN_00702690, whose exec-context mutation (DAT_00ceeac0)
is only valid from the engine's top-level frame-script dispatch; firing it from
inside the item builder (mid-Script_Set*, and in the wild under a stack of addon
Set* wrappers) raised through the outer wrappers. Two fixes make the fire safe
for normal handlers:
- Call the inner invoker FUN_00704d50(handler, tooltip) directly. It binds the
global `this` = tooltip and runs the handler under its own protected pcall
(0 args), self-contained and context-independent.
- Snapshot lua_gettop before the fire and lua_settop back after, so the still-
running outer C code sees exactly the stack it left. Without this the invoker
left the stack shifted and the outer code / other DLLs' hooks indexed a stale
slot -> ACCESS_VIOLATION.
Verified in-game: SetScript / HookScript / GetScript all work, `this` is the
tooltip (a1 is nil -- vanilla convention, not modern `self`), and no-op /
print / AddLine handlers fire cleanly on bag / equipped / merchant / auction.
Docs: correct the handler example to use `this` (not a `self` arg), and add the
caveat that re-entrant tooltip rebuilds from the handler (ShowCompareItem, Set*
on another tooltip) can crash in a hook-heavy client and must be deferred
(C_Timer.After(0, ...)) -- the event fires mid-build.
Backports the modern OnTooltipSetItem tooltip script to 1.12 as a real
frame script, settable via the standard SetScript / GetScript / HookScript
and firing whenever a tooltip's item is set (with the tooltip as self). Lets
addons annotate item tooltips by hooking one script instead of wrapping every
Set* method -- the destructive wrap-and-rebuild pattern (AtlasLoot's
AddSourceLine) that clobbers the tooltip's item linkage.
Fully native, two Game::HookAutoRegister co-hooks in Tooltip::SetItemEvent:
- Resolver (FUN_005295d0, the CGGameTooltip vtable script-name -> slot
vmethod): for "OnTooltipSetItem" (a name the engine doesn't know) return a
pointer to a C-side per-tooltip 8-byte {handler, context} cell. The tooltip
object (alloc size 0x460) has no free slot, and the resolver's only contract
is "return where the handler lives", so external storage behaves exactly
like the engine's own slots -- proven by OnTooltipCleared (+0x44c).
- Item builder (FUN_0052B650, the shared funnel for SetBagItem /
SetInventoryItem / SetHyperlink / SetMerchantItem / SetAuctionItem / ...):
after the build, fire the handler via FUN_00702690 (the no-arg runner the
clear uses for OnTooltipCleared). Recursion-guarded, and suppressed via
Tooltip::SetItemEvent::Suppressor during Tooltip::Compare's internal
equipped-item build so a handler doesn't run mid line-shift.
Item only for now; OnTooltipSetSpell / OnTooltipSetUnit follow the same
pattern with their own builders.
Introduce Item::TooltipItem::CurrentID -- the single "what item is this
tooltip showing" resolver. It trusts the link-path itemID (+0x398), and
otherwise resolves the CGItem GUID (+0x380) but only when no unit /
gameobject / spell id is set, since the clear does not zero the item GUID
and it goes stale after the tooltip moves on.
- GameTooltip:GetItem / HasItem now route through CurrentID: fixes GetItem
returning nil for CGItem tooltips (bag / equipped / merchant / loot) and
HasItem false-positiving on a unit tooltip after an item hover.
- Add GameTooltip:IsEquippedItem() (3.3.5 backport, registry index 63),
resolving the displayed item via CurrentID + the equipment-slot walk.
- SetHyperlinkCompareItem: the comparisonTooltip (4th arg) fallback uses
CurrentID; stat deltas now show by default and hide only for an explicit
false / 0 (was: show only for a truthy shift).
- GetItem round-trips the random-suffix id in the link for auction/compare
items (no CGItem instance) via the compare descriptor.
- FrameXML GameTooltip_ShowCompareItem falls back to self.compareLink, so a
caller can hand it an explicit link when the tooltip's item state was
rebuilt out from under it (e.g. an AH addon appending source lines).
- Offsets: compare-descriptor + FontString offsets; drop the duplicate
tooltip item-id/guid offsets in favor of the canonical OFF_TOOLTIP_*.
Backports the 3.3.5+ shopping-tooltip method, fully native (no lua_pcall):
fills a tooltip with the item currently equipped in the given item's slot
— a grey "Currently Equipped" header, the equipped item's tooltip, then a
green/red per-stat delta breakdown gated on shiftButton.
- Extract the stat engine (base record + on-equip-spell auras + random
suffix) out of item/Stats.cpp into the shared Item::StatAccum, reused by
C_Item.GetItemStats/GetItemStatDelta and the compare deltas.
- Build via the engine's own item-tooltip builder; prepend the grey header
by shifting the line FontStrings, replicating the show/hide protocol,
keying on the visible flag (no stale text) and resetting cell color.
- Delta colors sourced from ColorData.h via a new constexpr ByTag helper.
- offset selects the slot for two-slot items (rings/trinkets/1H weapons);
shiftButton gates the deltas (true/1 vs false/0/nil); a comparisonTooltip
4th arg lets it run link-free, 1:1 with the retail call.
- FrameXML GameTooltip_ShowCompareItem consumer with a dynamic,
backdrop-derived anchor gap.
Vanilla GameTooltip:AddLine silently drops any line past the 30
TextLeftN/TextRightN FontStrings the FrameXML template declares, so
stat-heavy tooltips (pfUI eqcompare, AtlasLoot) lose their tail.
Tooltip::LinePool co-hooks the tooltip's XML-OnLoad region scan
(FUN_00529650, CGameTooltip vtable slot 9) via HookAutoRegister. After
the engine sets up the template's 30 lines it creates TextLeft/Right
31..60 in C++ (alloc from the CSimpleFontString pool -> ctor(parent) ->
name -> font copied from the previous line -> anchor) and appends each
pair to the tooltip's three line arrays, bumping numLinesAllocated. Runs
once per GameTooltip-family frame at creation and per /reload, so it
auto-covers GameTooltip, ShoppingTooltip1/2, ItemRefTooltip, AtlasLoot,
WorldMapTooltip, etc. No addon Lua and no per-line AddLine hook.
SetPoint stores offsets in internal coordinates, not pixels, so the
anchor offsets are scaled the way Script_SetPoint does
(pixel * [0x00832A44] / ([0x00832A4C] * 1024)) — without that a -2px gap
rendered ~3950px off.
Adds the tooltip line-pool, FontString-creation, and UI-scale offsets to
Offsets.h; documents the behavior in the README and marks TODO #97 done.
Cross-checked against Turtle's server source (patch_1172): ItemModType
is unchanged vanilla (0-7) and ApplyItemEquipSpell applies only
ON_EQUIP, confirming special stats are all equip-spell auras.
- Value now uses the engine's CalculateSimpleValue = EffectBasePoints +
EffectBaseDice (new OFF_SPELL_RECORD_EFFECT_BASE_DICE = 0x10C) instead
of a hardcoded +1; equal for the fixed-die stat auras but exact.
- Decode the additional stat auras Turtle puts on custom gear: dodge %
(49), parry % (47), block chance % (51), spell crit % (57/71), and
flat health (34) / mana (35) folded into the existing keys. Adds shield
block value from the record's m_block. New keys ITEM_MOD_DODGE_RATING,
ITEM_MOD_PARRY_RATING, ITEM_MOD_BLOCK_RATING, ITEM_MOD_CRIT_SPELL_RATING,
ITEM_MOD_BLOCK_VALUE (percent stats keep vanilla-native values).
Documents the expanded key set in docs/API.md.
Weapon DPS (avg damage across the slots / swing time) is emitted under
ITEM_MOD_DAMAGE_PER_SECOND_SHORT, matching 3.3.5's GetItemStats.
Vanilla stores the "special" gear stats (crit, attack power, spell
power/healing, hit, mp5, defense) as on-equip spells rather than stat
slots, so decode each item's SpellTrigger == ON_EQUIP spells - and the
random-suffix equip enchants - through the SPELL_AURA_* effects and fold
them into the modern GetItemStats keys. Percent stats (crit/hit/defense)
are reported as the raw vanilla percentage under the *_RATING key, since
vanilla has no (level-dependent) rating conversion; e.g. Krol Blade's
+1% crit is CRIT_MELEE_RATING = 1, not the 13 rating TBC/Era would show.
Generic attack power subsumes its paired ranged-AP aura; rarer auras
with no clean key (percent AP, per-weapon-line skill, procs) are skipped.
Documents the full key set and its sources in docs/API.md.
Read an item's stats out of the cached ItemStats_C record and return
them keyed by the FrameXML global-string names WoW's GetItemStats uses
(ITEM_MOD_*_SHORT, RESISTANCE0..6_NAME). GetItemStatDelta returns the
per-stat difference (item2 - item1).
Random suffixes are resolved from the link's third
item:id:enchant:SUFFIX:unique field through ItemRandomProperties.dbc ->
SpellItemEnchantment.dbc: type-3 (equip-spell) enchants defer to the
spell's SPELL_AURA_MOD_STAT / MOD_RESISTANCE effect (value =
EffectBasePoints + 1), type-4 add a direct resistance. Reported set
stays consistent with the base record - base attributes, armor, and the
six resistance schools - so a plain item and its suffixed variant report
the same categories.
Adds OFF_SPELL_RECORD_EFFECT_BASE_POINTS, VAR_ITEMRANDOMPROP_RECORDS/
COUNT, and OFF_ITEMRANDOMPROP_ENCHANT to Offsets.h.
Returns whether an item with the given "0x..." GUID is currently carried
by the player. Parses the GUID, then scans the 19 equipment slots plus
bags 0..4, comparing each item's instance GUID (+0x08 -> +0x00, the same
read GetItemGUID uses). Reuses the existing ResolveEquipmentSlot /
ResolveBag / GetBagSlotCount iteration.
Scope matches retail: equipment slots count (verified in-game —
IsItemGUIDInInventory(GetItemGUID({equipmentSlotIndex=1})) is true), bags
count, bank does not (GetItemCount(itemID, true) covers the bank). Returns
false for a malformed / zero GUID.
Factored the CGItem GUID read into a shared ReadItemGuid helper (also used
by GetItemGUID). Documented in docs/API.md and the README Item row.
Registers Enum.InventoryType (equip-type enum) as a hardcoded mirror of
Blizzard's 0..34 spec, via the same RegisterIntegerEnum path as
Enum.PowerType / Enum.AddOnSecurityStatus. Values 0..28 are the ones
vanilla items report; 29..34 (profession / equippable-spell types) are
post-vanilla, included so backport code referencing those keys resolves.
The field names (IndexHeadType, Index2HweaponType, …) aren't present in
engine data and don't derive cleanly from the INVTYPE key strings, so a
static mirror is the correct approach — it's a fixed API contract, not
engine-driven. For the live set of slot keys the engine actually has, use
C_Item.GetItemInventorySlotKey.
Documented in docs/API.md and the README enum row.
Three item-taxonomy lookups backported to the C_Item namespace, reusing
the ItemSubClass.dbc scan and INVTYPE key table already in the tree.
- GetItemSubClassInfo(classID, subClassID) -> subClassName,
subClassUsesInvType. Name is the localized ItemSubClass.dbc verbose
form (short-name fallback). subClassUsesInvType comes from the flags
field at record +0x10, bit 0x200 — set on exactly the armor material
types (Misc/Cloth/Leather/Mail/Plate), clear on weapons/shields/etc.
Bit derived by dumping ItemSubClass.dbc and verified against the 1.15
client (Cloth true, One-Handed Swords false, Shields false).
- GetItemInventorySlotKey(inventorySlot) -> the equipLoc key
("INVTYPE_HEAD"), straight from the engine's INVTYPE table.
- GetItemInventorySlotInfo(inventorySlot) -> the localized slot name
("Head"), resolved via the FrameXML global of that key so it's
localized on non-English clients.
All three return nil for out-of-range / missing input. Documented in
docs/API.md and the README Item row.
C_VoiceChat / C_TTSSettings (the SAPI text-to-speech surface) is moving to
its own injected DLL (C:\Git\VanillaTTS) so it can grow a cross-platform
software-synthesis backend (Flite under Wine) without bloating ClassicAPI.
- Delete src/voicechat/Tts.cpp.
- Drop the ole32 link from CMakeLists (it was only for SAPI/COM).
- Strip the VoiceChat section + TOC from docs/API.md and the VoiceChat
row + VOICE_CHAT_TTS_* events from the README.
CVar::Factory stays — it's general-purpose infra now, not TTS-specific
(reworded its header comment to drop the TTS example).
The repo was internally inconsistent: the LICENSE file is GPL v3 but every
src/ header carried the LGPL v3 notice and the README said "LGPL v3 or
later". LGPL was also the wrong fit — this is an injected mod plus a
bundled addon, not a library other code links against, so LGPL's linking
exception buys nothing.
It's also required: src/voicechat/Tts.cpp is ported from awesome_wotlk's
VoiceChat.cpp, which is GPL v3, and a derivative of GPL code cannot be
relicensed under more permissive terms. Dependencies stay compatible —
MinHook (BSD-2) and the nampower-mirrored packet shapes (BSD) are
GPL-compatible, and SAPI is covered by the system-library exception.
Swept "GNU Lesser General Public License" -> "GNU General Public License"
across all 257 src/ headers and changed the README license line to match
the GPL v3 LICENSE file. Comment-only; no build impact.
Backports modern WoW's TTS Lua surface, ported from awesome_wotlk's
3.3.5 VoiceChat.cpp. The SAPI core is client-version-agnostic; only the
three integration seams are ClassicAPI-native:
- namespaces via Game::Lua::RegisterTableFunction
- the five VOICE_CHAT_TTS_* events via Event::Custom (AutoReserve + Fire)
- ttsVoice / ttsSpeed / ttsVolume settings via CVar::Factory, persisted
to Config.wtf and clamped through change callbacks
C_VoiceChat: GetTtsVoices, GetRemoteTtsVoices (stub == local; vanilla has
no voice chat), SpeakText(voiceID, text[, dest, rate, volume]),
StopSpeakingText. C_TTSSettings: Get/SetSpeechRate, Get/SetSpeechVolume,
GetSpeechVoiceID, GetVoiceOptionName, SetVoiceOption[ByName],
SetDefaultSettings, RefreshVoices.
SAPI is created lazily on first use under an STA; SetNotifyCallbackFunction
marshals playback events back to the main thread's message pump (WoW pumps
every frame), so OnSpeechNotify fires PLAYBACK_STARTED/FINISHED on the main
thread — verified in-game. Voice enumeration uses CLSID_SpObjectTokenCategory
directly so the build needs no sphelper.h; GUIDs are instantiated via
<initguid.h>, so only ole32 is linked (no sapi.lib). Degrades gracefully
without SAPI (e.g. Wine): CoCreateInstance fails, SpeakText fires
PLAYBACK_FAILED and returns, voice list comes back empty, no crash.
Vanilla 1.12 exposes CVar reading and Lua-side RegisterCVar(name, default)
but no way to register a cvar with a change-callback or react to value
changes from C. CVar::Factory wraps the engine internals so modules can
publish persisted, validated settings.
- FUN_REGISTER_CVAR (0x0063DB90), __fastcall(name, edx, flags, default,
callback, categoryId, hiddenBool, userData). Dedups by name (idempotent
across /reload) and forces the archive flag bit on, so registered cvars
persist to Config.wtf.
- FUN_SET_CVAR_VALUE (0x0063DF50), __thiscall(cvar, value, a3..a6) — NOT
__fastcall (that routes value through EDX and leaves the callee popping
4 bytes too many off the stack, corrupting ESP). Fires the change
callback.
- Change callback ABI verified at both the set path (0x0063DF7D) and the
re-register path (0x0063DC51): __fastcall(cvar, prevValue, newValue,
userData) returning char — 0 rejects the change, nonzero accepts. Clamp
by setting the clamped value from inside the callback and returning 0.
Offsets + struct fields (value +0x20, flags +0x1C, callback +0xBC,
userData +0xC0) documented with their call-site derivations.
UnitClassBase("player") and UnitRaceBase("player") returned nil until
~PLAYER_ENTERING_WORLD because they always read the class/race byte from
the in-world unit descriptor ([unit+0x110]+0x79/+0x78), which doesn't
exist until the player object spawns.
Vanilla's Script_UnitClass (0x00518350) avoids this: for the literal
"player" token it reads a login-time global (FUN_005abde0 ->
DAT_00c27e81) instead of the descriptor. That global mirrors the local
player's UNIT_FIELD_BYTES_0 race/class/sex and is populated at
character-enter, so UnitClass("player") resolves at addon-load.
Mirror that fast path: the "player" token now reads the login-time byte
globals (race 0x00C27E80, class 0x00C27E81, sex 0x00C27E82); all other
tokens keep the descriptor path, so remote-unit behavior is unchanged.
The "player" test is factored into Unit::Identity::IsPlayerToken, shared
by both modules (and GuidForToken). It routes through the engine's own
SStrCmpI (FUN_SSTR_CMP_I) to match Script_UnitClass's comparator exactly,
rather than the CRT _stricmp.
Vanilla doesn't populate the local player object (and its +0xC0 GUID) until
SMSG_UPDATE_OBJECT in the main loop, after FrameScript_Initialize and the
addon load pass — so UnitGUID("player") / PlayerGuid() returned nil/0 at
addon file-load on first login.
Co-hook the glue enter-world worker (FUN_0046B500, behind Script_EnterWorld)
to snapshot the selected character's GUID at the moment of entry: read from
the selected-character struct (VAR_GLUE_SELECTED_CHAR; GUID at offset 0,
verified against Script_GetCharacterInfo), gated on the "entering world"
state code so the rename/locked bail paths don't capture. PlayerGuid()
returns the live engine value when present and falls back to the captured
GUID otherwise — the same value, just available from addon-load time.
Captured fresh per entry, so a character switch can't serve a stale GUID.
This makes the GUID *value* available early (keying SavedVariables,
comparisons, the aura/cast caster checks); the player *object* still doesn't
exist until +0xC0 populates.
Also simplify GetPlayerGuid() to a passthrough now that UnitGUID("player")
is reliably available early and cheap — the memo saved only a trivial
C-call + snprintf and the GUID is session-stable.
Add FUN_GLUE_ENTER_WORLD / VAR_GLUE_SELECTED_CHAR offsets.
The 1.12 engine drops a unit's UNIT_FIELD_AURA slots when it loses sync with
the unit (rogue stealth, party range fluctuation) even though the auras are
still active server-side, so C_UnitAuras.GetAuraDataByIndex /
GetBuffDataByIndex returned nil for auras that are still up.
Fall back to the Aura::Source cache (caster/timing captured from
SMSG_SPELL_GO at cast time, which survives descriptor clears) when the
descriptor has no slot:
- Aura::Source: entries gain a helpful/harmful kind, recorded from the
descriptor slot at application time (OnAuraAdded/OnAuraStacksChanged);
SpellGo-only entries stay unknown and are excluded. New Enumerate() returns
non-expired entries of a given kind for a unit.
- Aura::Data: a shared BuildTable feeds both the descriptor Push and a new
cache-fed path; PushNthCacheFallback / AppendCacheFallbacks append cache
entries after the descriptor ones, deduped against populated slots.
- Aura::Api: the index path and GetUnitAuras consult the fallback.
To keep the fallback from resurrecting auras that are genuinely gone
(replaced by a higher rank, dispelled, fell off early), co-hook OnAuraRemoved
(FUN_00612320) and evict the cache entry. A rogue stealthing does NOT route
through OnAuraRemoved (object-visibility drop, not per-aura removal), so
eviction and stealth-recovery coexist: real removals evict, visibility drops
don't.
Add FUN_ON_AURA_REMOVED offset.
Fixes#5
The 1.12 engine short-circuits a chained same-spell recast: Spell_C_CastSpell
bails at `spellID == VAR_CURRENT_CAST_SPELL` before setting the global or
firing SPELLCAST_START, so the client never runs its cast path for the 2nd of
a back-to-back same-spell cast (common with nampower spell queueing). And the
player channel field (+0x228) is server-pushed, landing ~1 tick after the
channel starts, so polling it shows the bar ~1s late.
Rework the player UnitCastingInfo / UnitChannelInfo tracking into a hybrid:
- Normal casts: co-hook FUN_CAST_START_SET, stamp g_cast from the spellID
arg before the original (zero latency); cleared by the VAR==0 WorldTick
poll.
- Chained same-spell recasts: stamp g_cast from the SMSG_SPELL_START co-hook
(server castTime), deduped against a fresh client stamp, exempt from the
VAR clear, self-expiring on endMs.
- Channels: stamp g_channel from the same packet (engine-computed duration)
instead of the lagging +0x228 poll; self-expire on endMs.
- Cast and channel are mutually exclusive for the player.
- Co-hook SMSG_SPELL_DELAYED for cast pushback (extends endMs + delayMs).
Add FUN_SPELL_DELAYED and FUN_CAST_START_SET offsets.
Vanilla stores no cast record for other units, so UnitCastingInfo(other)
returned nil and remote channels had no timing. Co-hook the SMSG_SPELL_START
handler (0x006E7640, gated on opcode 0x131) -- the only packet carrying
another unit's cast with a server-authoritative cast time -- and cache it
per caster GUID (cast vs. channel split by AttributesEx channel bits).
- UnitCastingInfo(unit): full timing for any unit within its cast window;
enemy/target/focus/nameplate cast bars now work, not just the player.
- UnitChannelInfo(unit): remote channels get real start/end times when the
channel's begin was observed, validated against the live +0x228 field so a
stale entry never sticks; falls back to the old no-times path otherwise.
- Player path unchanged (self-tracked); the cache skips caster==PlayerGuid.
Factor the CDataStore packet reader out of Aura::Source into a shared
Net::PacketReader header (vtable-correct) used by both packet hooks.
Best-effort: only casts begun while the unit was in range are seen, and an
interrupted remote cast lingers until its computed end (1.12 sends no
per-unit interrupt packet to hook).
SpellGo only sees directly-cast auras, so proc-applied debuffs (Shadow
Weaving etc.) had no expiration. Co-hook two more engine functions that
fire for every aura application:
- CGUnit_C::OnAuraAdded (0x006123F0) - any aura newly occupying a slot,
procs included; stamps now + base duration (no caster).
- CGUnit_C::OnAuraStacksChanged (0x00612450) - re-stamps on a stack-count
change so a climbing stacking debuff (Shadow Weaving 1->5) refreshes.
Both pass fromCast=false to Store, which skips entries SpellGo already owns,
so directly-cast auras keep their talented timing + caster regardless of
hook order. Consolidate the object-GUID read into Unit::Identity::
GuidForObject so the lookup and the new hooks key the cache identically.
Known limitation (documented): a refresh at MAX stacks (5->5) changes no
client-visible aura field, so the server sends no UpdateField and no
callback fires - there's no packet to hook. The countdown runs out and
expirationTime falls back to 0 (unknown) while the aura still shows.
duration came from a base Spell.dbc/SpellDuration.dbc lookup while
expirationTime came from the Aura::Source cache computed with the caster's
duration modifiers. For a talented DoT (e.g. 2/2 Improved Shadow Word: Pain,
18s -> 24s) the two disagreed, so remaining (expirationTime - GetTime())
exceeded duration -- impossible for a single aura.
Cache the applied durationMs alongside expirationMs in Aura::Source and have
Push prefer it for the duration field, keeping the two consistent
(remaining <= duration). Falls back to the base DBC value on a cache miss.
With the Aura::Source caster cache in place, the PLAYER filter token is now
derivable: restrict results to auras whose cached caster GUID matches the
local player (Unit::Identity::PlayerGuid). Wired into every getter that
takes a filter — GetAuraDataByIndex, GetUnitAuraBySpellID,
GetAuraDataBySpellName, and GetUnitAuras — via a playerOnly flag threaded
into the slot finders and the new Data::IsPlayerCast predicate.
Auras whose cast we didn't observe (predate login / no SMSG_SPELL_GO) count
as not-player-cast and are excluded, matching the best-effort caster model.
Also fix GetUnitAuras range selection so a bare "PLAYER" (no HELPFUL/HARMFUL)
returns both ranges restricted to player casts, instead of defaulting to
helpful-only.
RAID / CANCELABLE / nameplate filter tokens remain accepted-but-no-op.
Populate AuraData sourceUnit/sourceGUID and non-player expirationTime by
co-hooking SMSG_SPELL_GO (FUN_SPELL_GO) and caching (targetGuid, spellId)
-> caster + expiration. The vanilla unit aura array stores only spell IDs;
the SpellGo packet is the only place the client sees an aura's caster and a
server-authoritative duration. We parse the engine packet ourselves, so
this is self-sufficient (no nampower dependency) -- when nampower is loaded
we simply co-hook the same site.
sourceGUID is a ClassicAPI extension (raw "0x..." string), set whenever a
caster is known including when sourceUnit is nil; stable across the session,
unlike the volatile nameplate token, and usable as a unit token under
SuperWoW.
Also consolidate the local-player-GUID read into Unit::Identity::PlayerGuid()
(was duplicated inline in four files) and expose Unit::Identity GuidForToken
/ TokenFromGUID for reuse.
Lead with downloading the prebuilt ClassicAPI.dll from
/releases/latest (build-from-source is the alternative), and note the
bundled !!!ClassicAPI addon ships inside the DLL so there's no separate
addon install.
GameObject analog of C_CreatureInfo, reading the gameobject cache
(gameobjectcache.wdb, SMSG_GAMEOBJECT_QUERY_RESPONSE):
- GetGameObjectInfoByID -> { gameObjectID, name, type, displayID } | nil
(synchronous peek)
- RequestLoadGameObjectByID -> bool, fires GAMEOBJECT_DATA_LOAD_RESULT
when an uncached GO arrives.
The GO cache is a sibling class of the creature cache: same hash-map
shape but a different vtable and a larger entry (loaded flag at
entry+0x98 vs +0x50), so it has its own _GetRecord (FUN_00558560) and
response parser (FUN_005588E0). Generalized Cache::QueryLoad to take a
per-cache _GetRecord via Register and to hook BOTH parsers (creature +
gameobject), dispatching completion by cache instance. Renamed the
creature offsets FUN_CACHE_* -> FUN_CREATURE_* now that "generic" was a
misnomer.
GO field offsets: name@+0x08 verified (FUN_0062dc10 reads [block+0x08]),
type@+0x00 / displayID@+0x04 from the wire/WDB order. Verified in-game:
Mesa Elevator 47297 type 11 displayID 360, Windrunner 176250 type 15
displayID 7087, and RequestLoadGameObjectByID populated an uncached GO
("Lighting Impact Large", type 5). Creature path re-verified after the
refactor (GetCreatureInfoByID + the load event still work).
RequestLoadCreatureByID asynchronously fetches an uncached creature:
issues SMSG_CREATURE_QUERY and fires CREATURE_DATA_LOAD_RESULT(id,
success) when the response lands (or on timeout); fires success
synchronously on a cache hit. Mirrors C_Item.RequestLoadItemData.
New Cache::QueryLoad dispatcher generalizes Item::Data's
pending/timeout/event machinery so multiple query caches share one
response-handler hook. MinHook allows only one hook per target, and
the creature/gameobject (and other non-item/quest) caches all route
their SMSG_*_QUERY_RESPONSE through the generic parser FUN_00556E20 —
so we hook it once and dispatch by cache instance to the right pending
set + completion event. Item/quest use their own parsers, so no
collision. The gameobject cache will register on this same dispatcher.
Verified in-game: RequestLoadCreatureByID(10184) on an uncached Onyxia
returned true, fired CREATURE_DATA_LOAD_RESULT, and populated the cache
(type 2 Dragonkin, rank 3 world boss, displayID 8570).
GetCreatureID was catalogued under NameCache; with the new Creature
section it belongs there next to GetCreatureInfoByID. Moved the README
row entry and the API.md TOC + section body, fixed the now-stale
"above" cross-ref to C_PlayerInfo.GUIDIs*, and linked the two
C_CreatureInfo calls (GetCreatureInfoByID(GetCreatureID(guid))).
Reads the client-side creature cache (creaturecache.wdb, fed by
SMSG_CREATURE_QUERY_RESPONSE) for an NPC by ID and returns
{ creatureID, name, subName, type, family, rank, displayID } — or nil
if not cached. A synchronous peek (no network query): answers for any
cached creatureID, unlike UnitCreatureType/UnitClassification which
need a live unit token.
Cache machinery is the same generic query-cache class as Item/Quest:
instance 0x00C0E354, generic _GetRecord FUN_00556AA0 (callback=0 peeks).
Field offsets verified against the binary — rank read by the engine's
classification helper FUN_00605620 as [block+0x20], anchoring the
wire/WDB field order (Name[4], SubName, typeFlags, type, family, rank,
…) — and cross-checked in-game against real creaturecache.wdb rows
(Misthoof Stag 61332 type 1; Nordrassil Nymph 61582 type 7 rank 1;
Greathorn Hunter 61337 family 26). All match.
RequestLoadCreatureByID (async fetch for uncached creatures) + the
gameobject cache follow.
Companion to ExportInterfaceFiles: dumps every .dbc table the client
loads to DBFilesClient\ next to the exe. Reuses the same MPQ
enumeration + file-read pipeline (factored into a shared WriteFiles),
and unions two sources for a complete set:
1. MPQ (listfile) under DBFilesClient\ — catches archive files with
no loader (e.g. wowerror_strings.dbc).
2. A .text scan for the DBC path-getter pattern
(mov eax, &"DBFilesClient\X.dbc"; ret) — the authoritative list of
what the build loads. The master DBC init (FUN_0053f8b0) is
straight-line with no iterable name table, so the per-DBC getters
are the registry; scanning for them is the technique that built
docs/DBCs.md. Bounded reads, can't fault.
Listfile alone yielded 137; the union yields 155 (+18 cosmetic
anim/sound/lookup DBCs the listfile omits, 0 lost). Results deduped
case-insensitively. Verified in-game: old vs new extract diff is
exactly the 18 expected additions.
UnitCastingInfo / CastingInfo / UnitChannelInfo / ChannelInfo were
registered as globals (mirroring modern WoW), but that clobbers addons
that ship their own vanilla cast-tracking via the
`_G.UnitCastingInfo or <fallback>` idiom — notably ShaguTweaks'
libcast.lua, which scrapes the combat log for remote/enemy casts the
1.12 engine never exposes. With our global present those addons adopt
our player-only version and drop their (superior, for enemies)
fallback, killing enemy cast bars. We can't match the combat-log
scraper in the DLL (the engine carries no remote regular-cast data),
so cede the global names and register under C_Spell instead.
Tradeoff: addons calling the global UnitCastingInfo/UnitChannelInfo
must use the C_Spell.-prefixed names now; enemy-cast consumers got nil
from the global anyway.
Resolves an item-enchantment ID (the enchantID from
C_Item.GetWeaponEnchantInfo, and item permanent-enchant IDs) into its
SpellItemEnchantment.dbc record:
{ enchantID, name, effects = {{type,amount,arg},...}, spellID }
- name: localized display name ("Crusader", "Brilliant Mana Oil").
- effects: each active slot's ITEM_ENCHANTMENT_TYPE / amount / arg.
- spellID: convenience for spell-type enchants (1/3/7) — the proc/
equip/use spellID, e.g. Crusader -> 20007 "Holy Strength", to chain
into C_Spell.GetSpellDescription.
Record layout verified by parsing the on-disk DBC against known
records (Crusader 1900: type 1, arg 20007; Sharpened+3 id 13: type 2,
amount 3; Reinforced Armor+8 id 15: type 4, amount 8): Type[3]@+0x04,
Amount[3]@+0x10, EffectArg[3]@+0x28, Name[8]@+0x34. The engine's
by-ID readers only touch name/flags/condition, so the effect columns
couldn't be pinned from disassembly — the extracted dbc/ dump was the
oracle. Verified in-game across all three example enchants.
Placed in C_Item (not C_Spell): the id originates from
C_Item.GetWeaponEnchantInfo and the concept is an item enchantment.
Source-item is not derivable (no back-reference, no client reverse
index).
The console registrar (FUN_0063F9E0) dedups by name, and we pass the
same string literal so its pointer-equality fast path makes a repeat
call a hard no-op. Verified in-game: after a logout→login cycle
(which re-fires the glue hook), ExportInterfaceFiles stays listed once
in `help debug` and still runs. The static latch was unnecessary.
Console commands are process-global and persist for the session, so a
single registration at glue boot (the earliest hook, fires at launch)
covers both the login console and in-world — confirmed in-game. Drop
the redundant in-game ModuleAutoRegister; it only ever hit the latch.
The AddOns\ skip dropped Blizzard's own MPQ-baked UI addons
(Blizzard_AuctionUI, Blizzard_TalentUI, …) — part of the stock UI
source. Loose on-disk addons are never in an archive listfile, so
nothing surfaces them (verified: both branches of FUN_00401470 walk
MPQ listfiles / an in-memory registered-file list, not the loose disk
dir); the skip was both wrong and unnecessary. Matches 4.3.4
(extension filter only, no path exclusion).
Backport of the 4.3.4 developer-console command: dumps Blizzard's UI
files out of the mounted MPQs to disk (`ExportInterfaceFiles code`
writes .lua/.xml/.toc/.xsd to BlizzardInterfaceCode\; `art` writes
.blp/.tga to BlizzardInterfaceArt\), preserving the Interface\ subtree
and skipping the user's own AddOns\.
- New src/interface/Export.cpp: enumerates each archive's (listfile)
via the MPQ walker FUN_00401470, reads each entry through
FUN_FILE_READ, and writes it out with Win32.
- New generic Game::Console facility (RegisterCommand / Write / Category
enum) wrapping the engine's console-command registrar and output
paths, so future commands register in one line.
- Offsets.h: FUN_MPQ_ENUM_FILES, FUN_CONSOLE_COMMAND_REGISTER,
FUN_CONSOLE_WRITE; corrected FUN_FILE_READ to __stdcall and documented
its archive-handle arg0.
- Docs: README "Console commands" section + docs/API.md "Console" entry.
Holding both mouse buttons to run forward never fired
PLAYER_STARTED_MOVING. Ghidra trace of the input button handler
(FUN_00514840) shows that gesture drives the forward move directly at
the movement layer (FUN_005103e0) and explicitly clears the autorun
bit on the both-button press — so it sets none of the
INPUT_FLAGS_MOVING_ANY bits the mover-detection reads. (The Offsets.h
comment claiming the both-button combo sets autorun was wrong;
corrected.)
Detect it from the bit combo instead: both INPUT_FLAG_MOUSELOOK (RMB)
and INPUT_FLAG_FREE_LOOK (LMB) held means the engine is mouse-steering
the character forward. ORed into `moving`. Composes cleanly with the
TURNING/LOOKING latches (unchanged).
Stock 1.12.1 already defines DAYS_ABBR/HOURS_ABBR/MINUTES_ABBR/
SECONDS_ABBR (bare labels: "Day"/"Hr"/"Min"/"Sec") and the four
*_ONELETTER_ABBR. Our enUS.lua loads after FrameXML, so redefining
them with the modern "%d |4Day:Days;" format silently overwrote the
native values and broke any native/other-addon code reading the bare
labels.
Remove those redefinitions from enUS.lua (keep only the strings stock
genuinely lacks), and drop SecondsToTime / SecondsToTimeAbbrev /
FormatUnit from TimeUtil — they were the only consumers of the
modern-format _ABBR strings. SecondsToClock stays (uses the
stock-missing HOURS_MINUTES_SECONDS / MINUTES_SECONDS). Remaining
enUS entries sorted, all still have live consumers.
`GetPhysicalScreenSize()` → (widthPixels, heightPixels). Modern WoW
added this native in 7.0; FrameXML's PixelUtil builds its entire
pixel↔UI-unit conversion on it (768/physicalHeight). 1.12 lacks it and
has no pre-parsed pixel-dimension global — GetScreenWidth/Height are
UI-space (UIParent aspect at [UIParent+0x7c]), and the engine only
holds the current mode as the gxResolution CVar string, sscanf'd on
demand by SetScreenResolution / display-init. So the native reads the
gxResolution CVar directly (FUN_FIND_CVAR, value at +0x20) and parses
"WIDTHxHEIGHT" — the same source pfUI's GetPerfectPixel uses. Verified
in-game: 2560x1080.
7.0 was the only binary exposing the function, but retail .text is
encrypted on disk so static decompilation yields garbage; derived the
implementation from the 1.12 binary instead.
Also backports FrameXML/PixelUtil.lua (SetPoint/SetSize/SetWidth/
SetHeight/SetStatusBarValue/Get*). Its math deps (Round/Lerp/
ClampedPercentageBetween) already live in MathUtil; it consumes the
native GetPhysicalScreenSize (no Lua polyfill).
`C_Spell.CastAtUnit(spellID, unit)` and `C_Item.UseAtUnit(itemInfo,
unit)` are the unit-position analogs of the existing `…AtCursor` pair:
they commit the engine's in-flight ground-target placement at the
resolved unit's world position instead of the cursor raycast.
ClassicAPI's `/cast [@unit]` / `[@unit]` for AoE reticle spells and
on-use items. Verified in-game against "player", "target", and
"nameplate1".
Implementation reuses the cursor path's placement primitives:
- Extracted `Spell::AtCursor::DispatchSpellCast` + `CommitAtCoords`
(explicit-coords commit, sharing the same placement-ready gate as
the cursor `Resolve`).
- New header-only `Unit::Position` (vtable slot-5 `GetPosition` read);
migrated `UnitInRange` onto it to drop the duplicate copy.
The unit is resolved before the cast/use is dispatched, so an absent
unit fails cleanly without arming placement mode or consuming the
item. Unknown token strings raise the engine's standard "Unknown unit"
error, matching `UnitHealth("garbage")`.
Source the strings FormattingUtil/TimeUtil consume (LARGE_NUMBER_SEPERATOR,
PERCENTAGE_STRING, GOLD/SILVER/COPPER_AMOUNT_SYMBOL, the time
abbreviations + D_* + *_ONELETTER_ABBR, clock formats) from Classic Era
enUS GlobalStrings and define them in locales/enUS.lua, which loads
before the Util files. `or`-guarded so any value the 1.12 client already
defines is preserved (the time abbrevs use |4 plural tokens, which 1.12
supports — our existing ITEMS_* strings already rely on |4).
The Util files no longer define these (single source of truth in the
locale layer); they retain only the non-locale numeric constants
(COPPER_PER_*, SECONDS_PER_*) and the formatting logic. FormatUnit still
inserts the count whether the unit string carries a %d or is a bare label.
1.12's native unit-abbreviation globals (HOURS_ABBR, MINUTES_ABBR, …)
are bare labels ("Hr", "Min") with no %d placeholder, unlike modern's
"%d Hr". So string.format(HOURS_ABBR, n) returned just "Hr" — the count
was lost (SecondsToTime(3661) -> "Hr Min" instead of "1 Hr 1 Min").
Add a FormatUnit helper that string.formats when the unit string has a
%d and otherwise prepends "<count> ", so it's correct whether the global
is modern ("%d Hr") or a bare 1.12 label ("Hr"). Verified expectation:
SecondsToTime(3661) -> "1 Hr 1 Min".
Proactive Tier-1 backports of the most widely-assumed FrameXML utility
globals, ported to Lua 5.0 (no `#`/`%`/string-method syntax; `%` ->
math.mod). Feasible subsets only:
- FrameUtil: RegisterFrameForEvents / UnregisterFrameForEvents,
GetRootParent, SetParentMaintainRenderLayering, DoesAncestryInclude(Any).
Omits the fade/flash helpers (native in 1.12) and the scale/layout/
secure/RegisterUnitEvent helpers (APIs absent here).
- FormattingUtil: FormatLargeNumber, FormatPercentage, FormatFraction,
SplitTextIntoLines, SplitTextIntoHeaderAndNonHeader, GetMoneyString.
GetMoneyString uses text symbols (g/s/c) since coin texture markup
doesn't render in 1.12; currency/cost helpers omitted.
- TimeUtil: SecondsToTime, SecondsToClock, SecondsToTimeAbbrev,
ConvertSecondsToUnits, SecondsToMinutes/MinutesToSeconds + unit
constants. Omits the modern SecondsFormatterMixin.
FrameXML format-string globals (LARGE_NUMBER_SEPERATOR, PERCENTAGE_STRING,
DAYS_ABBR, GOLD_AMOUNT_SYMBOL, …) are defined with `or` fallbacks so a
native/localized value wins when present and we degrade sanely otherwise.
Loaded after MathUtil so FormatPercentage's Round() is available.
Reverts the relocate-to-ColorUtil approach (b2db474): PLAYER_FACTION_COLORS
and BAG_ITEM_QUALITY_COLORS belong in Constants.lua, matching Blizzard's
own layout (its FrameXMLBase/Constants.lua references the named *_COLOR
objects directly).
Blizzard gets away with it via load order: the color objects are
materialized before its Constants loads. We now do the same — move
Mixin.lua + Color.lua above Constants.lua in the TOC, so Color.lua's
C_UIColor loop creates COMMON_GRAY_COLOR / PLAYER_FACTION_COLOR_* before
Constants.lua references them. Safe because Color.lua only depends on
Mixin.lua (+ the native ITEM_QUALITY_COLORS and the DLL's C_UIColor),
nothing else in the load chain; Constants.lua doesn't depend on the
utils that now follow it.
PLAYER_FACTION_COLORS was built in Constants.lua (loads early), but it
references the PLAYER_FACTION_COLOR_HORDE/ALLIANCE color objects that
Color.lua only materializes from C_UIColor at a later load step. So the
table captured nils -> ended up {}, and GetFactionColor always returned
nil. (Can't just reorder the TOC: Color.lua depends on ITEM_QUALITY_COLORS
from Constants.lua, so it's circular.)
Move the PLAYER_FACTION_COLORS construction to ColorUtil.lua, which loads
after Color.lua (and is where its only consumer, GetFactionColor, lives),
so the color objects exist by then. PLAYER_FACTION_GROUP stays in
Constants.lua (no such dependency).
GetFactionColor needs the FrameXML PLAYER_FACTION_GROUP global (faction
tag -> 0/1 index) to pair with our PLAYER_FACTION_COLORS, but we only
shipped the latter, so the lookup always returned nil. Add
PLAYER_FACTION_GROUP next to it, matching Blizzard SharedConstants
({[0]="Horde",[1]="Alliance",Horde=0,Alliance=1}); `or`-guarded so a
native definition wins if present.
Fills the gaps found diffing against Blizzard's reference (same class of
missing-global that broke tContains), porting to Lua 5.0:
Color.lua:
- WrapTextInColor(text, color)
- ColorMixin:IsRGBEqualTo (IsEqualTo now routes through it, per ref)
- ColorMixin:GetHSL
- ColorMixin:GenerateHexColorNoAlpha
ColorUtil.lua:
- CreateColorFromRGBHexString / CreateColorFromRGBAHexString
- IsRGBAEqualToColor
- RGBToColorCode / RGBTableToColorCode
- GetFactionColor (guarded: PLAYER_FACTION_GROUP/COLORS may be absent)
Color.lua and ColorUtil.lua now match the reference's function set. The
deliberately-skipped Secure* variants in Mixin/Pools (modern secure-
template machinery with no 1.12 equivalent) are left out.
tDeleteItem (added in 11191e8) used the 5.1 length operator `#tbl`, which
doesn't exist in 1.12's Lua 5.0. Swap both occurrences to tGetN(tbl)
(table.getn) to match the rest of the file.
Channel end time read SpellDuration.dbc base directly; route it through
the engine's own duration helper FUN_006ea000 (base + level scaling +
duration SpellMod op 1, unit arg 0 = local player) — mirroring how the
cast time is sourced, so the channel bar matches the engine and any
per-level / talent duration is honored. Stock vanilla channels are
fixed-duration, so no change there (verified: Blizzard still 8s).
Completes the SpellMod op map derived this session: duration 1, range 5,
radius 6, cast time 10, cost 14.
GetSpellPowerCost had a duplicate power-type -> token map. Promote the
existing UnitPowerType mapping out of Power.cpp's anonymous namespace
into a shared unit/Power.h (Unit::Power::PowerTypeToken) and have both
consumers use it. Out-of-range PowerType now reports "UNKNOWN" in
GetSpellPowerCost (was ""), matching UnitPowerType.
The mana check read the base ManaCost (+0x80) directly, so a spell with
a cost-reduction talent was wrongly reported as unusable (noMana) when
the player's power sat between the reduced and base cost. Route it
through the engine's effective-cost helper FUN_006e31b0 (op-14 SpellMod +
descriptor power-cost mods + ManaCostPercent), with a base-cost fallback
if the engine can't resolve a cost.
Example: Blizzard base 1400, 3/3 Frost Channeling -> effective 1190; with
~1200 power the spell is now correctly usable instead of noMana.
Returns a SpellPowerCostInfo[] (one entry in vanilla — spells have a
single power cost), or nil for an unknown / no-cost spell. The `cost` is
the player-effective value via the engine's own cost helper FUN_006e31b0
(base + level + ManaCostPercent-of-resource + descriptor power-cost mods
+ the cost SpellMod, op 0xE) — so it matches what casting actually
deducts, talents included.
Verified in-game: Blizzard (10187) on a Mage with 3/3 Frost Channeling
returns cost 1190 = 1400 base x 0.85 (15% reduction).
Keeps C_Spell.GetSpellInfo's `cost` field as the base ManaCost (intrinsic
spell data); GetSpellPowerCost is the player-modified counterpart — same
base/modified split as GetSpellRadius. Calling the full engine helper
beats replicating op 14 via Spell::Mod, since the helper also folds in
the descriptor power-cost modifiers.
Fields: type (Enum.PowerType), name (power token), cost, minCost (==cost,
no optional cost in vanilla), costPercent, costPerSec 0, requiredAuraID 0,
hasRequiredAura false.
Docs: API.md section + README Spell row.
Cast/channel info for the local player with full timing, plus channel
spellID/name/texture for any unit. Vanilla stores no cast times anywhere
readable (confirmed via the cross-binary technique: 3.3.5 reads them from
per-unit CGUnit fields that don't exist in 1.12), so the player's
cast/channel start/end are self-tracked on WorldTick:
- cast: VAR_CURRENT_CAST_SPELL 0->nonzero transition stamps startMs = now
(FUN_OS_TICKCOUNT_MS) and endMs = now + effective cast time. The cast
time comes from the engine's own helper FUN_006e3340 (base + level
scaling + cast-time SpellMod op 10 + the caster's haste/cast-speed
multiplier), the same one the engine cast-start uses -- so end time
matches the cast bar, e.g. a talented Frostbolt reads 2.5s not 3.0s.
Instant casts (cast time 0) and channels are filtered out.
- channel: player's broadcast UNIT_FIELD_CHANNEL_SPELL (+0x228),
endMs = now + SpellDuration base ms.
FUN_OS_TICKCOUNT_MS shares GetTime()'s epoch (x0.001), so startMs/endMs
feed addon progress math directly. Other units: channel info from the
broadcast +0x228 field (no times, since per-unit start isn't tracked);
regular casts return nil (no vanilla data -- remote casts are transient
spell-visual objects, not a queryable record).
isTradeskill is real: the spell's SPELL_ATTR_TRADESPELL flag (Attributes
bit 0x20). Remaining placeholders: castID/castBarID nil, notInterruptible
false, delayTimeMs 0.
Verified in-game: Blizzard channel 8s, Conjure Food cast 3s, talented
Frostbolt 2.5s, correct icons/spellIDs, GetTime()*1000 between start/end;
isTradeskill true for Rough Blasting Powder (Engineering).
Docs: API.md sections + README Spell row; TODO #68 marked done.
GetSpellRadius now returns (baseRadius, modifiedRadius). modifiedRadius
applies the local player's talent/item radius modifiers; equal to base
when no modifier matches.
The modifier logic is extracted into a reusable Spell::Mod helper
(src/spell/Mod.{h,cpp}) since the same engine SpellMod mechanism drives
cast time, mana cost, duration, range, cooldown, etc. — only the op
differs. Spell::Mod::Apply(spellRecord, op, base) replicates
FUN_006e6b30/FUN_006e6bf0: gate on the spell's SpellFamilyName matching
the player's class family (ChrClasses.dbc[class]+0x3c), sum the player's
flat/percent tables (0x00CEAD60 / 0x00CECB30, stride 0x74) over the
spell's 64-bit SpellFamilyFlags, then (base + flat) * (100 + pct) * 0.01.
Inherently local-player-only — the client tracks spell mods for the
player alone.
Offsets: VAR_SPELLMOD_FLAT/PCT_TABLE, SPELLMOD_SLOT_STRIDE/COUNT,
SPELLMOD_OP_RADIUS, OFF_SPELL_RECORD_FAMILY_NAME/FLAGS/ATTRIBUTES_EX2,
SPELL_ATTR_EX2_NO_SPELL_MODS, OFF_CHRCLASSES_SPELL_FAMILY.
Verified in-game: Frost Nova (10230) -> (10, 10) without Arctic Reach,
(10, 12) with rank 2 (+20%). op 6 = radius confirmed via FUN_006e6350;
percent constant 0.01 confirmed from .rdata.
C_Spell.GetSpellRadius(spellID) plus the vanilla-positional
GetSpellRadius(slot, bookType). Returns the spell's AOE radius in yards,
or nil for a non-AOE spell.
Radius is per-effect: Spell.dbc EffectRadiusIndex[3] at record +0x160
(verified via FUN_006e6350, which reads spellRec[+0x160]/[+0x164] for
effects 0/1), each resolving into SpellRadius.dbc (records 0x00C0D7B0,
count 0x00C0D7B4; base radius f32 at record +0x04). Returns the largest
base radius across the three effects. Base radius only — no caster-level
scaling, matching modern GetSpellRadius.
Dual signature mirrors SpellHasRange/GetSpellInfo: namespaced form takes
a spell identifier, the bare global takes a (slot, bookType) spellbook
position (bookType "pet" -> pet book, else player book).
Docs: API.md section + README Spell row; TODO #38 marked done.
Add the API.md section (return contract, always-English rationale,
common mechanic IDs + the Sap/Gouge=14 vs nonexistent 30 caveat) and
the README Spell-row entry.
Reads the spell-level Mechanic field straight out of Spell.dbc
(record +0x14) and resolves the name from SpellMechanic.dbc. Covers
every spell the client knows (not just the player's spellbook), with no
caching or network round-trip since the DBCs are resident from boot.
Replaces hand-maintained spellID -> mechanic tables addons keep because
vanilla exposes no Lua reader for the field (e.g. SuperCleveRoidMacros'
CCSpellMechanics, used for [cc:stun] conditionals).
Returns: nil for an invalid spellID; (0, nil) for a known spell with no
mechanic; (mechanicID, name) otherwise. The name is always the enUS
column (locale-independent) since it's a stable token consumers
string-match against; nil if the mechanic has no record / no English
name (numeric ID still returned).
Mechanic offset (+0x14) verified two ways: schema anchor off the
verified Attributes@+0x18, and FUN_006e9ca0 (mechanic-immunity check)
reading spellRec[+0x14] as the spell's mechanic. SpellMechanic.dbc name
at record +0x04 confirmed in-game: 118 -> (17,"polymorphed"),
339 -> (7,"rooted"), 133 -> (0,nil). The .dbc has 27 rows; only the
enUS name column is populated.
commit 52064f025188267ab44dae41af93261ae1bcfc03
Author: Brues <5278969+brues-code@users.noreply.github.com>
Date: Thu Jun 18 23:33:09 2026 -0500
Item cache: fast-fail server rejections; report pending overflow
Two follow-ups to the WarmCache startup-latch fix.
Failure fast-path: the escalation path used a no-op callback, so a
server "no such item" reply (high-bit itemID — the FUN_0055BDB0 failure
branch does NOT set the entry loaded flag) went undetected and the item
waited out the full kMaxWaitTicks (~20s) before reporting failure.
Replace it with ItemLoadCallback_Escalated, which marks the pending
entry failed when the engine reports !success. A shared TryCompletePending
helper resolves each entry either way (loaded -> success, failed ->
failure) and is now used by both the response-handler sweep and the
tick, so rejections fire in the same response. Genuinely lost queries
(server silent) still fall back to the timeout.
Overflow: Track now returns false when the pending set is full instead
of silently dropping, and the explicit RequestLoadItemData/ByID Lua
functions propagate that as their boolean return. Bump kMaxPending
256 -> 512 since the unsettled window now also holds implicit warmups.
commit 797a73de44e6a4ceb0ac434944a137dcd47c18f3
Author: Brues <5278969+brues-code@users.noreply.github.com>
Date: Thu Jun 18 23:24:04 2026 -0500
Item cache: gate WarmCache behind a startup-settled latch
The previous fix (ad13440) deferred only the explicit
RequestLoadItemData path out of the engine startup window. The implicit
WarmCache path — the GetItemInfo hook plus every GetItem* getter cache-
miss warmup — still eagerly called CacheFetch(callback) with no gate, so
it orphaned cache entries during the exact PLAYER_ENTERING_WORLD /
addon-load window the original bug was about. pfQuest/Questie scan item
data there, so those items stayed permanently nil for the session.
WarmCache now gates on a one-way g_settled latch:
- Pre-settle (startup): defer through the same pending set as
RequestLoadItemData, tagged implicit so the response-handler sweep
fires GET_ITEM_INFO_RECEIVED (not ITEM_DATA_LOAD_RESULT).
- Post-settle: eager CacheFetch as before, for low-latency mid-session
lookups.
The latch flips true the moment the engine response handler fires (proof
dispatch is live) or after kSettleTicks world ticks, whichever first.
Per-process, not re-armed on relog (cold-WDB race is initial-login only).
Unify both paths through Track(itemID, implicit); sweep and timeout fire
the correct event by tag. Escalation now gates on g_settled instead of a
fixed per-item tick count, draining the startup backlog as soon as it is
safe. Bump kMaxPending 64 -> 256 to absorb the larger startup burst.
The original implementation only consulted the SMSG_NAME_QUERY_RESPONSE-
fed player NameCache, which only populates for GUIDs the engine has
explicitly queried — so currently-synced players (just-targeted, in
view, etc.) typically miss until the engine sends a name query.
Switch to vanilla's own `Script_UnitName` path: resolve the GUID via
`FUN_CLNT_OBJ_MGR_OBJECT_PTR` with TYPEMASK_OBJECT, then call the
engine's polymorphic name getter `FUN_OBJECT_GET_NAME`. That routes
through the player NameCache AND the creature cache, so both player
and NPC GUIDs return real names for any currently-synced unit. Gate
on the "UNKNOWNOBJECT" / "Unknown Being" sentinels so non-unit
objects fall through. Persistent NameCache fallback kept for ex-units
that have left the engine's sync window.
Modern WoW returns the subtitle as UnitName's second return; vanilla
1.12's UnitName returns only one value, leaving no direct route to
the subtitle. UnitSubName reads it straight off the engine's creature
cache (`[unit + 0xB30] → [+0x10]`), no tooltip scrape required.
Returns nil for players, unsynced NPCs, and NPCs with no subtitle.
Modern backport that reuses GetPlayerInfoByGUID's lookup chain
(engine NameCache → persistent on-disk fallback) but pushes only
(name, realm) — narrower return shape for callers that only need
the name. realm is always "" in vanilla (single-realm, engine
doesn't populate per-player realm).
CMakeLists rejects CLASSICAPI_TAG values that aren't empty or
vMAJOR.MINOR.PATCH. Branch pushes were passing `github.ref_name`
(= the branch name, e.g. "master") and failing the regex.
Was tag-only. Now builds on every push to any branch, uploading the
DLL as a workflow artifact (ClassicAPI-<sha>, ~90-day retention) so
pre-release DLLs are downloadable from the run page without cutting
a tag. Tag pushes still build + stamp the embedded TOC version +
publish the GitHub release.
Renamed release.yml -> build.yml to match the broader role.
The engine stores expiration timestamps as GetTickCount-derived uint32
milliseconds. We were reading as signed int, then bailing on `<= 0` to
catch the "no timing" sentinel of 0. Once system uptime crossed 2^31 ms
(~24.86 days) the high bit flipped, signed reads went negative, and
every active buff started reporting expirationTime = 0 while
GetTime() kept working through a different code path.
Switch to uint32_t with `== 0` sentinel check. The unsigned read flows
correctly through GetTickCount's 2^32-ms (~49.7-day) wrap because
addon math (`expirationTime - GetTime()`) operates on values that wrap
together — same as the engine's own `expirationTable[slot] - currentMs`
subtraction.
Vanilla's CMSG_SPLIT_ITEM rejects splits where count == srcStack
(EQUIP_ERR_COULDNT_SPLIT_ITEMS — would leave source empty). The
drag-and-drop UI's "drop whole stack on matching stack" goes through
CMSG_SWAP_ITEM, which the server merges for same-item destinations.
In MoveCount, read ITEM_FIELD_STACK_COUNT off the resolved source
CGItem and route count == srcStack to Item::Swap::Containers. Also
return false locally for count > srcStack (server would reject anyway,
fail fast). count < srcStack keeps the existing SPLIT path.
3.3.5+ fires UNIT_FACTION("player") on rep-state changes that vanilla
silently drops. Hook each path's inner setter / SMSG handler and fire
the event post-original via the engine's printf-style dispatcher:
- FactionToggleAtWar — fire if rep slot's flag byte changed
- SetFactionInactive / SetFactionActive — same shape
- SMSG_SET_FACTION_STANDINGS — fire if any of 64 flag bytes changed
(catches AT_WAR side-effect flips on standing threshold crossings)
- SMSG_SET_FACTION_ATWAR — unconditional after byte write
- SMSG_INITIALIZE_FACTIONS — fire after initial sync at login
Hoist the event-table strcmp walk into Event::Custom::LookupByName
(sibling to Lookup, which only consults our reservation cache). Replaces
duplicated walks in event/Util.cpp::IsKnownEventName and the polyfill.
Re-registers the standard five entries (create / resume / yield /
status / wrap) on the global `coroutine` table. The C-level
machinery (lua_resume, lua_yield, lua_newthread, threads as tag 8)
was always linked into the engine; only the script-side library
was stripped from the registration tables at build time.
Two WoW-specific quirks discovered along the way and worked
around:
- `lua_resume` returns 1 for BOTH yield and caught runtime error.
Disambiguated by checking `co->ci > co->base_ci` — a yielded
coroutine still has its body frame on the call stack; an
errored one has been unwound to base_ci.
- The byte at L+0x31 is `allowhook` (saved/restored around
protected calls), not `L->status` as it initially looked.
Status reading is replaced by the ci comparison above.
Single-call kitchen-sink reader for the cached `ItemStats_C` record.
Returns a table with ~50 fields covering everything the cache holds:
identity (name, description, icon, class/subclass/equipLoc strings),
quality + bind type + decoded flag bits (isConjured, isOpenable,
isLootable, isWrapper), stacking, economy, requirements, equipment
stats (sparse map keyed by statType), resistances, weapon damage
arrays, spell slots, and the misc fields (lockID, itemSet, pageText,
startQuest, area/map, etc.).
Returns nil for uncached / invalid input — passive reader, no cache
warming. Caller wanting to ensure load should `RequestLoadItemData`
and listen for `ITEM_DATA_LOAD_RESULT`.
Implements via existing offsets where available; adds the remaining
`ItemStats_C` fields to `Offsets.h` based on VanillaHelpers's full
struct layout, anchored against the offsets we'd already verified
(`m_stackable@+0x60`, `m_lockID@+0x1AC`, `m_bagFamily@+0x1D0`, etc.).
Bursts of `WarmCache` calls for the same uncached itemID (e.g. Bagnon
re-enumerating a slot while the SMSG_ITEM_QUERY_SINGLE_RESPONSE is in
flight) were each appending a fresh `Implicit` callback to the
entry's pending list. When the response landed the engine walked the
list and fired our callback N times, surfacing as duplicate
`GET_ITEM_INFO_RECEIVED` events for the same itemID.
`_GetRecord`'s 6th arg (`param_5` in the decompilation, previously
documented as the unused `requestIfMissing` bool) controls whether
the engine dedups appends. With `0` it always appends; with non-zero
it walks the existing list and skips the append on a matching
`(callback, userData)` node. We always have the same callback ptr and
encode the itemID in userData, so flipping the bit collapses repeat
warmups for the same item to a single registration.
`Item:CreateFromItemID(X):ContinueOnItemLoad(cb)` was leaving items
permanently uncached when pfQuest called it at addon-load /
PLAYER_ENTERING_WORLD against an empty WDB. Three intertwined bugs:
1. `GetItemInfoInstant` returned no values for uncached items, so
`ItemMixin:GetItemID()` returned nil and `AddCallback(nil, cb)`
crashed with "table index is nil".
2. `DoesItemExistByID` returned false for uncached items, which made
`IsItemEmpty()` return true and erroneously gated `ContinueOnItemLoad`
on cache state — modern semantic is "is this a valid ID", independent
of cache.
3. Our `RequestLoadItemData` path eagerly called `CacheFetch` with our
own callback at addon-load. That created a cache entry before the
engine's natural inventory prefetch had a chance to run; the engine
then saw the entry already existed and skipped its own SMSG, leaving
the entry permanently pending. `GetItemInfo` retries didn't help —
subsequent `_GetRecord` calls just appended callbacks to the dead
list, no new query was sent.
Fixes:
- `GetItemInfoInstant` always pushes the itemID (echoed from input) and
the remaining 6 fields when cached / nils when not. No cache touch.
- `DoesItemExistByID` returns `itemID > 0`. No cache touch.
- `RequestLoadItemData` tracks itemIDs in a pending set without touching
the cache. We hook `FUN_ITEMSTATS_CACHE_RESPONSE` (the engine's
`SMSG_ITEM_QUERY_SINGLE_RESPONSE` handler at `0x0055BDB0`) and sweep
pending after the engine fills entries — observing the natural
prefetch instead of racing it. `WorldTick` only handles escalation
(after ~60 ticks, kick a query for items the engine doesn't prefetch
itself, e.g. quest rewards) and the hard timeout.
Also fix the truthiness bug in modern (id, success) events:
`Event::Custom::Fire("%d%d", id, success)` was pushing `success` as a
Lua number — both 0 and 1 are truthy, so `if success then` fired the
success branch on failed loads. New `Event::Custom::FireIdSuccess`
pushes `1` for success and `nil` for failure (via the engine's
`lua_pushstring(NULL) → lua_pushnil` tail-jump). `ITEM_DATA_LOAD_RESULT`,
`GET_ITEM_INFO_RECEIVED`, and `QUEST_DATA_LOAD_RESULT` now all use this.
Hoists the macro-parser hook + long-line guard + per-line walk out
of Spell::CastNoToggle into a new Spell::MacroPrimarySpell module
that dispatches to (prefix, extractor) pairs registered by feature
modules. The body walk now visits each line once and checks every
registered prefix at each position; extractors only parse args
after a matched prefix.
Registers a pattern for C_Spell.CastAtCursor(<spellID>) so macros
like `/run C_Spell.CastAtCursor(10)` get tagged with the spellID
the same way `/cast Blizzard` does, driving action-bar cooldown
overlays and auto-repeat highlight in pfUI etc.
Vanilla 1.12.1 requires a manual click on the AoE reticle to land a
ground-target spell. This pair of functions fires the cast (or
on-use item spell) and immediately commits placement at the
cursor's current world position, matching modern `[@cursor]`
semantics:
C_Spell.CastAtCursor(spellID)
C_Item.UseAtCursor(itemInfo)
Chains the engine's own primitives — name-resolver to spellbook
slot, cast dispatcher, then a screen→world raycast via the existing
mouseover machinery and the placement-commit function. Cancels
cleanly when the spell isn't ground-target or the cursor is over
UI / off-screen.
Also extracts the bag-search pattern (used by `UseAtCursor` and
existing by-name action APIs) into `Item::Location::FindByArgInBags`,
alongside the existing `FindByItemID` / `FindByGUID` family.
Vanilla 1.12.1 hashes FrameXML.toc + Bindings.xml + each addon's
contents and compares against signature blobs (Interface\FrameXML.sig
+ per-addon stored hash). ClassicAPI's file-read hook diverges those
in memory — Bindings.xml splice and the embedded !!!ClassicAPI addon
— so the engine fires FUN_00401560(10) and terminates with "Your
game interface files are corrupt".
Hook FUN_00401560 and drop calls with code 10. Other codes (login
failures, etc.) pass through unmodified. Stock vanilla binaries
(WoW.exe without a runtime patcher) now load ClassicAPI without
needing TLS-bootloader-style modifications.
Splice into the engine's FrameXML Bindings.xml at load time via a
file-read hook so the new bindings receive display indices from the
same running counter as native bindings — landing visually inside the
TARGETING block instead of orphaned at the bottom (where addon-side
Bindings.xml entries always render, regardless of header="TARGETING").
The two functions have different signatures in modern (Classic Era
1.15.x) WoW, verified against BlizzardInterfaceCode's generated
ItemDocumentation.lua:
GetItemUniqueness(itemLocation) → (limitCategory, limitMax)
GetItemUniquenessByID(itemID) →
(isUnique, limitCategoryName, limitCategoryCount, limitCategoryID)
We were returning a 3-tuple in the wrong order for both. For a
"Unique (10)" item the byID variant now correctly returns
(true, nil, 10, nil) instead of (10, nil, 0).
Mail helms were showing up for cloth classes — the function only
checked invType→slot shape, not whether the player could actually
equip the item.
Adds a proficiency gate using the engine's per-itemClass mask table
at [0x00C4D4A0], populated by the SMSG_SET_PROFICIENCY handler at
0x005E7B70. `table[itemClass] & (1 << subclass)` is the same lookup
4.3.4's Script_GetInventoryItemsForSlot does (via FUN_00553AD0 ->
DAT_00DD4D38), and the same source the engine's own tooltip red-text
coloring and cursor-drop equip check read from. Covers both armor
and weapon proficiencies in one mask lookup.
Adds the modern (2.0.1+) query function backed by the same
WH_GETMESSAGE hook stream that drives GLOBAL_MOUSE_DOWN/UP — state
flips at the exact same point the events fire. Accepts the 1..5 ID
form, the "LeftButton".."Button5" name form, or no arg for "any
button held".
Engine InvMgr state lags behind our atomic-swap packets (server has
to ACK before CGItem moves register locally), so mid-loop FindGUID
lookups returned stale locations and could undo prior swaps.
Mirrors 4.3.4's `Script_UseEquipmentSet` at 0x009A15D0, which walks
all 19 set slots up front to build an action list before any
execution. Combined with two-phase sequencing:
1. paperdoll→paperdoll only — keeps bag state untouched, supports
chained rotations via a virtual-paperdoll walk
2. bag→paperdoll — atomic swap displaces current occupants into
the source bag slot, never strands a future set item
3. unequip slots the set wants empty into pre-snapshotted free
bag slots
Eliminates the "trinket A in 14, B in bags" failure mode where an
earlier bag-to-paperdoll swap displaced an item later needed by the
set, then stale FindGUID returned the pre-swap paperdoll location.
Trace SMSG_QUEST_QUERY_RESPONSE serializer at 0x006DBCEC to map the
remaining cache-block field offsets, exposing four new fields plus
per-requirement override text. Empirical dumps across three quests
confirmed the +0x14..+0x24 slots (would-be SuggestedPlayers /
LimitTime / RequiredRaces / RequiredSkill / RequiredSkillValue) are
never populated by the vanilla server — those constraints are
enforced server-side and never broadcast, so addons needing them
must use an external DB.
Also corrects OFF_SORT_PTR -> OFF_ZONE_OR_SORT (int32, positive =
zone areaID, negative = -QuestSort.dbc row).
Modern method that renders the tooltip for a specific item *instance*
identified by GUID — same string `C_Item.GetItemGUID` returns. Distinct
from `SetItemByID` (which uses base `ItemSparse` data with no
per-instance state): this path goes through the live CGItem, so the
tooltip includes enchant lines, random-suffix-decorated name +
bonuses, and locked/broken state.
Implementation: parse GUID via `Item::Location::ParseGUIDString`,
resolve through the engine's `FUN_OBJECT_RESOLVE_BY_GUID`, then build
the per-instance hyperlink via `Item::Link::FromCGItem`. Strip the
`|cff…|H…|h[…]|h|r` decoration to the bare `item:N:enchant:gem:gem:
gem:gem:suffix:unique` payload (SetHyperlink rejects the decorated
form with "Unknown link type") and dispatch to the engine's existing
SetHyperlink. Closes the loop with `GameTooltip:GetItem()`, which
returns the same dressed link back.
Previously only `GUID_IGNORED` and `GUID_EMPTY` were both treated as
"skip this slot," so equipment sets with cleared (but not ignored)
slots couldn't be used to unequip — running a "naked" set returned
`true` but left the player's gear on.
Now `GUID_EMPTY` triggers a paperdoll-to-bag move. Implementation:
- `Item::Swap::ToBag` — new atomic primitive (paperdoll → bag) using
the same `FUN_INVENTORY_SWAP` engine helper as the equip variants,
just with player+slot as the source side and a bag-encoded
destination.
- `Script_UseEquipmentSet` snapshots all empty bag slots at entry
and consumes them sequentially. Snapshotting up front avoids the
client-side stale-state collision where iterating `ResolveBag`
per unequip would keep picking the same "first empty" slot — the
client's bag state doesn't update between packet sends, so the
second packet would target a slot the first had already filled,
and the server would try to swap the previously-unequipped item
back onto the wrong paperdoll slot ("This item cannot be
equipped").
Backports the modern WoW global that surfaces whatever's picked up
on the cursor. Reads the engine's cursor-state globals (type tag at
0x00B4D900 + per-type payload slots) and returns one of:
nil empty cursor
"item", itemID, itemLink bag item (full per-instance link)
"item", itemID, itemLink drag-source item (action bar /
equipment / mail) — itemLink
recovered by walking equipment+bags
for the live CGItem, same trick the
action-bar tooltip uses to display
enchant info on dragged gear
"money", copper gold-split / PickupPlayerMoney
"spell", slot, type, spellID spellbook spell
"macro", index macro
"merchant", merchantSlot merchant-frame item
Also splits the credstore/login offsets back into the same enum
(resolves the lingering merge in Offsets.h) and adds a shared
Item::QualityColor helper that reads the engine's `"|cffRRGGBB"`
color-prefix table at 0x00854124 directly instead of duplicating
the hex palette in cursor/Info.cpp and item/Tooltip.cpp.
New Item::Location::FindByItemID walks equipment then bags looking
for the first CGItem matching an itemID — used by the drag-source
link recovery.
* FocusUnit
* UnitTokenFromGUID: place focus after target, not after mouseover
Retail's documented scan order is
player -> vehicle -> pet -> party -> partypet -> raid -> raidpet
-> nameplate -> arena -> arenapet -> boss -> target -> focus
-> npc -> mouseover -> softenemy -> softfriend -> softinteract
Earlier commit placed focus at the very end. Moves it into the
target-adjacent slot so a focused unit reverse-resolves to "focus"
the moment it isn't also "target", matching retail.
---------
Co-authored-by: Brues <5278969+brues-code@users.noreply.github.com>
Extend EncodeBagSlot with two new branches matching vanilla's bagID
conventions:
- bagID = -1 -> main bank (24 slots at linear 39..62, container =
player GUID; same shape as backpack with a +38 offset instead of
+22).
- bagID = 5..10 -> equipped bank bags (containers at equipment slots
64..69; same shape as equipped bags with the index range shifted).
Both paths require the bank window to be open in the current
session — the engine doesn't sync bank-side CGContainers or bank
inventory entries before then, so ResolveEquipmentSlot returns null
and EncodeBagSlot bails cleanly. Same constraint
GetContainerItemInfo has on bank IDs.
Now that the engine recognizes `nameplateN` tokens (via the resolver
hook in nameplate/TokenResolver.cpp), they belong in the reverse-
lookup walk too. Iterates the dense ordered list directly via
NamePlate::Events::GetGUIDByIndex — bails on the first zero return,
skipping the token-resolver + SStrCmpI cost per index.
* C_NamePlate.GetNamePlateGUIDs
* fixed NameCache header
* C_NamePlate.GetNamePlates
* GetNamePlates will show blizzard nameplates as well
* C_NamePlate.GetNamePlateForUnit
* nameplate events
* C_NamePlate.GetNamePlateForGUID
* NAME_PLATE_CREATED now sends frame as argument
* document verification provenance for nameplate-adjacent offsets
- Offsets.h: annotate LUA_REF_REF / LUA_REF_UNREF with their Ghidra
decompile shapes (FREELIST_REF chain + LUA_REFNIL/NOREF sentinels)
so the next maintainer doesn't have to re-derive whether these are
really luaL_ref/unref.
- Walk.h: warn that the 0x00B41414 player global overlays multiple
sub-objects; reading +0x1C/+0x24 here is safe, feeding it to the
inventory path crashes.
* harden nameplate frame push against stale registry refs
PushNamePlateFrame now type-checks the rawgeti'd value; on a stale
slot (post-/reload windows, freed slot, etc.) it pops and falls back
to a fresh wrapper instead of returning whatever the registry happens
to hold at that index in the new Lua state. Script_GetNamePlates now
routes through the same helper so it inherits the guard.
Also document the bound on g_seenPlates: pool reuse via DAT_00c4d920
caps it at the session's peak simultaneous-plate count.
* support nameplateN unit tokens
Hook FUN_TOKEN_TO_GUID so every Script_Unit* (UnitName, UnitGUID,
UnitHealth, UnitClass, UnitExists, ...) accepts `nameplateN` tokens
and their `target`-suffix chains (`nameplate1target`,
`nameplate1targettarget`) — modern WoW semantics on the vanilla
engine. Hook is gated by an SStrCmpI prefix check; non-nameplate
tokens fall straight through to the original resolver.
- Ordered GUID list is maintained alongside the existing
NAME_PLATE_UNIT_ADDED / _REMOVED diff. Append on ADDED, erase on
REMOVED — creation-order, stable for a plate's lifetime, modern
semantics.
- Suffix walker mirrors the engine's own LAB_005159d3 walker
instruction-for-instruction: SStrCmpI "target" + ObjectByGUID +
read UNIT_FIELD_TARGET (m_objectFields + 0x28).
- SStrCmpI typedef is __stdcall, not __cdecl. The function ends with
ret 0xc; declaring it cdecl makes MSVC emit a redundant add esp, 12
on top of the callee cleanup, drifting ESP +12 per call and
crashing somewhere deep in Lua once L gets read from a poisoned
stack slot.
- m_objectFields is a *pointer* at obj+0x110, not an inline byte
offset. Two dereferences (mov eax, [obj+0x110]; mov edi, [eax+0x28]),
not one — earlier version mistook the inline-vs-pointer shape and
read instruction bytes as GUIDs.
* document nameplateN unit-token support
- API.md NamePlate section: replace the "scope vs. modern (deferred)"
callout with a pointer to the new Unit tokens subsection; update
the events table and "modern divergence" callout to explain why
the GUID payload remains (engine dispatcher can't carry Lua values
through %s/%d/%u/%f format codes) even though the token form now
works.
- New "Unit tokens (`nameplateN`)" subsection covering the resolved
~30 UnitX functions, creation-order semantics, suffix chains
(`nameplate1target`, `targettarget`), and the SStrCmpI prefix-gate
implementation detail.
- UnitTokenFromGUID's "post-1.12 omitted" list: nameplateN is no
longer engine-unsupported (we hook the resolver) but it's still
not used as a reverse-lookup key — indices can be reassigned
during a session, so it'd be unstable.
- README: new Unit tokens table row pointing to the API.md anchor.
* return stable nameplate wrapper across calls
PushNamePlateFrame was building a fresh Lua table on every call for
unregistered (default vanilla) nameplates — so fields addons set on
the wrapper they received in NAME_PLATE_CREATED were lost on the
next GetNamePlateForUnit lookup. pfUI specifically writes
`plate.nameplate = styledButton` on the CREATED-delivered table to
hand its overlay back through our API, and that field was
disappearing.
Fix: pin the first wrapper for each nameplate pointer in the Lua
registry via luaL_ref, cache the refkey in g_wrapperRefkeys, and
rawgeti on every subsequent push. Same table identity for the life
of the engine plate. Bound by the CGNamePlateFrame pool size (~80).
Cleared on /reload via a new NamePlate::Info::PrepareForReload()
wired into FrameScript_Initialize_h next to the existing
Event::Custom and NameCache reload hooks — stale refkeys would
otherwise point into the freed registry.
* NAME_PLATE_UNIT events now fire token
* nameplate events: ship "nameplateN" tokens, delegate wrappers to engine
Two intertwined changes that together make nameplate-mod addons
(pfUI) survive pool recycle.
1. NAME_PLATE_UNIT_ADDED / _REMOVED arg1 is now the "nameplateN"
unit token, matching modern WoW exactly. For REMOVED we compute
the token before erasing from g_orderedGUIDs so the handler can
still resolve the leaving unit. Addons that want a stable hash
key call UnitGUID(arg1) to convert.
2. PushNamePlateFrame now delegates to the engine's own
FrameScript_Object::ScriptRegister (0x00701BD0) instead of
maintaining our own luaL_ref'd wrapper cache. Per-pointer the
engine builds a wrapper table once, refs it into the registry,
and stores the refkey at nameplate+0x08 - every code path
(ours, addons' CreateFrame(parent) extraction, the engine's own
internal pushers) then funnels through
rawgeti(REGISTRY, nameplate+0x08) and gets the same Lua table.
The prior approach kept our own wrapper alongside the engine's
once pfUI's CreateFrame inside NAME_PLATE_CREATED caused the
engine to register the underlying frame itself - fields set on
our wrapper (plate.nameplate = decoratedButton) disappeared from
the API surface even though they still existed on the orphaned
table. Single-source-of-truth fixes it. The refcount-pinning
caveat CLAUDE.md warned about is benign for pool-managed
nameplates (never destroyed during a session).
Also clear seenPlates / lastTickPlates / orderedGUIDs on
FrameScript_Initialize so currently-visible plates refire CREATED
+ UNIT_ADDED after /reload - re-presents them to the freshly
reloaded UI, matching modern WoW.
---------
Co-authored-by: Brues <5278969+brues-code@users.noreply.github.com>
error("CreateColorFromBestRGBHexString input must be hexadecimal digits in either of these formats: "..COLOR_FORMAT_RGBA.." / "..COLOR_FORMAT_RGB..".",2);
returnnil;
end
functionCreateColorFromBytes(r,g,b,a)
@@ -31,6 +64,10 @@ function AreColorsEqual(left, right)
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>
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.
| Group | Calls |
|-------|-------|
| CharacterOrder | `GetSavedCharacterOrder`, `SetSavedCharacterOrder` (persist to `WTF\Account\...\ClassicAPI.txt`) |
| [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) |
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 |
@@ -80,10 +144,29 @@ 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 |
| `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). |
</details>
<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 [Predefined focus bindings](docs/API.md#predefined-focus-bindings-focustarget--targetfocus)
for the implementation note.
</details>
## Installation
Use [VanillaFixes](https://github.com/hannesmann/vanillafixes) to load the
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). |
@@ -201,4 +379,4 @@ at configure time; the value exposed to Lua will be `X*10000 + Y*100 + Z`.
## License
LGPL v3 or later. See the headers in `src/` for the full notice.
GPL v3 or later. See the headers in `src/` for the full notice.
| `0x006F2F80` | `lua_xmove` | fastcall | `(from, to, n)` — decr `from->top` by `n*0x10`, copy each 16-byte TValue, incr `to->top`. Used by `coroutine.*` to shuttle args/returns between threads | ★ |
| `0x006F3030` | `lua_newthread` | fastcall | GC check then calls inner `luaE_newthread` at `0x006F6B10`; pushes the new state onto L with tag 8 (LUA_TTHREAD). Returns `lua_State *` in eax — Ghidra reports `void` (decompiler artifact) | ★ |
| `0x006F4940` | `lua_error` | cdecl | `(L, msg)` — args on stack; reaches `luaD_throw` via wrappers | ✓ |
| `0x006F4940` | `lua_error` | **cdecl** varargs | `(L, fmt, ...)` — printf-style; engine prepends `luaL_where` then `lua_concat`. Single-string callers OK (cdecl tolerates extra-arg-less calls); user-controlled strings should use `Error(L, "%s", msg)` to avoid format-string interpretation | ✓ |
| `0x006F6620` | `lua_resume` | fastcall | `(L_co, nargs) → int`. Zero internal callers (coroutine lib was stripped from registration tables); linker kept the symbol. Stores nargs in a local, calls `luaD_rawrunprotected(L, &resume_inner, &nargs)` at `0x006F5DAC`; inner static helper at `0x006F67B0` | ★ |
| `0x006F6860` | `lua_yield` | fastcall | `(L, nresults) → int (-1)`. Sets `0x10` flag on CallInfo state field rather than directly writing `L->status` like later Lua versions. Errors with "attempt to yield across metamethod/C-call boundary" or "cannot yield a C function" when called from the wrong context | ★ |
| `0x006F6B10` | *internal*`luaE_newthread` | fastcall | Inner allocator called by `lua_newthread` at `0x006F3030`. Allocates the new state, sets tag 8 via the push helper at `0x006F7B20`, inits via `0x006F6C40`, copies global-state fields from the parent. Returns `lua_State *` | ★ |
### Misc / GC / version
@@ -206,6 +213,8 @@ filenames in Lua 5.0 by behaviour.
| `0x006F58B0` | `equalobj` | called by `lua_equal` |
| `0x006F6050` | `luaD_precall` | frame setup for a call; Lua closure: reads Proto (`+0xc` of the closure), `is_vararg` at Proto `+0x46` → `adjust_varargs`, `numparams``+0x45`, `maxstacksize``+0x47`; C closure: reserves 0x14 slots and calls the C function |
| `0x006F6200` | `adjust_varargs` | stock 5.0: pads missing fixed params with nil, then `luaH_new(L, nvar, 1)` + `luaH_setnum` per extra arg + sets field `"n"` and pushes the table. So EVERY call to a vararg Lua function allocates one table, whether the body uses `arg` or `...` — keep vararg helpers off per-frame paths |
| `0x006F65A0` | `luaD_call` | the actual call dispatcher |
Some files were not shown because too many files have changed in this diff
Show More
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.