Candidates for new DLL functions, prioritized. All are things the !!!ClassicAPI Lua addon polyfills badly, hackily, or not at all, and where engine access is the right answer.
The original TODO note ("engine maintains a completed-quests bitfield in
client memory") was based on 3.3.5 / WotLK semantics. Vanilla 1.12 has no
equivalent: neither QueryQuestsCompleted nor GetQuestsCompleted
exists, the protocol has no CMSG_QUERY_QUESTS_COMPLETED, and the
client doesn't track historical completions. The polyfill at
Util/C_QuestLog.lua:5-8 works on Frostmourne (3.3.5a) where those
functions exist; on the 1.12 Octo client they're absent.
Servers that want to expose this in 1.12 (notably Turtle WoW) do it
via the CHAT_MSG_ADDON channel — the addon sends .queststatus over
guild/whisper chat and the server replies with addon-channel messages
the addon parses. Fundamentally async, server-specific, and requires
SavedVariables-style persistence across sessions to amortize the
round-trip — all of which belong on the Lua side, not in this DLL.
There's nothing the DLL can usefully add here.
Calls the engine's own spell-description formatter at 0x005075F0,
which reads the description string from Spell.dbc (record +0x228,
locale-applied) and resolves $s1/$s2/$o1/$d-style placeholders
character-by-character. 8 callers in the engine (spell tooltip builder,
talent UI, trainer, etc.); we mirror the simplest one's args
(contextFlag=0 = "no caster scaling, base-rank values"). Output goes
into a 1024-byte caller-provided buffer that the helper null-terminates.
The interesting find: I expected the description to need a CGUnit
context for substitution, but the engine has a "no scaling" code path
that produces "14 to 22 Fire damage" (Fireball Rank 1's actual base
range) without any unit reference. That's the right semantic for
"describe this spell" — modern WoW behaves the same when called outside
a unit context.
Side note: a global at 0x00BE0B80 is the formatter's parser cursor
(it walks the description string char-by-char, advancing the cursor in
place). It's set and managed entirely inside the helper — callers don't
touch it. Lua C functions are single-threaded, so the global state is
safe to share with whatever else might call the formatter (it's all on
the same thread).
See src/spell/Description.cpp and the
FUN_FORMAT_SPELL_DESCRIPTION block in src/Offsets.h
for the full calling convention (8-arg __fastcall).
Implemented as C_Item.IsBound(itemLocation). Reads the soulbound bit
(ITEM_FIELD_FLAGS bit 0) directly off the CGItem's UpdateField
descriptor — no scan-tooltip hack, no string comparison. Accepts the
modern {equipmentSlotIndex=N} and {bagID=B, slotIndex=S} location
shapes. See src/item/Bound.cpp for the soulbound
read; the slot-resolution chain it depends on lives in
src/item/Location.cpp.
Implemented as the global GetSpellInfo(spellID). Returns the full 9-tuple
matching 3.3.5 semantics, including true/false for isFunnel (verified
against the 3.3.5 client). See src/SpellInfo.cpp.
Reads year/month/day/hour/minute from the engine's game-time struct at
0x00CE8538 (populated by SMSG_LOGIN_VERIFY_WORLD /
SMSG_LOGIN_SETTIMESPEED and advanced by the internal tick handler)
and converts via _mkgmtime to a Unix epoch timestamp.
The interesting find: the engine has its own "to seconds since epoch"
helper at 0x00642320 that I almost reused — but decoding the magic
constant 0xC22E4507 after the _mkgmtime call revealed the helper
divides the result by 86400 (seconds in a day), so it returns
days-since-epoch, not seconds. Rolled our own _mkgmtime call
instead.
The 1.12 wire protocol carries time at minute granularity — there are
no seconds in SMSG_LOGIN_SETTIMESPEED's packed gametime field — so
the engine's stored hour/minute steps once per minute. To make the
returned timestamp tick every second (which is what GetServerTime
callers actually need), we interpolate within the minute using
GetTickCount: whenever the engine's minute changes, we anchor to the
current tick and add (now - anchor) / 1000 for subsequent calls. The
cold-start value is off by 0..59 seconds since we have no way to know
how far into a minute the engine is the first time we observe it, but
after the first minute rollover the anchor lands at the boundary and
the timestamp is accurate for the rest of the session.
See src/time/Server.cpp and
VAR_GAMETIME_STRUCT / OFF_GAMETIME_* in
src/Offsets.h.
Reads the title out of the quest static-info cache at offset +0x9C
(inline null-terminated C string within the data block returned by the
cache's _GetRecord). Title-offset derivation: traced from the engine's
own quest-log title path at 0x004DF180, which calls _GetRecord and
then does add eax, 0x9C; ret — the result is fed directly to
lua_pushstring, so +0x9C is the C string itself, not a pointer.
The original TODO note "engine has the title in Quest.dbc" turned out
to be wrong — there is no Quest.dbc in 1.12 (only QuestInfo.dbc for
reward types and QuestSort.dbc for category headers). All quest static
data is server-authoritative, fetched via SMSG_QUEST_QUERY_RESPONSE and
held in the runtime cache at 0x00C0E1B0. The "async dance" the polyfill
does is the unavoidable cost of that — though now it's just one call to
C_QuestLog.RequestLoadQuestByID plus an OnEvent listener instead of
a hidden tooltip and a 180-second timeout.
See src/quest/Title.cpp and the shared cache
helper at src/quest/Cache.h. The data-block field
offsets (currently just +0x9C for title) live in Cache.h alongside
the Lookup/Peek wrappers, so additional cached fields like
description and objectives can be added there as we trace them.
Modern's slot system was a Shadowlands-era continuation-token pattern
for huge aura sets — pointless on vanilla where the per-unit aura
array caps at 48 and C_UnitAuras.GetUnitAuras(unit, [filter])
returns all of them in one call. GetAuraDataByIndex /
GetUnitAuraBySpellID / GetPlayerAuraBySpellID cover the
lookup-by-index and lookup-by-spellID consumer patterns. The
Util/Aura.lua / Util/AuraUtil.lua files referenced by the
original TODO no longer exist in the repo.
The "cache-fill path with a proper async callback so addons stop polling on a frame timer" goal landed as the request/event pair below:
- #12 ships
C_Item.IsItemDataCachedByID(item)/C_Item.RequestLoadItemDataByID(item)(and the ItemLocation variants), wrapping the engine's existingDBCache_ItemStats_C_GetRecordat0x55BA30with a non-NULL callback to trigger the SMSG_ITEM_QUERY_SINGLE network fetch. - #13 ships
ITEM_DATA_LOAD_RESULT(itemID, success), fired from our__stdcallcallback when the engine drops the response into the cache (or synchronously if it was already loaded).
Together those replace the polyfill's 180-second polling frame
(ItemEventListener in Util/ItemUtil.lua:254-372) — addons can now
fire RequestLoadItemDataByID(id) and listen for
ITEM_DATA_LOAD_RESULT instead.
A single GetItemInfo(item) that "returns nil and triggers a load" in
one call (matching modern WoW's GetItemInfo semantics for uncached
items) would be a thin convenience wrapper on top, but the modern
preferred pattern is exactly the request/event pair we have, so it's
not pursued separately.
The always-available subset of GetItemInfo — fields that depend only on
classification, not on player-specific state. Accepts itemID or
item:NNN link strings (full chat links work too); returns nil for
uncached items rather than blocking on a server query. See
src/item/Info.cpp. Reading ItemSubClass.dbc was the
fiddly bit (parallel-storage hack at +0x00/+0x04 for compound-key
lookup, plus a verbose→short locale-name fallback chain) — full notes in
CLAUDE.md and src/Offsets.h.
Returns the itemID for the item at a given equipment slot or bag/slot
tuple. Useful as the input to GetItemInfoInstant/GetItemInfo when you
only know which slot an item came from. Required factoring out the shared
slot-resolution code from IsBound into src/item/Location.cpp;
the GetItemID-specific bit (one extra deref) lives in
src/item/ID.cpp. The non-obvious part: the itemID
isn't in the CGItem's UpdateField descriptor (its OBJECT_FIELD_ENTRY
slot is empirically 0), but in a separate instance block at CGItem+0x08.
Walks the engine's static event-name table at 0x00BE11D8 and returns
true if any populated slot contains the queried name. See
src/event/Util.cpp. The interesting bit: third-party
DLLs (notably SuperWoWhook on Turtle clients) inject custom events by
overwriting .rdata event-name strings in place rather than appending
to the table — so a runtime walk picks up their additions for free. Full
notes in CLAUDE.md.
Wraps the engine's existing client-side item cache (_GetRecord at
0x55BA30, requestIfMissing flag). Both ByID and ItemLocation
variants ship. See src/item/Data.cpp.
Fires with (itemID, success) when an item finishes loading after a
RequestLoadItemData* call (or synchronously when the data was already
cached). See src/event/Custom.cpp for the
custom-event plumbing and src/item/Data.cpp for
the wiring to the cache callback.
The investigation needed three engine pieces nobody had documented:
- Dispatcher at
0x00703F50—__cdecl void(int eventID, const char *fmt, ...), 149 callers, indexes the table at[0xCEEF68] + id*16, walks the subscriber chain. Format is printf-style (%s,%d,%u,%f); no bool code, pass%dwith 0/1. - Registration table at
[0xCEEF68](count at[0xCEEF64]), 16-byte entries, name at+0x00, subscriber chain head at+0x0C.Frame::RegisterEventat0x00702140strcmps event names against this table. - Teardown landmine.
FrameScript_Initialize's reload path (0x00701A40) callsSMemFree(entry.name)on every entry; our static literals are not Storm-allocated and trip the safety check. There's a NULL-name shortcut (jzat0x00701A45) so we null our entries before the engine sees them.
Full notes in CLAUDE.md under "Firing a custom event end-to-end".
Fires QUEST_DATA_LOAD_RESULT(questID, success) when quest static info
(description, objectives, reward text) finishes loading. Synchronously
fired when the questID was already in the client cache; asynchronously
fired after SMSG_QUEST_QUERY_RESPONSE when the engine has to round-trip.
Implemented as a near-direct mirror of Item::Data: the quest cache
_GetRecord at 0x00562A40 has the same five-arg shape and the same
callback != NULL gate as the item cache _GetRecord. The interesting
investigation was confirming that [0x00BB7480] (passed as the cache
key in Script_GetQuestLogQuestText) is a questID, not a struct
pointer — verified by tracing the load site at 0x004DEF5D back through
mov eax, [esi + 0x00BB71C0], which reads field +0 of a quest log
entry (= questID per the quest log struct). The cache is then keyed by
questID just like the item cache is keyed by itemID.
See src/quest/Data.cpp and "Quest static-info
cache" in CLAUDE.md. The custom-event plumbing
(src/event/Custom.cpp) is shared with
ITEM_DATA_LOAD_RESULT — no new infrastructure needed.
GetFactionInfoByID(factionID) returns the same 11 values as
GetFactionInfo(factionIndex) keyed by factionID — implemented as a thin
wrapper that walks the engine's index→ID resolver at 0x004D5FA0 to find
the displayed-list index for the requested ID, then replaces Lua arg 1 and
tail-calls Script_GetFactionInfo (0x004D64F0). Only works for factions
the player has rep with, matching modern WoW's semantics.
GetFactionIDByIndex(factionIndex) exposes the same resolver directly —
returns the factionID for real entries, 0 for headers (normalizing the
engine's mix of 0 for "Other" and -1 for "Inactive"), nil for OOB.
Modern WoW (5.0+, including Classic Era 1.15.x) returns this as the 14th
value of GetFactionInfo, but 1.12-3.3.5 don't expose it at all.
See src/faction/Info.cpp and "Faction lookups" in CLAUDE.md for the resolver/dual-count details.
Shipped under the modern C_Container.* namespace (matching post-MoP
WoW). Wraps the existing bag→CGItem path: a new public
Item::Location::ResolveBag(L, bagID, slotIndex) exposes the bag
resolver that was previously private inside the table-shape
Resolve(), and a small ItemIDFromCGItem helper in
src/item/ID.cpp factors out the CGItem → instance
block → itemID step now used by both C_Item.GetItemID and
C_Container.GetContainerItemID.
Note: ships under C_Container rather than as the historical legacy
global GetContainerItemID since the modern namespace is the canonical
home for bag/container APIs (the global was deprecated in 10.0). If
addons need the legacy global it's a one-line addition.
Three Lua entry points that all share a PushIconForItemID(L, itemID)
helper in src/item/Info.cpp. The helper routes
through the existing FetchItemRecord (item cache) and BuildIconPath
(ItemDisplayInfo.dbc lookup at +0x14) — same code path
GetItemInfoInstant already runs, just stripped to the icon-only return.
The refactor that fell out: extracted Item::ID::FromCGItem (the
CGItem → instance-block → itemID step) into src/item/ID.h
so both Item::ID (for C_Item.GetItemID and
C_Container.GetContainerItemID) and Item::Info (for
C_Item.GetItemIcon's ItemLocation form) can share it. Was static-dup
across two files before this; now it's one definition.
Returns the path string (deviation from modern's fileID:number —
1.12 has no fileID system; same call-out as
C_Spell.GetSpellTexture's docs).
Returns the unit's 64-bit GUID as "0xHHHHHHHHLLLLLLLL" (16 hex
digits, hi dword first). Reads *(unit + OFF_UNIT_GUID_PTR) to get
the 8-byte struct, formats with snprintf. Lives in
src/unit/Identity.cpp.
Two behavioral notes worth flagging in docs:
- Vanilla format, not modern's prefix shape. 1.12 GUIDs are plain
64-bit integers (the
"Player-1234-..."/"Creature-0-..."formatted GUIDs were a 6.0 addition). Addons backporting modern GUID parsers need to accept the"0x..."form. - Errors on totally-unknown unit tokens.
ResolveUnitTokenitself raises"Unknown unit name: <token>"for strings outside the known unit-ID list. We don't suppress it — matches how every other 1.12 unit-token function (UnitName,UnitAffectingCombat, etc.) behaves. Tokens that resolve to "no current unit" (e.g."target"with nothing targeted) cleanly return nil via the GUID-is-zero short-circuit.
Reads Attributes (+0x18) bit 6 (SPELL_ATTR_PASSIVE = 0x40) off the
Spell.dbc record — same pattern src/spell/Info.cpp
already used for isFunnel (AttributesEx bit 6, on a different field).
Shipped both forms with shared PushIsPassive back-end:
IsPassiveSpell(spellID)/IsPassiveSpell(slot, bookType)— the 3.0-era global, supports both arg shapes viaResolveLuaArgsToSpellID(mirrorsGetSpellInfo/GetSpellLink).C_Spell.IsSpellPassive(spellID)— the modern 10.0 form (word order flipped during theC_Spellnamespace migration). spellID-only.
Builds |cff71d5ff|Hspell:ID:0|h[Name]|h|r via snprintf after a
single Spell.dbc name read. The trailing :0 after the spellID
matches modern's hyperlink shape so addons backporting from later
expansions can gsub with the standard |Hspell:(%d+): pattern; 1.12
ignores it during link parsing.
Both forms shipped:
GetSpellLink(spellID)andGetSpellLink(slot, bookType)(global) return(link, spellID). The slot-and-bookType overload reuses the sameSpell::Lookup::ResolveLuaArgsToSpellIDhelper asGetSpellInfo.C_Spell.GetSpellLink(spellID)(table) returns just the link string — the caller already had the spellID on hand.
See src/spell/Info.cpp Script_GetSpellLink /
Script_C_GetSpellLink and the BuildSpellLink helper.
Reads UNIT_FLAG_IN_COMBAT (bit 19, 0x00080000) of the player's
UNIT_FIELD_FLAGS — same bit Script_UnitAffectingCombat at
0x00517E4A-0x517E5C tests via mov eax, [fields+0xA0]; shr eax, 19; test al, 1. Modern WoW's "lockdown" gates secure-frame UI changes;
1.12 has no secure-frame system, so the function reduces to a plain
"is the player in combat" check (equivalent to
UnitAffectingCombat("player") but skips the unit-token resolution).
Lives in src/unit/Combat.cpp — first occupant of
the new src/unit/ directory. Future unit-flag accessors
(UnitIsAFK, UnitIsDND, UnitIsFeignDeath, UnitClassBase, etc.)
can land alongside without polluting the per-domain Lua-namespace
directories.
Locale-independent class file string ("WARRIOR", "MAGE", etc.).
Resolves the unit via FUN_RESOLVE_UNIT_TOKEN, reads the class
byte from UNIT_FIELD_BYTES_0 (descriptor +0x79, same byte
Script_UnitClass's general-token branch uses), and looks up
ChrClasses.dbc::Filename (record +0x38) for the english token.
Works for any synced unit — both fields are broadcast UpdateField /
static-DBC data. Returns nil for unresolvable units (empty
partyN slot, target with no target, out-of-range class byte
for non-player units). Implementation uses the existing
DBC::StringField helper so the bounds-checking and locale-array
hairiness stays in one place.
Returns (spellName, spellID) for the on-use spell attached to an
item (potions, trinkets, scrolls, hearthstone, food/drink), or nil
for items without one. Reads the cached ItemStats_C record's
m_spellId[5] array at +0x11C and m_spellTrigger[5] at +0x130,
matching the first slot with trigger == 0 (ITEM_SPELLTRIGGER_ON_USE).
Combines with Spell.dbc.Name[locale] at record +0x1E0 for the
spell name.
Offsets computed from VanillaHelpers's fully-decoded
ItemStats_C struct; verified empirically against Hearthstone
(itemID 6948, slot-0 spell 8690, trigger 0). Other trigger codes
(ON_EQUIP=1, CHANCE_ON_HIT=2, SOULSTONE=4, LEARN_SPELL=6)
are deliberately ignored — matches modern WoW's GetItemSpell,
which only reports the on-use spell.
Same auto-warmup pattern as the other cache-backed accessors:
returns nil for uncached items and silently fires the cache fill,
so a follow-up call after GET_ITEM_INFO_RECEIVED lands the data.
See src/item/Spell.cpp.
24. GetInventoryItemDurability(invSlot) / C_Container.GetContainerItemDurability(containerIndex, slotIndex) — DONE
GetInventoryItemDurability(invSlot) / C_Container.GetContainerItemDurability(containerIndex, slotIndex)Returns (current, max) durability for the player's equipped item or
bag/bank slot, or nothing if the slot is empty or the item has no
durability concept (consumables, materials, rings, trinkets, shirts,
tabards). Both forms shipped:
GetInventoryItemDurability(invSlot)— character-pane equipment slot (1-based, 1..19). Player-only by design, matches modern API; the original TODO line had(unit, slot)but modernGetInventoryItemDurabilitytakes only the slot.C_Container.GetContainerItemDurability(containerIndex, slotIndex)— bag/bank slot. Goes throughItem::Location::ResolveBag→PackBagSlot→GetItemBySlot, same chainC_Container.GetContainerItemIDalready used.
Both share the inner PushDurabilityForItem helper — only the
CGItem-resolution path differs. Reads ITEM_FIELD_DURABILITY (+0xA0) and
ITEM_FIELD_MAXDURABILITY (+0xA4) off the CGItem descriptor at +0x114
— the same descriptor src/item/Bound.cpp reads
FLAGS from. Field offsets verified by decoding
Script_GetInventoryItemBroken at 0x004C8590, which tests
[descriptor+0x3C] & 0x08 for the broken flag bit AND
[descriptor+0xA4] > 0 && [descriptor+0xA0] == 0 as a fallback.
Modern semantics confirmed against 3.3.5's
Script_GetInventoryItemDurability at 0x005EA170: returns nothing
(0 values) when max is 0.
Walks the player spellbook at 0x00B700F0 first, then the pet
spellbook at 0x00B6F098, returning (slot, bookType) for the first
match. Bounds at SPELLBOOK_MAX_SLOTS = 0x400 (the engine's own cap
in Script_GetSpellName); empty/zero entries past the populated count
are skipped naturally since spellID 0 doesn't match any positive
input. Result feeds directly into the slot-and-bookType API surface
(GetSpellName, GameTooltip:SetSpell, etc.).
Logic added to src/spell/Lookup.cpp as
Spell::Lookup::FindSpellbookSlot; the Lua C function lives next to
the other spell registrations in src/spell/Info.cpp.
Cache-record read of m_inventoryType at OFF_ITEMSTATS_INVENTORY_TYPE.
Non-zero = equippable. Lives in item/Equipment.cpp alongside
OffhandHasWeapon (which uses the same field) and IsEquippedItem.
Synchronous; returns false on cache miss without firing a load.
Namespaced under C_Item to match Classic Era 1.15.x.
Shipped as C_Item.IsEquippedItem in
src/item/Equipment.cpp. Walks equipment
slots 1..19, reads each CGItem's itemID, returns true on first match
against the input (accepts itemID, item:NNN link, or chat link).
Total count of an item across the player's bags (and optionally bank).
Walks bags 0..4 always (live walk via Item::Location::ResolveBag).
Stack count read directly from ITEM_FIELD_STACK_COUNT at descriptor
+0x20 — verified by decoding Script_GetContainerItemInfo
(0x004F9670), which does mov eax, [esi+0x114]; fild [eax+0x20] for
the count return.
Bank works cold — no banker visit required. The 1.12 server sends
bank inventory at login (verified empirically on Turtle WoW: fresh
WoW.exe launch + cleared WDB folder shows GUIDs already populated in
main bank slots 39..62 with the gate at VAR_BANK_GATE_GUID = 0).
The engine's GetItemBySlot (0x006228A0) gates bank slots 39..68
on a non-zero banker-GUID at 0x00BDD038 — set when bank window
opens, cleared when it closes — but the underlying GUID data in
invMgr+0x04 is always present from session start.
Bank-walk path bypasses the gate by reading GUIDs directly out of
the player invMgr's slot array and resolving each via
FUN_OBJECT_RESOLVE_BY_GUID = 0x00468460 (same function the engine
itself calls inside GetItemBySlot, just without the gate wrapper).
Bank bags (slots 63..68) are followed via the bag's vtable +0x10 to
get its own invMgr, then walked by the same direct-read path. Cold
count of a bank-only item now works from login without ever
approaching a banker — confirmed in-game.
The 1.12 STACK_COUNT field index (0x8) puts it before the contained/
creator GUIDs, opposite the more commonly documented vanilla
layout (which has STACK_COUNT at index 0xE = +0x38). Trust the
binary: 0x20 is what the engine itself reads, and our test (cross-
checked against a manual GetContainerItemInfo walk) confirms it.
includeUses reads ITEM_FIELD_SPELL_CHARGES[0] (signed dword at
descriptor +0x28) and multiplies each match by max(abs(charges), 1)
— so a wand with 50 charges contributes 50 instead of 1, while
non-charge items pass their plain stack count through unchanged. The
offset was derived from the descriptor field-name table builder at
FUN_0047f840, which iterates { name_ptr, ?, count, ?, ? } entries
at 0x0083a328 — summing the counts in declaration order puts
SPELL_CHARGES at field index 10 (fields 10..14, +0x28..+0x38).
STACK_COUNT/FLAGS/DURABILITY landing on their known offsets
cross-checks the derivation.
See src/item/Count.cpp.
Returns the BagFamily bitmask for the item, or nil if it isn't in
the cache. Reads m_bagFamily at +0x1D0 on the cached ItemStats_C
record (offset verified by decoding the engine's own
GetItemBagFamily(itemID) helper at 0x005DA050, which calls
_GetRecord then mov eax, [eax+0x1D0]; ret).
Encoding-shift gotcha. 1.12 stores the raw 1-based BagFamily ID
(arrow=1, bullet=2, soul shard=3, herb=6, …); modern WoW (Wrath+)
flipped to the bitmask form 1 << (ID-1) (arrow=0x1, bullet=0x2, soul shard=0x4, herb=0x20, …). Addons backporting from later
expansions expect the bitmask, so we convert on the way out via
bitmask = id ? (1 << (id - 1)) : 0. Verified empirically on Turtle
WoW: Soul Shard (item 6265) reads raw 3 from the cache, returns 4
(1 << 2) to Lua callers — matches modern.
Vanilla data sparsity. 1.12 server data (at least on Turtle WoW)
populates m_bagFamily reliably on individual loot items but
leaves it 0 on most bag containers themselves. Soul Pouch,
Enchanting Bag, Herb Pouch all return 0 even though their
classification clearly implies a family. Only Light Quiver is
populated among the bags we tested. This means
GetContainerNumFreeSlots's second return is reliable only for
quivers; for general bag categorization, addons should fall back to
checking (class, subClass) from GetItemInfoInstant. The
GetItemFamily item path is solid for routing loot to the right
specialty bag.
See src/item/Info.cpp.
Strict spellbook walk via Spell::Lookup::FindSpellbookSlot, filtered
by the requested book type. Returns true only when the spell is in
the player's (or pet's) spellbook UI arrays — does NOT cover talent
passives, profession recipes, or anything else that's "known" but not
displayable as a spellbook button.
Initially implemented IsSpellKnown using the same bitmap as
IsPlayerSpell, on the (wrong) assumption that the two functions
were equivalent in 1.12 since the bitmap was the engine's
authoritative knowledge store. Caught in review — IsSpellKnown
is meant to be the stricter check, distinct from IsPlayerSpell.
Cross-binary lookup confirmed: 3.3.5's Script_IsSpellKnown at
0x0053C3A0 calls an inner helper at 0x0053B4E0 that does a strict
spellbook array walk ([0x00BE6D88] for player, [0x00BE7D98]
for pet) — not a bitmap. Modern WoW deliberately splits the two
functions: IsSpellKnown is the strict "spellbook button" check,
IsPlayerSpell (added in 5.0) is the broad "any kind of known"
query. We need both with their respective semantics.
| Spell type | IsPlayerSpell |
IsSpellKnown |
|---|---|---|
| Trained class ability | true | true |
| Active talent grant | true | true |
| Passive talent | true | false |
| Profession recipe | true | false |
Same split as modern WoW. The cross-binary technique paid off again — caught the wrong implementation before it shipped.
See src/spell/Info.cpp and the worked example in CLAUDE.md.
All three shipped in src/unit/Flags.cpp:
UnitIsAFK(unit)/UnitIsDND(unit)— read PLAYER_FLAGS at[unit + 0xE68] + 0x08, bits 1 (AFK =0x02) and 2 (DND =0x04). Works for any player-controlled unit (local self, target, party, raid, inspect targets). Gated onUNIT_FLAG_PLAYER_CONTROLLEDfirst to avoid the crash hazard on creatures (where+0xE68is uninitialized garbage).UnitIsFeignDeath(unit)— readsUNIT_FIELD_FLAGSbit 29 (0x20000000) at[m_objectFields + 0xA0]. Verified in-game with a Hunter using Feign Death — the function returns 1 while the feign-death buff is active, 0 otherwise.
Where PLAYER_FLAGS lives — and the misleading first guess. The
field index 0x8A (= byte offset 0x228 in m_objectFields) is the
"natural" place to look (matches modern WoW's broadcast UpdateField),
but vanilla 1.12 doesn't broadcast PLAYER_FLAGS as a UpdateField —
reading [descriptor + 0x228] returns a different field entirely.
The actual storage is in a CGPlayer-side sub-struct at [unit + 0xE68],
shared with the visible-items table at +0x118. Verified by
disassembling 1.12's nameplate AFK-prefix renderer at 0x005EC9E0,
which is the only C-level code in stock 1.12 that decorates
nameplates with <AFK> — and which works for any unit, contrary to
the initial reading where I thought the JNE went the other way.
Vanilla nameplate display. Stock 1.12 shows <AFK> over any
nearby player's head, not just yourself — the function at 0x005EC9E0
checks [unit + 0xE68] + 0x08 bit 1 universally and only special-cases
the local player when a global at [0x00B6E5CC] is set (probably a
"hide my own AFK indicator" preference toggle). Verified in-game
on a non-Turtle stock 1.12.1 server.
Shipped in src/spell/IsCurrent.cpp under
the modern C_Spell.IsCurrentSpell(spellIdentifier) name. Checks
three slots: VAR_CURRENT_CAST_SPELL (0x00CECA88 — cast bar),
VAR_QUEUED_CAST_SPELL (0x00CECAA8 — mid-GCD queue), and the
player's UNIT_FIELD_CHANNEL_SPELL (+0x228 — channels). Accepts
the full spellIdentifier shape (number / link / known-spellbook
name) via Spell::Arg::ResolveSpellID.
Modern table-shape and ID-based variants of the existing reputation API. Most are thin wrappers around what we already have — useful for addons backporting modern Lua-side code that prefers the C_Reputation namespace.
- ✅
C_Reputation.SetWatchedFactionByID(factionID)— DONE. Calls the engine's inner watched-faction setter atFUN_PLAYER_SET_WATCHED_FACTION = 0x004D6240(__fastcall(ecx = factionID) → void) directly, bypassingScript_SetWatchedFactionIndex's displayed-index round-trip. Works for unencountered factions too (the rep bar shows nothing until the player gains rep, then displays normally). factionID 0 clears. Verified: Darnassus → 72 (Stormwind) → 0 (clear) all reflected byGetWatchedFactionInfo()immediately. Lives insrc/faction/Info.cpp. C_Reputation.SetWatchedFactionByIndex(index)— modern alias forSetWatchedFactionIndex. One-line registration that points to the engine function via our existing wrapper.C_Reputation.GetFactionDataByIndex(index)/GetFactionDataByID(factionID)— table-returning variants ofGetFactionInfo/GetFactionInfoByID. Pack the existing 11-tuple into named fields (name,description,reaction,barMin,barMax,currentStanding,atWarWith,canToggleAtWar,isHeader,isCollapsed,hasRep,factionID).C_Reputation.GetWatchedFactionData()— table form ofGetWatchedFactionInfo(which 1.12 has natively). The watched factionID is stored at[player + 0xE68] + 0x10C4per the inner setter's compare-and-skip path — useful if we add aIsWatchedFaction(id)getter too (engine has one at0x004D62F0already).
Shipped as part of the full C_DateAndTime.* backport — see #76.
Uses 86400 - (Time::Server::CurrentEpoch() % 86400) for server-
midnight semantics.
Modern 7-tuple localizedClass, englishClass, localizedRace, englishRace, sex, name, realm for any GUID the engine has cached.
Implementation in src/player/Info.cpp.
The original investigation thought the engine's NameCache at
0x00CE870C was the only client-side cache, and that it was fed
only by visible objects (which would mean offline friends/guildies
miss). Re-investigation with Ghidra MCP found a different cache
— a dedicated player-info cache at VAR_PLAYER_NAME_CACHE
(0x00C0E228) with lookup helper FUN_PLAYER_INFO_LOOKUP
(0x0055F080). This one is fed by SMSG_NAME_QUERY_RESPONSE
(opcode 0x51 handler at 0x005551A0), which the engine
auto-fires for any GUID surfacing in chat / raid / guild / etc.
Cache hits give us name@+0x00, realm@+0x30, race@+0x138,
sex@+0x13C, class@+0x140. Race goes through ChrRaces.dbc
records (instance 0x00C0DEE0 / count 0x00C0DEE4,
Filename@+0x3C for englishRace and Name[locale]@+0x44 for
localizedRace). Class similar via ChrClasses.dbc.
Sex value: engine stores 0/1, modern Lua API returns 2/3 — we add 2 to match.
Anyone who's shown up in chat, your raid/party, your guild, or as
a visible nameplate will be in the cache. Cold-target lookups for
"never seen this character" return nil. The earlier-investigated
"Path B" (active CMSG_NAME_QUERY firing for cache misses) wasn't
needed because the existing cache populates from passive traffic
broadly enough that the typical "addon wants info for someone in
chat" case Just Works.
Race::EnglishRaceForID and the class-string maps in this module
read from DBCs at runtime rather than hardcoding the strings — so
custom-server race additions (Turtle's Goblin/etc.) are picked up
without code changes.
Bridges the talent system to the spell system: once you have the
spellID, every spell API chains naturally (GetSpellInfo,
C_Spell.GetSpellDescription, GameTooltip:SetSpellByID,
IsPassiveSpell, C_Spell.GetSpellLink, …). 1.12's GetTalentInfo
returns (name, icon, tier, column, currentRank, maxRank, ...) — no
spellID — so addons used to have to maintain their own
(class, tab, idx) → spellID lookup tables.
Reads from the engine's per-tab talent arrays at [0x00BDCD28]
(populated at login from Talent.dbc filtered by the player's class),
not Talent.dbc directly. Each TabInfo * exposes:
+0x00TalentTab.dbc rowID+0x04pointsSpent+0x08numTalents+0x0CTalentEntry *array (stride0x54)
Each TalentEntry:
+0x00talentID (Talent.dbc primary key)+0x10..+0x33SpellRank[9](rank-N spellID at index N-1; vanilla uses 0..4, higher slots stay zero)
When rank is omitted, default = player's currentRank, derived by
delegating to Script_GetTalentInfo and reading its 5th return —
piggybacking on the engine's spell-knowledge logic. If currentRank is
0, falls back to rank 1.
-
The engine's currentRank loop walks SpellRank backwards —
lea edx, [ebx+0x30]thensub ecx, 4per iteration, ending at +0x10. I initially read +0x30 as the start ofSpellRank[]; it's actually the END (slot 9, always zero in vanilla). Confirmed empirically by a debug build returning 0 from +0x30 vs the right spellID from +0x10. -
Stock 1.12.1's
GetTalentInforeturns 8 values, not the 6 I expected based on naive disassembly. Reading the 5th return usesstack[-(n - 4)](dynamic from the actual return count) instead of a hardcodedstack[-2].
See src/talent/Info.cpp and the talent block
(VAR_TALENT_TAB_* / OFF_TABINFO_* / OFF_TALENT_SPELL_RANK) in
src/Offsets.h.
Shipped as src/spell/School.cpp. Returns
(schoolID, schoolName) where schoolID is 1-based (1=Physical,
7=Arcane) and schoolName is the locale-independent English name.
Vanilla 1.12 stores School as a 0-based integer at record +0x04
(NOT the SchoolMask form the original TODO note speculated — that's
TBC+). Verified empirically via the probe Lua functions:
/dump _classicapi_FindSpellField(133, 116, 4, 16) -- mask form: empty
/dump _classicapi_FindSpellField(133, 116, 2, 4) -- 0-based int: offset 4 ✓
/dump _classicapi_FindSpellField(133, 116, 3, 5) -- 1-based int: empty
Fireball (133) → School=2 (Fire); Frostbolt (116) → School=4 (Frost).
The probe helpers (_classicapi_ProbeSpellRecord,
_classicapi_FindSpellField) were temporarily exposed to derive the
offset and removed in the same commit that shipped GetSpellSchool.
Returns the AOE radius in yards, or nil for non-AOE spells.
Spell.dbc has a SpellRadiusIndex field that points into
SpellRadius.dbc (instance at 0x00C0D7A8 per docs/DBCs.md,
already wired up via VAR_SPELL_RANGE_RECORDS-style helpers in
src/spell/Info.cpp).
Stride and field layout for SpellRadius.dbc: needs derivation
(should be {id, radius_min, radius, radius_max} per the public
schema, but verify). Sub-DBC lookup pattern matches what
LookupSubRecord already does in Info.cpp for icon / cast time /
range.
Useful for AOE damage trackers, ground-effect addons, and any aura
library that wants to render an indicator radius. Modern WoW exposes
this via GetSpellRadius (deprecated) or returns from
C_Spell.GetSpellInfo.
Reads Faction.dbc ParentFactionID at record +0x48 via the
existing Faction::Info::FactionRecord helper. Returns 0 for
top-level factions, nil for invalid IDs. Verified in-game:
GetFactionParentID(72) → 469 (Stormwind → Alliance Forces),
GetFactionParentID(469) → 0 (Alliance is top-level),
GetFactionParentID(99999) → nil.
See src/faction/Info.cpp and
OFF_FACTION_PARENT_ID in src/Offsets.h.
Shipped as sibling to UnitClassBase in
src/unit/RaceBase.cpp. Reads byte 0 of
UNIT_FIELD_BYTES_0 (descriptor +0x78) and looks up
ChrRaces.dbc::Filename. Returns (raceFile, raceID).
Vanilla has no formal "spec" system but every class has 3 talent tabs
and the player always invests in one primarily. Returns the
1-based tab index (1, 2, or 3) of the tab with the most points spent,
or 0 if the player has no points spent at all (low-level
characters, or after a respec).
Trivial implementation: walk GetNumTalentTabs() (= 3), call
GetTalentTabInfo(tab) for each, compare pointsSpent. Pick max.
No DBC reads, no engine internals — pure Lua-equivalent computation
that's just easier to call than write inline every time.
Modern WoW's GetSpecialization() returns 1..4 for the player's
active spec. This is the closest 1.12 analog, useful for class
addons that want to render different UI per spec ("am I tanking?
healing? DPS?") without having to do the talent-counting dance
themselves.
Reads TalentEntry+0x00 from the per-tab talent arrays. The
talentID-at-+0x00 offset was already confirmed empirically during
the GetTalentSpellID debug pass (entryFirstDword=174 matched
Inner Focus's row ID), so this was a 5-line addition piggybacking
on the existing struct walk. Verified in-game:
GetTalentIDByIndex(1, 9) → 174,
GetTalentIDByIndex(1, 1) → 166.
Unblocks #43 GameTooltip:SetTalentByID whenever someone wants the
modern by-ID tooltip dispatcher.
See src/talent/Info.cpp.
Shipped as src/talent/Tooltip.cpp. Works for any class's talents, not just the player's. Two-tier resolution:
- Player-class fast path — walks the local player's TabInfo
arrays for a matching talentID (same arrays #42 reads from,
inverted). On hit, dispatches to
Script_GameTooltip_SetTalent(registry slot 34,0x00535170) with the resolved(tab, idx). Renders the full talent tooltip with rank counter, prereqs, and the "click to learn next rank" prompt. - Cross-class fallback via
Talent.dbc— vanilla 1.12 only loads the local player's class talent data into TabInfo, so other classes' talents aren't findable in tier 1. Tier 2 looks up the talent record directly inTalent.dbc(records pointer0x00C0D6E8, count0x00C0D6EC, indexed by talentID), reads the rank-1 spellID at+0x10, and dispatches the spell tooltip via the sameSpell::Tooltip::ShowByIDhelper thatSetSpellByIDuses. Renders the spell info (cast time, range, mana cost, description) without talent-specific decorations (rank counter, prereq lines).
Modern WoW's cross-class tooltip is slightly richer (talent name +
"Rank 0/N" header before the spell description). Building that
would require Lua-level GameTooltip:AddLine work; deferred since
the spell tooltip already answers "what does this talent do?".
Extracted Spell::Tooltip::ShowByID(L, spellID) into
src/spell/Tooltip.h so the resolve-self +
BuildSpellTooltip chain is shared between
GameTooltip:SetSpellByID and the cross-class fallback in
SetTalentByID. Saves duplicating the
FrameScript_PushObject/GetObject inline-asm sequence.
| Offset | Field |
|---|---|
+0x00 |
uint32 ID |
+0x04 |
uint32 TabID |
+0x08 |
uint32 TierID (row, 0-based) |
+0x0C |
uint32 ColumnIndex (0-based) |
+0x10 |
uint32 SpellRank[0..8] — rank-1 spellID at +0x10 |
The per-player TalentEntry (in TabInfo->talents[]) shares the
same SpellRank offset, so we reuse OFF_TALENT_SPELL_RANK = 0x10
for both. The TalentEntry stride (0x54) and the Talent.dbc
record stride differ — Talent.dbc records may be larger — but for
our purpose only the rank-1 spellID at +0x10 matters.
Both tiers tested — own-class talents render the full talent tooltip, cross-class IDs render the corresponding spell tooltip.
snprintf the hyperlink string and dispatch to the existing
Script_GameTooltip_SetHyperlink. Same registration pattern as
SetSpellByID.
Initial test showed only the item name, not the full tooltip, for
arbitrary itemIDs. Cause: 1.12 lazy-loads item data into the
client-side cache (the same cache C_Item.GetItemInfoInstant reads).
Items the player has never encountered show only what's in the link
itself (the name) until the cache is populated via
SMSG_ITEM_QUERY_SINGLE_RESPONSE. This isn't a bug in our
implementation — it's the same behavior as modern's SetItemByID,
which also requires C_Item.RequestLoadItemDataByID first for
arbitrary IDs.
Documented the caveat in docs/API.md with the
recommended IsItemDataCachedByID / RequestLoadItemDataByID /
ITEM_DATA_LOAD_RESULT pattern callers should follow.
See src/item/Tooltip.cpp.
Pure dispatch wrapper. Branches on filter string ("HARMFUL" →
SetUnitDebuff, anything else → SetUnitBuff) and forwards via
the existing script function. filter defaults to helpful when
omitted, matching modern.
See src/unit/Tooltip.cpp and the
FUN_SCRIPT_GAMETOOLTIP_SET_UNIT_* block in
src/Offsets.h.
46. GameTooltip:SetFrameStack([showHidden]) — deferred (Lua-side fix in place)
Renders a tooltip listing the chain of frames at the mouse cursor,
sorted by strata + level. The 3.0+ debug-tooling primitive used by
Blizzard's Blizzard_DebugTools and every layout-debugging addon.
Our bundled DebugTools/ backport ships its own FrameStackTooltip
in DebugTools/DebugTools.lua, driven
by _CollectFramesUnderMouse. As of the EnumerateFrames switch,
the iterator walks the engine's own frame list — fast (typically
~hundreds of entries) and includes anonymous frames the engine
sees but a _G walk would miss.
What's still missing relative to the real engine SetFrameStack:
- Anonymous-frame display name. We currently show
<anonymous>for frames whereframe:GetName()returns nil. The engine's method shows the frame's pointer + the Lua-side registry-table address. To get there we'd need a Lua-callable shim that exposes a numeric handle per frame (vftable + Lua-ref lookup), which is non-trivial. - Single-tooltip semantics. Modern addons can call
GameTooltip:SetFrameStack(...)directly on any tooltip; ours requires theFrameStackTooltipwe ship inDebugTools/. Most consumers (Blizzard_DebugToolsports, layout addons via/framestack) don't care, but a strict polyfill would.
What Script_GameTooltip_SetFrameStack
actually does in 3.3.5 (Frostmourne client, registration entry at
VA 0x00AD2CF0 → function VA 0x00626560 → inner builder
FUN_006230d0):
- Pulls cursor (in frame space) via
FUN_0047BFF0from a render- state global at[DAT_00B499A8 + 0x1224 / +0x1228]. - Walks an intrusive linked list at
[DAT_00B499A8 + 0xCD4]. Each frame node reads:[+0x00]— vftable;vftable[+0x10]= "is the frame visible enough to hit-test" predicate.[+0x0E0](node[0x38]) = strata; gates(strata != 0 || showHidden).[+0x0D0]..[+0x0DC](node[0x34..0x37]) = frame rect for hit-test.[+0x0D8](node[0x36]) flags, bit 2 → visible.[+0x288](node[0xA2]) = next pointer.
- Per-frame name via
vftable[+0x04](aGetDebugName()-style virtual). If the frame is Lua-side (vftable[+0x10]returns type code 5), an extralua_rawgeti(L, REGISTRY, frame->luaRef)pulls the Lua-table reference for the"%s: %p (%s)"format; otherwise it's"%s: %p". - Builds into a scratch sort array at
DAT_00C5D370(entry stride0x410),qsortagainst comparatorFUN_0061A5B0. - Header line via
FUN_00819D40("DEBUG_FRAMESTACK", ...)(LuaGetGlobalString) then per-entryFUN_0061FEC0(tooltipAddLineequivalent, takes color codes from a 4-byte-stride table at0xAD2DC0/0xAD2DB8/0xAD2DB0for visible/hidden/special states).
For a 1.12 port we'd need to derive:
- The frame-manager global (3.3.5's
DAT_00B499A8— different VA in 1.12, different field offsets likely). - Frame struct offsets for rect, strata, flags, next, name vftable
slot — the layout shifted between 1.12 and 3.3.5 (same way unit
field offsets did, per the
OFF_UNIT_FIELD_HEALTHdiscovery in CLAUDE.md). - AddTooltipLine engine address (1.12 has it — every
GameTooltip:SetXmethod calls it internally). - Lua-side reference lookup helper (whatever 1.12 uses to map
frame → Lua table; the
FrameScript_GetObjectchain at0x6F3740is part of this).
The Lua-side iterator switch is done. The C++ port stays deferred
unless we need the engine frame list for something else
(GetMouseFocus polyfill, anonymous-frame inspector with extra
fields beyond what Lua exposes, single-tooltip strict polyfill,
etc.) that would amortize the RE cost.
Single bitmap lookup at [VAR_PLAYER_SPELL_BITMAP] = [0x00B710FC].
The engine maintains a dword bitmap with one bit per spellID — set
when the player learns a spell (any source: training, talent
investment, profession recipe, racial unlock, etc.) and cleared on
unlearn. We just consult the same bit the engine itself reads via
the helper at 0x0060C740:
return (bitmap[spellID >> 5] & (1 << (spellID & 31))) != 0;The naive walk-based implementation (originally drafted) failed for
profession recipes — turns out vanilla 1.12's VAR_PLAYER_SPELLBOOK
arrays DON'T contain profession recipe spellIDs (verified
empirically: a tailor with Bolt of Linen Cloth fails
FindSpellBookSlotByID(2963)). Surfaced during in-game testing.
To confirm whether 1.12 had a unified spell-knowledge data structure,
I dumped 5.4.8's Script_IsPlayerSpell (5.4 = the version where the
function was first added) and saw a clean bitmap pattern:
mov ecx, eax
and ecx, 0x1F ; bitPos = spellID & 31
shl edx, cl ; mask = 1 << bitPos
mov ecx, [imm32] ; bitmap base ptr
shr eax, 5 ; idx = spellID / 32
and eax, [ecx + eax*4] ; bitmap[idx] & mask
Searched 1.12 for the same pattern → matched at the helper
0x0060C740, which Script_GetTalentInfo calls for currentRank
derivation. Same shape, same purpose, just at a different bitmap
address ([0x00B710FC]).
Verified against 1.15.x: only the player's currentRank spellID has
its bit set; lower-rank IDs return false. A 5/5 Unbreakable Will
player sees IsPlayerSpell(14791) (rank 5) = true but rank 4 / 3 /
2 / 1 = false — same in 1.12 with our implementation, same in 1.15.
This means our implementation matches modern exactly, including the "only current rank counts" quirk. Addons backporting from later expansions that key on currentRank's spellID work unmodified.
The same bitmap makes #30 IsSpellKnown(spellID, isPet) trivial —
ship it as a thin wrapper using the same lookup (or split: player
scan via bitmap, pet via existing pet spellbook walk). The
Spell::Lookup::FindSpellbookSlot walk is now only needed for the
slot-and-bookType return shape, not for knowledge queries.
See src/spell/Info.cpp and
VAR_PLAYER_SPELL_BITMAP in src/Offsets.h.
Shipped as the modern table-shape variant in
src/spell/Cooldown.cpp: C_Spell.GetSpellCooldown(spellIdentifier)
accepts number / name / name(rank) / |Hspell:N|h link and returns
a SpellCooldownInfo table. The original goal — "query cooldowns by
spellID without forcing the caller to resolve a spellbook slot first"
— is covered.
Reads the engine's per-spell cooldown manager at
FUN_SPELL_QUERY_COOLDOWN (0x006E2EA0), which Script_GetSpellCooldown
also goes through internally after its slot-resolution step. Fixed
the (*outDuration, *outStart) argument order while doing this —
the typedef in Spell::Usable had them reversed (worked anyway
because both fields are 0 off-cooldown, but the var names were
backward).
Both shipped. IsUsableSpell accepts spellID or (slot, bookType)
via the same Spell::Lookup::SpellbookSlotToID resolver other
spell-API functions use. Returns (1, nil) / (nil, 1) /
(nil, nil) per legacy convention. C_Spell.IsSpellUsable is the
modern shape — same logic, returns proper booleans.
Two engine-cache approaches investigated and discarded:
- Per-spell cache at
0x004F0F40returning fields at +0x564 / +0x568 — initially looked promising (the action-bar dispatcher at0x004E5BA0reads these), but the writers (0x004EFF17cluster) showed they're parsed config integers, not real-time usability bools. False trail. - Action-bar per-slot caches at
0x00BC67A0(noMana) and0x00BC6B60(usable) — these ARE real-time, but only updated for the 120 action-bar slots. Spells off the bar stay uninitialized. Action-bar-only is the wrong abstraction since modernIsUsableSpell(spellID)is action-bar-agnostic.
Settled on a manual five-check approach:
- Player knows the spell (
VAR_PLAYER_SPELL_BITMAPlookup — covers profession recipes, talents, racials, etc.). - Player is alive (
HEALTH > 0). - Spell is not on cooldown (
FUN_SPELL_QUERY_COOLDOWN— the engine helperScript_GetSpellCooldowncalls internally after slot resolution, queried withbookType=0for player). - Player has ≥
ManaCostof the spell'sPowerType— only this failure flipsnoMana=true. - Player has all required reagents in bags
(
Spell.dbcReagent[8] / ReagentCount[8] at+0x110/+0x130, walked viaItem::Location::ResolveBag).
Not checked (deliberate, different concerns): silence, GCD, stance/form, range, target type, line-of-sight, casting state.
Verified on Turtle WoW:
- Mana check: Renew rank 3 (cost 105) reports usable at 144 mana and unusable at 39 mana, transitioning exactly at the cost boundary.
- Reagent check: SHIPPED BUT UNVERIFIED — the Reagent[8] /
ReagentCount[8] offsets at
+0x110/+0x130are the CMaNGOS-documented vanilla layout, and we know spell records match CMaNGOS docs (PowerType=+0x7C and ManaCost=+0x80 both match), so high confidence — but no in-game verification yet. Should test with a reagent spell (e.g., Mage Teleport spell 3565 needs Rune of Teleportation 17031). Failure mode if offsets are wrong: reagent check becomes a no-op (always passes). - Cooldown check: SHIPPED BUT UNVERIFIED in this implementation —
the helper signature was traced via
Script_GetSpellCooldownat0x004B40A0but not exercised in-game. Should test with a cooldown'd spell.
Important offset correction along the way. Initial implementation
read POWER1 at descriptor +0x5C based on the CMaNGOS-documented
field index 0x17. That was max mana — current was at +0x44
(field index 0x11, six fields earlier than the standard layout).
The whole UNIT_FIELD layout in 1.12.1 is shifted 0x18 / 6 fields
earlier than CMaNGOS documents:
| Field | CMaNGOS docs | 1.12.1 actual |
|---|---|---|
| HEALTH | +0x58 | +0x40 |
| POWER1 (mana) | +0x5C | +0x44 |
| POWER2..5 | +0x60..+0x6C | +0x48..+0x54 |
| MAXHEALTH | +0x70 | +0x58 |
| MAXPOWER1..5 | +0x74..+0x80 | +0x5C..+0x6C |
| LEVEL | +0x88 | +0x70 |
| FLAGS | +0xA0 | +0xA0 ✓ |
FLAGS still lands at +0xA0 (verified separately by
Script_UnitPlayerControlled and Script_UnitAffectingCombat).
Trust the binary, verify with _classicapi_DescDump if you need
to derive new ones.
See src/spell/Usable.cpp.
The IsPlayerSpell discovery proved out the cross-binary disassembly
technique (documented in CLAUDE.md).
Other pending TODOs that look like good fits for the same approach:
- #7
UnitAuraBySlot/UnitAuraSlots— modern slot-based aura iteration. Aura state is in CGUnit's UpdateField data; modernUnitAurareads slot offsets we can decode from 5.4.8 and use in 1.12. - #32
IsCurrentSpell(spellID)— runtime cast-state global. The engine has a "currently casting spellID" global (the cast-bar UI reads it). Modern'sIsCurrentSpellreads the same value. - #42
GetTalentIDByIndex— already easy with the talent struct we now know (talentID at TalentEntry+0x00); cross-binary not needed here but could verify against 5.4.8'sGetTalentInfoByIDfor semantics.
Tag here so we remember the technique exists when picking up these items.
Returns (numberOfFreeSlots, bagType) for the player's backpack
(bagID = 0) or one of the four equipped bag slots (bagID = 1..4).
bagType is the BagFamily bitfield — vanilla 1.12 has only three
non-zero families (quiver=1, ammo pouch=2, soul shard bag=4); modern
expansions added 20+ more, but reading the bitfield gives addons a
forward-compatible shape regardless. Always returns two values, falling
back to (0, 0) for unsupported bag IDs (bank/keyring/out-of-range) —
matches 3.3.5's behavior.
Implementation walks the bag and counts empties via
Item::Location::ResolveBag. Bag metadata (slot count + BagFamily) for
real bags 1..4 comes from the equipped bag's ItemStats_C cache
record: m_containerSlots at +0x64, m_bagFamily at +0x1D0. Backpack
short-circuits to (16, 0) since vanilla's main backpack is fixed-size
and unfamilied. Field offsets derived from VanillaHelpers's
struct ItemStats_C layout, cross-checked against the existing
m_stackable at +0x60 (already in Offsets.h).
The slot-walk uses ResolveBag, which clobbers Lua stack[1]/[2] each
call (a requirement of the engine's PackBagSlot). That's safe here —
we own the stack inside the callback and the user's bagID arg is
copied to a local before the loop. Cross-checked in-game against a
manual GetContainerItemID walk; counts match for all five bags.
bagType return is sparse on vanilla data. Empirically, only Light
Quiver carries a non-zero m_bagFamily field among the bags we tested
on Turtle WoW; Soul Pouch, Enchanting Bag, Herb Pouch all return 0.
The same field on individual items (arrows, shards, herbs) is
reliable — see TODO #29's notes. Addons that need bag-category
discrimination should look at (class, subClass) from
GetItemInfoInstant rather than bagType. The conversion from
1.12's raw-ID encoding to the modern bitmask
(1 << (ID-1)) still applies for the rare populated cases.
See src/item/Bag.cpp.
Modern convenience pair for the most-used bag walk in addons. Walks bags 0..4 looking for the hearthstone (vanilla itemID 6948 — the only one in 1.12; modern WoW recognizes many hearthstone-toy itemIDs but none exist in this client). Stops on first match.
PlayerHasHearthstone() returns the itemID (always 6948 or nil).
UseHearthstone() invokes the engine's Script_UseContainerItem
(0x004FA0E0) with (bagID, slot) set up on the Lua stack — same
secure-action path a regular UseContainerItem(bag, slot) Lua call
would take, so cooldown / combat / movement-cancel handling is
identical. Returns true if a hearthstone was found and the call
dispatched, false otherwise (the cast itself can still fail
downstream — same caveat as manually calling UseContainerItem).
Walking helper is shared between the two via FindHearthstone(L, *outBag, *outSlot). Slot counts come from delegating to the engine's
own Script_GetContainerNumSlots (0x004F9560) rather than a
hardcoded vanilla cap, so custom servers with larger-than-24-slot
bags work without code changes.
Twelve expansion-level enum globals (LE_EXPANSION_CLASSIC through
LE_EXPANSION_MIDNIGHT) plus LE_EXPANSION_LEVEL_CURRENT, all
matching the canonical retail Enum.ExpansionLevel table values.
On 1.12, LE_EXPANSION_LEVEL_CURRENT resolves to
LE_EXPANSION_CLASSIC (= 0). Lets addons backported from later
expansions use the modern if LE_EXPANSION_LEVEL_CURRENT < ...
gating idiom without conditionally defining the constants
themselves.
Set via the same Lua C API path CLASSIC_API_VERSION uses (no
FrameScript_Execute source). One per entry, written to
_G[name] = value from the module's RegisterLuaFunctions hook
which fires after the engine's LoadScriptFunctions boot phase.
See src/expansion/Constants.cpp.
Modern WoW (10.x) moved most addon API into the C_AddOns namespace.
1.12 already exposes most of the underlying functions as globals;
we deliberately do not duplicate them under C_AddOns (Tier 1 in
the prior version of this entry — pure namespace mirror, low value).
What's worth adding is the post-vanilla new functionality.
Bulk wrapping of 17 existing 1.12 globals (IsAddOnLoaded,
LoadAddOn, etc.) under C_AddOns.*. Skipped — addons that need
the namespace can C_AddOns = C_AddOns or {} ; C_AddOns.IsAddOnLoaded = IsAddOnLoaded themselves in two lines.
Shipped as src/addons/Info.cpp. Six wrappers, all bypassing
Script_GetAddOnInfo and reading directly from the engine's
addon-registry helpers we found at 0x0051DF00..0x0051E050:
C_AddOns.GetAddOnName(indexOrName)— direct viaGetByIndex(numeric: returns the entry's inline name buffer; string: echoes the input after validating existence).C_AddOns.GetAddOnTitle(indexOrName)— callsGetTitleByName(0x0051DF20) directly.C_AddOns.GetAddOnNotes(indexOrName)— callsGetNotesByName(0x0051E050) directly.C_AddOns.IsAddOnLoadable(arg [, character, demandLoaded])— returns(loadable, reason). Still dispatches throughScript_GetAddOnInfo(5th + 6th return) because the engine's per-field accessor is buried behind a locale-derived field key. Gated on aResolveAddOnNameexistence check first to avoid thelua_errorScript_GetAddOnInforaises for out-of-range numeric indices.C_AddOns.GetAddOnSecurity(indexOrName)— same dispatch shape asIsAddOnLoadable.C_AddOns.DoesAddOnExist(indexOrName)— direct viaResolveAddOnName; correctly returnsfalsefor unknown names rather than the dispatch-based heuristic which false-positived on garbage strings (engine echoes the input back as ret1 even on miss).
The underlying FUN_ADDON_GET_BY_INDEX / _TITLE_BY_NAME /
_NOTES_BY_NAME accessors share a hash-table walk: they take a
char* name (entry pointer doubles as a name string thanks to
the inline 12-byte buffer), hash it to find the registry entry,
then walk a per-entry metadata hash table keyed by the field
name ("Title", "Notes") to extract the value. We piggyback
on the same name → entry lookup for all wrappers.
Notable bug caught in testing: the original implementation used
PushValue + Insert(L, 1) + SetTop(L, 1) to pull the
desired return down to the bottom of the stack. That combo
silently returned the LAST pushed value of Script_GetAddOnInfo
regardless of returnPos — same family of broken-stack-state
issues as the RegisterTableFunction gotcha in CLAUDE.md.
Replacing with SetTop(L, 1 + returnPos) (truncate to keep
the desired return on top, return 1 to Lua) fixed it.
GetAddOnInterfaceVersion(index)— reads## Interface:from the .toc. The engine parses this at addon-load time; we'd need to find where it stores the per-addon value. (Note: vanilla also exposesGetAddOnMetadata(name, "Interface")natively, which may already cover this — verify before implementing.)GetAddOnLocalTable(index)— NOT FEASIBLE in 1.12. The modern API returns the addon's private namespace passed as the second...arg to each addon file (local name, addon = ...). Verified empirically: vanilla 1.12 callslua_pcall(L, 0, 0, -2)on every addon chunk —nargs = 0, so...is always empty. Confirmed at four pcall sites in the addon-loader region (0x704B68, 0x704D22, 0x704E79, 0x705199), all with the samexor edx, edx; mov ecx, L; call lua_pcallpattern. The convention was added in 3.0+ when WoW moved to Lua 5.1; the underlying engine machinery (per-addon table allocation, vararg injection) doesn't exist in 1.12. Adding it would require hooking the pcall sites to inject args AND maintaining a per-addon-name table map ourselves — a feature add, not just an API exposure. Skip unless an addon explicitly needs it.DoesAddOnHaveLoadError(index)— load-error tracking. May not exist in 1.12 in a queryable form.IsAddOnDefaultEnabled(index)— initial-enabled state from the .toc## DefaultState:tag (not in 1.12 .toc syntax).
GetScriptsDisallowedForBeta— beta-specific; no 1.12 analogue.
Almost every modern addon released in the last few years uses
C_AddOns.IsAddOnLoaded instead of the global. Backporting an
arbitrary modern addon to 1.12 currently means find/replace
across the codebase. Tier 1 alone (one afternoon) makes that
unnecessary for the most common 17 entry points.
Goal: make Tier 2 convenience wrappers (GetAddOnName/Title/
Notes/Loadable/Security/DoesAddOnExist) read directly from the
engine's per-addon struct instead of dispatching to
Script_GetAddOnInfo and discarding 6 of 7 returns. Status:
deferred — struct layout is more complex than a clean inline-
string packing, and time-to-derive exceeds the perf upside for
the typical caller.
What we learned:
- Addon array lives at
[0x00BE1B94](pointer to array of 4-byte pointers) and count at[0x00BE1B90]. Confirmed viaScript_GetAddOnInfodisassembly: lookup isif (idx >= [0x00BE1B90]) return null; return ((void**) [0x00BE1B94])[idx];. - Each entry points to a per-addon struct (heap-allocated, sized per-addon).
- Struct fields we identified (relative to struct base):
| Offset | Content |
|---|---|
+0x00..+0x0B |
Name buffer — inline, 12 bytes, null-terminated. Both 8-char "Atlas-TW" and 9-char "aux-addon" fit. |
+0x0C |
Heap pointer (Storm allocator addr like 0x29B4D908) OR null. Non-null for Atlas-TW; null for aux-addon. Derefs to non-printable bytes — likely a struct/array, not a direct string. |
+0x10..+0x17 |
Often zeros across multiple addons. |
+0x18 |
Constant 0x909 across all addons probed. Format/version marker? |
+0x1C |
Constant 5 across all addons probed. Schema version? |
+0x20+ |
Variable — for shorter addons this is unrelated adjacent heap memory (saw "ItemSync" appear once and "Notes" appear in another session for aux-addon, both clearly not part of aux-addon's data). |
-
Title and Notes are NOT inline-packed at the start of the struct as I initially hypothesized. Atlas-TW's actual title is
"Atlas-TW"(matches name) and notes is the long"Atlas-TW: instance Map Browser..."string. Neither appears in the first 0x60 bytes of the struct except where the inline name field happens to look like the title. -
The pointer at
+0x0Cis the most likely indirection to the title/notes data, but it points to a binary blob (non- ASCII first bytes), suggesting another layer of indirection (struct of pointers, or a tagged section list). Would require a second-level scan to chase. -
Engine helper functions for field extraction live around
0x0051DF00–0x0051E0xx(LookupByIndex, GetTitle, GetNotes, etc.), reachable via call-target arithmetic fromScript_GetAddOnInfo. Initial decoding was confused by off-by-one arithmetic on my part — the targets land on what appear to be mid-instruction addresses, suggesting the helpers may use non-standard prologues or are tail-call thunks. Worth tracing more carefully. -
Diagnostic that worked:
_classicapi_AddonStructDump(idx)with SEH-wrapped pointer deref (the bare pointer-deref approach crashes with0xC0000005when interpreting inline string bytes as pointers —"s-TW"happened to fall in0x10000000..0xF0000000range and AVed). TheSafeReadAsciiStringhelper using__try/__exceptis the pattern to keep if we resume this work.
Recommended next step if revisiting: extend the diagnostic
to recursively chase the +0x0C pointer (read 32 bytes there,
check if any look like further string pointers), and trace
1.12's Script_GetAddOnMetadata (0x0048E530) which is more
self-contained than GetAddOnInfo and might reveal a cleaner
field-access pattern.
Tier 3 (interface version, addon local table, etc.) still deferred — none of those have a proven 1.12 storage location.
Shipped as src/unit/State.cpp. Modern globals that 1.12
doesn't bind to Lua, despite the underlying state being tracked
by the engine. All four read directly off the local player
struct or its descriptor — no dispatch:
IsMounted()— checksUNIT_FIELD_MOUNTDISPLAYIDat descriptor+0x1FC. Non-zero means the engine is rendering a mount.IsStealthed()— checks bit0x02of the player visibility byte at descriptor+0x17C, AND-gated withMountDisplayID == 0(mount also sets that bit). May false-positive on shapeshift/possess/etc. — untested, fix by walking the player's aura list looking for the actual stealth/prowl spell if it turns up in practice.IsFalling()— checksMOVEFLAG_FALLING | MOVEFLAG_FALLING_FAR(0x2000 | 0x4000) on the local player's movement-flags u32 at+0x9E8. This is client-side state (outbound MSG_MOVE_* data), not broadcast — only meaningful for the local player.IsSwimming()— same movement-flags word,MOVEFLAG_SWIMMING(0x200000).
A general-purpose probe system in src/debug/ (Probe.cpp +
Log.cpp + Log.h) writes labeled descriptor / player-object
snapshots to <WoW dir>\Logs\classicapi_debug.log (alongside
the engine's FrameXML.log, Sound.log, etc.). Path is
resolved at runtime via GetModuleFileNameA(nullptr) so the
DLL works against any client install. User runs
_classicapi_DescLog("baseline", 0, 0x100) then
_classicapi_DescLog("mounted", 0, 0x100) etc.; agent reads
the log file directly and diffs.
Findings:
- MountDisplayID found by diffing baseline vs mounted
descriptor — only
+0x1FCchanged from 0 to a creature display ID like0x4306. - Stealth bit found by diffing baseline vs stealthed —
+0x17Cbyte 0 went0 → 0x02. Mounted state went0 → 0x1A(= bits 1, 3, 4). Bit 1 is shared, bits 3+4 are mount-specific. - Movement flags found by diffing standing/swimming/falling
player-object dumps.
+0x9E8went0 → 0x200000(swimming) and0 → 0xE001(falling: bits 0x1 FORWARD + 0x2000 FALLING- 0x4000 FALLING_FAR + 0x8000 PENDING_STOP). Bit values
match documented vanilla
MOVEFLAG_*constants exactly.
- 0x4000 FALLING_FAR + 0x8000 PENDING_STOP). Bit values
match documented vanilla
The probe helpers (_classicapi_DescDump, _classicapi_PlayerDump,
_classicapi_DescLog, _classicapi_PlayerLog,
_classicapi_LogClear, _classicapi_LogAppend,
_classicapi_LogPrintf, _classicapi_HexDump) are kept in
the build as a reusable offset-finding scaffold. They write
labeled blocks to <WoW dir>\Logs\classicapi_debug.log,
sharing the location convention engine logs and other
injected DLLs (nampower, etc.) use. Path is computed at
runtime — no hardcoded install location.
The C++ Debug::Log API (in src/debug/Log.h) backs the
Lua bindings and is available to any module via
#include "debug/Log.h":
Debug::Log::Clear()— truncate the file.Debug::Log::Append(label, content)— labeled block, content written verbatim. Best for snapshot diffs.Debug::Log::Printf(fmt, ...)— printf-style one-line trace. Auto-newlines if the caller didn't include one.Debug::Log::HexDump(label, ptr, len, [base])— xxd-style hex+ASCII dump of a memory range, 16 bytes per row, with the offset column showingbase + i. SEH-wrapped: if a row faults (page boundary, freed memory) the dump stops cleanly and notes the failed offset rather than crashing the host.
HexDump is the most useful for arbitrary-VA inspection — call
it on a struct pointer to scan layouts, or from Lua via
_classicapi_HexDump(label, addr, len) to inspect engine
globals without a per-investigation probe module.
Shipped as src/item/Equipment.cpp. Resolves the off-hand
equipment slot (slot 17) via Item::Location::ResolveEquipmentSlot,
reads the cached ItemStats_C record's m_inventoryType at
+0x2C, and returns true iff it's INVTYPE_WEAPON (13) or
INVTYPE_WEAPONOFFHAND (22). Shields, holdables (tomes/orbs),
empty slots, and uncached items all return false.
No async load is fired — modern API behavior matches; if the off-hand item isn't cached the function silently returns false until something else warms the cache. Typically only matters on first login before the player's own gear is in the cache, which in practice is loaded eagerly anyway.
Modern IsFlying() returns true when on a flying mount. Vanilla
1.12 has no flying mounts, so the closest semantic mapping is
"on a flightpath" (i.e., UnitOnTaxi("player") == 1).
Initial implementation tested MOVEFLAG_FLYING = 0x01000000 on
the local movement-flags word at [CGPlayer + 0x9E8]. The bit
is documented in CMaNGOS as MOVEMENTFLAG_FLYING, but verified
empirically that 1.12 does NOT set it during taxi flights —
/dump IsFlying() stayed false through an entire flightpath
on which /dump UnitOnTaxi("player") correctly returned 1.
Vanilla taxi rides are server-driven splines that animate the
mount/character without flipping a local movement-flag bit;
the FLYING bit only ever gets set on flying mounts (which
don't exist) or possibly GM fly mode (untested). Bit 0x01000000
in vanilla may instead be MOVEMENTFLAG_SPLINE_ENABLED per
some references, but if so, that bit also stays clear during
normal taxi.
Conclusion: there is no useful interpretation of IsFlying()
in vanilla. UnitOnTaxi("player") covers the only flight-like
state the client tracks. Function and MOVEFLAG_FLYING
constant both removed; Offsets.h carries a comment noting
the empirical result so this doesn't get re-attempted.
Shipped as src/classes/Info.cpp. Iterates ChrClasses.dbc
records and writes tbl[classToken] = localizedClassName for
each, returning the same table for chaining.
ChrClasses record layout (derived from Script_GetSelectedClass
at 0x004716E0):
+0x14—char *Name[9]localized class names (one per locale)+0x38—char *Filenameclass token ("WARRIOR","MAGE", etc.)
Vanilla 1.12 has no separate female-name array — Name[9] is
exactly the 36 bytes between +0x14 and +0x38 (= 9 locales × 4
byte ptr), with the token immediately after. The modern
isFemale arg is accepted for signature parity but ignored;
same names returned regardless. Most locales (English included)
don't differentiate gender forms anyway, so callers won't typically
notice. Sparse classIDs (vanilla skips classID 6 — Death Knight
didn't exist) have NULL records and are silently skipped.
Stack discipline: takes the table at idx 1, drops any extra args
via SetTop(L, 1), then loops PushString(token); PushString(name); SetTable(L, 1) per record. SetTable consumes both stack values
so the table sits alone at idx 1 throughout the loop. Returns 1
to push the (same) table back to Lua.
The current implementation in
src/item/Equipment.cpp is a cursor-state
guard: refuses to operate when CursorHasItem() is true. Modern
WoW's EquipItemByName silently preserves the cursor by routing
through an engine-internal "swap by GUID" helper. With Ghidra on
the right binary we found that helper.
Script_EquipCursorItem at 0x00489660 calls FUN_005E0C40 —
the engine's own swap-and-send function. Signature decoded by
reading the call site and the function body:
void __thiscall FUN_005E0C40(
CGPlayer *this,
u32 srcItemGuidLo, u32 srcItemGuidHi,
u32 srcContainerGuidLo, u32 srcContainerGuidHi,
u32 srcLinearSlot,
u32 dstContainerGuidLo, u32 dstContainerGuidHi,
u32 dstLinearSlot,
int flag); // 0 = normal pathInternal behavior:
- Validates source/dest containers (player invMgr vs CGContainer
bag) — looks up
FUN_00468460to resolve container by GUID. - Picks the opcode based on swap type:
0x10D(CMSG_SWAP_INV_ITEM) — same-container swap. Payload{src_slot, dst_slot}. Used when both src and dst resolve to the player's direct invMgr.0x10C(CMSG_AUTOEQUIP_ITEM) — cross-container swap. Payload{dst_bag, dst_slot, src_bag, src_slot}. Used when src or dst is in a CGContainer (equipped bag).
- Writes the packet via
FUN_00418190(opcode)+FUN_00418070(byte)…into the same buffer atPTR_LAB_007FF9E4that every real network packet flows through. - Sends via
FUN_005AB630— the actual send call the previous investigation couldn't find.
Script_EquipCursorItem reads cursor state via FUN_00494C80
before passing it to FUN_005E0C40, but FUN_005E0C40 itself
neither reads nor writes the cursor globals at [0xBE0810] /
[0xBE0814]. The dead-code packet builder at 0x005E0B50 was
unrelated; the real production path is just FUN_005E0C40. We
hand it the right args and it sends the packet — no cursor touch.
The previously-identified gate at 0x501130 was the right-click
"use item from cursor" path (opcode 0x276), not the swap path.
Different concern entirely.
The engine's linear slot:
| Linear slot | Container | What |
|---|---|---|
0..18 |
player invMgr | paperdoll slots 1..19 (subtract 1 from Lua slot) |
19..22 |
player invMgr | equipped bag containers (bag IDs 1..4) |
23..38 |
player invMgr | backpack (bag ID 0) contents, 1-based slot S → linear = 22+S |
0..N-1 |
bag CGContainer | slot inside the bag (subtract 1 from Lua slot) |
For destination paperdoll equip: dstContainer = player, dstSlot = paperdollSlot - 1.
For source:
- Item in paperdoll slot N →
srcContainer = player,srcSlot = N - 1 - Item in backpack slot S (bagID 0) →
srcContainer = player,srcSlot = 22 + S - Item in equipped bag B (bagID 1..4) slot S →
srcContainer = bag GUID,srcSlot = S - 1. Bag GUID read off its CGItem instance block at+0x8.
The previous TODO worried about hand-built packets disconnecting the client. We never build a packet — we hand off high-level args and the engine's own builder/sender runs verbatim. Same code path drag-and-drop equip uses. Only risk is bad GUIDs / slot indices, validated client-side before the call.
2.4.3 has ItemMgr::EquipByGUID at 0x5E8310 (__thiscall(this=invMgr, guidLo, guidHi, dstSlot, 0)) which bundles find-by-GUID + swap.
1.12's equivalent is split: find-by-GUID is our existing
Item::Location::FindByGUID + Locations::FindGUID, swap is
FUN_005E0C40.
src/item/Equipment.cpp Script_C_Item_EquipItemByName:
- Find the item by name (existing
FindItemInBags). - Resolve source location via the table above →
(containerGuid, linearSlot). - Compute target paperdoll slot (explicit arg, or auto-pick from
m_inventoryTypefor the no-slot form). - Read player GUID off the CGPlayer instance block at
+0x8. - Call
FUN_005E0C40(player, itemGuid, srcContainer, srcSlot, playerGuid, playerGuid, dstSlot-1, 0). - Drop the cursor-guard. Cursor state is now irrelevant.
Modern method that appends spell info to the current tooltip
without clearing existing lines — natural pair to SetSpellByID,
which clears + rebuilds. Lets callers compose tooltips like
"talent name (line 1) + AddSpellByID(rankSpell) (rest)".
Direct upgrade for our cross-class GameTooltip:SetTalentByID
fallback (#43): the current tier-2 path renders just the spell
tooltip, losing the talent name. With AddSpellByID we could do
`ClearLines + AddLine(talentName) + AddDoubleLine("Rank 0/N", "")
- AddSpellByID(spellID)` to match modern's richer cross-class tooltip exactly.
Implementation question: 1.12's BuildSpellTooltip (the inner
helper at 0x0052E610 we use for SetSpellByID) almost certainly
clears the buffer first. We'd need a different entry point that
appends, OR find where the line-emission helper sits inside
BuildSpellTooltip and call that directly.
Approach: disassemble Script_GameTooltip_SetSpell (0x00532D10)
and BuildSpellTooltip to identify the "ClearLines" prologue and
the "emit description / cost / range / cooldown" loop. If those
are factored apart, the loop is callable standalone and AddSpellByID
is a thin wrapper. If they're inlined, we'd either need to skip
the clear ourselves (call after some no-op state setup) or
manually call the line-emission primitives.
Modern query methods that return what the tooltip is currently showing:
GetItem()→(name, link, itemID)— DONEGetSpell()→(name, rank, spellID)— DONEGetUnit()→(name, unitToken)— skipped intentionally
Each Set* path stashes a "currently displayed" field on the tooltip
frame instance, and the per-tooltip Clear at FUN_00530050 zeroes
the full set on Hide/before-redraw. Verified offsets:
| Field | Offset | Written by | Cleared by |
|---|---|---|---|
| Item ID | +0x398 |
BuildItemTooltip (0x0052B6FE) |
FUN_00530050 |
| Spell ID | +0x39C |
BuildSpellTooltip (0x0052E6D5) |
FUN_00530050 |
GetSpell reads +0x39C, looks up the Spell.dbc record, and pushes
the localized name + rank + spellID. Returns nothing if the slot is
zero (no spell tooltip displayed). See src/spell/Tooltip.cpp.
GetItem reads two fields: itemID at +0x398, item GUID at
+0x380/+0x384. When the GUID is non-zero (any path with a real
CGItem — SetBagItem, SetInventoryItem, SetLootItem,
SetMerchantItem, etc.) we resolve to a CGItem and dispatch to the
engine's own per-instance link builder at FUN_0052AE00 — same
helper Script_GetContainerItemLink (0x004F9930) uses after
resolving its slot-form args. That produces the fully dressed link
with enchant ID, random-suffix factor, unique ID, and decorated
name. When the GUID is zero (SetItemByID / SetHyperlink with no
instance data), we fall back to a basic colored link built from
itemID + cached quality + cached name. Returns (name, link, itemID) — the third return is non-modern but saves callers a
gsub. See src/item/Tooltip.cpp.
GetUnit was prototyped two ways (token-walk over 92 hardcoded
tokens; MinHook on Script_GameTooltip_SetUnit + side-map) and
both scrapped. The 1.12 engine drops the token string immediately
after converting it to a GUID at +0x368/+0x36C, so there's no
clean way to return the original token. The walk-list approach is
brittle (misses any future-added token); the hook approach works
but adds collision surface with other Octo DLLs on a high-traffic
function. Modern WoW (3.3.5+) added a dedicated "current unit
token" field on the tooltip frame for exactly this reason — 1.12
just doesn't have the storage. Re-investigate if a low-collision
path emerges (e.g. a write site we haven't seen in 1.12 that
already preserves the string).
Shipped as src/HookSecureFunc.cpp. Pure C
implementation using a lua_pushcclosure with two upvalues
(orig, callback). The wrapper calls orig with LUA_MULTRET,
then callback (returns discarded), then returns orig's full result
list with no count cap.
Originally drafted as a Lua source string executed via
FrameScript_Execute at boot. Reworked to pure C to keep all
implementation in one language and avoid embedded source strings;
the refactor added three Lua C API offsets we hadn't wrapped before
(lua_gettop at 0x6F3070, lua_remove, lua_call at
0x6F4180), plus the LUA_UPVALUEINDEX(i) = GLOBALS_INDEX - i
helper.
While diagnosing why hooksecurefunc("GetSpellInfo", fn) was returning
"target field is not a function", a series of _classicapi_*Probe
helpers traced the failure to three misidentified offsets in
docs/LuaCAPI.md (and Offsets.h):
| VA | Doc'd as | Actually is |
|---|---|---|
0x006F30D0 |
lua_pushvalue |
lua_remove (shift-down loop + decr_top) |
0x006F32B0 |
lua_remove |
lua_replace (TValue copy + decr_top) |
0x006F3350 |
lua_replace |
lua_pushvalue (TValue copy → top + incr_top) |
How we caught it: _classicapi_PushValueProbe showed GetTop going
from 1 → 0 after PushValue(L, 1) — the supposed lua_pushvalue
was actually popping (lua_remove behavior). Disassembly of 0x6F30D0
confirmed the shift-down loop + add [ecx+8], -0x10, vs 0x6F3350
which has the standard add [ecx+8], 0x10 incr_top after a single
TValue copy.
Offsets.h and docs/LuaCAPI.md both updated. The misidentified
offsets had no observable effect previously because no code in the
project used PushValue outside HookSecureFunc.cpp — every
existing call path uses PushString/PushNumber for fresh pushes
and SetTable/GetTable for stack manipulation.
Modern's "secure" label is about taint propagation in 3.x+ protected
frames. 1.12 has no taint system; the function is functionally a
plain after-hook with return preservation. We ship it for modern
API parity — addons being backported from later expansions
frequently use hooksecurefunc(GameTooltip, "SetInventoryItem", ...)
and similar patterns, expecting the original to run first and their
callback to fire afterward. Both forms (global by name, table+method)
are supported.
Modern WoW rejects hooksecurefunc("<core lua/secure fn>", ...) outright
to keep callers from clobbering language primitives or breaking taint
propagation. We mirror that with a name blacklist in Script_HookSecureFunc
that fires lua_error with "hooksecurefunc: function is unhookable"
when the two-arg form targets one of: getfenv, getmetatable,
hooksecurefunc, ipairs, issecurevalue, issecurevariable, next,
pairs, pcall, pcallwithenv, rawget, rawset, scrub,
securecall, securecallfunction, secureexecuterange, select,
setfenv, setmetatable, type, unpack, wipe, xpcall.
Three-arg form (table + name + callback) is exempt: hooking
MyLib.pairs is fine even if _G.pairs is not — the blacklist is
specifically about replacing functions on the globals table. A user who
passes _G explicitly as the table target is opting in deliberately.
2.4.3 has the strings hooksecurefunc/securecall/issecure
clustered together at FO=0x4D2CF8/0x4D2D08/0x4D2D28 in .rdata,
suggesting they're a "secure environment helpers" string table for
the engine's taint-propagation code. The hooksecurefunc Lua
implementation itself is in FrameXML/RestrictedFrames.lua even in
modern WoW — the engine never had an internal C version we could
mirror.
Shipped in src/item/Tooltip.cpp.
Confirmed empirically distinct from SetItemByID: with run-speed
enchanted boots equipped, SetInventoryItemByID shows the
enchant line; SetItemByID shows the base item only.
Implementation walks character-pane slots 1..19 looking for an
equipped item matching itemID; on hit, dispatches to the
engine's existing Script_GameTooltip_SetInventoryItem (registry
slot 19, 0x00532EE0) with ("player", slot) rewritten onto the
Lua stack. Silent no-op when the item isn't currently equipped —
caller falls back to SetItemByID for unworn items.
When the player has duplicates of the same itemID equipped, modern client renders the lower-numbered slot first: MAINHAND (16) before OFFHAND (17), FINGER1 (11) before FINGER2 (12), TRINKET1 (13) before TRINKET2 (14). Our ascending 1..19 walk + first-match- break naturally produces the same ordering — lucky, since the INVSLOT constants happen to be ordered with the "primary" slot numbered lower than its peer. Locked in with a code comment so the loop direction doesn't accidentally drift in future refactors.
Modern WoW fires OnTooltipSetItem / Spell / Unit script handlers
on GameTooltip after each SetX finishes filling the tooltip. The
canonical addon pattern:
GameTooltip:HookScript("OnTooltipSetItem", function(self)
local _, link = self:GetItem() -- depends on TODO #61
-- augment tooltip
end)1.12 has the tooltip script-handler infrastructure but only fires
three specific names: OnTooltipAddMoney, OnTooltipCleared,
OnTooltipSetDefaultAnchor. The modern set is missing.
-
1.12 has
GetScript(0x00774780) andSetScript(0x007748D0) natively, but no nativeHookScript. Addons polyfillHookScriptLua-side — confirmed via:!!!ClassicAPI/api/api.lua:458pfUI/modules/eqcompare.lua:224
Both polyfills compose through
GetScript+SetScript, so hooking those two makes every existingHookScriptpolyfill in the wild work for our new names. We don't need to write one ourselves. -
The script-name → handler-storage mapping lives in a virtual method on
GameTooltip(resolved viavtable[?]), pointed at by one entry in.rdataatFO=0x408F6C→ matcher function at0x005295D0. The matcher's body:push 0x7FFFFFFF; push "OnTooltipSetDefaultAnchor"; push scriptName call SStrCmpI → if match: lea eax, [frame+0x444]; ret push 0x7FFFFFFF; push "OnTooltipCleared"; push scriptName call SStrCmpI → if match: lea eax, [frame+0x44C]; ret push 0x7FFFFFFF; push "OnTooltipAddMoney"; push scriptName call SStrCmpI → if match: lea eax, [frame+0x454]; ret xor eax, eax; ret // unknown nameFrame storage layout: 8 bytes per script handler slot (probably
{lua_func_ref, flags}). -
GetScript's "doesn't have a 'X' script" error fires when this matcher returns 0 AND the name isn't in the standard frame scripts (OnEvent, OnEnter, etc.).
Phase 1 — Script handler intercept (~half-day)
- MinHook on
Script_GetScript(0x00774780) andScript_SetScript(0x007748D0). Both are real functions (already addressable, not just registry slots). - For each:
- Read scriptName from Lua stack.
- If name ∈
{"OnTooltipSetItem", "OnTooltipSetSpell", "OnTooltipSetUnit"}:- Validate stack[1] is a frame.
- For Set: store handler in our registry table.
- For Get: push from our registry table or
nil. - Return without invoking original.
- Else: tail-call original.
- Storage: Lua registry table (allocated once at boot, kept alive
via a known registry index). Keys:
frame_va * "/" * scriptName. Handler lookup islua_gettable(L, our_table_idx). - Lua ref management: 5.0 uses
lua_ref/lua_getref/lua_unref. Need to find these in the binary (probably near the existing Lua C API offsets at0x6F3xxx); else use a pure Lua-table approach (setour_table[key] = function, retrieve withlua_gettable).
Phase 2 — Fire from our existing entry points (~1 hour)
After Spell::Tooltip::ShowByID and Item::Tooltip::Set*ByID,
call a FireTooltipScript(L, frame, "OnTooltipSetItem") helper
that does lua_gettable for the handler and lua_pcall(L, 1, 0)
with frame as arg.
This gives a working but incomplete OnTooltipSetItem — fires for
our two ByID functions but not for the engine's
SetInventoryItem/SetBagItem/etc.
Phase 3 — Cover engine Set*Item functions (~2-4 hours)
Either hook each individually (~19 Script_*Item functions in 1.12,
list in docs/raw_methods.txt) or find a unified inner builder:
- Item: no unified
BuildItemTooltipapparent — eachScript_GameTooltip_Set*Itembuilds independently. Hooking each via MinHook is straightforward but adds 19 hook points. Alternative: find the deeper inner helper they all converge on. The Set*Item functions all have similar prologues; trace one thoroughly to find the shared call. - Spell:
BuildSpellTooltipat0x0052E610is the unified builder (we already use it fromSetSpellByID). Single hook. - Unit: find inner of
Script_GameTooltip_SetUnit(slot 31,0x005349B0).
Phase 4 — GetItem/GetSpell/GetUnit (TODO #61, related)
For handlers to be useful, addons typically need to know what was set. Find frame state offsets where the engine stores the current item/spell/unit. Approach:
- Set known value via
SetItemByID(6948)/SetSpellByID(133)/SetUnit("player"). - Dump GameTooltip frame instance memory.
- Search for
6948/133/ player GUID. - Each match reveals the offset for that state field.
Once located, GetItem(), GetSpell(), GetUnit() are trivial
deref+push. Implementation roughly the same as OffhandHasWeapon
or IsEquippedItem patterns.
- Hot-path hooks:
GetScriptandSetScriptare called by every frame for every script registration. Mistakes in our hooks can break the entire UI. Test extensively before shipping. - Lua ref leaks: if a frame is destroyed while we hold a ref to its handler, we leak. Need a "frame destroyed" hook (or rely on garbage collection of a weak-keyed table).
- Engine internals path: finding the unified item builder (Phase 3) might require deep RE; falling back to 19 individual hooks is safe but ugly.
out unworkable in practice)
Skip the SetScript/GetScript intercept entirely. Provide:
GameTooltip.OnTooltipSetItem = function(self) ... end(field-assignment, no HookScript polyfill needed). We fire from
all the same places as Phase 2-3, but instead of lua_gettable
on our registry table, we do lua_getfield(frame, "OnTooltipSetItem")
directly off the frame's Lua table. Simpler, but doesn't compose
with HookScript polyfills automatically (each addon would
clobber the previous).
Shipped in src/input/Modifier.cpp. Adds
the seven query functions (IsLeftShiftKeyDown, IsRightShiftKeyDown,
IsLeftControlKeyDown, IsRightControlKeyDown, IsLeftAltKeyDown,
IsRightAltKeyDown, IsModifierKeyDown) plus the 2.4.3+
MODIFIER_STATE_CHANGED(key, down) event. All seven match the
modern WoW return shape (1 for down, nil for not-down).
The engine has no L/R distinction anywhere in its state. 1.12's
own IsShiftKeyDown chain bottoms out at GetKeyState(VK_SHIFT) —
the merged virtual key 0x10 — at 0x00436024, never the L/R-aware
VK_LSHIFT / VK_RSHIFT (0xA0 / 0xA1). Helper3 at 0x00436D80
returns a struct with only 3 modifier slots (shift/ctrl/alt), confirmed
by inspection. No amount of digging in engine memory finds L/R state
because it never existed there.
There IS a 6-bit field at [*[0x00C0ED38] + 0x269C] that looks
structurally identical to 2.4.3's L/R modifier bitmask at [+0x130]
— same shift-and-mask helper at 0x005936C0, same 6-bit dispatch
limit. We chased it for a while. Empirically the field is something
else entirely (some kind of input-device-flags bitmask) — it stays at
0x3BF regardless of which modifier is held. The 2.4.3 → 1.12
structural similarity is misleading; the field's meaning evolved.
The OS-level keystate does have the L/R distinction (VK_LSHIFT /
VK_RSHIFT etc.), and 1.12 already uses Win32 GetKeyState for its own
modifier checks — so calling GetAsyncKeyState(VK_LSHIFT) ourselves
is the same primitive the engine already uses, just with the L/R-aware
VK codes.
We initially subclassed WoW's WNDPROC (via EnumWindows to find the
GxWindowClassD3d/GxWindowClassOpenGl HWND, then SetWindowLongPtrA
to install a wrapper). Worked for L/R queries and the event. Then the
user toggled vsync and the event went silent — WoW destroys and
recreates its main window on some renderer state changes, so the
subclass was left dangling on a destroyed HWND.
Switched to SetWindowsHookExA(WH_GETMESSAGE, ..., GetCurrentThreadId()).
The hook is per-thread, not per-HWND, so it survives any number of
window recreations. Decodes WM_KEY{,SYS}{DOWN,UP} messages,
maintains a 6-bit cached bitmap that the queries read, and fires
MODIFIER_STATE_CHANGED on transitions.
VK_SHIFT(the merged virtual key the OS delivers for either key) →MapVirtualKeyA(scancode, MAPVK_VSC_TO_VK_EX)returnsVK_LSHIFTorVK_RSHIFTbased on the hardware scancode.VK_CONTROL/VK_MENU→ theKF_EXTENDEDflag (bit 24) oflParamdiscriminates: extended = right, plain = left.VK_L*/VK_R*delivered directly (some keyboards, SendInput) → accepted as-is.
Documented as part of the broader event-table investigation in CLAUDE.md's "Events" section. Short version: we tried three approaches to inject custom event names into the engine's event table.
Approach A — slot-claim (original Event::Custom): walk the live
table for NULL slots, write our SMemAlloc+memcpy'd name. Works.
Downside: first NULL slots were near the head of the table (slot 1
got MINIMAP_BLIP_TRACKING_CHANGED from VanillaMinimapTracking,
which felt off).
Approach B — rebuild-hook + tail-injection: hook
RebuildEventTable at 0x00703D90, pad the input array to 1024
entries, place our names at the tail (slots 1020+). Engine's own
SStrDup handles allocation, so we never write to engine memory.
Worked for a single DLL. Crashed when SuperWoWhook, nampower,
transmogfix, and VanillaMinimapTracking all hooked the same
function: their layered modifications to (names, count) produced
a count→buffer-size mismatch, and the engine's fill loop wrote off
the end of the allocated buffer on its last iteration.
Approach C — slot-claim + backward walk (final): same as A but
walk the table from count-1 downward looking for NULL slots, so
high indices are claimed first. Each DLL works on the table
independently — no hook chaining, no argument modification, no
crashes. Custom events naturally group at the tail (verified: slots
695..699 with all five custom events when ClassicAPI +
VanillaMinimapTracking are both loaded). Also swapped
SMemAlloc + memcpy for the engine's own SStrDup at 0x0064A620
— cleaner allocator, exactly matches what the engine's bulk
rebuild does.
The intermediate rebuild-hook approach also uncovered the engine's
hardcoded post-rebuild slot writes: the bulk rebuild populates
slots 0..548 from its 0x00BE1198 name array, then engine code
writes specific events to hardcoded slot indices in the 549..699
range (SPELL_DAMAGE_EVENT_SELF at slot 549,
SPELL_DAMAGE_EVENT_OTHER at 550, etc.). Documented in CLAUDE.md.
This is why claiming slots from low indices early-on would have
gotten clobbered — the slot-claim approach only fires after Lua's
first RegisterEvent call, by which point the engine's writes have
settled.
Modern 5.4.8+ event that fires once per game frame in which any
BAG_UPDATE fired. The 5.4.8 engine sets an
m_bagUpdateDelayedPending flag from the bag-change pipeline and
drains it from CContainerMgr::Update (its per-frame routine),
producing exactly-one coalesced fire per frame.
A pure C++ hook chain:
- Naked thunk over
FUN_FIRE_EVENT(0x00703F50) — peek at the event ID, set a pending flag on match against the cachedBAG_UPDATEslot,jmpto the trampoline so the variadic stack passes through untouched. - Normal
__thiscall-style hook over the per-frame OnUpdate dispatcher at0x00772890— drain the pending flag and fireBAG_UPDATE_DELAYEDonce per frame.
Reservation via the existing Event::Custom::AutoReserve slot-claim
machinery. The DLL compiled cleanly and registered the event;
IsEventValid("BAG_UPDATE_DELAYED") returned true.
The DLL crashed during the login-screen XML load — EIP=0x15FF075B
inside MinHook trampoline space, with zero bytes at that address,
ESP=0x001AEFEB (misaligned), and stack ret-addr 0x00772954 inside
the patched function. Classic signature of a corrupted MinHook
trampoline.
The user's Octo install loads SuperWoWhook, nampower, transmogfix,
UnitXP_SP3, VanillaHelpers, VanillaMinimapTracking, and
VanillaMultiMonitorFix alongside ours. Each links its own MinHook
copy. Hooking high-traffic engine functions like the event dispatcher
or per-frame OnUpdate sits squarely in the path where trampoline
regions can collide between DLLs — same class of problem as the
RebuildEventTable hook-chaining failure we already documented in
CLAUDE.md.
We removed src/bag/UpdateDelayed.{cpp,h}, the two HOOK_FUNCTION
calls in DllMain.cpp, and FUN_FRAMESCRIPT_FRAME_ONUPDATE from
Offsets.h.
Before the hook attempt, I shipped a version that used
FUN_FRAME_SCRIPT_EXECUTE (0x00704CD0) to inject a Lua snippet
creating a hidden frame with RegisterEvent("BAG_UPDATE") /
SetScript("OnUpdate", ...). The user pushed back: embedding Lua
source in the DLL is architecturally wrong for this project. The
right answer is always a C++ engine hook. FUN_FRAME_SCRIPT_EXECUTE
is gone from the codebase; pretend it doesn't exist. (See the
saved feedback memory.)
Disassembling 1.12 reveals three call sites of
FUN_00703F50(0x148, "%d", bagID) (event ID 0x148 = 328 =
BAG_UPDATE — derived from the BAG_UPDATE name pointer's position
in the input array at 0x00BE16B8, offset 0x520 from array
base, divided by stride 4):
-
FUN_004F91A0— bag-slot diff loop (2 callers — quiet). Walks linear slots0x13..0x16(player bags 1..4) and0x3B..0x44(bank bags 5..10), comparing each against a cached GUID snapshot atDAT_00BDD060. FiresBAG_UPDATE(bagID)for each slot whose GUID changed. Also fires event0x149(BAG_CLOSED) when a bag is removed. Called from the per-session init (FUN_004F8CC0) and from packet-handlerFUN_004F8EC0. -
FUN_004F8DB0— item-descriptor-change callback (4 callback registrations, all in init helpers). DirectFUN_00703F50(0x148, "%d", -2)for slot range0x51..0x70(keyring). Other slot ranges delegate toFUN_004F9370. -
FUN_004F9370— item→bag resolver (3 callers, all inside the bag subsystem). Given an item GUID, searchesDAT_00BDD060for a match. FiresBAG_UPDATE(0)if the item belongs to the player (backpack),BAG_UPDATE(N)if it's in bag N, returns silently if not found in any bag. This is the most common path — every "item moved within bags" event flows through here.
All three targets are quiet (≤4 callers each, all inside the bag
code region 0x004F8DB0..0x004F94D0). None overlap with the
hot-loop hook targets the previous attempt collided on
(FUN_FIRE_EVENT, the per-frame OnUpdate).
Approach: hook all three fire sites + read engine's per-frame counter to coalesce.
The engine maintains a monotonic frame counter at 0x00C7B2C8,
incremented once per world tick from FUN_0066FD50 (the world-
subsystem update). Only two writers in the binary —
FUN_0066F6C0 zeros it on world load, FUN_0066FD50
increments it +1 per frame. Reading it tells us, exactly,
"are we in a new frame since the last observation?"
constexpr uintptr_t VAR_WORLD_FRAME_COUNTER = 0x00C7B2C8;
uint32_t g_lastSeenFrame = 0;
void EmitDelayedOnFrameBoundary() {
const uint32_t now = *reinterpret_cast<uint32_t *>(VAR_WORLD_FRAME_COUNTER);
if (now == g_lastSeenFrame) return; // same frame — coalesce
g_lastSeenFrame = now;
const int slot = Event::Custom::Lookup(kBagDelayedEvent);
if (slot >= 0) Event::Custom::Fire_None(slot);
}
void __cdecl Bag_004F91A0_h() {
g_004F91A0_orig();
EmitDelayedOnFrameBoundary();
}
// Same wrapper pattern for FUN_004F8DB0 (__thiscall taking
// `(int slotID, GUIDlo, GUIDhi, int *cachedGUID)` per the
// decompile) and FUN_004F9370 (cdecl `(int lo, int hi)`).Properties:
- Exact frame coalescing — multiple BAG_UPDATEs in the same frame produce exactly one BAG_UPDATE_DELAYED, regardless of how close they are in wall-clock time.
- No time math, no arbitrary threshold — the engine's own frame counter is the source of truth.
- No new hook on a per-frame routine — we read the counter, not hook the writer. Zero MinHook footprint outside the three bag-subsystem targets.
- No glue-screen activity —
DAT_00C7B2C8doesn't increment before the player enters world, which is fine since BAG_UPDATE doesn't fire there either.
Why this avoids the previous attempt's crash: none of these
three targets are common hook destinations for other DLLs. The
problem before was hooking FUN_FIRE_EVENT (100+ callers,
trafficked by every event) and FUN_FRAMESCRIPT_FRAME_ONUPDATE
(per-frame dispatcher) — both prime collision targets for
SuperWoWhook / nampower / transmogfix. The bag subsystem
internals at 0x004F9xxx aren't touched by those DLLs (none of
them care about bag-update timing), and the per-frame counter
at 0x00C7B2C8 is a pure memory read with no hook at all.
Semantic match vs modern: modern coalesces at exact end-of- frame; we coalesce within a single world-tick frame using the same counter the engine itself uses to drive the framerate sampler. Equivalent in practice — addons doing "rescan inventory on BAG_UPDATE_DELAYED" see exactly one fire per frame in which any bag activity occurred.
Drop to hooking only FUN_004F9370 (the most common path).
Misses bag-equip-itself and keyring cases but covers the 90%
case of "user picked up an item / moved an item in bag." Single
hook, lowest collision risk.
If both hook plans collide, document the limitation — addons
that want this behavior on Octo-style multi-DLL clients can
fall back to debouncing BAG_UPDATE themselves via an
OnUpdate timer.
Modern signatures return (name, _, texture, startMs, endMs, _, _, spellID) for the unit's currently channeled / cast spell. 1.12
exposes nothing equivalent — the closest is SPELLCAST_CHANNEL_*
events, which only fire for the local player and don't carry
spellID payloads.
The data is sitting in the broadcast descriptor:
UNIT_FIELD_CHANNEL_SPELLat descriptor+0x228(already inOffsets.hasOFF_UNIT_FIELD_CHANNEL_SPELL). Verified via the warlock/clicker/fishing diffs in theIsAssistingRitualsession — non-zero = currently channeling that spell.UNIT_FIELD_CHANNEL_OBJECTat descriptor+0x38/+0x3C(OFF_UNIT_FIELD_CHANNEL_OBJECT). 64-bit GUID of the channel target — bobber for fishing, lock for lockpicking, etc. Zero for channels that don't bind to a GameObject (warlock spells, Ritual of Summoning).
That gives us spellID immediately, and name + texture via the
existing GetSpellInfo(spellID) chain. Worth shipping a partial
function that omits the startMs/endMs returns and let callers
do their own duration estimation off SPELLCAST_CHANNEL_START —
or hunt for the per-unit cast-time fields the engine uses for the
local castbar.
What's missing for the full modern signature: the start/end-time
fields. The local player has Script_SpellStopCasting-adjacent
state at fixed VAs (m_castingSpellId, m_castStartMs,
m_castEndMs) — but for other units we'd need to either find
broadcast UpdateFields holding the same, or compute end-time on
the client from Spell.dbc's cast time when we see a fresh
UNIT_FIELD_CHANNEL_SPELL value. The 5.4.8/3.3.5 binaries are the
right next stop for this.
Companion field to investigate while we're in here: desc 0x1320
captures the prior channeled spellID after the channel ends —
see entry #71 for what that's about.
No-arg local-player boolean that fires while the player is
mid-gather: herb pick, ore mine, fishing-bobber-loot, lockpicking,
skinning. Modern WoW doesn't expose this exact concept but addons
that care (auto-attack suppression, mouse-look reversion, autorun
toggles) reverse-engineer it from LOOT_OPENED /
UNIT_SPELLCAST_* events. A direct read is cleaner.
State machine from the IsAssistingRitual session, verified by
diffing the player while gathering:
[player + 0x1D28]/[player + 0x1D2C]— 64-bit GUID of the GameObject the player is gathering from.0xF110xxxxhigh half = vanilla GameObject prefix. Set when the gather animation starts, cleared when it ends or breaks.[player + 0xD48]— small int,0baseline,1during gather. Confirmed for herb gather; mining sets the same field. Cleanest single-bit signal we have.UNIT_FIELD_FLAGS(descriptor+0xA0) bit0x1000is cleared during gather — unclear what the bit semantically means (other emulator sources don't agree on this bit's name in vanilla), but it's another way to detect the state.
Three implementation options worth picking between:
IsGathering()boolean,[player+0xD48] != 0. One line.GatheringObjectGUID()returning(lo, hi)from[player+0x1D28]/+0x1D2C. Lets addons distinguish herb-from- herb / chest-from-chest. Modest extra surface.- Family of
IsHerbing()/IsMining()/IsFishing()— requires looking up the GameObject's type/sub-class from its GUID, which means walking the engine's GO cache. Significantly more work; defer unless someone has a use case.
Fishing is the trickiest case — /cast Fishing is a regular spell
cast (sets UNIT_CHANNEL_SPELL = 7620), the bobber sits in water
during a separate wait phase (UNIT_CHANNEL_OBJECT = bobber GUID),
then the right-click bobber action triggers the gather/loot
machinery (sets [player+0x1D28]/+0x1D2C to the bobber GUID, but
NOT 0xD48). So [player+0xD48] is "gather-cast active"
(herbing/mining), and [player+0x1D28] is the broader "engaged
with a lootable GO" pointer. Pick which semantic you want before
naming.
Modern GetUnitStandStateValue(unit) or UnitStandState(unit) (the
name varies by expansion) returns an integer describing whether
the unit is standing / sitting / sleeping / kneeling. Vanilla 1.12
exposes only IsSitOrStanding() (local boolean) and lacks any
"is target sitting" check.
Verified directly via the IsAssistingRitual session: descriptor
+0x210 is UNIT_BYTES_1 (CMaNGOS field 132 in the 1.12.1
layout). The standstate is the low byte of that u32. Observed
transition during a /sit on a chair: 0xEE00 → 0xEE05,
i.e. low byte 0 → 5 (STANDSTATE_SIT_MEDIUM_CHAIR).
Vanilla standstate enum (from emulator sources, low byte of
UNIT_BYTES_1):
| Value | Meaning |
|---|---|
| 0 | STANDING |
| 1 | SITTING |
| 2 | SITTING_CHAIR |
| 3 | SLEEPING |
| 4 | SITTING_LOW_CHAIR |
| 5 | SITTING_MEDIUM_CHAIR |
| 6 | SITTING_HIGH_CHAIR |
| 7 | DEAD |
| 8 | KNEELING |
Implementation: resolve unit → descriptor → [+0x210] & 0xFF.
Broadcast field, so it works for any synced unit (player, target,
party/raid, inspect targets) — not local-only.
While we're documenting UNIT_BYTES_1, the other bytes of the same
u32 are worth deriving when needed (CMaNGOS docs say byte 1 =
PetTalents, byte 2 = VisFlag, byte 3 = AnimTier), but none
have an obvious modern-API hook in vanilla.
Full backport of modern's equipment-set API on top of a client-side
persistent store at WTF\Account\<acct>\<realm>\<char>\ ClassicAPI_EquipmentSets.txt. 18 functions including Create,
Save, Modify, Delete, Use, GetItemLocations, plus the
ignored-slot quartet. Identity is by item GUID — Locations::FindGUID
walks the player invMgr's flat GUID array for paperdoll / backpack /
main bank, and reaches bag contents (player bags 1..4 and bank bags
5..10) via each bag's CGContainer→invMgr through vtable[+0x10].
Same direct-read technique C_Item.GetItemCount uses, so bank items
resolve without the bank window being open.
Also exposes the ITEM_INVENTORY_LOCATION_PLAYER / _BAGS / _BANK
/ _BAG_BIT_OFFSET / _BANK_BAG_OFFSET constants as Lua globals so
addon code copy-pasted from modern FrameXML's
EquipmentManager_UnpackLocation works unchanged. Fires
EQUIPMENT_SETS_CHANGED (no payload) on any mutation and
EQUIPMENT_SWAP_FINISHED(success, setID) at the end of
UseEquipmentSet. See docs/API.md and src/equipmentset/.
UseEquipmentSet currently walks slots in order and does a
pickup→equip pair per item. If two items in the set want each other's
slots (e.g. ring A in slot 11 wants slot 12, ring B in slot 12 wants
slot 11), the first pickup succeeds, the equip drops it correctly,
but the second pickup picks up B and EquipCursorItem swaps it
into the now-occupied slot — leaving one ring stashed on the cursor
which our cleanup ClearCursor either drops back or routes to a free
bag slot. Re-running UseEquipmentSet finishes the job, but it
shouldn't take two calls.
FUN_005E0C40 (decoded in #59) is the engine's atomic swap-and-send.
Replacing the pickup+equip pair with a direct FUN_005E0C40 call
fixes the cycle case naturally: each swap is one server packet that
atomically exchanges two slots. The classic 2-cycle "A in 11, B in
12, want A in 12, B in 11" resolves in a single call —
FUN_005E0C40(player, A, player, 10, player, player, 11, 0) sends
opcode 0x10D {src=10, dst=11} and the server swaps both slots.
Longer chains (3+ items rotating) are rare with 1.12's equipment slot set — the realistic conflicts are 2-cycles between paired slots (rings 11/12, trinkets 13/14, weapons 16/17). One swap each.
For sequential non-cycle moves (item from bag → empty slot), the
swap is still atomic and doesn't touch the cursor. The catch is
that consecutive FUN_005E0C40 calls in the same frame queue at
the server before any inventory update arrives — so subsequent
iterations see stale local state. For independent moves this is
fine (each swap target is different); for chains it can produce
no-op or undo behavior.
src/equipmentset/Api.cpp
Script_UseEquipmentSet:
- Drop the
ClearCursor()calls at start and end — irrelevant now. - Replace the per-slot pickup+equip pair with a single
FUN_005E0C40call:- srcItem GUID: read off the CGItem at the resolved
Locations::FindGUIDlocation - srcContainer + srcLinearSlot: from
locdecode per the table in #59 - dstContainer = player, dstSlot =
targetSlot - 1
- srcItem GUID: read off the CGItem at the resolved
- Keep the existing skip-conditions: GUID empty/ignored, already in target slot, in bank, etc.
The bank-skip stays: vanilla server rejects equip-from-bank, and no client-side workaround changes that.
Same shared helper between #59 and #73: an Item::Swap::EquipByGuid
or similar that takes (CGItem*, dstPaperdollSlot) and handles the
container-guid + linear-slot resolution. Implement once, use in
both call sites.
Fires (setID) at the start of UseEquipmentSet right after the
set-exists check, before any pickup/equip work. Skipped when the
set doesn't exist (only FINISHED with success=false fires
there). Added Event::Custom::Fire_D (single-int variant) to
support the one-arg signature.
Item::Count::CountInBankBags and EquipmentSet::Locations both
walk bank-bag contents via the same pattern: read GUID at linear
slot 63..68 of player invMgr → FUN_OBJECT_RESOLVE_BY_GUID with
OBJ_TYPE_CONTAINER → invoke vtable[+0x10] to get the bag's own
invMgr → walk that invMgr's flat GUID array at +0x04. Same
boilerplate twice. If a third caller needs it, extract to
Item::Inventory::WalkBagsByGUID(callback) or similar in a shared
header. Not worth the refactor for two callers yet.
WotLK addition. Returns true if the player is in the world,
false at character select / login screen.
Prototyped twice and pulled both times:
- World-only registration (via the regular module-register
hook) — implementation was trivial (
FUN_RESOLVE_UNIT_TOKEN("player") != nullptr), but the function only existed in the world Lua state. Glue-screen Lua couldn't reach it, so addons that gate on the world transition would seeattempt to call a nil valuepre-login. - Glue + world (added a MinHook on the glue script registration
at
FUN_0046DDF0) — crashed on the "Tiny" 1.12.1 build with an access violation at the patched bytes. The glue-register function is too small (~9 byte prologue) for a stable 5-byte JMP across binary variants and likely overlaps with another loose-loader DLL's hook.
For now, addons that need login-state gating can use the
PLAYER_LOGIN / PLAYER_ENTERING_WORLD events (both fire
in-world reliably). Revisit if a safer glue registration path
surfaces.
Vanilla 1.12's global GetWeaponEnchantInfo returns 8 values
(has/expire/charges per weapon slot) but no enchant IDs.
WotLK+ extended the signature to 12 returns by inserting the
temp-enchant ID after each weapon's data. We provide the modern
12-tuple in the C_Item namespace, leaving the vanilla global
intact for addons that depend on its position layout.
Reads ITEM_FIELD_ENCHANTMENT slot 1 (descriptor +0x4C..+0x54,
the TEMPORARY enchantment slot) for mainhand / offhand / ranged
equipment slots — the same slot the engine drains as the
oil/stone/poison expires. The permanent enchant (Crusader,
Mongoose, etc. in slot 0 at +0x40) isn't reported here; modern's
GetWeaponEnchantInfo is specifically about temp data.
See src/item/WeaponEnchant.cpp.
Dispatches the engine's Script_GetCVar at 0x00488BA0 to read
the cvar's string value, then coerces: non-zero numeric or
"true" (case-insensitive) → true; "0" / empty / nil /
unknown cvar → false. Exposed as C_CVar.GetCVarBool(cvar).
See src/cvar/Bool.cpp.
WotLK additions. Always returns false on 1.12 — vanilla
predates trial accounts (introduced 5.x). Modern addons gate
features on these and crash without them. One-liner.
Reads Spell.dbc.AttributesEx (+0x1C) bit 0x80 —
SPELL_ATTR_EX_NEGATIVE (CMaNGOS' SPELL_ATTR_EX_NEGATIVE_1).
Same bit the engine uses to classify auras into the debuff slot
range, so it's reliable for combat spells.
The earlier TODO note about AttributesEx2 bits 0x10 / 0x20
turned out to be wrong — those bits are CANT_REFLECTED and
AUTO_REPEAT per CMaNGOS, not helpful/harmful. The right flag is
the single NEGATIVE bit in AttributesEx.
Helpful side: "spell exists in Spell.dbc and isn't marked negative" — vanilla has no dedicated positive-spell flag, so utility spells that aren't harmful read as helpful. Approximation; close enough for most addon use cases. Strict modern semantics (where some spells return false for both) would require parsing every effect's implicit target — deferred.
Four Lua surfaces: IsHelpfulSpell(spell), IsHarmfulSpell(spell)
(global, accepts spellID or (slot, bookType)), and
C_Spell.IsSpellHelpful(spellID) / C_Spell.IsSpellHarmful(spellID)
(C_Spell namespace, takes numeric ID only).
See src/spell/Info.cpp.
Returns (limitCategory, maxCount) — used by stack-aware addons
(BankItems, ARL) to know whether an item is "unique" (max 1 in
inventory) or "unique-equipped" (max 1 equipped).
Reads from ItemStats record's flag field. Vanilla items have a
simpler binary "unique" flag; the modern Limit Category system
(ItemLimitCategory.dbc) was added in TBC and may not exist in
1.12. Implementation: return (0, 1) for items with the unique
flag set, (0, 0) otherwise.
Returns (startTime, duration, enable) for an item's
on-use cooldown — the standard cooldown tuple. Different from
GetContainerItemCooldown (which takes bag+slot); modern addons
use the itemID form because they don't always know where the
item lives.
Implementation: items with on-use spells map to spell cooldowns
via Item::Spell::OnUseSpellIDForItemID (already wired for
Hearthstone/GetItemSpell). From the spellID, query the engine's
spell-cooldown machinery — same function Script_GetSpellCooldown
uses internally.
Returns a { ITEM_MOD_STAMINA_SHORT = 5, ITEM_MOD_STRENGTH_SHORT = 10, ... }
table for the item. Heavy addon dependency — Pawn, AtlasLoot,
GearScore, and every gear-comparison TSM-style addon use it.
ItemStats record carries up to 10 stat-type/stat-value pairs at
fixed offsets. 4.3.4 implementation at 0x0044C200 parses the
itemLink (for enchant + gem deltas) then walks those 10 slots and
emits a table keyed by stat-type-id-string.
In vanilla, enchant IDs from itemLinks need lookup via
SpellItemEnchantment.dbc to add their stat deltas. Gems are N/A
(no socket system in vanilla — drop that branch). The base
ItemStats walk is the main work.
Returns true if the item is currently usable by the player —
considers level requirement, class restriction, race restriction.
Used by tooltip code and "use item" buttons to gray themselves
out for unusable items.
Vanilla itemstats has m_requiredLevel, m_allowableClass, and
m_allowableRace fields. Implementation: peek the cache, check
those three against player's level/class/race (race is in
UNIT_FIELD_BYTES_0, already read in GetPlayerInfoByGUID).
Range-check predicates. SpellHasRange is a Spell.dbc
RangeIndex != 1 check (range index 1 is "Self Only").
IsSpellInRange(spell, unit) requires:
- Resolving spell name to spellID (lookup is already in
Spell::Lookup) - Reading
RangeIndexfrom Spell.dbc (already wired inSpell::Infofor the range column) - Looking up
minRange/maxRangefromSpellRange.dbc(already inOFF_SPELLRANGE_*constants perOffsets.h) - Computing 3D distance between player and unit
- Returning
1if inside range,0if outside,nilfor invalid unit (can't get position)
Step 4 needs the engine's unit position read primitive — we have no offset for that yet. CGUnit has a position field at some descriptor offset; finding it is an hour's work.
Implemented in src/unit/MirrorTimer.cpp.
The vanilla engine fires MIRROR_TIMER_START / _PAUSE / _STOP
events with the full payload but doesn't cache the state, so we hook
the SMSG handler at FUN_005E7990, peek the packet via cursor
save/restore on the CDataStore at +0x14, and build a 3-slot side
cache. GetMirrorTimerInfo returns the snapshot; GetMirrorTimerProgress
interpolates against engine ticks for the live value. Type names are
"EXHAUSTION" / "BREATH" / "FEIGNDEATH" (engine wording — modern
uses "FATIGUE" for the off-map one; we preserve what the engine
actually returns).
Implemented in src/macro/Spell.cpp. Turned out to
be a one-read job: the vanilla engine already walks every macro body
at create / edit / refresh via FUN_MACRO_PARSE_PRIMARY_SPELL
(0x004EFE00) and caches the resolved spellID on the macro struct at
+OFF_MACRO_PRIMARY_SPELL (+0x564). GetMacroSpell reads the cache
via FUN_MACRO_SLOT_TO_ENTRY (0x004F0E40), translates 0 /
0xFFFFFFFF sentinels to "no match," and looks up name + rank from
Spell.dbc for the resolved ID. Returns (name, rank, spellID)
matching modern's 3-tuple. CastSpellNoToggle("<name>") macros are
also recognized because Spell::CastNoToggle already hooks the same
parser to register that pattern.
WotLK-era event that fires every time a paperdoll slot changes (equip, unequip, swap). Heavily used by gear-tracking, tooltip-decoration, and stat-sheet addons. The single highest- value event missing from vanilla — backporting it would unblock dozens of addons that gate refresh logic on it.
Vanilla fires UNIT_INVENTORY_CHANGED for the player when any
slot changes, but doesn't say WHICH slot. Implementation: cache
the player invMgr's GUID array for slots 0..18 at our hook
point, diff against last snapshot, and fire
PLAYER_EQUIPMENT_CHANGED(slot, hasItem) once per changed slot
via Event::Custom.
User strongly prefers an event-driven hook over per-frame polling. Multiple promising targets tried; none fired on user-driven equip/unequip actions in the Octo build.
FUN_004F8DB0 (the bag/inventory descriptor-change callback)
— our existing BAG_UPDATE_DELAYED hook target. Empirically only
called for backpack contents (offsets 0x560..0x5D9, slots
23..38) and keyring (offsets 0x730..0x829, slots 81..112). The
FUN_004F8CC0 registration setup confirms there is no
registered per-slot descriptor-change callback for equipment
slots 0..18 (offsets 0x4A8..0x53F). Equipment slot descriptor
writes happen silently during SMSG_UPDATE_OBJECT processing.
Three direct push 0xBA; call FUN_00703F50 sites found for
UNIT_INVENTORY_CHANGED:
0x004C710Band0x004C72DB(both inFUN_004C7180,__cdecl(uint guidLo, uint guidHi), 5 callers in the0x5D8xxx-0x5D9BxxSMSG region).0x004C8504(inFUN_004C84F0, 0 direct callers — vtable or dead).
Two mov edx, 0xBA; call FUN_00515E50 sites at 0x0051BD73
and 0x005D85FE. FUN_00515E50(guid, eventID) is the
unit-event firer — walks the GUID→token reverse resolver
(FUN_00515C50) and fires (eventID, "%s", token) for each
matching unit token. 37 callers across many event IDs — direct
hook is too generic / high traffic.
FUN_005D8440 (containing 0x5D85FE) is the item-update
post-processor — __fastcall(void *cgItem), 2 callers, fires
UNIT_INVENTORY_CHANGED via FUN_00515E50(itemGUID, 0xBA) for
the affected item.
Hook attempts that did NOT fire on equip/unequip:
FUN_004F8DB0viaBag::InvDescChangedispatcher subscription — slot range is backpack + keyring only, never equipment.FUN_004C7180direct hook — function's preconditions (visible-item filter, slot-range gate) bail before FIRE_EVENT on plain bag↔equipment swaps.FUN_005D8440direct hook — debug log never printed despite the user confirmingUNIT_INVENTORY_CHANGEDfires.
Open question: if all three known 0xBA-firing functions are
hooked and none of our hooks trigger on equip, then either:
(a) the hooks aren't being installed for some reason (collision?
some Octo modification?), or (b) the event fires through a
mechanism we haven't found — possibly via the LUA-side
FrameScript_FireEvent("UNIT_INVENTORY_CHANGED", "player")
called from another function we haven't traced.
Next steps to try:
- Add
_classicapi_DumpHookor similar diagnostic that confirms the hook is actually in place afterMH_EnableHookreturns. Maybe Octo or another DLL is unhooking us. - Search the binary for
"UNIT_INVENTORY_CHANGED"string references — if the event name is referenced in code (not just in the boot-time table), there's a string-based firing path we missed. - Investigate the
mov ecx, 0xBAsites at0x60341Band0x604F77— both callFUN_005AB650/FUN_005AB670(packet handler registration), so0xBAthere is a server OPCODE, not an event ID. Confirm and rule out. - Try hooking one tier higher:
FUN_005AB650(opcode, handler)callers in the SMSG_UPDATE_OBJECT region might give us the actual update path. - Alternative architecture: skip event-driven entirely and use
Tick::WorldTickpolling. Per-frame cost is 19 itemID reads- 19 compares (~negligible). User pushback on polling was philosophical, not perf-based; might be acceptable as a fallback if a clean hook can't be found.
Bag::InvDescChange shared dispatcher (src/bag/InvDescChange.{h,cpp})
modeled on Tick::WorldTick::AutoSubscribe. Bag::UpdateDelayed
now subscribes to it instead of owning the FUN_004F8DB0 hook
directly. Currently has a single subscriber so adds no value
on its own — revert if not needed for resumed work.
Same diff feeds EQUIPMENT_SETS_CHANGED /
EQUIPMENT_SWAP_FINISHED for our existing C_EquipmentSet
module — currently those fire only on user-side mutations, not
on inventory changes that affect a set's "equipped count".
Fires when any equipped item's durability changes — picks up combat damage, repairs, item destruction. Used by Repair-O-Matic type addons and durability HUDs to avoid polling.
CGItem descriptor [item + 0x114] has:
+0xA0—ITEM_FIELD_DURABILITY(field index0x28)+0xA4—ITEM_FIELD_MAXDURABILITY(field index0x29)
Both already read by Script_GetInventoryItemBroken
(0x004C8590) and by ClassicAPI's existing
Script_GetInventoryItemDurability in
src/item/Durability.cpp — so the
read path is mature; only the change-detection path
remains.
Field name strings "ITEM_FIELD_DURABILITY" (0x0083D9FC) and
"ITEM_FIELD_MAXDURABILITY" (0x0083D9E0) each have exactly
one xref — into a metadata table at 0x0083A414 (see below).
Confirmed by reading the 4.3.4 binary: there is exactly one
firing site for UPDATE_INVENTORY_DURABILITY (eventID 0x1BA)
in Cataclysm, and it's purely server-driven:
// FUN_00551580 — SMSG packet handler:
read_u32(&ctx);
read_u32(&selector);
if (ctx == 0) {
switch (selector) {
case 1: dispatch(0x1BA); // UPDATE_INVENTORY_DURABILITY
case 2: dispatch(0x1BB); // UPDATE_TRADESKILL_RECAST
case 3: dispatch(0x1BC); // OPEN_MASTER_LOOT_LIST
case 4: dispatch(0x1BD); // UPDATE_MASTER_LOOT_LIST
}
}The 1.12 server doesn't send this opcode, so we can't mirror
it. The event's semantics are just "refresh durability UI" with
no payload — addons handle it by iterating equipment slots and
calling GetInventoryItemDurability. So we don't need to know
which item changed; we just have to fire when any equipped
item's durability changes.
ITEM_LOCK_CHANGED (eventID 188, slot 0xBE1488 in the input
names array) is the closest model — it fires when
ITEM_FIELD_FLAGS changes. Searched the binary for both push 188 (imm8) and push 188 (imm32) followed by call to the
event dispatcher at 0x00703F50 — zero hits. Confirms
1.12 has a generic per-field event-fire mechanism that doesn't
push the eventID as a literal.
The per-field metadata table at 0x0083A414 looks like the
right structure:
| Field | Idx | Leading dword | Other bytes |
|---|---|---|---|
ITEM_FIELD_DURABILITY |
0x28 |
0x04 |
1, 1 |
ITEM_FIELD_MAXDURABILITY |
0x29 |
0x14 |
1, 1 |
(idx 0x27) |
0x27 |
0x04 |
1, 1 |
(idx 0x26) |
0x26 |
0x01 |
1, 1 |
(idx 0x25) |
0x25 |
0x01 |
1, 1 |
Entry layout: {leading_dword, name_ptr, field_idx, ?, ?}
(stride 0x14 = 20 bytes). The leading dword varies per
entry — could be an event-fire flag, broadcast scope, or
network-stream type code. Need to find the consumer function
(table base 0x0083A414 has zero direct VA refs, so it's
consumed via base+index relative to a CGItem m_objectFields
metadata pointer somewhere — we don't have that pointer's
addr yet).
-
Per-frame snapshot polling. Hook a per-frame engine tick, walk 19 equipped slots each frame, hash durability, compare, fire on change. Modest cost (19×2 reads/frame). Hardest part: finding a clean per-frame hook point that's not in the JIT-collision-risk class. Candidate: hook the network packet-flush boundary (one-off per game tick, cheaper than per-render-frame).
-
SMSG_UPDATE_OBJECThandler hook. Hook the packet handler that applies UpdateField changes to objects. Snapshot durabilities before, check after; fire when different. More targeted than (1) — only fires when there's a network reason for state to have changed. Needs the opcode handler VA; not yet pinned down. -
Decode the per-field metadata table. Reverse-engineer the
0x0083A414table layout to understand what the leading dword means. If it's the event-fire mechanism the engine already uses forITEM_LOCK_CHANGED/UNIT_HEALTHetc., we patch theITEM_FIELD_DURABILITY/ITEM_FIELD_MAXDURABILITYentries to fire our reservedUPDATE_INVENTORY_DURABILITYslot. Cleanest end state, deepest spelunking — may hit a dead end if the table's consumer isn't where we expect.
The MinHook collision-risk memory note ("hooking high-traffic
engine functions risks JIT/trampoline corruption from
SuperWoWhook/nampower/etc.") argues against (2) since
SMSG_UPDATE_OBJECT is one of the hottest opcode handlers.
That pushes us toward (1) or (3). Of those, (3) is the right
end state — no per-frame overhead, fires exactly when the
engine sees a field change — but (1) is a defensible ship-
now answer with a clear migration path to (3) later.
When picking this back up, the 5.4.8 client (C:\WoW\World of Warcraft 5.4.8\Wow.exe) is the cleanest reference — it has
the same client-side detection (UpdateField → event) but with
post-vanilla code structure. The 4.3.4 packet-driven path is
a Cataclysm-specific oddity and won't reflect what 1.15.x or
modern does.
Modern event that fires once after a storm of BAG_UPDATEs
settles (typically inside a single frame). Lets addons debounce
inventory-scan work without each one rolling its own timer.
Previous attempt: see #67. Reverted because the implementation
hooked something hot and caused issues. Re-attack with the
hook installed at Frame::FireEvent (the dispatcher we already
know) — count consecutive BAG_UPDATE fires per frame, and on
the first frame that has zero, fire BAG_UPDATE_DELAYED.
Lower risk than the previous approach since we're observing
fires rather than hooking the update path. Event::Custom
machinery is more mature now and matches what the EquipmentSet
module already does.
The modern per-cast notification with unit + target + spellID.
Vanilla ships SPELLCAST_START for the player only, with just
the spell name — modern addons want spellID and unit info.
Player-only backport is feasible without wire-protocol work:
hook SPELLCAST_START fires (or the underlying engine path that
sends them), translate name→spellID via Spell::Lookup, pass
through with "player" and the current target token. Cross-unit
(party member casting) requires SMSG_SPELL_GO observation —
defer that to a "Phase 2" or skip permanently.
If we ship the player-only version, document the limitation
prominently — addons that rely on unit ~= "player" events
won't get them, but everything that only cares about the local
player works.
5.4.8-era addition. Range check based on the item's "range modifier" — long-range items (rifles, throwing weapons) report true when the target is in firing range. Used by hunter / rogue addons to gate ability use on item range.
Implementation: read ItemStats range index, look up in
SpellRange.dbc (already wired in Spell::Info), compute 3D
distance to unit. Same machinery as TODO #85 (IsSpellInRange)
but item-keyed instead of spell-keyed.
If we wire up the unit-position read primitive for either of these, the other comes nearly free.
Backport of modern's date-math namespace. Six functions are pure
<ctime> arithmetic over a CalendarTime table; the seventh
(GetSecondsUntilDailyReset) reads Time::Server::CurrentEpoch()
and returns 86400 - (epoch % 86400). Server midnight semantics
work naturally because CurrentEpoch treats the engine's server-
time components as UTC for the epoch conversion — day boundaries
in epoch math align with server-clock midnight.
GetSecondsUntilWeeklyReset deliberately omitted — vanilla has no
server-broadcast weekly reset schedule, and Turtle realms vary, so
any hardcoded weekday/hour would be wrong somewhere. Addons that
need it can compute their own value (e.g. "next Tuesday at server
midnight") via GetCurrentCalendarTime + AdjustTimeByDays.
Side effect: refactored Time::Server::CurrentEpoch() out of
Script_GetServerTime so the C++ helper is reusable.
During the IsAssistingRitual investigation, the player's
descriptor showed +0x1320 flip from 0 to 0x2BA (= 698 =
Ritual of Summoning) immediately after the channel ended,
where the active channel was at +0x228. So +0x228 =
UNIT_CHANNEL_SPELL (live), and +0x1320 looks like a "previously
channeled" stash that's populated on transition-to-zero.
Not enough data to commit to a semantic yet. Open questions:
- Does it also stash for fishing / lockpicking / mining channels? We have one data point (the portal click) showing the behavior.
- Does it clear, and if so when? (
/reload? Next channel? Never?) - Is it a broadcast UpdateField or local-only?
If it turns out to be a stable "last completed channel" field, it
unlocks polyfills for the modern UNIT_SPELLCAST_CHANNEL_STOP
event payload (which carries the spellID that just ended) without
hooking the channel-stop code path. Lower-priority; useful but
not load-bearing for any specific API in the polyfill addon
today.
Investigation plan when picked up: snap +0x1320 before any
channeled action, perform a clean channel, snap again immediately
after channel end (use _classicapi_DescDump(0x1320, 4) for a
targeted read). Repeat across several channel types and confirm
the pattern.
Currently, if the player has a junk item picked up on their cursor
when calling SellAllJunkItems, the item still sells (the CGItem
remains in its bag slot internally; we walk and find it, the sell
fires, and the engine clears the cursor as a side effect of the
item being destroyed). For non-junk on the cursor, the cursor is
left alone. The incidental behavior is actually pretty good
already — junk gets cleared, non-junk preserved.
The "clean" version would explicitly call Script_ClearCursor
(0x004895B0) at the start of SellAllJunkItems iff the cursor
is currently holding a junk item. The cursor-type global at
[0x00B4D900] (1 = item, 3 = ?, 9 = ?) gates "is the cursor
holding anything," but the cursor's actual itemID / GUID / bag-slot
reference isn't sitting in an obvious adjacent global — the
neighboring globals at +0x04 / +0x08 are unrelated floats and
pointers, not item identifiers.
Vanilla has no GetCursorInfo (only CursorHasItem), so there's
no Lua-visible accessor to crib from. Finding the cursor item
state means tracing through Script_PickupContainerItem's 200+
bytes of arg-validation to locate where it stashes the picked-up
(bag, slot) tuple after pickup. Probably an hour of recon.
Defer until either:
- a user actively reports the current behavior as annoying, or
- the cursor state needs to be read for some other purpose
anyway (e.g. a
GetCursorInfopolyfill).
Two glue-only globals for persisting the character-select reorder UI
(GlueXML port of Blizzard's anniversary drag-to-reorder). Vanilla 1.12
glue has no general-purpose persistence API — only
GetSavedAccountName / SetSavedAccountName, which is saturated by
the autologin system.
API (registered on the glue Lua state only — in-world Lua sees a nil global, by design):
GetSavedCharacterOrder(realm) -> string -- "" when missing, never nil
SetSavedCharacterOrder(realm, order) -- order="" clears the entry
Storage: WTF\Account\<account>\ClassicAPI.txt, one CharacterOrder.<realm>=<order>
line per realm. Account scope is implicit via the file path; realm scope
is explicit via the line key. The <order> payload is opaque (the
GlueXML side uses pipe-delimited names). Implementation lives in
src/charlist/Order.cpp.
Required three pieces of new infrastructure, all in their own commits:
-
Glue Lua state hook —
FUN_0046ABB0is the master glue init (linear caller of all 5 glue batch trampolines, single caller, 35-byte body). Hooked in src/DllMain.cpp. Fires once per glue boot (launch + every world→glue return). Earlier attempt at inner trampolineFUN_0046DDF0crashed on Octo — smaller target, sibling-DLL collision. The linear caller is structurally identical to our existing in-gameFUN_LOAD_SCRIPT_FUNCTIONShook and has none of those problems. -
Game::GlueModuleAutoRegister(src/Game.h) — parallel of the in-gameModuleAutoRegister. Modules opt into glue exposure with a file-scope static.Game::Lua::RegisterGlueFunctionis an alias ofRegisterGlobalFunction(the engine readsVAR_LUA_STATEto route, and that pointer is the glue state for the duration of our hook). -
Settings::Accountregistry (src/settings/Account.h) — shared per-account persistence file. NameCache and Order both register reset/serialize/parse sections; the registry handles load, save, and account-change detection (re-loads on autologin switch from the glue screen) in one place. Avoids each module managing its ownWTF\Account\<acct>\*.txtfile.
FUN_GET_LOGIN_ACCOUNT_NAME(0x005ABDC0) is misnamed inOffsets.h— it actually returnsVAR_CHARACTER_NAME. The real account name pointer isVAR_ACCOUNT_NAME_PTR(0x00BE1C0C), which is what Order and NameCache use.- WoW.cfg was considered for persistence and rejected: extending the
cfg parser is more invasive than a self-managed file under WTF,
and we already have the
Settings::Accountshape.
We shipped C_CharacterList.GetNumCharacters /
GetCharacterInfo(index) / GetCharacters() for a while —
exposing the realm's character list (name, class, race, level,
area, sex, GUID) from anywhere in the game, not just the glue
char-select screen. The implementation was removed because the
single addon driving the need moved off it; keeping a binding
nobody calls is dead weight, and the engine-hook surface (the
char-list teardown function) is the kind of thing better owned by
whichever DLL actually uses it. The history is preserved in git
(see commits around src/charlist/Cache.cpp).
If another project picks this up, the work is straightforward — no novel reversing needed:
Three Lua entry points, all under C_CharacterList:
GetNumCharacters() -> nGetCharacterInfo(index) -> name, localizedRace, localizedClass, level, areaName, englishRace, sex, guidGetCharacters() -> { { name=, localizedRace=, englishRace=, localizedClass=, level=, areaName=, sex=, guid= }, ... }
guid was the 64-bit character GUID formatted with our
Guid::FormatAsString helper (16 hex digits, "0x" prefix —
matches UnitGUID's convention). areaName was nil when the
character's last-known area wasn't in AreaTable.dbc.
Vanilla 1.12's GetCharacterInfo / GetNumCharacters are
registered on the glue FrameScript engine and the underlying
data at VAR_CHARLIST_ARRAY (0x00B42144) /
VAR_CHARLIST_COUNT (0x00B42140) is freed by the
char-list teardown function during the glue→world transition. By
the time addons run in-world, the array is gone and the globals
are zeroed.
The DLL hook took a snapshot of the records before the engine freed them.
-
Hook the teardown.
FUN_CHARLIST_FREEat0x00472090—__cdecl void(), single caller (FUN_0046AC90, the glue-shutdown wrapper that runs on world transition). Very quiet hook target. Pre-hook (snapshot before the original runs); the original then zeros the globals. -
Iterate the record array. Flat array, stride
0x120bytes. Field offsets within each record (derived fromScript_GetCharacterInfodisassembly at0x004732A0):Offset Field Type / notes +0x00GUID u64 (verified via Script_RenameCharacterat0x00473520)+0x08name inline char[], max 12 + NUL+0x3CareaID u32, indexes AreaTable.dbc+0x100raceID u8, indexes ChrRaces.dbc+0x101classID u8, indexes ChrClasses.dbc+0x102sex u8 (0/1) +0x108level u8 -
Cache memory-only. Every game launch hits char-select before world-enter, so the cache is naturally rebuilt every session. No filesystem persistence, no staleness across restarts, no deleted-character bookkeeping (a server-side delete drops the entry from the next
SMSG_CHAR_ENUM). -
Edge case. If the DLL is injected mid-session post-world-enter, the cache stays empty until the player returns to char-select. Under normal VanillaFixes load order that's not a concern (DLL loads before any Lua runs).
Settings::Account (the per-account WTF-file mechanism that backs
GetSavedCharacterOrder) is the wrong shape: it persists across
sessions, which would mean stale entries surviving server-side
deletes, and the data is already authoritative on each launch.
Memory-only is simpler and self-correcting.
The 1.12 EditBox widget offers two arrow-key modes via the
ignoreArrows attribute, and both are bad for a developer console:
ignoreArrows="true"— widget consumes all four arrows and uses UP/DOWN to walk its internalAddHistoryLinering. LEFT/RIGHT then can't move the text cursor. This is whatChatFrameEditBoxTemplatedoes, which is why you can't move the cursor inside a WoW chat input.ignoreArrows="false"— LEFT/RIGHT move the cursor; UP/DOWN do nothing, and there's no Lua-visible event when UP/DOWN are pressed. The focused EditBox consumes key events before the parent'sOnKeyDowncan fire, and noOnArrowPressedhandler exists on the 1.12 widget (verified inBlizzard-WoW-Interface/1.12.1/FrameXML/UI.xsd— no element, and zero hits across all of Blizzard's 1.12 FrameXML).
So Lua-driven history navigation is impossible: there's no way to intercept UP/DOWN without also losing LEFT/RIGHT.
Octo/Interface/GlueXML/GlueConsole.lua — the in-glue Lua console
used during glue-side development. Wants the obvious developer-
console UX: UP/DOWN to walk through recently-submitted commands,
LEFT/RIGHT to edit. An in-game Lua console (probable future
addition) wants the same thing.
Mirror modern WoW's OnArrowPressed script handler:
editBox:SetScript("OnArrowPressed", function(self, key)
-- key = "UP" | "DOWN" | "LEFT" | "RIGHT"
end)Fires when an arrow key is pressed while the EditBox has focus,
regardless of ignoreArrows. The widget's normal handling still
runs after — for ignoreArrows="false", LEFT/RIGHT continue to
move the cursor; for ignoreArrows="true", the built-in history
walk still happens (or we can drop the built-in history entirely
once Lua can drive it). The XML element also needs to be
registered under EditBoxType so <OnArrowPressed>...</OnArrowPressed>
parses alongside SetScript.
After UP swaps a history line into the editbox, the Lua side
wants to drop the cursor at end-of-text rather than position 0.
3.3.5 exposes editBox:SetCursorPosition(n) and
editBox:GetCursorPosition()
(Blizzard-WoW-Interface/3.3.5/FrameXML/AutoComplete.lua:249,289);
1.12 has neither (zero hits anywhere in
Blizzard-WoW-Interface/1.12.1). Same widget, presumably the
same internal cursor field — worth exposing in the same change as
OnArrowPressed since the navigation UX is broken without it
(history line appears with cursor at column 0).
Likely path: hook the EditBox key-handler dispatch (whatever
function decides "is this key consumed by the widget? does it
affect the text? move the cursor?"). When key ∈ {UP, DOWN, LEFT,
RIGHT} and the EditBox has focus, fire the new OnArrowPressed
script before the default behavior runs.
SetCursorPosition is presumably a thin write to a cursor-index
field on the EditBox struct (plus whatever invalidation forces
the caret to redraw at the new position). GetCursorPosition is
the matching read. Both should be straightforward once the field
offset is identified — 3.3.5's Script_* equivalents are a good
starting point for what the parameter shape and bounds-clamp
behavior should be.