ui+docs: the GUID token's real spelling, 40-yard interact, and the whole patch map
Working down the 29 `.tdata` hooks from the realm's patched `WoW.exe`.
Most turned out to be already ported; two were wrong here, and one of
those was wrong in a way that mattered.
**The GUID token was the wrong string.** `unit::guid_token` wrote 16
lowercase hex digits and no prefix. The realm writes `0x` + 16 upper-case
digits: its formatter at `0xd06500` — which `UnitExists`'s second return
goes through — lays down `0x30 0x78` then sixteen nibbles each `+0x37`
above 9, and its parser at `0xd06650` demands the `0x` and takes upper
case only. grom's own addons agree from the other end: BigWigs decides a
string is a GUID with `string.sub(guidOrName, 1, 2) == "0x"`
(`MageTools.lua:963`), and AI_VoiceOver reads fields from position 3 and
mints its own with `format("0x%04X%06X%06X", …)`.
Same number, wrong string, and wrong where it mattered most:
`UNIT_CASTEVENT` and the aura events carry these tokens, so every such
test read our GUIDs as unit *names* and every `sub(3, …)` field read came
out two characters off. Now written the realm's way; both spellings and
either case are accepted on the way in, since refusing one buys nothing.
Pinned literally rather than by round trip — a round trip held fine
through the whole bug.
**`CheckInteractDistance` type 4 is 40 yards, not 30.** `0x48bafd` jumps
to `0xd0686c`, which tests the type against 4 and compares against the
float the section carries at `0xd06868`: `1600.0`, squared thresholds.
Stock 1.12's `900.0` is still what the other three types use.
`docs/ADDON_COMPAT.md` now carries all 39 hook sites of both sections
with a status each: 10 in `.octoql` (one feature, the quest log, ported),
and 29 in `.tdata` — the aura ids, the master looter, `UnitExists`'s
GUID, the char-create mask and `CHAT_MSG_HARDCORE` already done; the two
challenge-mode functions needing nothing, because the realm's own
`ChallengeList.lua` defines them in Lua for exactly our case; four read
but unported with the reason (unnamed struct fields, no consumer in the
corpus); and three that cannot apply to a client that loads no DLLs.
Also `scripts/dll-surface.py`, which asks the injected DLLs the same
question. Their export tables are one `DllMain` each, but a DLL that
registers a Lua function must point at the name: collecting NUL-delimited
identifier strings the image holds a pointer to cuts `ClassicAPI.dll`
from 2872 CamelCase candidates to 706, and subtracting the 1.12 census,
Lua's own names and benilla's real surface leaves a candidate list the
addon corpus can rank. It is a list to read, not a specification —
statically linked libraries contribute their own strings, and
`UnitXP_SP3.dll` yields nothing. It found 38 names in `nampower.dll` that
grom's addons call and this file did not know about; they are written
down rather than guessed at.
269 data-gated failures, identical to the baseline, all nine gates green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
This commit is contained in:
@@ -419,3 +419,70 @@ with the load checked after each.
|
||||
| A frame left in saved variables came back as a bag of its fields | **fixed** | `attempt to call method 'AddMessage' (a nil value)` at `Decursive.lua:1393`, every login. Decursive keeps its output window in its own saved table (`Dcr_OutputWindow = DEFAULT_CHAT_FRAME`) and guards the reload with `if Dcr_Saved.Dcr_OutputWindow == nil or not ...` (`:1245`) — which a non-nil **data** table passes. Our writer walked the frame like any other table and wrote ChatFrame1's own fields (`buttonSide`, `channelList`, `editBox`, …), doubling the file (2217 bytes against his real client's 1108) and losing every method. The reference tests the value's `T[0]` identity first — `lua_rawgeti(v, 0)` then `lua_touserdata` at `0x704613`-`0x704628`, the lightuserdata `0x701bd0` wrote — and never walks a widget: a **named** one writes as its bare global name, a Lua expression, so the reloaded chunk's own lookup hands the live object back, and an anonymous one as `nil --[[ unnamed object ]]` (`0x872e0c`). His own real-client file is the proof: `["Dcr_OutputWindow"] = ChatFrame1,`. Deviation: a name that is no Lua identifier writes as an anonymous object, where the reference writes it raw (`%s`) and can leave a file that will not parse. Tests: `a_saved_frame_comes_back_as_the_frame_not_a_copy_of_its_fields`, `an_anonymous_frame_saves_as_the_references_unnamed_object` |
|
||||
| `SetScript("OnMessageScrollChanged")` raised, aborting the builder around it | **fixed** | WIM sets it on every window it builds — the history frame at load and then one per correspondent (6 whisper windows in one session of his log, each `WIM_msgFrame<Name>ScrollingMessageFrame`) — so refusing the name by raise killed the rest of that builder function every time, not just the handler. It was on the deliberate "real 1.12 kinds nothing fires, so raise rather than silently accept" list (`object/events_regions.rs`), which is the right rule and the wrong side of it here: the slot now **fires**, from the ScrollingMessageFrame's own scroll verbs when a scroll actually moves the view (`messageframe::scrolling::scroll`), which is what a chat addon hangs its scroll-down arrow on. A scroll refused at either end moves nothing and says nothing; whether the reference also fires one there is untraced. Test: `a_scroll_that_moves_the_view_fires_the_frames_scroll_script`, and the raise list's own test keeps its rule (`the_unfired_script_kinds_still_raise_rather_than_silently_accepting`) |
|
||||
| nampower's equipment and item-cache getters (`GetEquippedItem`, `GetEquippedItems`, `GetItemLevel`, `GetItemStats`, `GetItemStatsField`) | **built** | *"then we gotta fix bettercharacterpanel, that one is driving me nuts"*. BetterCharacterPanel is built on all five: `GetEquippedItem` was 8 raises in one session (`BetterCharacterPanel-lib.lua:93`, `:193`, `-paperdoll.lua:179`) and the other four failed inside the addon's own `pcall`s, so the panel's item-level column, its quality colours, its missing-enchant warning and its two-hand test were all dead at once. DoiteAuras reads the same getter in six places. **The spec is the installed `nampower.dll`'s own string pool, not inference**: its usage strings give the signatures (`GetEquippedItem(unitStr, slot) or GetEquippedItem(guid, slot)`), its validation message **"Slot must be a number between 1 and 19"** gives the band — 1.12's own `GetInventoryItemLink` numbering, so `NampowerAPI.lua`'s `slot - 1` in `IsItemInSlot` is that shim's bug and not the contract — and the key names are its pooled strings in order: `itemId`, `permanentEnchantId`, `tempEnchantId`, `randomPropertiesId`, `stackCount`, `duration`, `spellChargesRemaining`, `flags`, `durability`, `maxDurability`, `tempEnchantmentTimeLeftMs`, `tempEnchantmentCharges` for an item, and `class`/`subclass`/`displayInfoID`/`quality`/`buyPrice`/`sellPrice`/`itemLevel`/… for a cache row. Its miss, **"Item not found in DBCache"**, is a raise, which is why the addon `pcall`s exactly these three and calls `GetEquippedItem` bare — ours raises the same string and records the template ask, so the call after the query's answer succeeds. `itemLevel` is the field 1.12 cannot reach at all: `GetItemInfo`'s fourth value is the *required* level (`0x48e070` pushes nine values; later clients are the ones that insert `itemLevel` at 4), so the item-level column had no possible source before this. Left absent rather than guessed: `duration` (the descriptor's seconds, where this client holds a display countdown in ms), the `resistances` array (six scalars here, index base in nampower's table untraced), the five-slot `spell*` block, and `ammoType`/`rangedModRange`/`languageID`/`pageMaterial`/`material`/`sheatheType`/`area`/`bagFamily`/`bonusStat`/`bonusAmount` — nothing installed reads any of them. An inspected unit answers from `PLAYER_VISIBLE_ITEM_*`, which carries the item and its two enchant slots and nothing else; nampower's own limit there is the same ("Cannot inspect unit"). Tests: `the_character_panels_four_item_reads_all_answer`, `an_uncached_item_raises_the_dlls_own_miss_and_asks_for_the_row`, `the_slot_band_is_the_dlls_own_one_to_nineteen` |
|
||||
|
||||
## The client's own patches, read off `WoW.exe` — all 39 hook sites accounted for
|
||||
|
||||
The realm patches its client, and it leaves the patches **in PE sections it names after them**. The
|
||||
launcher keeps that file current (it replaced it on 2026-10-23), so this is a first-class source
|
||||
rather than a last resort: scan `.text` for a `jmp`/`call` whose target lands in one of those
|
||||
sections and you have every entry point. There were 39, in two sections. Recipe and worked example:
|
||||
`benilla-octo-exe-patches-are-readable` (the project memory) and
|
||||
`FIELD_PLAYER_QUEST_LOG_21_1`'s doc comment.
|
||||
|
||||
`.octoql` (10 sites, VA `0xd26000`) is **one feature**: the extended quest log. Nine of the ten are
|
||||
register shims calling one eight-instruction routine; the tenth detours
|
||||
`GetNumQuestLogEntries`. Ported — `PLAYER_QUEST_LOG_SLOTS` is 25 and
|
||||
`player_quest_log_field` holds both blocks.
|
||||
|
||||
`.tdata` (29 sites, VA `0xd06000`) is TurtleWoW's loader patch set. Its strings name it:
|
||||
`twloader.dll`, `HARDCORE`, `GetSelectedChallengeModes`.
|
||||
|
||||
| Hook(s) | What the patch does | Status |
|
||||
|---|---|---|
|
||||
| `0x515c14` | a unit token spelled `0x` + 16 hex digits resolves as a raw GUID (parser at `0xd06650`, uppercase only; the `"Unknown unit name: %s"` arm is what it jumps ahead of) | **had the grammar, had the spelling wrong — fixed.** `unit::guid_token` wrote 16 lowercase digits and no prefix. Same number, wrong string: every addon that decides "is this a GUID" with `string.sub(g, 1, 2) == "0x"` (BigWigs `MageTools.lua:963`) read ours as a unit *name*, and every `sub(3, …)` field read came out two characters off. It now writes `0x` + 16 upper-case digits, the form the formatter at `0xd06500` lays down and AI_VoiceOver's own `format("0x%04X%06X%06X", …)` mints. Carried by `UNIT_CASTEVENT` and the aura events, so it was wrong where it mattered most; both spellings and either case are accepted on the way in |
|
||||
| `0x51601b` | `UnitExists(unit)` returns the GUID as a second value | done |
|
||||
| `0x51962e`, `0x519703` / `0x5199cd`, `0x519aec`, `0x519b14` | `UnitBuff` / `UnitDebuff` append the aura's spell id | done (`script/aura/mod.rs`) |
|
||||
| `0x4e9223`, `0x4e924e`, `0x4e926e` | `GetLootMethod` appends the master looter's roster index (helper `0xd06720` scans the party/raid table at `0xb712a8` for the GUID at `0xbc7608`) | done (`PartyState::master_looter`) |
|
||||
| `0x48bafd` | `CheckInteractDistance` type **4** compares against the float at `0xd06868`, which is `1600.0` | **fixed**: `INTERACT_DIST_SQ[3]` was stock 1.12's `900.0` (30 yd), now 40 yd |
|
||||
| `0x49f7aa`, `0x49b193` | chat type `0x5E` is `CHAT_MSG_HARDCORE` | done |
|
||||
| `0x5aad29` | appends the challenge mask to `CMSG_CHAR_CREATE` | done (`messages::client::char_create`) |
|
||||
| `0x46abc4`, `0x46acf4` | registers / unregisters `GetSelectedChallengeModes` and `SetSelectedChallengeModes` as Lua globals (table at `0xd062a8`, registrar `0x704120`) | **nothing to do, and worth knowing why**: the realm's own `GlueXML/ChallengeList.lua:15-23` defines both in pure Lua `if type(…) ~= "function"`, for exactly our case. Its header says so. We already read the mask it sets |
|
||||
| `0x5176d0`, `0x517726` / `0x517840`, `0x517886` | `UnitMana` / `UnitManaMax` append a second value read from `[unit+0x110]+0x44` / `+0x5c`, gated on a byte global being `0x0B` — on the evidence, a druid's real mana pool while shapeshifted. The `+0x60` hook of each pair exists only to preserve the unit pointer for the `+0xb6`/`+0xa6` one, which checks the first is installed by reading its own `jmp` opcode back | read, not ported: **nothing in the corpus reads a second return from either**, and the gating byte is a runtime value this static read cannot name. Port it when an addon asks |
|
||||
| `0x48e2fe` | `GetItemInfo` appends one number, `[item+0x28]` | read, not ported: the field is unnamed and no addon reads an 11th return |
|
||||
| `0x4ce715`, `0x4ce75d` | `GetAuctionSellItemInfo` appends a number (`[rec+0x60]`) and a string (`0x52ae00`), with the empty-slot arm padding to the same arity | read, not ported: same — unnamed fields, no consumer |
|
||||
| `0x4f161a`, `0x4f1986` | `CreateMacro` arg 2 / `EditMacro` arg 3 take a branch when a type test passes, bypassing the stock handling | read, not ported: our `CreateMacro` already takes a numeric icon index (its own usage string's shape), and which *other* type this admits is not decidable from the branch alone |
|
||||
| `0x505e19` | `Model:SetUnit` accepts a number (`lua_type == 3`) and clears three fields at `[frame+0x3e0/0x3e4/0x3f0]` | read, not ported: a GUID-as-model-unit, which nothing installed calls |
|
||||
| `0x404100` | `LoadLibraryA("twloader.dll")` + `GetProcAddress("func1")` (helper `0xd06228`) | not applicable: benilla loads no DLLs, and that one is not even in the folder |
|
||||
| `0x42a341` | raises an internal buffer bound (a global set to the section's own last byte) | not applicable |
|
||||
| `0x5e8a71` | wraps an assert in `Player_C.cpp` with a GUID format | not applicable |
|
||||
|
||||
## Asking the DLLs what they publish — `scripts/dll-surface.py`
|
||||
|
||||
The same question for the six injected DLLs, which is harder: every one of them exports a single
|
||||
`DllMain`/`Load` and registers with the client at runtime, so the export table says nothing.
|
||||
|
||||
**They do point at every name they register.** A DLL publishing a Lua function hands the registrar
|
||||
a `const char*`, so the name is a NUL-delimited string in its data and the image holds a
|
||||
little-endian pointer to it. Collecting identifier-shaped NUL-delimited strings, converting each to
|
||||
its virtual address through the section headers, and keeping the ones the file points at is what
|
||||
makes this usable: on `ClassicAPI.dll` it cuts 2,872 CamelCase candidates to 706. The script then
|
||||
subtracts `reference/1.12-globals.tsv` (the client's own), Lua 5.0's keywords and standard library,
|
||||
and benilla's real surface via `dump_globals` — and flags what the addon corpus actually calls.
|
||||
|
||||
It is a **candidate list, not a specification**: any pointed-at string survives, so statically
|
||||
linked libraries contribute theirs (freetype's `BlueValues`, the CRT's `FlsAlloc`). Treat a name as
|
||||
real when something else agrees — an addon calling it, or the mod's own docs. `UnitXP_SP3.dll` and
|
||||
`fmod.dll` yield nothing at all, so the technique has a floor as well as a ceiling.
|
||||
|
||||
What it found that this file did not already have: **38 names in `nampower.dll` that grom's addons
|
||||
call**, on top of the families the nampower section above already tracks. Unported, and listed
|
||||
rather than guessed at — `CancelPlayerAuraSlot`, `CancelPlayerAuraSpellId`,
|
||||
`CastSpellByNameNoQueue`, `CastSpellNoQueue`, `ChannelStopCastingNextTick`, `CombatLogFlush`,
|
||||
`CustomFileExists`, `EncryptPassword`, `ExecuteCustomLuaFile`, `FindPlayerItemSlot`, `GetBagItem`,
|
||||
`GetCastInfo`, `GetCurrentCastingInfo`, `GetItemIconTexture`, `GetItemIdCooldown`,
|
||||
`GetSpellModifiers`, `GetSpellRangeData`, `GetTrinketCooldown`, `GetTrinkets`, `GetUnitData`,
|
||||
`IsAuraHidden`, `LearnTalentRank`, `PlayerIsRooted`, `PlayerIsSwimming`, `QueueScript`,
|
||||
`QueueSpellByName`, `UseItemIdOrName`, `WriteCustomFile`, the six `NP_EnableUnitEvents*` CVars, and
|
||||
the events `SPELL_FAILED_SELF`, `SPELL_QUEUE_EVENT`, `UNIT_DIED`, `UNIT_HEALTH_GUID`. A name being
|
||||
called is not proof an addon needs it — most sit behind an `if GetTrinkets then` — so the next step
|
||||
is reading each call site, not implementing the list.
|
||||
|
||||
Reference in New Issue
Block a user