The queue stamped each entry's deadline when the press was made. That reads right and has a hole in it: a cast pushed back twice (`SMSG_SPELL_DELAYED`) ends after the window a press made at its start was given, so the entry times out while the thing it is waiting for is still running — and the press vanishes with no packet, no error line and no cast. That is the one failure mode a player cannot describe, and it is exactly the class of bug the press queue was supposed to remove rather than introduce. The same hole swallows a press held through a cast whose global cooldown then outlasts the window, which is the ordinary case rather than an edge one: a 500 ms window, a cast that ends, and 1.5 s of global cooldown behind it. So a slot now keeps the window and the base it is measured from, and the drain ratchets that base forward — only ever forward — past every live guard: the cast's reopening, the channel's trimmed end, and the global cooldown's. An entry gives up when nothing is holding it back any more and the window has run out since, which is what "how long past the guard's end" meant all along. Scripts keep a fixed deadline. Their contract is "after queued spells", and a script whose spells never fired has nothing left to run after. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
1152 lines
371 KiB
Markdown
1152 lines
371 KiB
Markdown
# Addon compat — SuperWoW / nampower / UnitXP_SP3 / ClassicAPI / VanillaUtils / VanillaHelpers
|
||
|
||
This fork has no `WoW.exe` and no DLL injection, so the six client-extension DLLs an OctoWoW install
|
||
ships beside the real client (`SuperWoWhook.dll`, `nampower.dll`, `UnitXP_SP3.dll`, `ClassicAPI.dll`,
|
||
`VanillaUtils.dll`, `VanillaHelpers.dll`) cannot be loaded. This tracks porting their features —
|
||
both the Lua API addons feature-detect and the direct client behaviour changes that aren't
|
||
addon-mediated — natively into benilla's own engine and Lua VM, always on, so addons written against
|
||
them behave the same here. Every row below is therefore intentionally "beyond 1.12.1" in
|
||
`scripts/api-coverage.sh`'s report; that script's "beyond" bucket is expected to be large because of
|
||
this file, not a regression.
|
||
|
||
Status: `todo` not started · `partial` some functions/behaviours land, not all · `done` complete for
|
||
what we could confirm. Confidence: `confirmed` from the tool's own public source/docs ·
|
||
`inferred` from exported-symbol names and/or related addon call sites, not a confirmed source —
|
||
these need checking against real OctoWoW addon behaviour in play, and ideally the OctoWoW team's own
|
||
source if this bundle is theirs.
|
||
|
||
Detection globals/flags flip to "present" only once that tool's functions are actually implemented —
|
||
never ahead of the functions themselves, so a feature-detecting addon doesn't find a flag with
|
||
nothing behind it.
|
||
|
||
## SuperWoW — confirmed (public wiki, `balakethelock/SuperWoW`)
|
||
|
||
| Area | Status | Notes |
|
||
|---|---|---|
|
||
| Detection (`SUPERWOW_STRING`, `SUPERWOW_VERSION`) | done | `script/compat/superwow.rs`; registered as an intentional beyond-1.12 exception in `script/tests/reference_surface.rs` |
|
||
| Unit-id grammar (`owner`, `mark1..8`, raw GUID as unit id) | done | `Model::unit` (`script/model.rs`) plus `superwow_token_recognised` (`script/unit/mod.rs`) gating it past the reference's own token-shape check. GUID scope is currently whatever's reachable by a named token this tick (party/raid/target/mouseover/pet/npc), not every loaded object — SuperWoW's own wider reach needs a per-tick GUID-indexed push from `benilla-app`, not yet built |
|
||
| `UnitExists` + GUID | done | second return only when a GUID resolves (`script/unit/bindings.rs`); 16-hex-digit opaque token, round-trips through the grammar above. Exact byte format SuperWoW itself uses is undocumented publicly — ours only needs to agree with itself |
|
||
| `UnitBuff`/`UnitDebuff` + id, `GetPlayerBuffID` | done | `script/aura/mod.rs`'s `returns_1121` (+ spell id), `script/aura/player_buff.rs` |
|
||
| `CastSpellByName` `"CLICK"` arg | done | `script/spellbook.rs` queues a separate `mouseover_spell_casts`; drained in `ui_spellbook.rs`'s `drain_spell_casts` straight at `"mouseover"`, bypassing the player's own selection. The *other* documented form, `arg2` as a unit **name**, is not built — no name-to-unit index exists here, only tokens |
|
||
| `LootSlot` force-loot arg | done | `script/loot.rs`; truthy (Lua truthiness, e.g. `1`) routes to the take queue instead of the confirm queue |
|
||
| `SetRaidTarget` `"local"` arg | done | `PartyRequest::SetRaidTarget.local` (`script/party.rs`); the live drain (`ui_party/feed.rs`) applies to `GroupState` directly instead of sending `CMSG`/`MSG_RAID_TARGET_UPDATE` |
|
||
| Nameplate `frame:GetName(1)` → GUID | done | `script/object/frame_state.rs`'s shared `GetName`, `NamePlates::guid_for_frame` (`script/nameplate.rs`) |
|
||
| `GetWeaponEnchantInfo([unit])` + `GetWeaponEnchantID` | todo, blocked | needs per-unit weapon-enchant tracking (today only `"player"`'s is fed at all) and no item-template data distinguishing an on-use charge slot from an equip/proc slot — would need new app-side state, not a binding tweak |
|
||
| `GetContainerItemInfo` charges (negative stack count) | todo, blocked | the raw signed field exists (`ObjectFields::item_spell_charges`, benilla-protocol), but nothing in `benilla-formats`' item template says *which* of an item's five spell-trigger slots (if any) is a consumable charge rather than an equip/proc effect — exposing it without that would be a guess, not a port |
|
||
| New functions (`SpellInfo`, `TrackUnit`/`UntrackUnit`, `SetMouseoverUnit`, `Clickthrough`, `SetAutoloot`, `UnitNameplate`, `CanLootUnit`, `CombatLogAdd`, Import/Export/AppendText, map/world coord conversions, `IsSwimming`/`IsMounted`/`isIndoors`/`GetSpeed`, mail/quest-link getters) | todo, blocked | each needs new app-side state, not a binding tweak: `IsSwimming`/`IsMounted`/`isIndoors`/`GetSpeed` need a self-movement snapshot (`move_flags() & SWIMMING`, `unit_mount_display_id()`, `area.rs`'s `indoor` bool, and a `MoveSpeeds` component — none currently reach a `feed_*` system that can push them, and `ui_unit.rs`'s `feed_units` is already at Bevy's 16-param ceiling); **`SpellInfo` is now done** — `compat/superwow.rs`, over `script/spellbook.rs`'s `spell_display`: the player's book first, then the host's `Spell.dbc` probe ([`SpellProbe`], installed by `ui_script::lifecycle::install_spell_probe` on every VM build), so it reaches every shipped spell as the real mod does. The range pair stays nil — `SpellRange.dbc` is not on the probe; the rest (`TrackUnit`, `SetMouseoverUnit`, `Clickthrough`, `SetAutoloot`, `CanLootUnit`, `UnitNameplate`, file I/O, map/world coords, quest links) are each plausible but unstarted. **The two mail link getters are now done**, built for ClassicAPI's own `Mail` namespace (`compat/classic_api/merchant_frame.rs`, see its row below) — SuperWoW specifies the same `GetSendMailItemLink`/`GetInboxItemLink`, so that half of this row is closed |
|
||
| `UnitPosition(unitid)`, `CursorPosition()` | done | `UnitState.position` (`script/unit/mod.rs`), fed by `Transform` from `ui_unit.rs`'s `feed_units` (player/target/targettarget/npc) and `ui_tooltip.rs`'s `drive_mouseover_tooltip` (mouseover); functions in `compat/superwow.rs`. Party/raid/pet aren't wired (their own `snapshot()` call sites in `ui_party/feed.rs`/`ui_pet/unit.rs` pass `None` — each needs its own `Entity`→`Transform` lookup threaded through, not attempted this pass). Reading `Transform` broadly past the usual one-frame-stale tolerance raised `game_plugins.rs`'s `UPDATE_ACTIONABLE_CEILING` by 30 (cited there) — `Transform` is written by enough movement/animation/physics systems that `With<Guid>` alone doesn't shrink the ambiguity count |
|
||
| `UNIT_CASTEVENT` | done | `crates/benilla-app/src/superwow_events.rs`: a `CastEventFeed` queue the cast handlers push into, drained once per tick by one VM-holding system that fires the event — the pattern `ui_mirror.rs` already uses, and the only way across, since `spell/net.rs`'s `Lifecycle`/`Scene` bundles deliberately hold no `UiScript` (`game_plugins.rs`'s `every_vm_holder_in_update_declares_its_side_of_the_tick` gates that). Edges wired: `START` from `SMSG_SPELL_START` with its wire cast time as `arg5` (an instant raises none — its only edge is `CAST`, which is what the wiki's own START/CAST split means), `CAST` from `SMSG_SPELL_GO`, `FAIL` from `SMSG_SPELL_FAILED_OTHER`, `CHANNEL` from `MSG_CHANNEL_START` with its duration. GUIDs go out in the same 16-hex-digit spelling every other `compat/` surface uses, so `arg1`/`arg2` compare directly against a `UnitExists`/`UnitGUID` return. Not wired: `MAINHAND`/`OFFHAND` — benilla's melee-swing path raises no cast edge at all, so there is nothing to forward. `MSG_CHANNEL_START` names no caster (1.12 sends it for the local player's own channel only), so `CHANNEL`'s `arg1` is self and its `arg2` is 0 |
|
||
| `CREATE_CHATBUBBLE`, `RAW_COMBATLOG`, `SUPERWOW_COMBAT_LOG_EVENT` | todo | `CREATE_CHATBUBBLE` needs the chat-bubble creation site in `chat_bubble.rs` (its `push`/`drive_bubbles`); now that `superwow_events.rs` exists with the queue-plus-drain pattern established, this is a small addition rather than new architecture. `RAW_COMBATLOG`'s funnel is `ui_chat/feed.rs`'s `ChatLog::push_combat`, where (almost) every combat line lands as a `combat::PendingCombat` from two call sites in `combat_log/chat.rs` — but that's a *queued* value, not rendered text, and the sentence `arg2` wants is built later at drain time, which this pass didn't trace into. Firing at `push_combat` with the structured fields in place of `arg2` would be a different (arguably more useful, but non-conforming) shape than the wiki's "text with GUID". `SUPERWOW_COMBAT_LOG_EVENT`'s full prefix/suffix param schema is its own sub-project |
|
||
| `NameplateRange` | done | one CVar, two readers: `nameplates.rs`'s `NameConfig::range_sq` (the overhead name — introduces a cull where the reference has none at all) and `vplates.rs`'s `VPlateMode::range_sq` (replaces the reference's own hardcoded 20 yd V-key-plate range). Default `80`, the top of the mod's documented 10-80 range — no shipped default is published, so this is the least-restrictive honest choice, not a confirmed number |
|
||
| `ChatBubbleRange` | done | `chat_bubble.rs`'s `BubbleConfig::range_sq`, replacing the reference's hardcoded 20 yd. Default `20` (today's exact value) — no shipped default published for this one either, within the mod's documented 10-200 yd range |
|
||
| `NameplateMotion`, `NameplateVerticalSpacing` | todo | needs locating whatever (if anything) already staggers overlapping nameplates, not investigated |
|
||
| `FoV` | todo, held back | the wiki's only confirmed default (`"1.57"`) doesn't obviously match benilla's existing hardcoded `benilla-world/src/view.rs`'s `CAM_FOVY = FRAC_PI_4` (0.785 rad, 45°) — 1.57 rad is 90°, double. Without confirming whether SuperWoW's `FoV` cvar and `CAM_FOVY` share the same angle convention (vertical half vs. full, radians interpretation) and whether 1.12's true default really is 45°, defaulting to the wiki's literal `1.57` risks a jarring, wrong-looking lens change rather than exposing the existing value as a tunable knob. Left alone rather than guess |
|
||
| `LootSparkle` | done, CVar `LootSparkle`, default `1` | `creature_anim/spell_visual.rs`'s `arm_loot_fx` grew a **GameObject** arm beside its unit and corpse ones, hanging the same `"HARDCODED Loot Art"` row (`0x13`) at the same tag. 1.12 sparkles a lootable corpse and nothing else — a chest, a herb, a vein or a quest object is found by eye — and sparkling those arrived in **2.3.0** ("interactable game objects sparkle and display their names"), which is what the mod backports: upstream describes it as "the sparkling effect on lootable treasure". The test takes **two** arms, because "lootable treasure" is neither one alone: the server's own per-viewer `GO_DYNFLAG_LO_ACTIVATE` in `GAMEOBJECT_DYN_FLAGS` (`GameObject::ActivateToQuest`), documented on its field reader as "the sparkle" — but vmangos raises it through `ActivateToQuest`, so on a chest it means *quest* loot and keying on it alone would light up nothing else — **or** `GAMEOBJECT_TYPE_CHEST`, which is exactly chests, herbs and mining nodes, treasure by construction and a type the client reads without the server's help, excluding `GO_FLAG_IN_USE`/`NO_INTERACT` so an emptied or disabled chest stops. Both arms are a judgement about what the mod does, not a trace of it: if only quest objects light up the first is doing all the work, if doors and signs do the second is too wide. Defaulted **on**: upstream publishes no default, and the sparkle is present on this install with SuperWoW loaded, which is what "as if its default config loaded" means here; `SetCVar("LootSparkle", "0")` is stock 1.12. `FxStage` is immaterial for this effect and the row no longer claims otherwise — `Particles\LootFX.m2` (the `"HARDCODED Loot Art"` row's own model, extracted from this install) holds **one** 2400 ms sequence with `flags = 0`, and bit 0 clear is a looping sequence, so `FxAnimLife::arm` repeats it whatever the stage says |
|
||
| Healing text, uncap sounds, selection-circle style, outline/shadow text, force-show-auras, XP text | todo | not investigated |
|
||
| Macro limit 255→510 | done | `script/macros.rs`'s `MAX_MACRO_BODY`; `ui_script/manifest.rs`'s `install_macro_letters_patch` re-patches the stock FrameXML's `SetMaxLetters`/char-count string at boot, since both come from the player's own install |
|
||
| Targeting-circle spells (Blizzard, Flamestrike) no longer refuse movement | done | `cast_target::is_ground_targeted` (`spell/cast_target.rs`) exempts them in `validator::cast_moving_refusal` (`spell/validator.rs`); the real cast-time interrupt-on-move rule (`inflight.rs`) is untouched |
|
||
| Ranged abilities (Aimed Shot) firing `SPELLCAST_START` | done | already satisfied — `spell/net.rs`'s `spell_start` already opens the bar on any real `SpellCastTimes.dbc` duration rather than the reference's narrower ranged-spell suppression (see the `has_cast_time` comment there); confirmed by the existing test `a_timed_ranged_cast_opens_the_shot_bar_but_an_instant_shot_does_not` |
|
||
| Friendly-player item-visibility range limit removed | todo | not investigated this session — need to find where benilla currently gates item-row/tooltip visibility by range for a friendly player, if it does at all |
|
||
| `ShowVKeyCastbar` | **registered, stored only** | pfUI writes `0` at `modules/nameplates.lua:3` ("disable original castbars") under `pcall`, and that write was one of the session log's six `unknown CVar '…' (not host-registered) — ignored` warnings. **Provenance, measured not assumed**: the name appears *nowhere* in this OctoWoW install except that one pfUI line — `grep -rli ShowVKeyCastbar` over the whole tree hits one file, and neither `WoW.exe` nor any of its eighteen DLLs (SuperWoW's included) carries it. So no mod here registers it and **there is no published default to match**; pfUI writes it defensively for whichever client does. Registered in `cvars.rs` as `ours("ShowVKeyCastbar", "0", …)` and **stored, not wired**: benilla's V-key plates have no cast bar to suppress (`benilla-ui`'s `script/nameplate.rs` header says so outright, and `vplates.rs` draws name, level, health bar and raid mark and nothing else), so `0` *is* this client's behaviour rather than a chosen value, shipping `1` would advertise a bar that does not exist, and an observer would give a knob two positions with one picture. If a plate cast bar is ever built this row becomes its switch |
|
||
|
||
## nampower — **two different mods share this name; this bundle ships the second**
|
||
|
||
A correction worth stating plainly, because it was wrong here for most of this project's life. The
|
||
original research targeted **`namreeb/nampower`**, whose entire effect is one cast-timing rule and
|
||
which exposes *no Lua API at all*. That is a real mod and the rule is correctly ported — but it is
|
||
not what the OctoWoW bundle runs.
|
||
|
||
This install ships **pepo's nampower v2**, a much richer fork with a real Lua-facing surface: a
|
||
block of `NP_*` CVars and `GetNampowerVersion()`, driven by the bundled `NampowerSettings` addon.
|
||
Live addon testing is what surfaced this (the addon asked for `GetNampowerVersion`, which
|
||
namreeb's mod has no concept of). Upstream has since moved again: `pepopo978/nampower` now 404s,
|
||
and **`brues-code/nampower`** — same author as the ClassicAPI this bundle uses — is its live
|
||
continuation and the source of every CVar default below.
|
||
|
||
One consequence worth flagging: the nameplate-distance feature was attributed to SuperWoW earlier
|
||
in this file on the strength of the namreeb research. pepo's v2 has its own
|
||
`NP_NameplateDistance`, so **both** mods offer that knob, and both now drive the same reach here.
|
||
|
||
| Area | Status | Notes |
|
||
|---|---|---|
|
||
| Cast-ready timing rule (namreeb's, and pepo's simplest case) | done | no Lua surface; `PendingCast::arm` in `crates/benilla-app/src/spell/inflight.rs` opens the guard from the spell's own known cast time at send, not a round trip through `SMSG_SPELL_START`; `SMSG_SPELL_DELAYED` pushback still extends it. This is the always-on version of what pepo's v2 makes configurable — see the stored-only row below for why that configurability is not wired |
|
||
| `GetNampowerVersion()` | done | `script/compat/nampower.rs`, reporting `2.8.6`. The number is a claim about *surface*, not about benilla's code (same reasoning as `CLASSIC_API_VERSION`). `NampowerSettings` has exactly one version gate, `HasMinimumVersion(2, 8, 6)`, guarding exactly one setting: `NP_NameplateDistance` — which is also the one nampower CVar wired to real behaviour here. So 2.8.6 is the lowest version that still shows the only knob that genuinely works, and no higher: claiming more would assert the queue/buffer machinery of later releases (upstream documents through 4.5.0) that this client does not implement. The addon prints its own "update available" notice below its latest, which is accurate |
|
||
| `NP_NameplateDistance` | **done, live** | the only `NP_*` row with a real engine reader. Drives both nameplate consumers — `nameplates.rs`'s `NameConfig::range_sq` (overhead names) and `vplates.rs`'s `VPlateMode::range_sq` (V-key plates) — i.e. the same knob SuperWoW's `NameplateRange` drives. **Two mods, one knob: last writer wins**, which is what a player moving either slider expects; neither is silently ignored. Each is clamped to the range its own mod documents: nampower 5..200 (the addon's own slider bounds in `settings.lua`), SuperWoW 10..80. Default `80` — upstream publishes *no* fixed default for this one ("Defaults to whatever was set by the game or vanilla tweaks"), so it keeps benilla's existing reach rather than inventing a number |
|
||
| The other 22 `NP_*` CVars | **superseded: 20 stored only, 2 live** | registered in `cvars.rs` with the mod's own documented defaults so the settings panel reads, writes and persists correctly — and consumed by nothing. benilla implements no spell-queueing system for them to tune: we carry namreeb's simpler always-on cast rule, not pepo's queue windows, dynamic latency buffer, retry and quickcast machinery. Storing them keeps the addon's UI truthful about what the player chose; claiming they change casting would be a lie the player would feel at the keyboard. They are listed in `cvars.rs`'s `LUA_ONLY` test table (whose rule is "a registered row needs a reader, or a named reason its reader is Lua") with that reason. The rows: the six `NP_Queue*` toggles, the four `*QueueWindowMs`, the four buffer knobs (`NP_MinBufferTimeMs`, `NP_MaxBufferIncreaseMs`, `NP_NonGcdBufferTimeMs`, `NP_OnSwingBufferCooldownMs`), `NP_ChannelLatencyReductionPercentage`, and the seven behaviour toggles (`NP_DoubleCastToEndChannelEarly`, `NP_InterruptChannelsOutsideQueueWindow`, `NP_OptimizeBufferUsingPacketTimings`, `NP_PreventRightClickTargetChange`, `NP_QuickcastTargetingSpells`, `NP_ReplaceMatchingNonGcdCategory`, `NP_RetryServerRejectedSpells`). **Superseded:** `NP_SpellQueueWindowMs` and `NP_QueueSpellsOnCooldown` are now LIVE — they are the explicit queue's own two knobs; see "The queue, and the one verb that needs it" |
|
||
| pepo v2's wider surface beyond this addon | todo | upstream's README documents much more than `NampowerSettings` drives — later releases add `NP_SpamProtectionEnabled`, `NP_PreventMountingWhenBuffCapped`, the greater-demon autocast pair, `NP_PreventRightClickPvPAttack`, `NP_QuickcastOnDoubleCast`, and a family of events (`SPELL_START_SELF`/`_OTHER`, `AUTO_ATTACK_SELF`/`_OTHER`, `SPELL_HEAL_BY_SELF`, `AURA_CAST_ON_SELF`…). None is registered, because nothing installed asks for them; add them when an addon does, and raise `GetNampowerVersion` to match only then |
|
||
| `GetNampowerVersion()` now reports **4.0.0** — and what the gate that opens actually reaches | **supersedes the `2.8.6` row above, which is now stale as a statement of the code** | `script/compat/nampower.rs`'s `NAMPOWER_VERSION` is `(4, 0, 0)`, set at the maintainer's explicit request; that file carries the full trade. The consequence this row exists to record: the number is not only read by `NampowerSettings`. **pfUI's `libs/libdebuff.lua:38-99` gates its entire debuff pipeline on `>= 3.0.0`** — below it the library prints "Debuff tracking disabled! Please update Nampower to v3.0.0 or higher" and raises `StaticPopup_Show("LIBDEBUFF_NAMPOWER_UPDATE")`, i.e. fails **loudly**. At `4.0.0` the gate passes, so the library enables five `NP_Enable*` CVars and registers `AURA_CAST_ON_SELF`/`_OTHER` and `DEBUFF_ADDED_OTHER`/`DEBUFF_REMOVED_OTHER` — which, before the rows below, were CVars that did not exist and events nothing fired. The version claim converted a loud failure into a silent one. The rows below are the other half of that claim being made good; `GetPlayerGuid`/`IsPlayerGuid` in the ClassicAPI section are the third half |
|
||
| The five `NP_Enable*` event switches | **superseded: three live, two stored only** | `cvars.rs`, all five at upstream's own documented `default 0` (`brues-code/nampower`'s `EVENTS.md` states it per row); names taken off the shipped `nampower.dll` (`strings` lists each beside its own "Set <name> to " line). pfUI's `libs/libdebuff.lua:56-88` writes all five at `PLAYER_LOGIN`. **`NP_EnableAuraCastEvents` is LIVE**: a `CvarChanged` observer moves `ui_cast_events.rs`'s `AuraCastFeed::enabled`, and off, the packet path queues nothing at all. `NP_EnableSpellStartEvents`, `NP_EnableSpellGoEvents`, `NP_EnableAutoAttackEvents` and `NP_EnableSpellHealEvents` are **stored only**, in `cvars.rs`'s `LUA_ONLY` table with that reason: benilla emits none of those event classes (see the `todo` row below for what each would need). Registering them is what stops the engine warning; it is *not* a claim that the stream arrived, and the chat line libdebuff prints ("Enabled 5 Nampower CVars") therefore over-reports by four. `NP_EnableSpellEnergizeEvents` and the `NP_EnableUnitEvents*` family exist upstream and are not registered — nothing installed writes them . **Superseded by the rows below and by "The event table, accounted for":** `NP_EnableAuraCastEvents`, `NP_EnableSpellStartEvents` and `NP_EnableSpellGoEvents` are now LIVE, read by `NpCastGates` in `ui_cast_events.rs`; the two unit-event switches stay stored only because nothing produces those events yet |
|
||
| `AURA_CAST_ON_SELF` / `AURA_CAST_ON_OTHER` | done, gated on `NP_EnableAuraCastEvents` | `crates/benilla-app/src/ui_cast_events.rs`'s `AuraCastFeed`: a second queue beside the `UNIT_CASTEVENT` one, pushed by `spell/net.rs`'s `on_spell_go` (where `Spell.dbc` is already in hand) and drained by the same VM-holding `drain_cast_edges`, behind the two cast families. Folded out of `SMSG_SPELL_GO`'s caster and **hit list** plus the spell's aura-applying effects: **one event per hit target per effect whose `EffectApplyAuraName` is nonzero**, which is upstream's own rule and the reason libdebuff dedupes inside 100 ms. The nine arguments are upstream's (`int spellId, string casterGuid, string targetGuid, int effect, int effectAuraName, int effectAmplitude, int effectMiscValue, int durationMs, int auraCapStatus`, matching the `%d%s%s%d%d%d%d%d%d` signal format in `nampower.dll`), and the shape was taken from the two live consumers' own destructures — `libs/libdebuff.lua:1105-1113` and `modules/innervatecall.lua:58-67`. **The SELF/OTHER split is by target, not caster.** GUIDs use benilla's own bare 16-hex spelling, as every other `compat/` GUID does. A ground-targeted AoE's GO carries no hit list and raises nothing: inventing the caster as the target would report Blizzard's area aura as landing on the player. `arg9` is computed from the target's `UNIT_FIELD_AURA` occupancy (bit 0 buff half full, bit 1 debuff half) |
|
||
| `DEBUFF_ADDED_OTHER` / `DEBUFF_REMOVED_OTHER` | done, ungated (upstream gates neither) | `crates/benilla-app/src/ui_aura.rs`, inside the existing VM-holding `feed_auras` and after it has pushed the aura lists, so a handler that asks `C_UnitAuras` about the unit it was just told about cannot get a contradicting answer. A **delta on the `UNIT_FIELD_AURA` spell-id dwords**, driven off `net::FieldChanged` edges inside that 48-dword span — the same trigger `creature_anim::spell_visual`'s `arm_aura_state_fx` already uses — so a frame with no aura change does no work at all. First sight of a unit **seeds** the memory and fires nothing: an aura that was already on a mob we had never seen was not newly added, the descriptor keeps no caster for it, and a crowd would otherwise cost a burst of Lua dispatch on streaming in. Seven arguments, upstream's and identical across all eight buff/debuff events: `string guid, int luaSlot, int spellId, int stackCount, int auraLevel, int auraSlot, int state` — every column a field of the descriptor benilla already parses, `auraSlot` **0-based** as upstream reports it (pfUI adds 1 at `libdebuff.lua:1350`), `luaSlot` the `UnitDebuff` index through the same hidden-aura filter the aura lists use and `0` for a hidden aura, `state` 0 added / 1 removed. **Removals fire before adds** within one delta, because pfUI keys slot ownership on the raw slot and clears that key on removal (`libdebuff.lua:1507-1512`) — the other order would wipe the entry the add just wrote |
|
||
| `durationMs` (`arg8`) for a combo-point or level-scaled `SpellDuration.dbc` row | todo | `ui_cast_events.rs`'s `aura_duration_ms` answers the row's `Duration[0]` when `Duration[1]` is 0, and **`0` otherwise** — including the permanent sentinel and a missing row. `0` is the consumer's own documented "absent" path. Reporting `Duration[0]` for a scaling row would quote 8 s for an 18 s five-point Rupture, and a debuff bar counting down to the wrong moment is worse than one that does not count down. What it would take: settling *which* input the row scales on (caster level vs combo points) per row, and reading the combo points as they stood at the cast rather than after the finisher consumed them. Upstream also promises "includes client modifiers if you are the caster": `spell/mods.rs` stores the server's `SPELLMOD_*` cells but has no duration reader, so this is the unmodified DBC length |
|
||
| A stack-count change as a buff/debuff edge (`state` 2) | todo, deliberately | Upstream's legend has `2 = modified (stack change)` and its prose says the events fire "whenever a buff/buff **stack** … is added or removed", which reads as: a stack increase is an ADDED and a decrease a REMOVED, both with `state` 2. Not emitted here. Nothing in the corpus reads `arg7` at all; the watch span is the spell-id dwords alone, so a stack move inside a slot that keeps its spell is not an edge this sees; and under that reading a stack *decrease* would make pfUI clear a live debuff's slot ownership and its timer (`libdebuff.lua:1521-1540`), which is the wrong-timer failure this whole family exists to avoid. Costs nothing for the timers: a refresh's duration comes from `AURA_CAST`, which fires on every `SMSG_SPELL_GO`, and pfUI knows DEBUFF_ADDED does not fire on a refresh (`libdebuff.lua:1427`) |
|
||
| `DEBUFF_ADDED_SELF` / `DEBUFF_REMOVED_SELF`, `BUFF_ADDED_*` / `BUFF_REMOVED_*` | **superseded: six of eight built** | The other six of upstream's eight buff/debuff events, same seven arguments. The `*_SELF` pair is the player's own half of the delta `ui_aura.rs` already computes, and the `BUFF_*` pair the positive half of the slot range it already walks past, so all six are small additions to the same loop. Not built because nothing installed registers one: pfUI's libdebuff takes only the two `*_OTHER` names . **Superseded:** all six `*_SELF` names and the two `DEBUFF_*_OTHER` names fire from `ui_aura.rs`; only `BUFF_ADDED_OTHER`/`BUFF_REMOVED_OTHER` are left, and those stay out deliberately — see "The event table, accounted for" |
|
||
| `SPELL_START_SELF`/`_OTHER`, `SPELL_GO_SELF`/`_OTHER`, `AUTO_ATTACK_*`, `SPELL_HEAL_*`, `SPELL_CAST_EVENT`, `SPELL_FAILED_OTHER` | **superseded: fired** | `libs/libdebuff.lua:863-869` registers all six families, and its `libdebuff_*_hooks` broadcast surface is what feeds `libs/libpredict.lua` (HoT send tracking), `modules/swingtimer.lua` and `modules/actionbar.lua`'s prowl hook. The start/go pair's packets are already folded into `ui_cast_events.rs`'s `CastEdge::Start`/`Cast`, so most of the data is in hand — but upstream's shapes want `itemId`, `castFlags`, `spellType` and `corpseOwnerGuid` columns this client does not carry, and those consumers key on arguments it would have to guess. `AUTO_ATTACK_*` needs a melee swing edge no path here raises at all (`CastEdge`'s own note). `SPELL_HEAL_*` needs `SMSG_SPELLHEALLOG`'s amount, which nothing reads. Guessing a column here is the same silent-failure shape the four rows above were written to avoid . **Superseded:** `SPELL_START_*`, `SPELL_GO_*`, `SPELL_FAILED_*`, `SPELL_DELAYED_SELF`, `SPELL_CHANNEL_START`/`_UPDATE` fire from `ui_cast_events.rs` and `SPELL_CAST_EVENT` from `ui_self_cast.rs`. Still unfired of this row: `AUTO_ATTACK_*` and `SPELL_HEAL_*`, which are combat-log producers, not cast-queue ones — they belong with the block in "The event table, accounted for" |
|
||
|
||
## UnitXP_SP3 — confirmed (open source, multiple mirrors)
|
||
|
||
| Area | Status | Notes |
|
||
|---|---|---|
|
||
| `UnitXP(command, ...)` dispatcher | done | `compat/unitxp.rs`. No documented detection global for this one — defining `UnitXP` at all is what an addon's `if UnitXP then` finds. **Collision note**: stock 1.12 already has its own single-argument `UnitXP(unit)` (experience points, `PLAYER_XP`); the mod shadows it on a real install exactly as a detour falls through to the original, so this dispatcher's unrecognised-command branch now carries that stock getter's exact behaviour (moved out of `script/unit/bindings.rs`, which no longer registers `UnitXP` itself — only `UnitXPMax` stayed there, untouched) |
|
||
| `distanceBetween(unit1, unit2)` | done | player side: `inspect.rs`'s `UnitReach::dist_sq` (works whenever one side is `"player"`). Both-non-player side: `UnitState.position` (player/target/targettarget/npc/mouseover only, same reach as SuperWoW's `UnitPosition`) |
|
||
| `target("nearestEnemy")` | done | queues `SelectionRequest::NearestEnemy` (`script/unit/mod.rs`) onto the same selection queue `TargetUnit`/`TargetLastEnemy` use, so a UnitXP target and a stock one commit through one path in call order. Handled by `target::scan::target_nearest_enemy`, which reuses `scan::best_enemy` over the **existing** candidate scan the tab-target and attack-order paths already run — so "nearest" means exactly what it does there (off-centre-plus-distance score, fighting-me bonus, on-screen first), not a second notion of nearest. Its own message+system rather than an arm in the drain: pulling the whole `TargetScan` into that shared system broke a UI-only test harness and cost +16 ambiguity pairs, the same reason `AttackNearestRequest` is already split out |
|
||
| `target("nextMarkedEnemyInCycle"/"previousMarkedEnemyInCycle")` | done | `SelectionRequest::MarkCycle { forward }`, handled in the selection drain (`target/click.rs`) off `GroupState::raid_targets`, walked slot-7-to-0 so the order is skull-first as the README documents; steps from whichever marked unit is held, wrapping, and enters at the skull (forward) or star (back) end when nothing marked is held. Only marks on a streamed unit are in the cycle |
|
||
| `inSight`, `target` distance-cycles/`worldBoss`/`rangeCone`, `notify` | todo | confirmed from `MarcelineVQ/UnitXP_SP3`'s own `README.md` (the authoritative command reference — more precise than this session's earlier research pass, which guessed at `AoE`/`meleeAutoAttack`/`behind`/`timer` variants this README doesn't mention; treat those as unconfirmed until seen elsewhere). `inSight` needs a real raycast with no current binding-layer access; `nextEnemyConsideringDistance`/`nextEnemyInCycle` (the 41-yd distance-banded cycles) would need a third cycle threaded through `TabHistory`, which is keyed to its own two sides; `worldBoss` needs classification data that doesn't reach here; `rangeCone` tunes the scan's own cone constant, not a Lua-settable knob today; `notify` needs OS-level taskbar-flash/system-sound |
|
||
| `modernNameplateDistance` | **built** | The command's name says distance; its own checkbox says what it does — **"Proper Nameplates Occlusion"** (`UnitXP_SP3_Addon.xml:151`), with three sibling options in that panel gated on it ("Need Proper Nameplates Occlusion to be also enabled"). So it is line-of-sight culling, not reach: benilla's reach is `NameplateRange`/`NP_NameplateDistance`, which have their own rows. 1.12 draws a plate for any unit in range whether or not a wall stands between, which is why a pull through a wall shows its whole room. Stored as the new `NameplateOcclusion` CVar rather than as private state, so it persists, appears in `/console`, and reaches the engine through the one `CvarChanged` path every other nameplate knob uses. **Two readers, as the reach CVars have**: `vplates.rs`'s `VPlateMode::occlusion` (V-key plates) and `nameplates.rs`'s `NameConfig::occlusion` (overhead names), sharing one `camera_can_see` — they must agree, because a unit with a plate has no overhead name and vice versa, so culling only one would swap a unit's plate for a floating name instead of hiding it. The ray is cast from the **camera**, not the player: the plate is drawn where the camera projects it, and in third person the two origins disagree often. It aims `OCCLUSION_AIM_LIFT` above the unit's origin (a transform sits at the feet, so a ray aimed there skims the ground and hides every unit on a slope) and stops `OCCLUSION_BACKOFF` short (so the floor under the unit's feet cannot count as the thing hiding it). Against the body layer only, so a unit standing in front of another cannot hide it — geometry occlusion, as the mod's is, not depth sorting. **The return value is part of the fix**: the addon warns "UnitXP_SP3.dll failed to execute" on a falsy result (`UnitXP_SP3_Addon.lua:108-113`), and the dispatcher's fall-through answers the integer 0, which is truthy in Lua — so before this the toggle reported success and changed nothing |
|
||
|
||
## ClassicAPI — confirmed (open source, `brues-code/ClassicAPI`)
|
||
|
||
| Area | Status | Notes |
|
||
|---|---|---|
|
||
| Lua 5.1 syntax (parsing: `#`, hex literals, long brackets, …) | done | free — benilla's VM is mlua/Lua 5.1 already; 1.12 itself is Lua 5.0 |
|
||
| Lua 5.1 *stdlib* (`select`, `coroutine`, `string.reverse`/`match`/`gmatch`, `table.maxn`, …) | blocked, needs a maintainer call | `script/lua50.rs` deliberately nils every one of these so a vanilla addon's own `string.gmatch or string.gfind`-style feature test takes 1.12's real branch — that's a fidelity mechanism for *every* addon, not a ClassicAPI concern. Restoring them in `compat/` (always on) would break that test for addons that never asked for ClassicAPI. Documented in `compat/classic_api.rs`'s own header; not resolved here |
|
||
| `UnitGUID`, `UnitTokenFromGUID` | done | `compat/classic_api.rs`, reusing SuperWoW's `guid_token`/`Model::unit` plumbing — a GUID from either round-trips through both |
|
||
| `Enum` (`PowerType`, `ItemQuality`, `ItemClass`) | partial | `compat/classic_api/enums.rs`, pure const tables. Only the three groups the installed addons index are built; the modern `Enum` has well over a hundred groups and a constant with the wrong number fails silently at whatever consumes it, so the rest are absent rather than guessed. Values are the **modern** ones — that is what ClassicAPI backports and what addons compare against — which for these three is almost free, since the modern numbering preserved 1.12's: `PowerType.Mana`/`Rage`/`Focus`/`Energy` (0–3) compare correctly against this client's own `UnitPowerType`, and the item classes/qualities line up for every value 1.12 has. **One real collision**: power type 4 is Happiness in 1.12 and ComboPoints upstream, so `UnitPowerType("pet") == Enum.PowerType.ComboPoints` is true for a happy pet. Dropping the name would turn a wrong comparison into one against nil, and 1.12 has no combo-point power type to offer instead (combo points are `GetComboPoints()`), so both names map to 4 and a test pins it. Also carries both spellings where the modern enum renamed a value (`ItemQuality.Standard`/`Common`, `Good`/`Uncommon`) |
|
||
| `UnitClassBase`, `UnitRaceBase` | done | `compat/classic_api/unit_extra.rs`. The token half of stock `UnitClass`/`UnitRace`, which later clients split into their own verb — `UnitState.class_file`/`race_file`, already fed. Named here because it is the single highest-frequency ClassicAPI call in the installed set: **33 sites in pfUI**, at file scope, so its absence stopped the addon loading outright. Players only: a creature carries no class row, and answering one would make `UnitClassBase(unit)` truthy for every mob |
|
||
| `UnitSubName` | done | `compat/classic_api/unit_extra.rs`, over `UnitState.subtitle` — the `<Stable Master>` line the overhead name and tooltip already draw |
|
||
| `UnitIsMinion`, `UnitIsFeignDeath` | done | `compat/classic_api/unit_extra.rs`. `UnitIsMinion` is `UnitState.owner != 0`, the same resolution the `"<token>owner"` grammar uses; `UnitIsFeignDeath` reads `dynamic_flags & 0x20`. Both answer `1`/nil, the 1.12 predicate shape |
|
||
| `UnitPower`, `UnitPowerMax` | done | `compat/classic_api/unit_extra.rs`. The later-client spelling of `UnitMana`/`UnitManaMax`, plus an optional `powerType` — and that argument is the whole point: it reads a slot the unit is **not currently on**, which is how pfUI draws a shapeshifted druid's mana while the active slot is energy or rage (`api/unitframes.lua:1601`'s own comment names this as the reason it uses the two-argument form). 1.12 exposes no way to ask, so this needed **new state**: `UnitState::powers`/`max_powers`, all five `UNIT_FIELD_POWER1..5`/`MAXPOWER1..5` slots, fed in `ui_unit.rs` through the *same* `unit_shown_power`/`unit_shown_max_power` getters the existing active-slot pair uses — so the `UNIT_DYNFLAG_DEAD` gate and the rage/happiness display divisors apply identically and cannot drift. An out-of-range `powerType` answers the number 0, not nil: `Enum.PowerType.None` is -1 and `HealthCost` is -2, so a negative index is a real call, and pfUI's own `UnitPower(unitstr, powerType) or 0` shows a caller assuming a number. **Fidelity deviation**: `script/unit/tests.rs`'s `power_bindings_read_the_active_type` previously asserted both were nil as 1.12 verbs they are not. Updated with the reason rather than deleted, the same way `reference_surface.rs` records intentional deviations — and the assertion was replaced with a stronger one, that the new pair answers *exactly* what the stock pair does for the active slot, so `UnitMana` stays the source of truth instead of drifting from a parallel read |
|
||
| Focus (`FocusUnit`, `ClearFocus`, the `"focus"`/`"focustarget"` tokens, `PLAYER_FOCUS_CHANGED`, `/focus`, `/clearfocus`) | done | the persistent secondary selection, which 1.12 has no verb, no token and no event for. `compat/classic_api/focus.rs` owns the *state*: the focus is a **GUID** in `Model::focus_guid`, the same shape as SuperWoW's `mouseover_override` and for the same reason — the token has to answer the unit's live health, power and auras, not a frozen copy of the tick `/focus` ran on. `Model::unit` resolves both tokens ahead of the fed-token map so a `FocusUnit`/`ClearFocus` in the current frame wins over the last push, and `unit/mod.rs`'s `classic_api_token_recognised` widens the token check by a `"focus"` *prefix* (so `"focustargettarget"` is recognised and answers nil, exactly as the reference's own `"target"` prefix recognises `"targettargettarget"`). The *data* is the app's: `ui_unit.rs`'s `feed_units` pushes both snapshots and fires their `UNIT_*` edges, `ui_aura.rs`'s `feed_auras` pushes both aura lists and fires `UNIT_AURA` per token, `feed_unit_reach` resolves both for `CheckInteractDistance`/UnitXP_SP3's `distanceBetween`. **No new system and no new `SystemParam`**: each feed already holds the `UiScript` and reads the guid back out of it, so the `UPDATE_ACTIONABLE_CEILING` is unchanged at 5,581 (measured). `feed_units`' gate gained the guid as an input, because focusing a unit that is standing still moves nothing else the gate watches. `PLAYER_FOCUS_CHANGED` is argless and fires only on a real change, assign and clear alike. **Deviation, by design**: a recognised token naming nothing (`/focus` with no target) clears the focus, the later clients' own behaviour; an *unrecognised* token raises `Unknown unit name` through the shared check, as every `Unit*` verb does, rather than silently dropping the focus |
|
||
| `/focus` argument grammar | partial, inferred | upstream's `docs/API.md` lists `FocusUnit`/`ClearFocus` and **no slash grammar**; pfUI's `modules/focus.lua` is the only evidence the aliases exist at all ("/focus and /clearfocus live in ClassicAPI's SlashCommandsRegistry now"). Bare `/focus` is the later clients' own meaning — focus the current target — and an argument is read as a *unit token*, the only thing this client can resolve, which is also why pfUI ships its own `/focusname` for names. Registered in `benilla-app`'s `ui_script::manifest::install_focus_slash_commands`, not beside the verbs, for a mechanical reason: the stock `ChatFrame.lua:680` **assigns** `SlashCmdList = { }` and `compat/` installs before FrameXML loads, so an entry made there would be dropped — same position and same reason as SuperWoW's macro-letters patch. `SLASH_FOCUS1` and `SLASH_CLEARFOCUS1` are therefore beyond-1.12 globals that the bare-VM surface test cannot see, which is why they are *not* in `reference_surface.rs`'s exception list (`FocusUnit`/`ClearFocus` are) |
|
||
| focus as an *app-side* token (`TargetUnit("focus")`, `CastSpellByName(spell, "focus")`, `/target focus`) | todo | `ui_unit.rs`'s `UnitTokens::resolve` is the app's own token resolver and is a plain `SystemParam` with no reach into the VM, where the focus guid lives, so it has no `"focus"` arm and `reach_tokens()` (which documents exactly what that resolver knows) stays unchanged. Nothing installed needs it: pfUI's `/castfocus` and `/swapfocus` both go through `TargetUnit(UnitGUID("focus"))`, a raw GUID — which is its **own** separate gap, since `UnitTokens::resolve` does not read SuperWoW's GUID-as-unit-id grammar either. Giving the resolver a focus arm means moving the guid app-side into a resource, which costs the request-pattern round trip `FocusUnit` is deliberately avoiding (`/swapfocus` is `FocusUnit("target")` then `TargetUnit(oldFocus)` in one Lua call, and a token resolved after that retarget committed would focus the wrong unit) — not guessed at here |
|
||
| `UnitInRange`, `UnitInLineOfSight`, `GetUnitSpeed`, `UnitSpellHaste`, `UnitCreatedBySpell`, `UnitCreatureTypeID` | todo | each needs data no feed supplies, and a **wrong** answer here is worse than an absent one because a caller cannot tell it is wrong: `UnitInRange` is a server-authoritative 40yd party check (not a distance calculation — the real client answers from a flag, and party positions aren't even fed, see the `UnitPosition` row); `UnitInLineOfSight` needs the collision raycast UnitXP's `inSight` also waits on; `GetUnitSpeed` needs per-unit movement-state speed, only the player's is known (`self_movement`); `UnitSpellHaste` has no 1.12 analogue to read (no haste stat exists in 1.12's `UNIT_FIELD`s); `UnitCreatedBySpell` needs `UNIT_CREATED_BY_SPELL`, unfed; `UnitCreatureTypeID` needs the numeric id behind `creature_type_name`, which arrives as a localized string only. Call counts in the installed set are 1–3 each, versus `UnitClassBase`'s 33 |
|
||
| Detection (`CLASSIC_API_VERSION`, `INTERFACE_VERSION`, `IsAddOnLoaded("!!!ClassicAPI")`) | deliberately not done | tried and reverted this session: a synthetic always-on addon entry is the real mechanism, but it leaked into enable-state file persistence (wrote `"!!!ClassicAPI: enabled"` into the player's own saved file) and inflated `GetNumAddOns()` for every other addon-lister — fixable, but not worth doing *before* a real namespace baseline exists per this file's own stated rule (a flag with nothing behind it is worse than no flag) |
|
||
| `Time.C_Timer.After`/`NewTimer`/`NewTicker` | done | `compat/classic_api.rs`'s `timer` submodule; a `PendingTimer` row per call (`script/model.rs`), drained by `UiScript::tick` against `GetTime()`. `NewTimer`/`NewTicker` return a handle table with `:Cancel()`; a `ReloadUI`/relog (a fresh VM) drops every pending one, matching the reference's own `C_Timer` lifetime. The reusable building block (an id'd, cancellable, `GetTime()`-driven callback row) UnitXP_SP3's deferred `timer` command can reuse rather than building its own |
|
||
| `RunNextFrame` | done | `compat/classic_api/timer.rs`'s `install_globals`, a zero-delay `C_Timer.After` row through the same queue — one drain, one place for a scheduling bug to live. pfUI uses it 5 times to defer work out of the handler that triggered it, and it is what broke the moment the addon's frames were driven by input. **It also exposed a real pre-existing bug in `tick.rs`'s `drain_timers`**: the drain empties `model.timers` with `mem::take` before iterating, so a timer armed from inside a callback landed in the emptied vec — and the drain then *assigned* its survivors back, discarding it. A self-rescheduling `After` chain fired exactly once and stopped dead, with nothing logged. Now merged; two tests pin it, and both were checked to fail against the old line (the chain test reported `left: 1, right: 4`) |
|
||
| `SetShown` on region leaves | done | `compat/classic_api/frame.rs`. It was Frame-only; pfUI calls it on a **FontString** (`f.group:SetShown(...)`, `api/unitframes.lua:582`). Now installed on all three method tables through the same one-line composition, which is sound because a region does have `Show`/`Hide` of its own: `script::REGION_LEAF_SHARED` registers the visibility four onto each leaf separately (Texture `0x79b580`, FontString `0x79cb70`) rather than hoisting them to the Region map, exactly as the reference does. A test pins that the pair the composition needs is actually on the leaf, so a tidying pass that moves them breaks the test rather than pfUI |
|
||
| `ObjectPoolMixin`, `CreateObjectPool`, `TexturePoolMixin`, `CreateTexturePool`, `TexturePool_HideAndClearAnchors` | done | `compat/classic_api/pools.rs`, the upstream's own structure as a Lua chunk over the existing `CreateFromMixins`. 1.12 has no pooling at all. **Load-blocking**: pfUI builds its tooltip buff-icon pool at module scope (`modules/tooltip.lua:217`), so the missing global aborted the rest of that module — which is also what left `pfUI.tooltip:Update` undefined and made the tooltip raise on hover. One missing global, two reported errors. The shape worth knowing: `EnumerateActive` returns `pairs(activeObjects)` and that table is keyed **by the object**, so `for icon in pool:EnumerateActive()` binds the object; a version storing objects as array values breaks that loop silently, and a test pins it. `table.getn` not `#` — `#` genuinely does not parse in this VM. `CreateFramePool`/`CreateFontStringPool` unbuilt, nothing calls them |
|
||
| `Totem`: slot constants + `GetTotemInfo` | partial (constants **inferred**) | `compat/classic_api/totem.rs`. **This deliberately reverses `misc.rs`'s earlier "not built" decision**, not because totem data appeared but because the absence turned out to cost most of pfUI's UI: it keys a table with the four slot constants at module scope, and `pfUI.lua:391` loops its modules with **no `pcall`**, so `table index is nil` aborted the loop and every later module plus the whole skins pass. `GetTotemInfo` answers **no values** — right for every non-shaman and for a shaman with nothing down, the only answer this client has data for, and all three pfUI call sites already guard it. Still wrong for a shaman with totems out, which is this row. `MAX_TOTEMS`/the four slots are the standard Blizzard values but unverified against ClassicAPI source; blast radius is bounded because `GetTotemInfo` always says "none", so the slot numbers only decide which of four colours an empty bar slot takes. `GetTotemTimeLeft`/`GetTotemDuration`/`TargetTotem` unbuilt — unreachable rather than broken (pfUI calls `TargetTotem` only from a right-click on an always-empty bar) |
|
||
| `EventRegistry`, `CallbackRegistryMixin:RegisterFrameEvent`/`RegisterFrameEventAndCallback` | done | `compat/classic_api/callback_registry.rs`. The bridge from real frame events into the callback bus; pfUI's new-item tracker is `EventRegistry:RegisterFrameEventAndCallback("BAG_NEW_ITEMS_UPDATED", ...)` (`modules/newitem.lua:60`). 1.12's `OnEvent` takes **no parameters** — the handler reads the globals `event`/`arg1..arg9` with `this` as the frame — so the forwarder cannot be upstream's `function(_, event, ...)`. It is defined at **chunk scope** on purpose: written as a closure inside `RegisterFrameEvent` it would capture that function's `event` parameter and shadow the per-fire global, making the frame report every event under the name of whichever registered first. A two-event test catches exactly that and was verified to fail against the closure version (`left: (1, 0)` — the wrong callback fired) |
|
||
| `GameTooltip_Hide` | done | `compat/classic_api/tooltip.rs`. A later-client FrameXML helper: `reference/1.12-globals.tsv` lists exactly six `GameTooltip_*` functions and this is not among them. Its purpose is to be passed *by name* as an `OnLeave` handler, which is all five of its call sites in the installed set — and nil there is not a no-op, it raised inside `HookScript` and took the rest of the module with it (`modules/friendnotes.lua:90`). Checked at the same time: `SetItemButtonTexture`/`SetItemButtonCount`, also called by the addons, **are** genuine 1.12 FrameXML globals and need nothing |
|
||
| `C_Spell.GetSpellReagents` | done | `compat/classic_api/spell.rs`, over new `SpellLookup::reagents` — `Spell.dbc`'s `Reagent[8]`/`ReagentCount[8]`, which `benilla-formats` already parses (`spells/display.rs:122`, with `catalog_tests.rs` pinning Slow Fall's `(17056, 1)`). Empty columns are dropped at the feed, so `reagents[1]` is the first *real* reagent. Returns an empty table for a spell that needs nothing and **nil** for an unknown id — genuinely different answers, both of which pfUI's `if reagents and reagents[1]` reads correctly (`modules/actionbar.lua:1553`). The subtle part: `spellbook.rs`'s `spell_display` resolves a *known* spell from the book, and a book slot carries no reagent columns — so the book arm now takes reagents from the probe while keeping the book's name/rank/icon. Without that, the spells most likely to be asked about (the ones on your bars) would be exactly the ones wrongly reporting no reagents; a test pins it and was verified to fail otherwise |
|
||
| **ClassicAPI tail — the measured remainder** | todo | A full sweep of every `C_<Namespace>.<Member>` the installed addons reference, minus everything built, leaves **46 members across 15 namespaces**. Recorded here so the next pass starts from measurement rather than guesswork. Grouped by what each actually needs: **pure algorithms, no client state** — `C_EncodingUtil` (`CompressString`/`DecompressString`/`EncodeBase`/`DecodeBase`/`SerializeJSON`/`DeserializeJSON`/`SerializeCBOR`/`DeserializeCBOR`), self-contained and mechanical. **Needs a live feed** — `C_Spell.UnitCastingInfo`/`UnitChannelInfo`/`ChannelInfo` (per-unit cast state; pfUI's real cast bars and nameplate cast bars depend on these), `C_Spell.IsSpellInRange`/`IsNextMeleeSpell`/`IsRangedAutoAttackSpell`/`ResetsMeleeSwing`/`GetSpellDispelType`. **Needs a 1.12-absent subsystem** — `C_EquipmentSet` (13 members; no equipment sets exist in 1.12 at all), `C_LootHistory` (3), `C_NewItems` (3), `C_PlayerCache` (2). **Small, data-backed** — `C_Item.GetCurrentItemLevel`/`GetItemUniquenessByID`, `C_FriendList.SetFriendNotes`, `C_QuestLog.GetQuestDetails`, `C_PlayerInfo.CanUseItem`, `C_Map.GetMapOverlays`, `C_ChatBubbles.GetAllChatBubbles`. (`C_Button.backdrop` is a field access on an addon's own table, not a namespace — a false positive of the sweep.) |
|
||
| `select` is nil, and real addons call it | open question, maintainer's call | Evidence for the standing stdlib-restore question in `lua_ext.rs`/`string_methods.rs`, not a new one. `lua50.rs` keeps `select` nil for 1.12 fidelity, and pfUI calls `select(7, GetTotemInfo(id))` in live handlers (`modules/totems.lua:18`, `:32`) — so on a real OctoWoW install, where ClassicAPI backports the 5.1 stdlib, those work and here they raise. Currently unreachable (the totem bar is always empty, see that row), so it costs nothing *today*; it is logged because it is the same decision as the string-metatable deviation and should be settled once, for the whole corpus, rather than per call site. Also confirmed while working here: **`#` does not parse in this VM at all**, so any chunk written for it must use `table.getn` |
|
||
| Lua 5.1 stdlib: `select`, `string.match`, `string.gmatch`, `math.fmod`, `math.modf` | done — **decision taken** | `compat/classic_api/stdlib_backport.rs`. This **resolves** the standing open question above, decided by the maintainer on live evidence (pfUI's `modules/marktracking.lua:300` uses `string.match` to parse a raid-target token and raised 8 times in one session). Scoped by **measurement**, not wholesale: these are the only five members any installed addon calls (12/7/5/2/1 sites). Deliberately still absent — `string.reverse`, `strrev`, `table.maxn`, `math.huge`, the hyperbolics, `coroutine`: nothing calls them, and `table.maxn` would fight `lua50.rs`'s `n`-based table library, which is 5.0 semantics this client needs. **Lives in `compat/`, not `lua50.rs`**: the dialect layer keeps describing 1.12 truthfully and compat re-adds what ClassicAPI adds, so a build that drops `compat/` is still strict 1.12. The restored functions are the **real 5.1 originals**, stashed under `lua50::REG_LUA51_PREFIX` before nil-ing — nothing is reimplemented, because a hand-rolled `string.match` would be a second, subtly different pattern engine, and a test proves the genuine one is in place (position captures, the non-greedy `-` quantifier). **Accepted cost, stated plainly**: an addon writing `if string.gmatch then` now takes the modern branch — which on this server, with ClassicAPI loaded, is the correct answer. Four fidelity gates updated rather than relaxed: `lua50.rs`'s own test now asserts the dialect still lists these on `REMOVED` *and* that they are reachable in the finished VM, so "compat restores it" is not licence for the dialect to stop removing it |
|
||
| `SetOwner: unknown anchor 'BOTTOMRIGHT'` | **unexplained — diagnostic added** | 566 occurrences in one live session, the loudest line in the log by 20×, and the cause is **not yet known**. Ruled out: every `SetOwner` call site in every installed addon `.lua` and `.xml` passes a valid `ANCHOR_*` string (verified with a paren-matching scan, after a naive regex truncated nested calls and gave a false reading); `script/tooltip/verbs.rs` does no prefix-stripping, matching the full uppercased name; and it does **not** reproduce under the addon harness, so it needs real in-world hovering. It is a *warning*, not an error — the anchor falls back to `ANCHOR_LEFT` and the tooltip still draws, just anchored wrongly. The warning now names the **owner frame**, which distinguishes an addon caller from the stock FrameXML off the player's patch chain; the next session log should identify it. Note `"ANCHOR_TOP"` (`pfUI/modules/mapreveal.lua:44`) and `"NONE"` (`modules/buffwatch.lua:117`) are separately invalid in 1.12 — the reference has nine anchor modes and neither is among them — so defaulting those to Left matches the reference's own silent behaviour |
|
||
| colour getters return a `ColorMixin`, not a tuple | fixed | `C_UnitAuras.GetAuraDispelTypeColor` returned `(r, g, b)`, but pfUI's line is `GetAuraDispelTypeColor(dtype or ""):GetRGBA()` (`api/unitframes.lua:1778`) — upstream's return *is* an object, so a tuple raises rather than merely reading awkwardly. Both it and `C_ClassColor.GetClassColor` now build through the real `CreateColor` (`color_mixin.rs`'s `color_object`), which fetches the global at call time rather than holding a `Function` handle, so install order inside this directory stays irrelevant. `GetClassColor` previously hand-rolled a shell carrying two formatters — enough for the `:WrapTextInColorCode` calls pfUI makes, latent for `:GetRGBA()`. Building a fresh object also settles that shell's own stated objection to a mixin (mutating a shared class colour would edit FrameXML's row): the `r`/`g`/`b` are copies, pinned by test |
|
||
| State (bare globals) | partial | `compat/classic_api/state.rs` + new per-tick posture plumbing: `SelfMovement` (`script/model.rs`), `UiScript::set_self_movement` (`script/self_movement.rs`), fed by `ui_unit::feed_self_movement` from the mover's movement flags (swim, fall), the descriptor (mount display id, `UNIT_FIELD_BYTES_2` form byte) and the area pass's own indoor down-ray. 1.12's Lua can reach none of these at all, so this is new engine data, not a wrapper. Done: `IsSwimming`, `IsMounted`, `IsIndoors`, `IsOutdoors`, `IsFalling`, `GetShapeshiftFormID`, and `IsInGroup`/`IsInRaid` (read the party/raid roster, the same authority `GetNumPartyMembers`/`GetNumRaidMembers` answer from — not duplicated into the posture snapshot). Not built, each for a stated reason in that file's header: `IsStealthed`/`GetSheathState`/`IsAssistingRitual` (no engine state tracks any of the three), `GetMirrorTimerInfo`/`GetMirrorTimerProgress` (benilla's mirror timers are event-only — `ui_mirror.rs` fires `MIRROR_TIMER_START`/`_PAUSE`/`_STOP` and keeps no queryable snapshot), `Dismount`/`CancelShapeshiftForm` (actions; vanilla cancels a form by re-casting its spell, and the engine knows the form *index*, not the spell behind it). Cost: `UPDATE_ACTIONABLE_CEILING` +3, cited in `game_plugins.rs`. **This same plumbing unblocks SuperWoW's own lowercase-initial `IsSwimming`/`IsMounted`/`isIndoors` siblings** (its "New functions" row above) — a later pass can wire those to `SelfMovement` directly instead of building anything |
|
||
| A **non-player** aura's `duration` / `expirationTime` | done | `crates/benilla-app/src/ui_aura.rs`'s `OtherAuraDurations`: a `(unit guid, spell id)` table stamped by `spell/net.rs`'s `on_spell_go`, joined into every other unit's aura list at the feed. **This was the hole behind "no debuff timers on the target frame".** pfUI-for-ClassicAPI reads its unit-frame timers straight off `C_UnitAuras.UnitAuraBySlot`'s `duration`/`expirationTime` (`api/unitframes.lua:1680,1788`) rather than through libdebuff — its own header says the plumbing "now runs on ClassicAPI's C_UnitAuras (which provides sourceUnit/sourceGUID and **non-player expirationTime**)" and libdebuff's `UnitDebuff`/`UnitOwnDebuff` survive only as adapters over it. So libdebuff could be tracking perfectly (it was: `libdebuff_all_auras` held rows) and the icons still draw no sweep, because the number they read came from us and we answered 0. There is no packet to answer from: `SMSG_UPDATE_AURA_DURATION` is self-only and `SMSG_SET_EXTRA_AURA_INFO`, which carries a caster's durations on others, is a 2.0 opcode. It is learned instead from the GO that applies the aura — which this client sees natively, with the apply instant being the packet's own arrival rather than a combat-log line to parse. Length comes from the same `ui_cast_events::aura_duration_ms` behind nampower's `arg8` and `GetSpellDuration`, so the three cannot disagree, and its `0` (permanent, no row, or a combo-point/level-scaled row — the `todo` row above) stamps nothing, leaving the icon timerless rather than counting to the wrong moment. The join is driven by the unit's own `UNIT_FIELD_AURA`, so a stamp for an aura the target resisted is never read, and a stamp that has run out reports nothing rather than a negative remainder. Not covered, by construction: an aura already on a unit before we streamed it, and one applied out of our range — no GO, no timer, which is what the mods this stands in for also do |
|
||
| UnitAuras (`C_UnitAuras`) | done | `compat/classic_api/unit_auras.rs`, modern named-table/slot shape over the same `AuraState` list the stock `UnitBuff`/`UnitDebuff`/`GetPlayerBuff` already read (including this session's SuperWoW spell-id extension). `GetAuraDataByIndex`, `GetBuffDataByIndex`, `GetDebuffDataByIndex`, `GetAuraDataBySlot`, `UnitAuraBySlot`, `GetAuraSlots`, `GetUnitAuras`, `GetUnitAuraBySpellID`, `GetPlayerAuraBySpellID`, `GetAuraDataBySpellName`, `GetAuraDispelTypeColor`, and the positional `UnitAura`/`UnitBuff`/`UnitDebuff` siblings. `RegisterComboDuration`/`RegisterAuraDurationModifierByTrigger` accept and discard: they are addon-side registration hooks with no 1.12 data behind them and nothing here would ever read the registration |
|
||
| Spell (`C_Spell`) | partial | `compat/classic_api/spell.rs`, spell-id-first: `DoesSpellExist`, `GetSpellName`, `GetSpellTexture`, `GetSpellSubtext`, `GetSpellInfo` (named table), `IsSpellPassive`, `IsSpellKnown`, `IsCurrentSpell`, `GetSpellCooldown`, `IsSpellUsable`, `GetSchoolString` (+ the seven `SCHOOL_MASK_*` constants), `CanDualWield`, `CastAtCursor`, `CastAtUnit`, `CancelSpellByID`. **The five display getters answer for any shipped spell**, not just the player's own ~200: `script/spellbook.rs`'s `spell_display` reads the book first (it carries the live icon substitutions) and falls back to the host's `Spell.dbc` probe for every other id — the fix for pfUI's `api/unitframes.lua:42`, which keys a predict table on `strlower(GetSpellTexture(774))` at file scope on every class. The probe carries only name/rank/icon, so the predicates below it (`IsSpellPassive`, `IsSpellKnown`, `IsCurrentSpell`, `GetSpellCooldown`, `IsSpellUsable`) still read the book alone, which is the right source for all five. The rest of the namespace's ~30 functions (`GetSpellDescription`, `GetSpellPowerCost`, `GetSpellReagents`, `GetSpellEffectInfo`, the range/harm/helpful predicates, `UnitCastingInfo`/`UnitChannelInfo`, `GetSpellLevelInfo`, …) are unstarted: each needs a `SpellDisplay` column the probe does not carry, and widening `SpellLookup` field by field is the route, with a reason each time |
|
||
| Spell — the per-unit cast feed (`C_Spell.UnitCastingInfo`, `UnitChannelInfo`, `CastingInfo`, `ChannelInfo`) | partial — **the feed is built; the `UNIT_SPELLCAST_*` events that drive pfUI's bars are not** | Split out of the `C_Spell` row above rather than folded into it, because this half is a live feed rather than a getter over data already in hand, and because what it does *not* yet reach is worth a row of its own. This closes four of the "ClassicAPI tail" row's 46 measured members — the three it filed under "needs a live feed" for cast state, plus `CastingInfo`, which upstream lists in the same breath (`docs/classic-api-functions.md`, the Spell section) and which is `UnitCastingInfo("player")` exactly. **Built**: `compat/classic_api/spell.rs`'s `cast_info` over `Model::unit_cast`, fed from `benilla-app`'s `ui_cast_events.rs` ([`CastWindows`]), which folds the **same queue of wire edges** that already raises SuperWoW's `UNIT_CASTEVENT` — `SMSG_SPELL_START`, `SMSG_SPELL_GO`, `SMSG_SPELL_FAILED_OTHER`, `MSG_CHANNEL_START`, plus three state-only edges added here (`SMSG_SPELL_DELAYED` pushback, `MSG_CHANNEL_UPDATE`, and `inflight.rs`'s local Esc/move cancel, none of which is one of the wiki's six `UNIT_CASTEVENT` words, so each folds state and fires nothing). One queue, not two: an addon woken by `UNIT_CASTEVENT` polls `C_Spell` for the timing the event does not carry (pfUI, `modules/nameplates.lua:648-656`), and each row's state is pushed to the VM *before* its own event fires so the two cannot contradict each other — `the_event_and_the_window_agree_inside_the_handler_the_event_woke` is that property, and it caught a real one-tick-stale bug on the way in. **Keyed by caster GUID, deliberately not a `UnitState` field**: tokens are a naming layer that moves under units, and a cast carried on `"target"`'s snapshot would hand the next target the old one's half-finished bar. A read folds token -> GUID through `Model::unit`, so every token spelling this client answers (stock, a raw SuperWoW GUID string, `"mark<n>"`, `"<base>owner"`, ClassicAPI's `"focus"`, `nameplate<N>`) reads the right window for free. **Units**: `startMs`/`endMs` are **milliseconds** on `GetTime()`'s clock, stamped at the drain — the same place and frame `ui_cast.rs` stamps the stock bar's `SPELLCAST_START`, so pfUI's bar and `CastingBarFrame` start on the same millisecond. The two verbs are disjoint (a channel answers only `UnitChannelInfo`), and the shapes differ by one field: `castID` exists for a cast and not for a channel, so `spellID` is the 9th return and the 8th respectively — both of pfUI's destructurings (`modules/nameplates.lua:83`/`:86`) are transcribed verbatim as the test, in `script/tests/cast_info.rs`, because a positional shape is only correct relative to the code that reads it. **Not backed, left nil rather than guessed** (each with its reason in `cast_info`'s doc): `isTradeskill` — 1.12 has no tradeskill column in `Spell.dbc` and no wire bit, so pfUI's queued-craft bar merge simply does not engage; `castID` — 1.12 carries no per-cast identifier anywhere; `notInterruptible` — no such flag exists in the client, in `Spell.dbc` or on the wire, and nil reads as false, which is what every 1.12 cast is; return slot 10 of the cast form — upstream puts something there (pfUI skips exactly one slot to reach `delayMs` at 11) and nothing in this tree says what. `text` (slot 2) answers as `name`; the one per-cast string the reference does spell differently is the stock channel bar's `CHANNELING` word, which is an argument of `SPELLCAST_CHANNEL_START` for the local player, not a per-unit field, and no consumer reads slot 2. `delayMs` (slot 11) **is** backed, from the accumulated `SMSG_SPELL_DELAYED` total. **Another unit's channel is not reachable** and is reported as nothing rather than faked: `MSG_CHANNEL_START`/`UPDATE` carry no caster and vmangos sends them to the caster alone, so only the local player's channel has a window. `UNIT_FIELD_CHANNEL_SPELL` (field 144, public) does name *which* spell another unit is channelling, but carries no start and no length, and a bar with an invented timer is worse than no bar — that field plus a `SpellDuration.dbc` column, with the 0 -> id edge as the start and an explicit nothing for a channel already running when the unit streamed in, is the measured route if it is ever wanted. **What this does not finish**: pfUI's cast bars are driven by ClassicAPI's `UNIT_SPELLCAST_*` token events, which benilla fires none of (the row below costs the per-`nameplate<N>` half; the stock `player`/`target`/`focus` half is equally unbuilt). Without them `modules/castbar.lua` stamps a bar only from its `PLAYER_TARGET_CHANGED`/`PLAYER_FOCUS_CHANGED` re-poll (`:340`) — so **a mob already mid-cast when you target it does now show a correct, correctly-timed bar, and one that starts casting while already targeted does not**; the player's own `pfPlayerCastbar` never stamps at all, since `arg1 == "player"` events are its only way in. Nameplates are the same shape: `modules/nameplates.lua:602` seeds `castState[guid]` from `PollCastInfo` at `NAME_PLATE_UNIT_ADDED`, so a plate that spawns on a casting mob draws its bar, while `:655`'s per-cast seed waits on the event. The feed was the blocking half (the row below records that an event with nothing to read behind it "would fire into a handler that learns nothing"); the events are now the whole remainder, and `C_Spell` is no longer what blocks them |
|
||
| Spell — the `UNIT_SPELLCAST_*` token events | done | **The other half of the row above, and the half pfUI's cast bars are actually driven by.** The feed answered *what* a unit is casting; nothing told an addon *when*, so `modules/castbar.lua` could only stamp a bar from its `PLAYER_TARGET_CHANGED`/`PLAYER_FOCUS_CHANGED` re-poll and `pfPlayerCastbar` never stamped at all. Built in `benilla-ui`'s new `script/cast_events.rs` (`UiScript::fire_unit_spellcast`, the fan-out and the arguments) plus `benilla-app`'s `ui_cast_events.rs` (`CastEdge::token_events`, which edge raises which event), fired by the existing `drain_cast_edges` off the **same queue of wire edges** that already raises `UNIT_CASTEVENT` and folds the windows — so an addon woken by an event and polling `C_Spell` in that handler cannot be told two different things. **Built: nine events.** `_START` (`SMSG_SPELL_START`), `_CHANNEL_START` (`MSG_CHANNEL_START`), `_SUCCEEDED` + `_STOP` (`SMSG_SPELL_GO`), `_INTERRUPTED` + `_STOP` (`SMSG_SPELL_FAILED_OTHER`, and the local Esc/move abort), `_FAILED` + `_STOP` (a failing `SMSG_CAST_RESULT`), `_DELAYED` (`SMSG_SPELL_DELAYED`), `_CHANNEL_UPDATE` and `_CHANNEL_STOP` (`MSG_CHANNEL_UPDATE`, split on its remaining time at 0). **Each is the stock bar's own word for the same packet** — the mapping is `ui_cast.rs`'s `CastBarEdge` one for one, which is the strongest available statement that `pfPlayerCastbar` and `CastingBarFrame` cannot tell two stories about one cast; `every_token_event_is_the_stock_bars_own_word_for_the_same_packet` is that table. The millisecond claim is the two drains' shared frame clock: both are in `UnitFeed`, both stamp off `script.now()`, and `the_players_bar_walks_start_pushback_and_stop_in_step_with_the_stock_bar` interleaves `SPELLCAST_START@100.000` with pfUI's own `startTime` of `100000` to show it. **`arg1..arg5` are `(unit, castGUID, spellID, name, rank)`**, taken from the consumer's own comment (`modules/castbar.lua:354`) and its reads (`arg5` → the bar's rank label at `:357`/`:102-107`, `arg3` → the craft counter at `:370`; the nameplate module reads `arg1` alone behind `strfind(arg1, "^nameplate")`). `arg2` is **nil** — 1.12 has no per-cast identifier anywhere, the same reason `UnitCastingInfo`'s `castID` is nil, and the two must agree. Name and rank come from the one `spell_display` body `C_Spell.GetSpellName`/`GetSpellSubtext` read, because pfUI *prefers* `arg5` over its own `GetSpellSubtext` fallback and two sources would print two ranks. All five slots are pushed on every fire, nil included: the handler is called positionally too, so a short push is a shorter event to anything reading `...`. **The per-token/per-GUID mismatch, which is the whole design.** A cast is per-GUID; these events are per-token; one mob can be `target` *and* `nameplate3` *and* `focus` at once and each drives a different frame, so a new `Model::tokens_for_guid` answers **every** live token and the fan-out fires once per token — `token_for_guid` (`UnitTokenFromGUID`'s singular) returns whichever the map walk reaches first, and firing under that one leaves whichever bar lost the coin flip permanently blank (`one_cast_fires_once_per_live_token_that_names_its_caster` fails with exactly one token when collapsed). The set is the **pushed token keys** only — `player`, `target`, `targettarget`, the focus pair, `mouseover`, `npc`, party/raid/pet, `nameplate<N>` and its target — deliberately not the derived spellings `Model::unit` also resolves (a raw 16-hex GUID, `mark<n>`, `<base>owner`): those are how a *caller* spells a unit it already holds, nothing registers a handler keyed on one, and fanning every edge across `mark1..8` plus an `owner` form per base would multiply the dispatch for no reader. Tokens resolve at **fire** time, not start time, which is what makes token loss safe: retarget off a casting mob and its `STOP` has no `target` to fire under, so the new target's bar survives the old cast ending. **Nothing fires on token *acquisition*** — pfUI already covers that itself (`castbar.lua:343`'s re-poll, `nameplates.lua:602`'s `NAME_PLATE_UNIT_ADDED` seed), so a synthetic start there would both duplicate it and be an event with no wire edge behind it. **Two orderings are read off the consumer, not guessed.** (1) A failing word always comes **before** its `STOP`: `ClearBar(cb, failed)` paints the cancelled-cast red flash only if `wasActive = cb.endTime ~= nil` (`:25`), and a plain `STOP` first nils `endTime` so the flash never paints — reversing the pair makes `failcolor` nil, verified. Both must fire, because the nameplate module registers `_STOP` and *not* the failing pair (`nameplates.lua:506-507`) and that is its only way to clear `castState[guid]`; without it an interrupted mob's plate bar runs to the cast's original end. (2) Events are **event-major, not token-major**, so when one unit answers two of a consumer's tokens (self-targeted: `player` and `target`, and pfUI's target bar handles both through its `UnitIsUnit(unit, "player")` leg) the failing word reaches that bar before any `STOP`. **Gating: `folded` — the window fold's own return — for every edge**, so events and windows are one state machine. A `START` fires exactly when a window opened, which means `Start { window: false }` (`opens_cast_bar`'s verdict, the *same* body `spell_start` uses for `SPELLCAST_START`) raises no token start, so Auto Shot's mid-Aimed-Shot `SMSG_SPELL_START` neither opens a bar nor reaps one; a terminal fires exactly when a window closed, which de-duplicates the `SMSG_CAST_RESULT`/`SMSG_SPELL_FAILED_OTHER` pair and the abort/FAILED_OTHER pair for free; and a pre-start rejection naming some *other* spell folds nothing, so it cannot reach in and wipe the bar of the cast actually running (pfUI's `ClearBar` has no way to tell which spell an event is about). **Two pre-existing window bugs this surfaced and closed.** (a) A failing `SMSG_CAST_RESULT` pushed no edge at all, so a cast the server killed with nothing but that packet — a mid-cast deselect, vmangos `HandleSetSelectionOpcode` → `Spell::cancel` → `SendCastResult(INTERRUPTED)` — kept reporting as live to its original end: a bar that starts and never ends. Now `CastEdge::ResultFail`, queued inside `cast_result` beside the `CastBarEdge::Failed` it mirrors. It raises **no** `UNIT_CASTEVENT`: that row sources FAIL from `SMSG_SPELL_FAILED_OTHER` alone, both packets reach us for one interrupted cast of our own, and doubling a word an addon counts to answer an unsettled question about the real hook would be worse than leaving it. (b) `prune` ran unconditionally before the rows, and a completed cast's `SMSG_SPELL_GO` lands within a frame of its window's own end *either side of it* (the end is `our START receipt + cast time`, the GO is sent at `server START + cast time`), so the GO found nothing to close and `_STOP` fired on a coin flip. `CastEventFeed::reprieve` now spares a window whose resolution is already in this tick's queue, keyed on the spell id like every other removal here, so the expiry sweep still means exactly what it says — a resolution that never came. **Not built, each with its reason.** `UNIT_SPELLCAST_SENT` — it fires at the client's own send, not off a packet, and no addon in the corpus registers it: a new producer in `cast_send.rs` for no reader. No `_STOP` for a window the expiry sweep *does* take (caster despawned, packet for a unit that left streaming range): both consumers end a bar at its own nominal end with no event (`castbar.lua:286-289`, `nameplates.lua:106`), and such a caster is typically named by no token to fire under anyway. No `_CHANNEL_STOP` when a cast `START` replaces a live channel — pfUI's `START` arm re-stamps and its nameplate arm overwrites, so the transition needs no announcement. `_SUCCEEDED` is fired for conformance, not for an effect that can be shown here: its only consumer is pfUI's craft counter, reachable only through `isTradeskill`, which 1.12 has no column for and the feed answers nil for by design (`merged` is asserted nil in the timing test). One **measured divergence left open**: the stock channel bar applies one gate the window does not (`0x6e7550`, `AttributesEx3 & 0x2000`), and it is unreachable — `benilla-formats`' `spells/display.rs:363-367` records that exactly two spells carry the bit, Blood Siphon 24322/24323, neither player-castable, and `MSG_CHANNEL_START` reaches us for the local player's own channel alone. The other arm of that gate (no `Spell.dbc` row) is invisible rather than divergent: a window whose id nothing can name makes `cast_info` answer nil, so no bar is drawn either way. **This also closes the cast half of the per-`nameplate<N>` row below**, which recorded the cast pair as blocked on `C_Spell.UnitCastingInfo`; that is built, the plate tokens do now receive `_START`/`_CHANNEL_START`/`_STOP`/`_CHANNEL_STOP`, and `a_nameplates_token_gets_the_cast_pair_its_module_registers_and_the_stop_that_clears_it` runs that module's transcribed arms. `UNIT_AURA`/`UNIT_FLAGS` per plate stay as that row leaves them. **Evidence**: `benilla-ui`'s `script/tests/cast_info.rs` for the fan-out and the five arguments, and `ui_cast_events.rs`'s own tests for the sequences — the latter transcribe pfUI's `ClearBar`/`StampBar`/`RefreshBar`/`OnUpdate` and its whole `OnEvent` body (`castbar.lua:24-39`, `:89-144`, `:148-175`, `:250-306`, `:340-420`) verbatim and run them against the real fold with both drains in one app, because an event's contract is only correct relative to the code that handles it. **Ambiguity cost: zero, measured.** `Update (actionable)` is 5581 against a ceiling of 5581, unmoved, and `PostUpdate` 466 — no new system (the work is inside the existing VM-holding `drain_cast_edges`, which `WOW_AMBIGUITY_DUMP=1` on the ratchet test, grepped for `drain_cast_edges`, puts at 0 pairs, being wholly in the VM-only explained bucket), no new `SystemParam` on it (the token set and `spell_display` are both reached through `UiScript`, on the far side of the boundary), and the one new app-side param — `Reply`'s `ResMut<CastEventFeed>` — is on `on_cast_result`, a `net_handler` one-shot that is not in `Update` at all (the same dump greps to 0 for it too). No new `benilla-app` file, so `ui_script_wall`'s game count stays at its pre-existing 43. No new bare Lua globals, so `reference_surface.rs` is untouched; the nine event names are modern and absent from `reference/1.12-events.tsv`, so `event_shape_gate` skips them as it does `UNIT_CASTEVENT` and the `NAME_PLATE_*` three. Harness unchanged at 13/13 load clean, 13/13 session start, 13/13 UI probe, 10/13 survived use — it drives no world, so no cast packet ever arrives and it cannot see this feature at all. |
|
||
| SpellBook (`C_SpellBook`) | partial | `compat/classic_api/spellbook.rs`: `FindSpellBookSlotByID`, `GetSpellBookItemInfo` (named table), `GetNumSpellBookSkillLines`, `GetSpellBookSkillLineInfo`, `GetSpellBookItemSkillLineIndex`. The remaining ~14 (`GetSkillLineIndexByID`, `GetSpellBookItemCastCount`, the loss-of-control pair, `IsClassTalentSpellBookItem`, `ContainsAnyDisenchantSpell`, `GetSpellLevelLearned`, `GetCurrentLevelSpells`, `GetPlayerSpellsByAura`, the skill-line name/rank getters, the auto-attack predicates) are unstarted; some have no 1.12 equivalent concept at all (modern "class talent" vs 1.12's talent tabs, loss-of-control as distinct from 1.12's CC) |
|
||
| ColorUtil (`C_ColorUtil`) | done | `compat/classic_api/color_util.rs`, all 8. The only namespace here with nothing to wrap: pure colour maths (RGB↔HSV↔HSL, achromatic input answering the upstream's own `h == -1` sentinel) plus the `|cAARRGGBB…|r` escape builders, which is 1.12's own format |
|
||
| ClassColor (`C_ClassColor`) | done | `compat/classic_api/class_color.rs`. Reads FrameXML's own `RAID_CLASS_COLORS` (`Fonts.xml`, off the player's chain) at call time rather than carrying a second copy of the nine colours that could drift from it; nil before FrameXML loads and for an unknown token. The returned table is the `r`/`g`/`b` fields every caller reads plus `GenerateHexColor`/`WrapTextInColorCode` — the two `ColorMixin` methods that are pure formatting over those fields. The mutating rest of `ColorMixin` (`SetRGBA`, `IsEqualTo`, …) is deliberately absent: a caller mutating a shared class colour would be editing FrameXML's own table |
|
||
| Expansion | done | `compat/classic_api/expansion.rs`: `GetClassicExpansionLevel` (0, `LE_EXPANSION_CLASSIC`) and the two comparisons. Constants by design — a fork targeting a later era changes the one constant, not three functions |
|
||
| Input | partial | `compat/classic_api/input.rs`: the six `Is{Left,Right}{Shift,Control,Alt}KeyDown` plus `IsModifierKeyDown`. **Each side of a pair answers the combined bit**: `Model::modifiers` is one `(shift, ctrl, alt)` triple (what the stock sideless `IsShiftKeyDown` family reads) and 1.12 exposes no sided getter, so there is no sided state to report — which is what ClassicAPI itself does on a client that cannot tell them apart, rather than fabricating `false` for one half. Not built: `IsMouseButtonDown` (the only press state is `Model::mouse_down_on`, which the pointer layer fills *only* for a press that landed on a frame — a world press would read as not-down, wrong more often than right) and `GetMouseButtonClicked` (wants the button that triggered the running handler; dispatch passes it to `OnClick` as an argument and keeps no ambient current-event button) |
|
||
| Lua | partial, by design | `compat/classic_api/lua_ext.rs`: `table.count`, `Mixin`, `CreateFromMixins`, `strreplace`. Only names 1.12 has in neither library nor global, so nothing `lua50.rs` nils gets un-nilled and no addon's `string.gmatch or string.gfind` feature test changes answer — the stdlib-restore question stays the maintainer's call (that file's header, and this table's own Lua-5.1-stdlib row above). A test in that file asserts `select`/`coroutine`/`string.reverse`/`string.gmatch`/`table.maxn` are all still nil, plus `strrev` — which ClassicAPI does provide, but `tests/stdlib.rs`'s `the_rest_of_the_bare_globals` already pins nil for the same era-feature-detection reason, so adding it is that same maintainer call rather than a separate one — so a later pass can't restore any of them by accident. `strsplit`/`strjoin`/`trim`/`wipe` already exist as benilla globals for unrelated TurtleWoW-FrameXML reasons and are not re-registered |
|
||
| Currency (`C_CurrencyInfo`) | done, delegating | `compat/classic_api/misc.rs`. `GetCoinTextureString` is a *FrameXML* function in 1.12 (`MoneyFrame.lua`), loaded off the player's chain after this installs — so the bare global is left to FrameXML (a Rust copy would be both a second source of truth and dead, since FrameXML's would overwrite it) and `C_CurrencyInfo.GetCoinTextureString` reads that global at call time, forwarding the optional font height. Answers nil before FrameXML has loaded |
|
||
| Events | partial | `compat/classic_api/misc.rs`. `GetFramesRegisteredForEvent` is real: the registry's own list in registration order, which is also the order `fire_event` walks, and an empty table (never nil) for an unregistered event so `getn` is always safe. `C_EventUtils` exists but is **empty** — `IsEventValid` has no authority to answer from: `RegisterEvent` accepts any string (`script/event.rs`) and the only 1.12 event catalogue is `reference/1.12-events.tsv`, a test fixture that isn't compiled in. Answering `true` for everything would be a lie an addon branches on |
|
||
| System | partial | `compat/classic_api/misc.rs`. `GetPhysicalScreenSize` answers the UI screen rect's size, with a stated deviation: modern WoW returns true *device* pixels, where this is the logical rect the host pushes through `set_screen_size` — the two differ under a non-1 UI scale or DPI factor, which this crate cannot see. `CopyToClipboard` not built: `benilla-ui` is deliberately engine-free (no windowing, no OS handles) and a clipboard is an OS capability; the workspace's `arboard` sits behind the app's dev tooling, so reaching it means a new host-callback seam through `Model`, which is plumbing rather than a binding |
|
||
| Combat | partial | `compat/classic_api/misc.rs`. `InCombatLockdown()` → always `false`, and that is the honest answer rather than a stub: the lockdown exists on modern clients to refuse secure-frame edits mid-combat and 1.12 has no secure-template system at all, so nothing here is ever locked down. `StartAttack`/`StopAttack` deliberately not added here — they are 1.12's *own* verbs that benilla hasn't built yet, so registering them from `compat/` would put a stock gap in the wrong layer and hide it from the reference-surface accounting |
|
||
| Class, Totem, Tracking, Instance | todo, blocked | `FillLocalizedClassList`: the nine localized class names are DBC-resolved app-side (`benilla-app`'s `ui_unit::class_names`) and `benilla-ui` deliberately holds no DBC — the player's own class reaches it through `PlayerRecord`, but not all nine, so this needs a feed like `SelfMovement`'s, not a binding. `Totem` (4 functions): no totem state exists in the model at all; 1.12's own totem bar is unbuilt, so there is nothing to wrap. `Tracking` (3): `Model::tracking` is the one *active* tracking aura (behind the stock `GetTrackingTexture`), not the enumerable list of known tracking spells these index into. `GetInstanceInfo`: `script/instance.rs` feeds only the instance *type*; the modern tuple also wants the instance name, difficulty index and name, and max players — none tracked, and the name would have to be guessed from zone text |
|
||
| Hooks (`hooksecurefunc`) | done | `compat/classic_api/hooks.rs`, both documented forms (over a global, over a table field). Pure Lua machinery, no game state. The contract it keeps, each covered by its own test: the original runs first with the caller's arguments; **the original's return values pass through untouched** and the hook's are discarded; a hook that errors has its error recorded (`Model::record_script_error`) and the original's returns still come back, while the *original's* own error still propagates; and hooking twice **chains** in install order rather than replacing. A name holding no function is a silent no-op — the reference raises, but an addon written against a client that has the verb it meant to hook is the common case here and taking its UI down over a missing one would be worse. The original and hook live in the Lua registry, since this crate holds no persistent `Function` handle |
|
||
| Frame (widget methods) | partial | `compat/classic_api/frame.rs`. These are **methods, not globals**, written straight into the stock class method tables (`__benilla_frame_methods` and the two region leaves) — `object.rs`'s `frame_meta_for` *links* those tables rather than copying them ("a later write to a table shows through"), so a late install from `compat/` reaches every widget already made and every one made later. Built: `SetShown`, `GetSize`/`SetSize`, `GetRect`, `SetFormattedText` (FontString), `HookScript`, `IsEventRegistered`, `GetEffectiveAlpha`, and `SetAttribute`/`SetAttributeNoHandler`/`GetAttribute`/`ClearAttribute`. The geometry/visibility/text ones are composed from the stock verbs as a Lua chunk, so they reuse the stock resolution exactly instead of duplicating it in Rust — note the chunks return a function rather than reading `...`, since this VM is 1.12's Lua **5.0** where `...` is a parameter-list token only, never an expression. Skipped, each with the reason in that file's header: the **mask** and **atlas** families (benilla's UI quad carries one texture and a UV rect — no mask slot, no atlas registry; needs a render-path feature first), the **secure-template** family (`RegisterStateDriver`, `RegisterUnitWatch`, `SecureCmdOptionParse`, `SecureButton_*`, … — modern restricted execution, which 1.12 has no equivalent of; `SetAttribute`/`GetAttribute` ARE built because stripped of secure semantics they are just a per-frame key/value store, and that much is honest, but the drivers that *watch* attributes are the actual subsystem and are not), the **per-line** text-measurement getters (`GetNumLines`, `GetWrappedWidth`, `IsTruncated`, … — need per-line layout facts the host measurer is not asked for today; `measure.rs` answers one whole-string width/height). **`GetStringHeight` was on that skipped list and has been built**, because the blanket reason did not apply to it: the whole-string height is the other half of the number `GetStringWidth` already reads, so `measure::current_measure` now owns the staleness rule for both and the method is a read, not a new host question. AceGUI-3.0 calls it **unguarded** (`AceGUIWidget-Label.lua:54` `height = label:GetStringHeight()`; `AceGUIWidget-CheckBox.lua:90,210` `self:SetHeight(28 + self.desc:GetStringHeight())`), so a nil method there raises and takes the library's own zero-height fallback with it. **Correction, and it matters:** those are the `Libs/` (Mainline), `2.4.3/` and `3.3.5/` library trees AI_VoiceOver ships — **not** the `1.12/` tree its TOC actually loads, which reads `label:GetHeight()` instead and has had every one-argument `SetPoint` rewritten besides. This method is therefore ClassicAPI **parity surface**, not the fix for anything on this install, and the earlier claim here that it explained "can't see the VoiceOver options" was wrong. That symptom was **ours and elsewhere**: the ScrollFrame child re-anchor replaced the child's whole anchor list where 1.12's `SetPoint` merges one point into it, so AceGUI's `TOPLEFT`/`TOPRIGHT`-authored scroll child lost the half that gave it a width and resolved to no rect — and nothing below it resolved either. Fixed in `layout.rs`'s `set_frame_anchored`; measured through the same probe, which went from every node under the scroll child answering `GetLeft() == nil` to a populated, correctly placed tree. Kept because the blanket "needs per-line layout facts" reason genuinely does not apply to it and a 2.4.3/3.3.5 client of this library would need it; what was learned is that an addon shipping several library copies has to be read through its own TOC chain before any call site in it is evidence. Also in this family, each plausible and untraced this pass: `IsMouseOver`/`GetMouseFoci`/`IsDragging`/`SetResizeBounds`/`RegisterUnitEvent`/`SetColorTexture`/desaturation/rotation/the EditBox cursor-and-highlight getters (plausible, untraced this pass). All 12 new methods are registered in `widget_surface.rs`'s `ALLOWED_BEYOND` with their reasons |
|
||
| Container (`C_Container`) | done | `compat/classic_api/container.rs`; almost entirely translation over the stock 1.12 bag verbs' own data (`Model.containers`), not new game logic. `GetContainerItemID/Info`, `HasContainerItem`, `GetContainerItemDurability`, `GetContainerNumFreeSlots`/`GetContainerFreeSlots`, `CalculateTotalNumberOfFreeBagSlots`, `PlayerHasHearthstone`/`UseHearthstone` (scans bags 0-4 for item id 6948), `SwapItems`/`MoveItem`/`AutoStoreItem` (queue the same `ContainerMove`/`BagAutoStore` the stock pickup/place path does), `SortBags`/`SortBankBags` (no-ops — 1.12 has no sort algorithm to run) and the three sort-preference toggle pairs (stored, nothing downstream reads them) are real. Deliberately nil/false, not guessed: `GetContainerItemCharges` (same charge-slot-identity blocker as SuperWoW's), `GetContainerItemRepairCost` (server-only in vanilla), `GetContainerItemQuestInfo`/`GetContainerItemEquipmentSetInfo` (no such tracking exists, and no such feature exists in 1.12 at all for the latter), `IsContainerItemOpenable` (no lockbox flag tracked) |
|
||
| CreatureInfo (`C_CreatureInfo`) | partial | `compat/classic_api/creature_info.rs`, the 3 of 9 anything installed actually calls. `GetRaceInfo` and `GetFactionInfo` read a **new app→VM feed of the player's own `ChrRaces.dbc`** (`benilla-formats`' `chr_races.rs` → `benilla-app`'s `chr_races.rs` → `UiScript::set_race_info`), built because this crate holds no DBC and a frozen race table gets a modified realm's names wrong: **OctoWoW's row 10 displays "High Elf" where Blizzard's says "Blood Elf"**, while both keep `clientFileString "BloodElf"` for the art paths — the two columns genuinely disagree, so neither can be derived from the other, and a test pins that. The side on `GetFactionInfo` comes from `ui_unit.rs`'s `race_faction_group`, the same answer `UnitFactionGroup("player")` gives, so the two cannot disagree about a race; a non-playable row carries no side and reads nil rather than a guess. **`GetRaceInfo` answering nil past the last race is load-bearing** — pfUI walks `i = 1, 2, 3…` until nil, so an always-non-nil answer hangs the client at load; its own test says so. `GetCreatureID` decodes the template entry out of bits 24-47 of the 16-hex-digit GUID token the rest of `compat/` passes around, accepting **only** the creature high word so a player or pet GUID answers nil instead of a plausible-looking wrong number (the decode is inlined rather than calling `benilla_protocol::guid::entry` — this crate depends on no protocol or engine crate, and one shift is not worth breaking that boundary for). Not built: `GetCreatureInfoByID`, `RequestLoadCreatureByID`, `GetCreatureFamilyInfo`/`IDs`, `GetCreatureTypeInfo`/`IDs` (each needs a creature/family/type table this VM is not fed, and nothing installed asks) and `GetClassInfo` (the class table exists app-side as `ChrClasses` but has no VM feed yet — the same shape of work this row's race feed just did, if an addon ever wants it). With no install the race table stays empty and every reader answers nil, so an addon's race walk finds none and still loads |
|
||
| Item (`C_Item`) | partial | `compat/classic_api/item.rs`, 37 entries. Modern id-first and `ItemLocation`-keyed readers over the same state the stock item verbs read: `Model::item_templates` (the one `ItemTemplateView` cache every item tooltip renders through — a miss records the same `CMSG_ITEM_QUERY` ask the stock `GetItemInfo` does, so a modern caller warms the one shared cache, not a parallel one), `Model::containers` and `Model::inventory_slots`. **`ItemLocation` is accepted as the plain table addons build** (`{bagID=, slotIndex=}` / `{equipmentSlotIndex=}`): modern WoW's mixin is pure Lua sugar over exactly those two fields and every entry point only reads them, so the table *is* the contract; the mixin constructors themselves belong with `Mixin`/`CreateFromMixins` in the `Lua` namespace and are not faked here. Built: the existence/identity trio, `GetItemData`/`GetItemDataByID` (the upstream's kitchen-sink table, including the `isConjured`/`isOpenable`/`isLootable`/`isWrapper` flag bools off the template's raw `flags`), `GetItemInfoInstant`, the by-id and by-location getter pairs (name, quality, max stack, sell price, set id, inventory type, icon), `GetItemLink`, `GetStackCount`, `GetItemCount` (bags + worn, with the `includeBank` and charge-multiplying `includeUses` flags), `GetItemSpell` (the on-use trigger only — triggers 0/5, not an equip or chance-on-hit effect), `IsUsableItem`/`IsConsumableItem`/`IsEquippableItem`/`IsEquippedItem`/`IsItemOpenable`/`IsBound`, the `IsItemDataCached` pair (which deliberately record **no** ask — polling is not asking), the two inventory-slot name getters, and `UseItemByName`/`EquipItemByName` over the stock container-use queue. Not built, each with the reason in that file's header: the **guid** family (`GetItemGUID`, `GetItemLocation`, `IsItemGUIDInInventory`, …) — nothing in the model keys items by instance guid, and the modern `"Item-Server-…"` string has no source here; the **item-level** family (`GetCurrentItemLevel`, `GetDetailedItemLevelInfo`, `GetAverageItemLevel`) — item level is not in `SMSG_ITEM_QUERY_SINGLE_RESPONSE` and 1.12 exposes no such concept to the client; `GetItemStats`/`GetItemStatDelta` — the modern shape is keyed by `ITEM_MOD_*` *string* names resolved app-side, and returning the raw numeric keys under the modern name would silently mismatch; `GetItemUniqueness` (wants a limit-category pair 1.12 has no counterpart for); the `LockItem`/`UnlockItem` family (`ContainerSlot::locked` is a pending-operation flag the app owns, not a lock Lua may set); and `UseAtCursor`/`UseAtUnit`/`PickupItem`/`RequestLoadItemData*` (each needs an app-drained queue that does not exist yet) |
|
||
| AddOns (`C_AddOns`) | done | `compat/classic_api/addons.rs`, 10 entries, and deliberately **pure delegation**: every one calls the stock global (`script/addon.rs`) rather than reading `Model::addons` a second time, so the index-or-name parsing, the unknown-name placeholder answers and the load-gate verdicts keep one owner — a caller cannot get a different answer from `C_AddOns.IsAddOnLoaded` than from `IsAddOnLoaded`. `GetAddOnName`/`Title`/`Notes` and `IsAddOnLoadable` are `GetAddOnInfo` returns 1/2/3/5; `IsAddOnLoaded`/`LoadAddOn` pass straight through; `DoesAddOnExist` reads the `"MISSING"` reason rather than "something came back"; `GetAddOnSecurity` maps the stock `SECURE`/`INSECURE` string onto the upstream's `Enum.AddOnSecurityStatus`, answering `NotAvailable` (3) for an absent addon rather than defaulting to insecure (`Banned` (2) is unreachable — it comes from a server addon-banlist this client does not model). `GetAddOnOptionalDependencies` answers **none**, not the required list: 1.12's `.toc` has no optional-dependency directive at all, and returning the required ones would make a caller treat a hard dependency as skippable. `GetAddOnLocalTable` is not built — modern WoW passes each addon file a private table as a load argument and 1.12 passes addon files no arguments, so there is no such table to hand back; inventing an empty one would read as "my locals are empty" rather than "this client has no such feature". A fixture note worth keeping: the addon *index* array stays empty until the server's `SMSG_ADDON_INFO` reply lands, so any by-index read raises before it — faithful to 1.12, and what a test must call `note_addon_info_reply` to exercise |
|
||
| GameTooltip (reader methods) | partial | `compat/classic_api/tooltip.rs`, 8 widget **methods** (not globals) on the GameTooltip class table, same linking mechanism `frame.rs` documents. The valuable half of the namespace: addons constantly ask "what is this tooltip showing?" before annotating it. This needed **new state** — `TooltipState::subject` (`widget/kinds/mod.rs`, with a `TooltipSubject` item/spell enum): 1.12 keeps the same facts (the reference holds the item id at `tooltip+0x398`) but exposes no getter, so nothing in this client read them. Recorded at the **two renderers every verb funnels through** — `tooltip_item/render.rs`'s `render_view` (12 call sites, each now passing its own `item_id`, so the compiler enforced coverage) and `tooltip_spell.rs`'s `render_spell` — which is why `SetBagItem`, `SetMerchantItem`, `SetLootItem`, `SetHyperlink` and the rest all answer `GetItem` without each needing its own wiring; cleared with the content, so a `ClearLines` drops it. Built: `GetItem` (name, link, itemID), `GetSpell` (name, rank, spellID), `HasItem`, `HasSpell`, `GetUnit` (over the `unit_token` the stock `SetUnit` already kept), `HasUnit`, `GetOwner` (returns the real frame object through the same `frame_wrapper` `GetParent` uses), `IsEquippedItem`. Each returns *no values* rather than nils when there is no subject, the upstream's documented shape and what `if name then` branches on. Not built, each with the reason: `GetUnitGUID` — the modern getter promises the guid as of when the tooltip was set, and resolving the stored *token* now would be right most of the time and silently wrong exactly when it matters (a `"mouseover"` tooltip outliving its mouseover); `GetGameObject`/`HasGameObject` — no GameObject subject is recorded anywhere and there is no object-template cache keyed like `item_templates`; the `Set*ByID` aliases (`SetItemByID`, `SetSpellByID`, `AddSpellByID`, `SetInventoryItemByID`) — the stock verbs they alias already exist under 1.12 names and each needs its own argument-shape check against them; `SetTalentByID`/`SetEquipmentSet`/`SetTotem` — no backing (`Talent.dbc` unreachable from this crate, no equipment-set feature in 1.12 at all, no totem state). Since built: `SetHyperlinkCompareItem`, which this list had turned down on the grounds that the compare chain needs a shopping-tooltip pair — wrong, because 1.12's own merchant and auction compares drive that pair already (its own row below) |
|
||
| Quest (`C_QuestLog`) | partial | `compat/classic_api/quest_log.rs`, questID-keyed over the same log rows the stock verbs index by position — the gap being that 1.12's API never hands Lua a questID at all, so an addon can otherwise only key by an index that shifts when the log changes. Built: `GetInfo`, `GetQuestLogTitle` (the documented positional list, whose third value is `suggestedGroup` where the stock global keeps the tag — both coexist), `GetQuestIDForLogIndex` (0 for a header, nil out of range), `GetLogIndexForQuestID`, `GetHeaderIndexForQuest`, `IsOnQuest` (counts rows folded under a collapsed header, via `hidden_quest_ids`), `IsUnitOnQuest` (answers for `"player"`; false for any other token, since another unit's quest list is not tracked — the same blocker the stock `IsUnitOnQuest` already has), `GetNumQuestObjectives`. Not built: `RequestLoadQuestByID`/`IsQuestDataCachedByID`/`GetTitleForQuestID`/`GetQuestDetails` — all four probe a *standalone* questID-keyed static-info cache fed by `CMSG_QUEST_QUERY`, which benilla has none of (`QuestLogEntryView::detail` is per-log-row, so nothing is knowable about a quest not in the log, and `QUEST_DATA_LOAD_RESULT` has no sender); a cache plus its query is app-side plumbing. The bare `GetQuestLogLeaderBoardID` is also not built: `QuestLogObjectiveView` carries `kind`/`text`/`cur`/`req` but no creature/object/item id, so there is nothing to return — closing it means carrying that id along from the app's template read |
|
||
| Faction (`C_Reputation` + 3 bare globals) | partial | `compat/classic_api/faction.rs`. Bare: `GetFactionIDByIndex`, `GetFactionInfoByID` (the same eleven values `GetFactionInfo` returns, keyed by id), `GetFactionParentID`. `C_Reputation`: `GetFactionStandings`, `GetFactionDataByID`/`ByIndex`, `GetWatchedFactionData`, `SetSelectedFactionByID`, `SetWatchedFactionByID`, `ToggleFactionAtWarByID`, `IsFactionActive`/`ByID`, `SetFactionInactiveByID`/`SetFactionActiveByID`. Every setter queues onto the **same** `reputation_sends` queue the stock pane's own toggles use, so an id-keyed call and an index-keyed one are indistinguishable downstream and cannot race; a faction not in the list sends nothing rather than a wrong slot. **One scope limit, stated once in that file's header:** upstream answers for any `Faction.dbc` row including unencountered factions (DBC name + Neutral defaults), and `benilla-ui` holds no DBC, so these answer for a **listed** faction and nil otherwise — narrower reach, identical shape for anything a caller reaches by walking `GetNumFactions()`. Not built: `GetLastStandingChange` — `ReputationState` is a whole-list snapshot with no delta or history to answer from |
|
||
| FriendList (`C_FriendList`) | partial | `compat/classic_api/friend_list.rs`, named tables over `Model.social`: `GetNumFriends`, `GetNumOnlineFriends`, `GetFriendInfoByIndex`, `GetFriendInfo` (by name, case-insensitive), `IsIgnored`, `SendWhoQueryByName` (queues the stock `SocialRequest::Who`, so modern and stock `/who` share one request path), `GetNumWhoResults` (both counts), `GetWhoInfo`. Not built: `SetFriendNotes`/`ByIndex` — friend notes are a 2.x feature and `SMSG_FRIEND_LIST` carries no note field, so accepting one would make a notes UI look like it worked; `IsFriend`/`IsIgnoredByGuid` — both key on a GUID and the social lists hold names only (the app resolves guid→name before pushing, keeping no reverse map), while the name-keyed `IsIgnored` sibling is built. `IsWhoQueryPending` answers false always: the app drains the request and pushes the reply as a whole new state, keeping no in-flight marker |
|
||
| Gossip (`C_GossipInfo`) | done | `compat/classic_api/gossip.rs`, all 11. Table-per-row where 1.12 returns flat positional runs (`GetGossipOptions` strides by 2). Selection stays **index-keyed**, documented in-file: `gossipOptionID` on a modern client is a server id 1.12 never sends, so `SelectOption`/`SelectOptionByIndex` both take the 1-based position and both queue onto the stock `gossip_selects`; `SelectAvailableQuest`/`SelectActiveQuest` take a per-side position and map it to the whole-list position, the same mapping the stock verbs make. A coded option queues nothing, as the stock binding skips it. Empty tables never nil, so `getn` is always safe |
|
||
| MerchantFrame (`C_MerchantFrame`) + Mail link getters | partial | `compat/classic_api/merchant_frame.rs`. Real: `GetItemInfo` (named table over the merchant row), `GetBuybackItemID` (the stock buyback verbs answer name/texture/price but never the id — the whole gap), `GetNumJunkItems` (grey stacks in bags 0-4, real because `ContainerSlot` tracks quality). Honest constants rather than stubs: `IsMerchantItemRefundable` → false (refunds are 4.x; 1.12 has buyback instead, already exposed), `IsSellAllJunkEnabled` → false and `SellAllJunkItems` a no-op (later-era vendor feature, so a UI gating a button on it correctly hides it rather than selling nothing while the caller believes otherwise). **Also closes two SuperWoW rows:** `GetSendMailItemLink` (real per-instance link off the attached cursor item) and `GetInboxItemLink` (the *basic* itemID-only form, which is what upstream specifies here deliberately — built from the row's `item_id`+`item_name`, since the app pushes links for bag/bank/cursor items but not inbox ones). Both take the optional `attachmentIndex` and answer nothing for anything but nil/1, as 1.12 mail holds one attachment |
|
||
| CVar (`C_CVar`) | done | `compat/classic_api/cvar.rs`, all 8, over the same registry the stock `GetCVar`/`SetCVar`/`RegisterCVar` drive (`script/cvars.rs`). `GetCVarInfo`'s seven returns; `DoesCVarExist` (true exactly when `GetCVar`/`SetCVar` work, which is upstream's own stated invariant and a test pins it); `AreCVarsLoaded` (always true, as upstream documents and for its reason — settings load before any Lua exists); `GetCVarBool` on upstream's documented coercion table (`"true"` case-insensitive, any non-zero numeric, everything else false, nil for no row so false and absent stay apart); `GetCVarBitfield`/`SetCVarBitfield` as real 1..=64 bit arithmetic routed back through the stock `write_cvar`, so a bit write queues to the host and dirties the config exactly as a `SetCVar` would. `isReadOnly` answers `false`, and that is honest rather than a stub: `write_cvar` refuses only a name with *no row*, never a registered one, so `SetCVar` really does work on every CVar this reports on — a fork that later makes some row refuse writes should flip this to read that. `SetTempCVar`/`RemoveTempCVar` are **fully correct, not just Lua-observable**: a new `CvarSlot::temp_original` remembers the pre-temp value and `UiScript::cvars_snapshot` reports *that* to the saver, so a temp override is live for `GetCVar` yet never written to `config.toml` — which is the whole contract of a temp CVar. Not a 1.12 concept at all (the reference has no temp CVar) |
|
||
| NamePlate (`C_NamePlate`) | done | `compat/classic_api/nameplate.rs`, all 4, over the plate pool benilla already drives (`script/nameplate.rs`'s `NamePlates`, fed by `vplates.rs`). The GUID↔frame mapping existed already for SuperWoW's `frame:GetName(1)` (`guid_for_frame`); this adds its two inverses, `NamePlates::live` and `frame_for_guid`. `GetNamePlates`/`GetNamePlateGUIDs` answer in pool order and pair by index (a test walks both lists and matches each frame to its own GUID); `GetNamePlateForUnit` resolves through `Model::unit`, so SuperWoW's extended token grammar (raw GUID, `markN`, `<token>owner`) works as a unit argument here too; `GetNamePlateForGUID` takes the same 16-hex-digit token the rest of `compat/` passes around. One stated deviation: modern WoW hands back a *container* whose `.UnitFrame` child is the plate, because its nameplates are FrameXML-driven — benilla's are engine frames with no wrapper, so these return the plate itself and an addon reaching for `.UnitFrame` finds nil rather than a fake child. `GetNamePlates`' `includeForbidden` argument is accepted and ignored: 1.12 has no protected-frame split to filter on |
|
||
| Macro (`C_Macro`) | partial | `compat/classic_api/macro_ext.rs`: `GetMacroIcon`, `CreateMacro`, `EditMacro`, over the same table the stock macro globals drive (`script/macros.rs`). Upstream's stated reason for the namespace only half-applies here — its complaint is that stock's icon argument is an *index* into a list holding no `INV_*` item icons, but benilla's own `icon_path` already took a string or an index, so a string icon was never refused. What these genuinely add is the signature: `CreateMacro`'s fourth argument is `isCharacterMacro` where the stock global's fourth is `localOnly` and per-character is its *fifth*, and `EditMacro` accepts a macro **name** in place of an index. `GetMacroIcon` re-adds the `Interface\Icons\` prefix on read, with the one deviation stated: upstream answers the icon a macro *shows* (the resolved spell's or item's when a `#showtooltip` resolved over a question-mark icon), where this is always the macro's own stored icon — upstream's own documented fallback. Not built, each for a reason in that file's header: `GetMacroSpell`/`GetMacroItem` (both answer for a resolved `#showtooltip`, backed upstream by a spellID cache the editor fills at save time; benilla parses no directive, keeps no such cache, and resolving a body's first `/cast` to `(name, rank, spellID)` needs the rank-aware spellbook lookup `C_Spell`'s row already notes is unbuilt), `StopMacro` (stops the body *running now*, and benilla executes no macro bodies at all — no `RunMacro`, no depth counter, nothing to stop), `C_Macro.SetMacroDisplay` (no per-macro action-button display override is tracked), and the four `Get*MacroIcons` enumerations (they split `loose` on-disk icons against `mpq` archive ones; `Model::macro_icons` is one flat `Vec<String>` with **no provenance**, so that axis cannot be answered at all, and splitting on basename alone would silently under-report whichever source the host did not push) |
|
||
| Action (`GetActionInfo`) | done | `compat/classic_api/action.rs`, one bare global, over the same action table the stock `GetActionTexture`/`GetActionCount`/`IsConsumableAction` read (`script/action.rs`'s `Model::actions`). Upstream unpacks a `uint32` type tag and then has to disambiguate its one ambiguous tag (macro-or-bag-item) by walking the macro-slot map; benilla needs none of that, because the host pushes `ActionSlot::kind` already separated into the three `ACTION_KIND_*` bytes — the tag is read, not inferred, and that ambiguity does not exist here. One place this is *better* than upstream: upstream returns `"item", nil` for a bag-instance reference because extracting the id "needs more reversing than we've done", where benilla's slot carries the resolved id for every kind. `subType` is `"spell"` only for a spell action, as upstream documents |
|
||
| `GameTooltip:GetUnitGUID` | **done — a deliberate reversal, with a new mechanism** | `compat/classic_api/tooltip.rs`, the 9th reader method. The `GameTooltip (reader methods)` row above lists this as *not built*, and the objection recorded there was and is correct: a unit *token* is resolved against the live snapshots per call, so re-resolving `TooltipState::unit_token` at read time answers for whatever that token names **now** — right most of the time and silently wrong exactly when it matters, because a `"mouseover"` tooltip routinely outlives its mouseover. What changed is not the standard but the mechanism: the guid is now **recorded beside the token at `SetUnit` time** (new `TooltipState::unit_guid`, `widget/kinds/mod.rs`; written by `tooltip_unit.rs`'s `set_live_token`, cleared by `tooltip/mod.rs`'s `clear_content` with the rest of the content). The getter therefore hands back a set-time fact and resolves nothing, which *is* the modern getter's promise — and the stale-token case it used to be wrong about is now the case it is most right about. Shape pinned by the live call site, not by memory: pfUI's `modules/tooltip.lua:81` is `local _, guid = GameTooltip:GetUnitGUID()` feeding `UnitTokenFromGUID`, so it returns `(name, guid)` — `GetUnit()`'s two-value shape with the guid in the token's place, and **no third value**, since nothing pins a third position. The *name* is resolved from the guid (through `Model::unit`'s SuperWoW raw-guid arm), not from the token, so the pair always describes one unit; a guid nothing can reach any more answers the guid with no name, because the identity outlives the snapshot. Nothing at all when `SetUnit` saw guid 0, which leaves `HasUnit()` true and is what pfUI's own `guid and UnitTokenFromGUID(guid)` reads. Built because the absence cost **14 errors in one play session** — that call site is on the tooltip's `OnShow`, so it fires on every unit hover. The test fails against the rejected mechanism, not merely against absence: `get_unit_guid_keeps_answering_for_the_unit_set_unit_saw_after_the_token_moves_on` |
|
||
| `INVSLOT_FIRST_EQUIPPED` / `INVSLOT_LAST_EQUIPPED`, `GetInventoryItemDurability` | done | `compat/classic_api/item.rs`'s new `install_globals` — bare globals, which is how upstream's own list spells them (it writes `C_Macro.CreateMacro` in full and leaves these unqualified). The two constants are **1 and 19**, read off this tree rather than a later client's headers: `char_stats.rs`'s `InventorySlots` is documented "0 ammo, 1..=19 equipment, 20..=23 the equipped bags", its `SLOT_INFO` is `PaperDollItemFrame.dbc`'s own table with `HeadSlot` 1 and `TabardSlot` 19, and `item.rs`'s existing `inventory_slot_token` enumerates the same 19. Neither name is 1.12's (`reference/1.12-globals.tsv` has no `INVSLOT_*` row at all — 1.12 spells each slot as `GetInventorySlotInfo("HeadSlot")`), and their absence was a session's **first** error: pfUI uses them as `for` bounds at module scope four times (`modules/panel.lua:414`, `:445`, `modules/itemcount.lua:7`) and a nil bound raises "'for' initial value must be a number", taking the enclosing block with it. The rest of the `INVSLOT_*` family is deliberately left out: nothing installed asks, and 1.12 already answers each slot by name. `GetInventoryItemDurability(slot)` is the one member of the `GetInventoryItem*` family 1.12 lacks — the reference ships the `GetInventoryItemBroken` predicate and never the numbers behind it — and needed **no new feed**: `InvSlotView::durability` already carries `ITEM_FIELD_DURABILITY`/`ITEM_FIELD_MAXDURABILITY` (fed by `ui_char.rs`'s `slot_view`), which the item tooltip's "Durability X / Y" line and the doll's red tint already render. Slot-only, no unit argument, as the modern function and every pfUI call site have it. Three answers kept distinct: **no values** for an empty slot (what `if cur and max then` branches on), the live pair for a damageable item, and **`(0, 0)`** for a worn item with no durability at all — the feed stores that as `None`, so it is unfolded here, because a worn ring is not an empty finger |
|
||
| `GetSpellBonusDamage` | done | `script/char_stats.rs`, beside the `UnitDamage` that reads the same wire field. A school's spell power: 1.12 has no character-sheet line for it and no global to ask, which is why it reads as a later-client function — but **the number is already streamed**. A vanilla server folds item spell power into `PLAYER_FIELD_MOD_DAMAGE_DONE_POS`/`_NEG` (`_ApplyItemBonuses` → `ApplySpellDamageBonus`), the very field `UnitDamage` already reports index 0 of as the physical pos/neg pair, so schools `[1..7]` were arriving and being dropped at the feed: `ui_char.rs` read `player_mod_damage_done_pos(0)` alone. The snapshot now carries `damage_bonus_pos`/`_neg` as `[i32; 7]` (the shape `resistance_pos`/`_neg` already use) and `UnitDamage` takes `[0]` of it, so there is one model of the field rather than a scalar and a derived duplicate. **Why it mattered**: pfUI's player module polls schools 2..7 every 0.25s from its throttled `OnUpdate` to draw a "+N SP" readout (`modules/player.lua:92`), so on a caster the missing global raised *per tick* — 433 runtime errors in one logged session on a fresh warlock, the single largest error source in the log, and invisible on a warrior because the readout resolves to 0 and never shows. Schools are 1-based (1 physical … 7 arcane) and anything off that range answers **0, not nil and not a raise**, which is what the call site's own `or 0` assumes. Its sibling `GetSpellBonusHealing` stays **unbuilt and is not approximable**: vanilla has no healing player field at all — 0x40A is `PLAYER_FIELD_BYTES`, the healing-done fields are a 2.0 addition — so there is nothing on the wire to read, and the number would have to be summed from equipped item stats. Nothing installed calls it. `UnitSpellHaste`, which the same pfUI function calls one line earlier, stays `todo` for the reason its own row gives (no haste stat exists in 1.12); it is reached only when that module's haste display is switched on, which this player's config has off |
|
||
| `GetAddOnEnableState` (the legacy global) | **deliberately not done — checked, not assumed** | Upstream's own `docs/API.md` AddOns section lists the ten entries above and **not** this one, and keeps 1.12's `GetAddOnInfo` with `enabled` 4th. So the DLL actually loaded on this install does not define it either: adding it here would be a *divergence from the player's own client*, not parity with it. It would also break the one addon that asked for it. `AI_VoiceOver/Compatibility.lua:121` gates **two** definitions on one `if not GetAddOnEnableState` — its own enable-state fallback *and* a modern-shaped `GetAddOnInfo` that drops the `enabled` return. Define the global and VoiceOver keeps 1.12's 8-tuple while reading it at modern offsets, so `VoiceOver.lua:84`'s `select(5, GetAddOnInfo("VoiceOver")) ~= "MISSING"` reads `loadable` where it means `reason`, is true for a folder that is not installed, and raises the "delete your old VoiceOver folder" popup every login — twice, `AI_VoiceOver_112` too. Recorded because this was first diagnosed the other way round: the three `AI_VoiceOverData_*` modules look unloaded in the log and are not. They load inside AI_VoiceOver's own `ADDON_LOADED`, 224 ms of it, before the walk ever reaches their rows — and the walk's `is LoadOnDemand — not loaded (no LoadAddOn() yet)` line used to be printed unconditionally, so it went on saying "not loaded" about addons already in memory. That line is now gated on `UiScript::addon_is_loaded` |
|
||
| `C_Item.GetItemInfo` | done, delegating | `compat/classic_api/item.rs`. Calls the **stock 1.12 global** (`script/item_stats.rs`), which owns the template cache and the `CMSG_ITEM_QUERY` warm an uncached id records — so a modern caller feeds the one shared cache and a hook over the stock global is honoured, same discipline as `C_AddOns`. The shape is modern WoW's, by exactly the transform `item_stats.rs`'s own comment already names ("later clients insert `itemLevel` at 4"): returns 1..3 pass through, **position 4 is nil**, stock's 4..9 land at 5..10. Nil rather than a number because item level is not in `SMSG_ITEM_QUERY_SINGLE_RESPONSE` and 1.12 exposes no such concept (the same reason `GetCurrentItemLevel` stays unbuilt) — a fabricated 0 would read as a level-0 item where nil reads as "not known here", which a caller can tell apart. Modern positions and not 1.12's because a live call site against the real DLL pins them: pfUI's `modules/bags.lua:457` reads `local _, _, q, _, _, _, itype` and tests `itype == "Quest"` at `:475`, and position 7 is the subType *string* in the modern run against the stack *count* in 1.12's — forwarding the nine verbatim would have left that test silently never firing. Same evidentiary standard as the `ColorMixin` row below. Stops at 10: modern's 11th onward (sellPrice, classID, subclassID, bindType, …) are facts this client holds, but nothing available pins their *order* and each already has its own `*ByID` entry, so they are left off rather than placed from memory |
|
||
| `IsSpellKnown` (bare) | done | `compat/classic_api/spell.rs`'s new `install_globals`, matching upstream's own heading "Spell (`C_Spell`, plus several bare globals)". It hands out **the same function value** `C_Spell.IsSpellKnown` holds, lifted out of that table rather than written twice — so the two spellings cannot drift and there is no second body to maintain (`the_bare_is_spell_known_is_the_very_function_c_spell_holds` asserts the identity, not just the behaviour). Not a 1.12 name: 1.12 asks "do I know this" only by walking the book with `GetSpellName(i, "spell")`, never by id. pfUI's `modules/bags.lua:703` calls it bare, 3 occurrences in one session |
|
||
| `GetMacroSpell` (bare) | done — **the "not built" note in `macro_ext.rs` was wrong about this client** | `compat/classic_api/macro_ext.rs`'s `install_globals`. The triage item that produced this said `C_Macro.GetMacroSpell` already existed and only needed exposing; it did not exist anywhere, and that file's header gave a reason — "resolving a body's first `/cast` line needs the rank-aware spellbook lookup `C_Spell`'s row notes is unbuilt". **Both halves do exist and are already load-bearing.** `benilla-app`'s `ui_macro/run.rs` is a traced reimplementation of the reference's own macro→spell derivation (`0x4efe00` writing `[rec+0x564]`, reading the `SLASH_CAST%d` aliases as Lua globals exactly as `0x703bf0` does, plus a `CastSpellByName(` arm) and resolves through `spellbook.rs`'s `resolve_spell_by_name`, the same resolver `CastSpellByName` and `/cast` use; the action bar has been reading its result for a macro slot's cooldown, usability and range all along. So this is **not new derivation** — it is the existing one, fed into the VM as `Model::macro_bound_spells` by the existing `rebind_macro_spells` system (no new system, no new file, no ceiling movement) and read back by id through the same `spell_display` `C_Spell.GetSpellName` uses. Returns `(name, rank)`, two values only: pfUI reads `effect, rank = GetMacroSpell(id)` (`libs/libpredict.lua:1027`) and compares the first to a spell name (`modules/swingtimer.lua:430`), so a bare-spellID return (what the newest clients give) would fail both, and no third value is appended because nothing pins its position. Rank is a string, `""` when rankless, the rule the stock `GetSpellName` already keeps. Only resolved spells cross the feed: `BoundSpell::None` and `BoundSpell::Unresolved` are both "no spell" to Lua, which has one way to say it, while the bar needs them apart. Stated latency: one frame after an in-Lua `EditMacro`, the identical delay the action bar already has, from the identical source. `GetMacroItem` stays unbuilt — the reference's macro record binds a spell and has no item counterpart to derive |
|
||
| `GetInventoryItemRepairCost` | todo, blocked | pfUI reaches it in the *hover* arm of the durability panel widget the two `INVSLOT_*` constants above unblocked (`modules/panel.lua:416`), guarded as `(GetInventoryItemRepairCost(id) or 0)` — which does not help, since a nil *global* raises before `or` runs. Deliberately not built, and not stubbed to 0: 1.12 computes repair cost **server-side only** and the client never receives or derives a per-item figure (the same blocker already recorded for `C_Container.GetContainerItemRepairCost`, `container.rs`'s header). `0` would read as "this item repairs free", which is a wrong answer a caller cannot detect, where a nil-call error names the function. The client-side repair numbers that *do* exist are the merchant window's own `CanMerchantRepair`/`GetRepairAllCost`, which are a different fact (the whole-inventory total at an open vendor) and cannot be divided back into per-slot costs |
|
||
| `C_Container.GetContainerItemCharges` (restated, measured) | todo, blocked — now the harness's first session error | No change made; recorded because fixing the `INVSLOT_*` `for` bound moved this to the front of the queue. `container.rs` installs it returning nil by the stated decision (`item_spell_charges` is a raw signed field and no item-template column says which of an item's five spell-trigger slots is a consumable charge rather than an equip/proc effect — the same blocker as SuperWoW's charges extension above). pfUI's `modules/bags.lua:452` then does `if charges > 1`, which raises "attempt to compare number with nil" and is now the harness's first session error for pfUI, two lines **above** the `C_Item.GetItemInfo` call this pass fixed. It did not appear in the live session log at all (bag stacks there were not 1, so the guarded block was skipped) — the harness's synthetic bag fixture is what reaches it. Unblocking it needs the item-template charge column, not a change here |
|
||
| `SetScript("OnInputLanguageChanged")` refusal | **examined and deliberately kept** | Not a DLL feature and not an addon's call at all: both occurrences in the live session (`errors…18-53-31.log`, world entry and `/reload`) are **stock FrameXML** — `ChatFrameEditBox` in `FloatingChatFrame.xml`, off `ChatFrame.xml:121`'s script slot. `object/events_regions.rs`'s `set_script` raises for real 1.12 script kinds nothing fires, by a stated decision ("an accepted handler that never runs fails silently"), and `OnInputLanguageChanged` is named in that comment. Accept-and-ignore was weighed and rejected: the reference accepts it because the reference has an IME to fire it, benilla has none, and silently installing a handler that can never run is the exact failure that comment exists to prevent. It is already recorded as a **warning**, not an error, and two tests pin it by name — `world_entry_tests.rs`'s `a_clean_world_entry_raises_only_the_warnings_we_have_named` (where it is one of exactly two named deviations) and `script/tests/input.rs`'s `the_unfired_script_kinds_still_raise_rather_than_silently_accepting`. The name leaves that list when something fires it, not to quiet a log line |
|
||
| ~60 namespaces, remaining ~515 functions, 50+ events | todo | full per-namespace function list fetched from the upstream repo's own `docs/API.md`: `docs/classic-api-functions.md` at this repo's root — re-fetch upstream before resuming, that file is a point-in-time names-only snapshot. One file per namespace under `compat/classic_api/` (`container.rs` done; `spell.rs`/`spellbook.rs`/`unit_auras.rs`/`state.rs` may be in progress or done by the time this is read — check their own status before re-doing them) |
|
||
| `ItemLocation`, `ItemLocationMixin` | done | `compat/classic_api/item_location.rs`, a Lua chunk over the existing `CreateFromMixins`. **The single largest error source measured in a real session: 12,570 lines**, one `attempt to index global 'ItemLocation'` per bag slot per `OnUpdate` pass of pfQuest's quest-item watcher (`database.lua:207` walks five bags, `:216` all 19 equipment slots). `item.rs` was already designed for this — its header states that the mixin is pure sugar over `{bagID, slotIndex}` / `{equipmentSlotIndex}`, that the plain table *is* the contract, and that the constructors "belong with `Mixin`/`CreateFromMixins`"; this is that deferred half, so nothing in `item.rs` changed and a hand-built table still works. Every member has backing and all are built: the two setters, `Clear`, `IsBagAndSlot`/`IsEquipmentSlot`/`HasAnyLocation`, `GetBagAndSlot`/`GetEquipmentSlot`, `IsEqualTo`, and `IsValid` — which is literally `C_Item.DoesItemExist(self)`, so it cannot disagree with the `GetItemName` on the next line. Three shapes are load-bearing and pinned: the constructors are defined with `:` (every installed call site writes `ItemLocation:CreateFromBagAndSlot(...)`, and so does Blizzard's own `ItemLocation.lua`); `IsBagAndSlot` compares against **nil**, not truthiness, because the backpack is bag `0` and pfQuest's loop is `for bag = 4, 0, -1`; and the setters clear the other kind's fields, because `item_loc` tests `equipmentSlotIndex` first and a half-cleared object would resolve to the wrong slot. Not built: `IsEqualToBagAndSlot`/`IsEqualToEquipmentSlot` (nothing calls them, and `IsEqualTo` plus a constructor is the same answer) |
|
||
| `C_CreatureInfo.GetClassInfo` + a new `ChrClasses.dbc` feed | done | `compat/classic_api/creature_info.rs`, closing the gap that row's own "Not built" list named. **Costliest absence in the surface by blast radius rather than by count**: pfQuest calls it at *file scope* inside `for id = 1, 32` (`database.lua:408`, building `pfDB.bitclasses`), so the nil call aborted `database.lua` there and **every `pfDatabase` function defined below line 408 was never defined at all** — the session log's separate `GetBitByRace` (`:609`), `AddCustomIcon` (`:1897`) and `GetQuestIDs` (`:1928`) failures were all this one abort, not three gaps, and the addon harness ranked it as one of only two first-load errors in the whole corpus. Built as a feed, not a table: `benilla-formats`' `chr_classes.rs` gains its name block's enUS column (field 5) and the class file token (field 14) beside the three columns it already read, `model.rs` gains `ClassInfo`/`class_info`, `script/class_info.rs` the 19-line `set_class_info`, and `benilla-app`'s new **`ui_chr_classes.rs`** the `PostStartup` push — the `ui_*` name because `ui_script_wall.rs` only lets that family name `UiScript`, and a second file because `chr_classes.rs` loads the same table for three engine readers and must not learn the VM exists. Read rather than carried for `ChrRaces`' reason: a patched realm owns the display name, and `classFile` is an independent column — a test pins both against the shipped DBC and confirms fields 5 and 14 by their nine-rows-with-gaps-at-6-and-10 shape. **Stated rather than glossed**: on this shipped enUS table the two columns *coincide*, `name:upper() == classFile` for all nine rows, and a test asserts that too — so a feed that derived the token from the name would pass every real-data assertion and go wrong only on a renamed or localized realm. That is why the app-side test uses a deliberately renamed row (the class-side shape of `ChrRaces` row 10's "High Elf"/"BloodElf"): without it the property is untested, which the discrimination pass caught. **Nil for a gap is load-bearing**, differently from `GetRaceInfo`'s nil: ids 6 and 10 carry no row in 1.12, pfQuest walks straight through them, and borrowing a neighbour would key `bitclasses[2^5]` to the wrong class rather than leave it empty. No `Update` system, so no `UPDATE_ACTIONABLE_CEILING` change |
|
||
| `TableUtil.Execute` | done | `compat/classic_api/table_util.rs`. **Not a ClassicAPI function and not 1.12's** — `docs/classic-api-functions.md` has no `TableUtil` at all; it is Blizzard's own FrameXML helper (`Blizzard_SharedXMLBase/TableUtil.lua`, 58 functions) and this install's pfQuest calls one of them at **file scope** (`map.lua:21`, renaming its minimap pins for Gatherer-compat). Line 21 of a 2000-line file, so the nil index aborted `map.lua` and `pfMap` was never defined — which is the whole of the harness's other first-load error (`pfQuest-octo/patchtable.lua:137`) and the two `pfQuest.ResetAll` failures under input (`quest.lua:616`). One function closes all of it. The body is **upstream's, fetched not remembered** (Gethe/wow-ui-source `live`): it walks `pairs`, not `ipairs`, and the callback takes the **value only**, never the key — two details the one installed caller cannot distinguish (it passes an array and reads one argument), which is exactly why the source was read instead of inferred. Tests pin both against upstream's body rather than against the call site. The other 57 members are not installed: nothing calls one, and several (`CopyTable`, `tInvert`, `tFilter`, `tContains`) collide with names the addon corpus and the stock chain already define |
|
||
| `table.wipe` | done | `compat/classic_api/lua_ext.rs`, **aliased** to the bare `wipe` `script/stdlib.rs` already defines — literally the same function value, so the two names cannot drift. That matters because `wipe`'s body is not a `pairs` clear: it walks `tremove` down from `getn` to reset Lua 5.0's *remembered table size*, which a second copy would get wrong and a refilled list would then read holes out of. pfUI's nameplate module calls `table.wipe(plate.cache)` on every plate refresh (`modules/nameplates.lua:1023`) and with only the bare name it raised **2,859 times in one session**, stopping the plate styling mid-refresh — the module was running (the `NAME_PLATE_*` events had just started firing), it simply could not finish. A test asserts identity (`table.wipe == wipe`), not merely presence, and was verified to fail without the alias |
|
||
| `UnitCreatureTypeID` | done | `compat/classic_api/unit_extra.rs`, over new `UnitState::creature_type_id` — `SMSG_CREATURE_QUERY_RESPONSE`'s `CreatureType`, which `ui_unit.rs` was already receiving and discarding after converting it to the localized word. Both are carried now: the word is what 1.12's `UnitCreatureType` answers, the number is what this answers, and deriving either from the other would mean a reverse lookup through a localized string — wrong on any non-enUS realm. **The argument is a GUID, not a token**, in its only caller (`pfUI/modules/nameplates.lua:295`), which resolves through the SuperWoW raw-GUID arm so neither form needs a special case. This supersedes the earlier `todo` that listed it as needing data the client lacked; the number was always there. **Why it mattered visually**: the call sits in `TotemPlate` → `CreatureType`, reached from the plate refresh at `:1111` — *above* the whole block that `SetPoint`s and `Show`s `plate.name`/`level`/`health`/`glow`/`guild`. So every refresh aborted before laying its own art out, and the user saw plates that were styled-but-misaligned with wrong colours rather than unstyled. Its own comment is `-- nil retries next call`, so it re-raised per plate per frame: 1,014 times in one session |
|
||
| `table.unpack` | n/a — not reached | 5.0's table library has eight members and this is not one, so it is genuinely absent. Left that way: its only call sites are in `pfQuest/toolbox/extractor.lua`, a data-extraction dev tool **not listed in pfQuest's `.toc`**, which never loads in play. Recorded so the next sweep does not re-flag it |
|
||
| `PlayerUtil.GetClassFile` | done | `compat/classic_api/player_util.rs`. The **second** of two walls in pfQuest's `database.lua`, found by re-running the harness after the `GetClassInfo` row above cleared the first: the abort moved 478 lines down, from `:408` to `:886`'s module-scope `local isdruid = PlayerUtil.GetClassFile() == "DRUID"`. Both are needed for the measured cascade — with only the first fixed, `GetBitByRace` (`:609`) exists but `AddCustomIcon` (`:1897`) and `GetQuestIDs` (`:1928`) still do not, and `icons.lua` still dies on the first of those. Not a ClassicAPI function either (`PlayerUtil` is Blizzard's own FrameXML helper, absent from `docs/classic-api-functions.md`). **Unlike `TableUtil.Execute`, the body here is not upstream's verbatim** — every path to `PlayerUtil.lua` 404s and `warcraft.wiki.gg`'s FrameXML index lists the name with no signature — so it is built on a *forced* reading rather than a fetched one: the namespace means the player so it takes no argument, "class file" is the engine's own word for the locale-independent token, and this client already answers that exact fact as `UnitClass("player")`'s second return. It reads the same `PlayerRecord::class` pair that binding reads, so the two cannot drift, and a test asserts they agree. nil before the record lands, which is both what `UnitClass` answers then and what the `== "DRUID"` call site needs. The other eleven members are unbuilt: four spec getters plus `CanUseClassTalents` describe a specialization system 1.12 has none of, and the rest would be second spellings of `UnitClass`/`RAID_CLASS_COLORS`/`C_CreatureInfo.GetClassInfo` |
|
||
| Map (`C_Map`) | partial | `compat/classic_api/map.rs`, the 3 of upstream's 27 that read data already in this VM, over the world-map catalog `ui_world_map.rs` pushes once at world entry — **no new feed and no second DBC read**, so these cannot disagree with what the map window itself draws. This supersedes the `C_Map.GetMapOverlays` entry in the "ClassicAPI tail" row above. The error count (19) badly understates this one: pfQuest's modified copy has **deleted** its shipped zone-size, subzone-placement and area-trigger tables in favour of these calls (`db/minimap.lua`, `db/zones.lua`, `db/areatrigger.lua` are now stubs saying so), and almost every call site is guarded with `if C_Map and C_Map.X then`, so an absent namespace degraded *silently* into a pfQuest with no zone sizes and no subzone index. Only pfUI's is unguarded (`modules/mapreveal.lua:119`), and that is where the 19 came from. Built: `GetMapWorldSize(areaID)` → the `WorldMapArea` loc rect's two spans in yards, `left - right` across and `top - bottom` down, the same pair `worldmap.rs`'s own `area_grid_cell` un-lerps hovers through and the order pfQuest divides by (`map.lua:1367`); it answers **no values** for a degenerate rect rather than a truthful `0`, because the caller's next line is `mapZoom / mapWidth` and an infinite scale stacks every pin in one place. `GetMapAreaIDs()` → `{ [mapFolder] = areaID }`, the first reader of a `WorldMapZoneView::area_id` column nothing had ever read, keyed by `GetMapInfo()`'s own string because a city or instance map has no zone-dropdown index to look itself up by. `GetMapOverlays([areaID])` → **every** `WorldMapOverlay` row of the zone, explored or not, which is the one thing separating it from the stock `GetNumMapOverlays`/`GetMapOverlayInfo` pair (those are the *revealed* list, gated on `PLAYER_EXPLORED_ZONES_1`) — pfUI diffs the two to find what is still fogged, and a test asserts they disagree on an unexplored zone. That needed two new `WorldMapOverlayView` fields: `texture_name` (the bare DBC leaf, carried rather than split back out of the path so `textureName` and `texturePath` are the same string by construction) and `area_id` (slot 0, the sub-area the overlay *is*, as against `explore_bits`, which is all four slots' reveal bits). Nil, not an empty table, when no zone resolves — a zone with no overlays and no such zone are different facts and both callers branch on it anyway |
|
||
| `C_Map.GetAreas`, `C_Map.GetAreaTriggerInfo` | todo — feed shape known | Both are what pfQuest deleted its own tables for, so both are silent functional loss rather than errors, and neither is guessable. `GetAreas()` wants `{ [areaID] = name }` for the **whole** `AreaTable.dbc` (~2700 rows); the model holds only the ~80 rows that have a `WorldMapArea` map, because that is all the map window needs. `AreaTableCatalog::rows` already exists app-side (`benilla-formats/src/area_table.rs`) and `benilla-app`'s `AreaTableRes` is already a live resource, so the work is exactly the `ui_chr_classes.rs` shape this pass just built: one `BTreeMap` feed and a `PostStartup` push. `GetAreaTriggerInfo(id)` wants `AreaTrigger.dbc` **plus a projection**: the caller reads `areaID`, `mapX` and `mapY` (`database.lua:790`), which means resolving the trigger's world position through the `WorldMapArea` rects — `AreaTriggers` is loaded app-side (`area_trigger.rs`) and never reaches the VM, and the projection is `map_proj`'s job, not Lua's. Not built here because each is a new feed and this pass's budget went to the error-producing members; the remaining 22 (nine waypoint members, the layered-art trio, the four-member projection family) are later-era or app-side by nature — except `GetMapInfo` and `GetPlayerMapPosition`, which are 1.12's own globals and already exist bare (`script/worldmap.rs`), so a `C_Map.` spelling of either is an aliasing decision rather than a gap and nothing in the corpus asks for one |
|
||
| `C_PlayerInfo.CanUseItem` | done | `compat/classic_api/player_info.rs`, superseding that member's entry in the "ClassicAPI tail" row above. It is the "would the tooltip print this item's requirements in red" gate, and that gate already existed here as exactly one function — `item_stats::item_usable_by_id`, the port of the reference's `0x5ea930` (required level, class, race, proficiency, skill + rank, required spell, honor rank, city rank, reputation standing). So this is the **same function value** `C_Item.IsUsableItem` answers from, under the second name upstream also publishes it under; a test asserts the two agree, because pfUI tints a bag slot from one and its tooltip red lines come from the other, and a second implementation that disagreed would red-tint slots whose tooltip is black. pfUI's own call-site comment (`modules/unusable.lua:18-21`) names the requirements-not-condition split and says it depends on it, which is what `item_usable` already implements. A bad argument answers **false**, not a raise — the hook fires per bag slot — and deliberately not the gate's own "an unpushed player declines to judge, answer usable" default: an unresolved player genuinely cannot be judged and greying the whole bag at login would be worse, but a nonexistent item id is not a judgement call. The namespace's other nine are unbuilt: nothing installed calls one, and the five getters take a modern `PlayerLocation` object whose facts (another player's class by guid, their connected state) are a name cache this VM is not fed |
|
||
| `nameplate<N>` / `nameplate<N>target` unit tokens | partial — **recognised, deliberately naming nothing** | `script/unit/mod.rs`'s `classic_api_token_recognised` now takes a `"nameplate"` prefix beside `"focus"`. 206 `Unknown unit name: nameplateNtarget` lines in one session, and the cost was not the lines: pfUI's aggro scanner walks its whole `pfValidUnits` table — which `env/tables.lua:48-49` fills with `nameplate1..40` **and each one's `target`** — calling `UnitExists` on every entry (`api/unitframes.lua:186`), and an unrecognised token raises, which longjmps out of `GetAggroState` entirely. So each of those 206 lines was also an aggro scan that never reached its *party and raid* tokens. Recognition alone fixes that and is the same answer the reference gives for `"raid5"` solo. **Resolution is deliberately not built, and this is the honest half of the row.** Three separate things block it, measured not assumed: (1) `Model::unit` can only turn a GUID into a `UnitState` that some *other* token already names (`unit_by_guid` scans `units_by_lower`), and no feed pushes a snapshot per plate — so `nameplate1` would answer only for a plate that is also your target or a party member, i.e. almost never, and unpredictably; (2) `NamePlates::live()` compacts retired slots out, so its dense rank is **not** slot-stable — a plate's index shifts when an earlier plate dies, and modern WoW's token is slot-bound, so building on `live()` would make `nameplate1` mean a different unit frame to frame; (3) `<token>target` needs the plate unit's `UNIT_FIELD_TARGET`, the same hop the `focustarget` comment in `Model::unit` already records this crate cannot make. A token that names the wrong unit is worse than one that names none, so these name none. What it would take: a per-plate `UnitState` feed (`ui_unit.rs`'s `token_snapshot` is the ready-made builder, but `feed_units` is at Bevy's 16-`SystemParam` ceiling and would need a sub-struct first) and a new `NamePlates` accessor exposing the **pool slot** rather than the dense rank. Note the pool's order is arrival order, not distance: the one sort in the pipeline (`vplates.rs:461`, screen-space distance from a fixed gx point) exists for the overlap solve and is discarded before `sync()` |
|
||
| Newly surfaced by the above: `Item`/`ItemMixin`, `GameTooltip:GetGameObject` | todo — measured, not guessed | Both were **masked** before this pass and are reported here so the harness's numbers are readable. Clearing pfQuest's two `database.lua` walls (`:408`, `:886`) moved its abort to `:2241`, `local item = Item:CreateFromItemID(HEARTHSTONE_ITEM_ID)` followed by `item:ContinueOnItemLoad(...)` — modern WoW's `Item` mixin, which pfUI also reaches for (the harness counts `CreateFromItemID` and `ContinueOnItemLoad` at two addons each). **That line is 5 lines from the end of a 2246-line file and no function definition follows it**, so the residual cost is only the locale name-check bootstrap in that last block — every `pfDatabase` function now exists, which is the whole point of the two rows above. Not built because `ContinueOnItemLoad` is an *async* data-load callback and `item.rs`'s header already states why that family is out: `RequestLoadItemData*` needs an app-drained queue that does not exist, and a `ContinueOnItemLoad` that fired synchronously off a cache miss would call back with no data. Separately, `map.lua` now loads, which makes `map.lua:296`'s `GameTooltip:GetGameObject()` reachable for the first time — that is why the harness's pfQuest input-raise count went **2 → 7** while its load errors went 2 → 1: the same one unbuilt method, now hit by a tooltip hook that previously never ran. It is a *widget method* (so `tests/widget_surface.rs`, not the global list) and already carries its reason in `tooltip.rs`: no GameObject subject is recorded at any renderer and there is no object-template cache keyed like `item_templates`. Honest net: pfQuest went from dying at 19% of `database.lua` to dying at 99.8% of it, and from `map.lua` not loading at all to loading and then raising on one tooltip hook |
|
||
| `C_Item.GetItemUniquenessByID` | todo — **the fact is here, the signature is not** | Deliberately not built, and not for want of data: 1.12's whole uniqueness story is `ItemTemplateView::max_count`, which `item.rs` already hands out as `maxCount`. What is missing is the **return shape**, and the two candidates are behaviourally different. Upstream publishes `GetItemUniqueness` *and* `GetItemUniquenessByID`, and every other `X`/`XByID` pair in that namespace returns the same thing — which argues the pair is modern WoW's `(limitCategory, limitCount)`, hence `(0, max_count)` here since 1.12 has no `ItemLimitCategory.dbc` at all. But the one installed call site is `C_Item.GetItemUniquenessByID(id) == 1` (`pfUI/modules/itemcount.lua:16`, suppressing stack counts on unique items), which only works if the return is a **single** uniqueness number. Guessing wrong is invisible to the caller in both directions — the pair shape makes that test silently always-false and pfUI prints counts on unique items; the single shape would mislead any modern-shaped caller — which is exactly the failure mode this file's scope rule refuses. Resolving it needs upstream's `docs/API.md`; note the path `docs/classic-api-functions.md` cites now **404s** on `raw.githubusercontent.com` (`brues-code/ClassicAPI` `main`), so re-fetching it is itself a prerequisite task. The compat doc's earlier `GetItemUniqueness` row reached the same "no 1.12 counterpart for the pair" conclusion from the other direction |
|
||
| `C_NewItems` (`IsNewItem`/`RemoveNewItem`/`ClearAll`) | todo — reason sharpened, not changed | Left unbuilt, agreeing with the "needs a 1.12-absent subsystem" classification in the tail row above, with the measurement that makes it conclusive: **building it would deliver nothing**. pfUI's module refreshes its glows from `EventRegistry:RegisterFrameEventAndCallback("BAG_NEW_ITEMS_UPDATED", ...)` (`modules/newitem.lua:60`) and benilla fires no such event, so even a perfect `IsNewItem` would never repaint. The tempting cheap version — `IsNewItem` → false, the other two no-ops — is not free either: it is a *positive* claim that a just-looted item is not new, indistinguishable by the caller from a correct one, and it would turn a loud failure into a feature that is permanently and silently off. A real implementation needs app-side state nothing feeds: a container-arrival diff gated on whether the bag UI was open when the item landed, plus the event. Cost of leaving it: an error per bag-slot update while a bag frame is open (`pfUI.newitem:UpdateSlot` is hooked onto `pfUI.bag.UpdateSlot`), which is why the measured count is low — the harness never opens a bag |
|
||
| `C_NewItems` (`IsNewItem`/`RemoveNewItem`/`ClearAll`) | done — **floor**, reversing the row below | `compat/classic_api/new_items.rs`. The row below is correct about *function* and is kept: benilla never fires `BAG_NEW_ITEMS_UPDATED`, so the glow would never refresh even with the namespace present, and `IsNewItem → false` is a positive claim about a just-looted slot. What it did not weigh is the cost of the **absence**. `pfUI/modules/newitem.lua:16` indexes `C_NewItems` from inside a bag-slot update that runs on hover, click and drag — so a nil namespace does not skip the glow, it *raises* and takes the rest of that handler with it. Measured the moment every addon began loading cleanly: **7 of 13 addons went dead to the touch on this one name**, against 1 before. "No glow" and "no working bag interaction" are different failures, and this is the same trade the `Totem` row records. `IsNewItem` answers the boolean `false` (not nil — upstream returns a boolean and nil is the one shape a `== false` could tell apart); the two removers are honest no-ops, since there is no marker state they could fail to clear. **Still wrong for a slot that really was just looted** — that is this row. The real feature needs benilla to track bag arrivals across a session and fire the event on the edge; when that lands this file becomes a getter instead of a floor |
|
||
| `C_Item.GetItemUniquenessByID` | done — reverses the decline below | `compat/classic_api/item.rs`, one `by_id!` line over `max_count`, whose own doc comment reads "1 prints Unique, N > 1 Unique (N); not the stack size". **The fact was never the problem** — it was declined twice because retail's shape is a `(limitCategory, limitCount)` pair, 1.12 has no limit categories, and upstream's `docs/API.md` truncates before the Item section so reading it settles nothing. What settles it is the **one real caller**: `pfUI/modules/itemcount.lua:16` reads `... or C_Item.GetItemUniquenessByID(id) == 1 then return end`, comparing the *first* return against 1 to mean "unique, draw no count". A limit *category* compared against 1 is meaningless there; the limit *count* is exactly right and answers every case — unique → 1 (skip), unlimited → 0, `Unique (3)` → 3 (draw, since three may be held), all three pinned by test. **One value, not a pair**: inventing a category id for a game with no categories is the part that would actually be wrong. Declining cost 27 raises per session on plain mouseover, the same trade the `Totem` and `C_NewItems` rows record |
|
||
| The modern nameplate contract: `NAME_PLATE_CREATED` / `NAME_PLATE_UNIT_ADDED` / `NAME_PLATE_UNIT_REMOVED`, slot-bound `nameplate<N>` and `nameplate<N>target` | done — **this is why pfUI was not styling nameplates at all** | Supersedes the `nameplate<N>` row above, which was right about the symptom and wrong about one of its three blockers. The user-visible fault was total: `pfUI/modules/nameplates.lua` registers these three events (`:498-500`) **and nothing else** — there is no `WorldFrame` child scan anywhere in that 1936-line file — and benilla fired none of them, so pfUI's module never received a single plate and every plate drew as benilla's own. All six of its styling paths are `C_NamePlate.GetNamePlateForUnit(arg1)` over the token those events carry. Built in `benilla-ui`'s `script/nameplate.rs` beside the existing 1.12 widget pool, **not** in a feed system of its own, and that is the central design call: the token, the plate and the event edge are one state machine, and the correct order inside a single tick is (1) fire every removal while its token still names the departing unit *and* its plate is still findable, (2) free those slots, run the widget sync, claim new slots, push every live snapshot, (3) fire `NAME_PLATE_CREATED` then `NAME_PLATE_UNIT_ADDED`. Split across two systems, step 1 lands after the plate is already retired, and pfUI's removal handler is `local plate = GetNamePlateForUnit(arg1) if plate then visiblePlates[plate] = nil end` (`:611`) — a nil plate there leaks that plate into its visible set for the rest of the session, with no event left to take it out. Its own source comment states the dependency ("UnitGUID still resolves inside the handler (the slot is freed after dispatch returns)"). **On blocker (2), slot stability, which the old row called the heart of the problem**: the token slot space is a new `NamePlates::token_slots` (`Vec<Option<u64>>`, guid per slot, lowest-free claim) and is deliberately *not* the widget pool index. A unit keeps its slot for the life of its plate; a slot is freed only after its removal has been dispatched. The pool cannot serve as the token space because the pool recycles a slot in the very tick it is freed — `sync`'s own "retire first, so a departing unit's slot is free for an arriving one", the reference's single free list — which would make one index mean two units inside one dispatch. Nothing an addon can observe ties the two numbers: `GetNamePlateForUnit` is token → GUID → `frame_for_guid`, never token → pool index. `NamePlates::live`'s dense rank is still the wrong answer for the reason the old row gave, and a test proves it — three plates up, the *middle* one dies, and the third must still be `nameplate3`. **On blocker (1), the per-plate snapshot**: `PlateState` became `PlateFeed { state, unit, unit_target }` and `sync_nameplates` takes that. One struct, not two parallel slices, so the plate an addon styles and the unit its token names are the same unit by construction. The snapshots come from `ui_unit.rs`'s own `token_snapshot` body, extracted into `SnapshotCtx::full` and now shared with `vplates.rs` — one body for `"targettarget"`, `"npc"`, `"focus"`/`"focustarget"` and the plate pair, so no two of them can come to disagree about one unit. Built in `drive_vplates`' own gate loop rather than a second system, because a second system re-deriving the plate set would be a second gate: the first time the two disagreed, a token would name a unit with no plate. **On blocker (3), `<token>target`: the old row was wrong — it is reachable and is built.** `UNIT_FIELD_TARGET` is a plain descriptor field (`unit_target()`), and the app has made exactly this hop for `"targettarget"` since that token existed; what the `focustarget` comment in `Model::unit` records is that *benilla-ui* cannot make it, which is a statement about the VM crate, not about the client. pfUI needs it: its aggro scanner walks every `nameplate<N>`/`<N>target` pair in `pfValidUnits` with `UnitExists`/`UnitIsUnit`/`UnitCanAttack` (`api/unitframes.lua:186`), which is how a nameplate drives the aggro glow on a unit frame. `nameplate<N>targettarget` is **not** built: it is one more hop for a third snapshot per plate (120 a frame at 40 plates) and the one scanner that reads it already scores through `<N>target`. **Prefer absent over wrong, three places.** A plate whose unit the app could not snapshot names *nothing*, not a blank unit — `UnitExists` answering true for a token with no GUID, no name and no health is undetectable by the caller, and a zero GUID would additionally make `Model::unit`'s raw-GUID fallback resolve `"0000000000000000"` to it. `UNIT_FIELD_TARGET`'s zero ("targeting nothing") is rejected before the object-manager lookup rather than after. And a free slot's token names nothing rather than the last unit that held it. **`NAME_PLATE_CREATED`'s `arg1` is a frame**, the one event in this client whose argument is a widget, so `ScriptValue` gained a `Frame(u32)` variant carrying the arena id — still plain data, no mlua handle, so the no-persistent-`Function`-handles rule (`LUAI_MAXCSTACK`) is untouched. It resolves through the cached wrapper (`0x701bd0`), because pfUI guards on `registry[arg1]` object identity and parents its whole overlay to that table; a fresh table per fire would build a second overlay per plate and lose it. **Not built, deliberately**: `UNIT_AURA`, `UNIT_FLAGS` and `UNIT_SPELLCAST_*` do not fire *for* `nameplate<N>` tokens, though pfUI's module has arms for all of them (`:628`, `:651`, `:665`, `:681`). Those are field-transition events and would mean running `ui_unit.rs`'s `fire_transitions` 40× a frame; without them pfUI falls back to its own throttled `OnUpdate`, which it has and which is its no-ClassicAPI path, so the module works — it just refreshes on a throttle instead of on an edge. Tracked as its own item, not folded in here. **Harness: unchanged at 11/13 load clean, 10/13 session start, 10/13 survived use — and that is the honest reading, because `examples/addon_harness.rs` drives no world, no camera and therefore no plate, so it cannot see this feature at all** (it never calls `sync_nameplates`; `grep` finds no `nameplate` in it). The end-to-end check it cannot make is instead a test: `script/tests/nameplate_tokens.rs`'s `pfuis_own_oncreate_body_runs_on_the_frame_the_created_event_hands_it` transcribes pfUI's `OnCreate` (`:756-897`) — `parent:GetChildren()` positionally, the six regions by index against its own `NAMEPLATE_OBJECTORDER`, `DisableObject` on each, `HookScript("OnValueChanged")` on the health bar, the raid icon reparented onto its own frame, `SetScript("OnUpdate", nil)` — and then its `_UNIT_ADDED` body, and asserts the module reaches the end on every plate. **Ambiguity cost: zero, measured.** `Update (actionable)` is 5581 against a ceiling of 5581, unmoved, so `UPDATE_ACTIONABLE_CEILING` is untouched. No new system (the work is inside the existing VM-holding `drive_vplates`, so `every_vm_holder_in_update_declares_its_side_of_the_tick` sees no new holder), and the four new `PlateWorld` fields are all read-only (`Res<GuidIndex>`, `Res<ChrClassTable>`, one `Query<(&ObjectStore, Option<&Transform>), With<Guid>>`), which Bevy does not count against another reader; `WOW_AMBIGUITY_DUMP=1 … | grep drive_vplates` gives 48 pairs, every one a `Transform`/visual writer the plate loop's pre-existing queries already paired with, and none naming `GuidIndex` or `ChrClassTable`. No new benilla-app file, so `ui_script_wall`'s game-file count stays at its pre-existing 43. No new bare Lua globals (`C_NamePlate` already existed) and no new widget methods, so neither `reference_surface.rs` nor `widget_surface.rs` changes; the three event names are modern, absent from `reference/1.12-events.tsv`, so `event_shape_gate` skips them as it does `UNIT_CASTEVENT` |
|
||
| Per-`nameplate<N>` field events (`UNIT_AURA`, `UNIT_FLAGS`, `UNIT_SPELLCAST_*`) and `nameplate<N>targettarget` | todo — reachable, costed, deferred | Split out of the row above rather than folded in, because these are the measurable remainder of that feature and nothing else blocks them. pfUI's nameplate module has an arm for each (`modules/nameplates.lua:628` `UNIT_FLAGS`, `:651`/`:665` the cast pair, `:681` `UNIT_AURA`), each guarded by `strfind(arg1, "^nameplate")`, and each only sets a dirty flag that bypasses its own throttle — so their absence costs *latency*, not function: the module falls back to the throttled central `OnUpdate` it keeps for exactly the no-ClassicAPI case, which is why the plates style correctly without them. The work is not a binding: `UNIT_AURA` and `UNIT_FLAGS` come from `ui_unit.rs`'s `fire_transitions`, which diffs a token's previous snapshot against its current one, and running that per plate means 40 snapshot diffs and 40 memo entries a frame on top of the 40 builds the token feed already does — a real per-frame cost for an earlier repaint, which wants measuring against a live session rather than guessing. The cast pair additionally needs `C_Spell.UnitCastingInfo` / `UnitChannelInfo`, which the "ClassicAPI tail" row above still lists as unbuilt (pfUI's `PollCastInfo` calls both), so a cast event with no way to read the cast behind it would fire into a handler that learns nothing. `nameplate<N>targettarget` is one more descriptor hop for a *third* snapshot per plate (120 a frame at 40 plates); the only caller is pfUI's aggro scanner, which reads it as the second of two terms and already scores through the first (`nameplate<N>target`, built), so the token currently names nothing — recognised, as the prefix rule has made it since the earlier pass |
|
||
| `IconDataProviderExtraType` | todo — the constant is the smallest part | Not built, because installing it fixes nothing and the values are unverifiable. pfUI only ever passes it *through*: `CreateIconPicker(name, parent, extraType, ...)` hands it straight to `CreateAndInitFromMixin(IconDataProviderMixin, extraType)` (`api/ui-widgets.lua:1288`), and the picker then calls `provider:GetNumIcons()`, `:GetIconByIndex(i)`, `:SetIconTypes({ IconDataProviderIconType.Spell })` and `:Release()`. So `IconDataProviderMixin`, `IconDataProviderIconType` and `CreateAndInitFromMixin` are all absent too (the harness lists `CreateAndInitFromMixin` among its missing globals), and the mixin itself needs an **icon catalog** — an enumeration of every `Interface\Icons` texture in the patch chain — which this VM has no feed for at all. Installing the enum alone moves the raise from module scope into `EnsureProvider`. And the values cannot be taken from the one call site, which only reads `.Spellbook` and `.Equipment` as opaque seeds: modern WoW builds this through `EnumUtil.MakeEnum` (1-based) where a plain table literal would be 0-based, and nothing available here distinguishes them. Two call sites, `modules/macroicons.lua:36` and `modules/equipmentmanager.lua:388`; both lose an icon picker, neither loses anything else |
|
||
| `C_Map.GetMapOverlays` — the `tiles` array | done — the shape the caller actually reads | Supersedes the `Map (C_Map)` row above on this one member. The function existed and was returning every field *except* the one pfUI's reveal pass walks: `modules/mapreveal.lua:151` is an unguarded `for _, tile in ipairs(overlay.tiles) do`, so "reveal unexplored map" raised `bad argument #1 to 'ipairs'` on the first row and drew nothing. Each cell now carries `file`/`width`/`height`/`texCoordX`/`texCoordY`/`offsetX`/`offsetY` — exactly the seven the call site hands `SetTexture`/`SetSize`/`SetTexCoord`/`SetPoint`, and no others. **The grid is the reference's own**, `WorldMapFrame.lua:112-159` off the player's chain: 256-px cells, row-major numbering from 1, the remainder cell on the last row and column. **The file sizes are measured, not predicted**, through the `TextureSizeProbe` `ui_script/lifecycle.rs` already installs — and that is the whole reason the member needed work rather than a rename, because this install's art is **not** the reference's: every `Interface\WorldMap\<Zone>\` overlay is re-exported at 2x. Measured over the whole table: **684 of 707 rows ship tile files whose dimensions are exactly 2x what the stock loop assumes, and not one matches 1x**. The stock loop survives that only because its `span / assumedPad` fraction is scale-invariant; it shears the moment a shipped pad is not the assumed one times that factor, which is what pfUI's own comment warns about and names (`Icepoint/KANEQNUUN`). So the per-overlay factor is read off tile 1 and every fraction is taken against the real denominator. Verified on real art, not a fixture: `world_map_tests.rs`'s `pfuis_reveal_body_tiles_the_real_alterac_overlays_rects_exactly` runs the addon's loop over Alterac's 16 shipped rows with the live probe and pins `GALLOWSCORNER` at the rect's 183x185 with a 366/512 crop — 183/512 if the factor is missed. **What this still cannot do**, stated because a caller cannot tell: the grid *shape* comes from the DBC rect, because a power-of-two pad bounds its content without determining it — a 1-wide/2-tall row (`Alterac/CORRAHNSDAGGER`) and a 2-wide/1-tall one ship indistinguishable pad sequences, so fitting the grid to the files alone misreads it. 23 of the 707 rows have art that disagrees with their rect, in three shapes: 6 ship **fewer** files than the grid asks for (those cells are **omitted** rather than listed, so a caller never acquires a texture for a file that is not there), 3 ship **more** (ignored — and 121 further rows ship extras past a grid that otherwise matches exactly, so appended files are ordinary in this data rather than a signal), and 14 ship the right *number* of files at the wrong *sizes* — `KANEQNUUN` among them, which has a sliver column the rect rounds away entirely and whose own tiles are trimmed on the other axis too, so no consistent grid fits it at all. Those 14 keep the rect's geometry with the measured denominator, which is undistorted-and-clipped rather than stretched, and is as close as a pad that bounds its content can get. Cost: one texel probe per tile per zone, memoised by path in the existing probe (~20-40 ms on the first open of a zone with overlays, nothing after); a probe-less VM (a bare `UiScript`, the addon survey) gets an empty `tiles` array, which is still an array |
|
||
| `C_Container.GetContainerItemCharges` (third entry) — **and why pfUI's bag loop is not the bug it looks like** | done — a reversal, and the earlier reason was looking in the wrong place | Supersedes the two `todo, blocked` rows above. The recorded reason was that nothing in **`benilla-formats`'** item template says which of an item's five spell-trigger slots is a consumable charge pool — true, and irrelevant: item templates do not come from a DBC here, they come over the wire. `benilla-protocol`'s `ItemSpellEntry` carries all five blocks with their own signed `SpellCharges`, so "which block is the pool" is answered by the block itself, and `ui_items/feed.rs` was **already** making exactly that choice — the tooltip's "N Charges" line has been printed off it all along (`charges_count`, the port of the builder's `0x52d8a0` gate). All that was missing was carrying the *instance's* countdown of the chosen block onto `ContainerSlot`. Now `charge_pool` returns the block and the two readers take different halves of it (the tooltip the template's full pool, the bag verb the live `ITEM_FIELD_SPELL_CHARGES + index`), so they cannot come to disagree about which block an item's charges live in. **The second half of this row is the fault that was reported as separate.** `pfUI/modules/bags.lua:210` raising `attempt to index field '?'` while looping `for bag = -2,11` over `pfUI.bags[bag].slots` is **not** an independent bug and needs no client-side special case: `pfUI.BACKPACK` and `pfUI.BANK` union to exactly `{-2..11}`, `CreateBags` creates a frame for every id in its list **unconditionally** (`:313`, before any `GetContainerNumSlots` read), and both calls run from one `PLAYER_ENTERING_WORLD` handler (`:133`, `:136`). So nothing about a real client's `GetContainerNumSlots`, its keyring at bag -2 or its bank bags answers differently — the loop is safe there because the table is complete. It was incomplete *here* because `:452`'s `if charges > 1` raised **inside** `CreateBags`' own `UpdateSlot` call (`:322`), aborting the handler before line 136 ever ran and leaving `pfUI.bags[-1]` and `[5..11]` absent for the next `BAG_UPDATE`. One nil return, two reported faults; the cascade is visible in `logs/errors_2026-10-07_16-40-25.log:327-339`. Hence the shape rule here: a **number for every `(bag, slot)`**, 0 for an item with no pool, an empty slot or an absent bag — the same 0-not-nil choice `GetContainerNumFreeSlots` already takes, and all three are true statements. **What is chosen rather than confirmed**: the arithmetic. "Uses left" is the member's documented meaning, but the other plausible candidate is `stackCount x usesPerItem`, and the two differ for a *stack* of charged items. That is safe here and the safety is checked rather than assumed — pfUI reaches the verb only inside `if count == 1`, where the candidates coincide, and only ever tests `> 1` (`container.rs`'s `container_item_charges_answers_a_number_for_every_slot_so_pfuis_bag_loop_cannot_raise` transcribes that block). SuperWoW's own `GetContainerItemInfo` negative-stack-count row above is blocked on the *same* mistaken reason and should be re-read next to this one |
|
||
| `GameTooltip:GetGameObject` / `HasGameObject` | done — a deliberate reversal, same mechanism as `GetUnitGUID`, 17x the cost | The `Newly surfaced by the above` row records this as `todo` with two reasons. One was right and is now fixed the way `GetUnitGUID`'s was; the other was simply wrong. **Right**: nothing recorded a GameObject *subject*, and re-deriving one at read time would answer about whatever the pointer is over *now*, where the getter promises the object the plate on screen is describing. Fixed by recording it at build time — a third `TooltipSubject::GameObject { object_id, name }` variant written by `world_tooltip_gameobject` and dropped by `clear_content` with the lines it described, exactly the `TooltipState::unit_guid` shape. **Wrong**: "there is no object-template cache keyed like `item_templates`". `benilla-app`'s `GameObjectTemplates` is a `QueryCache<u32, GoTemplate>` keyed by precisely that entry, and `ui_tooltip/mod.rs` was already holding the resolved template *and* the guid in hand when it called over to build the plate — only the id was being dropped at the seam. Built because the absence cost **236 raises in one play session**, seventeen times the 14 that justified `GetUnitGUID`, and for the same structural reason: pfQuest hangs its reader on a frame **parented to** `GameTooltip` (`map.lua:253`), so every tooltip that becomes visible fires it — unit plates, item plates, pfUI's xp bar, pfQuest's own tracker rows. Shape pinned by the live call site, not by memory: `map.lua:296` is `local _, oid = GameTooltip:GetGameObject()` feeding `pfMap.tooltips_object[oid]`, so it is `GetUnit`'s two-value `(name, id)` with the entry in the token's place and **no third value**, nothing pinning one. The id is `OBJECT_FIELD_ENTRY` and deliberately not the guid: that is what the caller's own comment at `:294` asks for ("sourced from the spawn-packet template field rather than parsed out of a GUID") and the only thing a static node index can be keyed by — a per-spawn guid matches nothing. The corpse plate borrows the same builder for its one gold line and records **no** subject, so `HasGameObject()` stays false there. The test fails against three rejected designs, not merely against absence: `tooltip_anchor_tests.rs`'s `pfquests_own_tooltip_hook_reads_the_object_entry_off_the_plate_the_world_hover_built` transcribes the addon's `OnShow` body, and a missing method raises at the call, a name-only return leaves `oid` nil so the index is never reached, and a guid in the id's place matches nothing. Widget method, so `tests/widget_surface.rs`'s `ALLOWED_BEYOND`, not the global list. **Honest correction to that earlier row's framing**: these 236 raises were *not* why pfQuest showed no tracker and no icons. `script/event.rs` records a handler error rather than re-raising it (`:157-171`), so the raise only truncates the tail of pfQuest's own `OnShow` — it costs the addon its GameObject tooltip annotations and 236 log lines, nothing more. The icons had a different cause; see the `GetQuestLogLeaderBoardID` row below |
|
||
| `GetQuestLogLeaderBoardID` + `C_QuestLog.GetQuestDetails` — **this is why pfQuest drew no tracker and no map or minimap icons** | done — two reversals, one of them the actual blocker | `compat/classic_api/quest_log.rs`. Both were refused in that file's header for reasons that have to be separated, because only one of them was about data. **`GetQuestLogLeaderBoardID`**: the stated reason was that `QuestLogObjectiveView` carries `kind`, `text` and the `cur`/`req` pair but no creature/object/item id, "so there is nothing to return". True of the view and untrue of what fed it — `ui_quest_log.rs`'s two line builders had `ReqCreatureOrGOId` and `ReqItemId` in hand when they formatted the sentence and were discarding them. The view now carries `QuestLogObjectiveView::id`, with the wire's `0x80000000` GameObject marker **stripped** (the bit is what `kind` already says; leaving it in would make every object id a nonsense 2-billion number). **Why this matters far more than its 0 logged raises suggest.** `pfQuest/database.lua:1470` calls it *unguarded*, inside `pfDatabase:SearchQuestID` — the one function that turns each quest in the log into map and minimap nodes — in a `for i = 1, GetNumQuestLeaderBoards(qlogid)` loop that this client's own objective count makes non-empty. So for any quest in the log with objectives the nil global raised and `SearchQuestID` returned before it handed back its map table: no pins, no minimap icons, and a tracker with nothing to list. It did not appear in the error log because the loop only runs once a quest with objectives is in the log; the 236 `GetGameObject` lines were the loud symptom of a different, harmless gap. The id is the function's whole point — pfQuest sorts by it into `parse_obj["U"]`/`["O"]`/`["I"]` — so the test transcribes that scan (`pfquests_node_builder_scan_sorts_real_objective_ids_into_its_unit_and_item_tables`) and fails against a nil global, a 0 id and an unstripped marker alike. Argument order is the stock verb's on purpose: the addon calls the two side by side on the same pair (`database.lua:1469-1470`, `map.lua:366-367`). **`C_QuestLog.GetQuestDetails`**: refused as one of four probes of a standalone `CMSG_QUEST_QUERY` cache. That classification is right for the other three and wrong for this one — both live call sites ask only about quests they already walked out of the log by an id `GetQuestIDForLogIndex` gave them (pfUI `modules/questitem.lua:5`, pfQuest `database.lua:732`), so the honest subset is the whole of what is asked, and nil off the log is a branch both callers already take. Shape from the call sites, the only specification available: `title`, `level`, `objectives` (the objective **paragraph**, because `GetQuestText(id, "O")`'s other branch is one shipped locale string and a list there would be the wrong type), `description`, and `requirements`, an `ipairs` array of `{ kind, id, count }` plus the `have`/`finished`/`text` the row already knows. The `id` is what makes pfUI's module do anything at all — it gates on `req.kind == "item" and req.id and req.id > 0` — so without the feed change above it would have been a verb that never fired. 4 raises for `GetQuestDetails`, and pfQuest's own quest-text reader stops falling back to its shipped locale table for quests in the log. `RequestLoadQuestByID`, `IsQuestDataCachedByID` and `GetTitleForQuestID` stay unbuilt for the original reason, unchanged |
|
||
| `UnitCanAssist` | done | `script/unit/bindings.rs`, beside `UnitCanAttack`/`UnitIsFriend`/`UnitCanCooperate` — **not** in `compat/`, because it is a real 1.12 verb (`reference/1.12-globals.tsv:18496`, `0x516bb0`, registered at `.data 0x8504c8`) and so needs no `allowed_beyond_1_12` entry; adding one would fail `the_exception_list_has_no_dead_entries`. It was simply never installed, and the cost was **649 raises in one session, the largest single count in the log**: `pfUI/modules/nameplates.lua:14` captures it as a file-scope upvalue, so the local stays nil for the module's life, and `:214` is an early-out inside the per-plate combat-tint path — every raise aborted one plate's styling partway. `libs/libpredict.lua` uses it three more times as the heal-prediction self-cast switch. All four call sites read a single value for truthiness with no destructuring, so `flag(bool)` (1 or nil, never a Lua boolean) is the whole contract. The predicate is `CanAssist`'s own three terms (`0x6066f0`), spelled the same way `benilla-app`'s port of the same engine function already spells them (`target/relations.rs`'s `can_assist`, which gates `UnitBuff` visibility) so the Lua verdict and the engine's cannot disagree about one unit: `UNIT_FLAG_NOT_SELECTABLE` clear, a reaction of at least `UnitReaction`'s 1-based 5 — the same cut `UnitIsFriend` takes, and the internal `>= 4` the engine applies plus the 1 it adds at `0x51683e` — and, for a unit that is not player-controlled, its PvP bit. That third term is the one a reaction-only stand-in gets wrong, and it is why a priest cannot heal a quest giver; a test enumerates all three. Two narrowings, each inherited from what `UnitState` carries rather than chosen: `IsPvP` reads the *owner's* flag first (`0x5ee5a0`) and the snapshot carries `owner` as a guid with no flags beside it, so a hostile minion that is not itself player-controlled reads its own bit (a player's pet carries `UNIT_FLAG_PLAYER_CONTROLLED` and takes the arm above, which is the case that matters); and the reference's player-controlled arm reads an unnamed `[obj+0xe68]` record that is not ported app-side either, so such a unit passes. `CanAssist(P, P)` is true in the reference (`0x6061e0` answers 5 for a unit toward itself) but every feed site holds the reaction *toward* the player, leaving the player's own snapshot at 0 "not fed", so a guid compare restores that one answer instead of leaving it silently nil |
|
||
| `MouselookStart` / `MouselookStop` / `IsMouselooking` | partial — `IsMouselooking` is real, the pair are documented no-ops | `script/pointer.rs`, beside `GetCursorPosition`/`GetMouseFocus`; all three are 1.12 engine verbs (`reference/1.12-globals.tsv:11821`, `:11822`, `:10611`), so no surface exception. `IsMouselooking()` is **backed by the real camera rig**: `CameraControl::mouselooking` — the reference's own `[cam+0x90] & 1` (set `0x50fe41`, cleared `0x50fddd`), which is `freelook`, a right-held turn or a both-button run and deliberately **not** a left-button orbit — pushed each tick as a new `SelfMovement::mouselooking` field. That rode into `ui_unit.rs`'s existing `feed_self_movement`, a system that already holds the VM, for **zero measured ambiguity cost**: `WOW_AMBIGUITY_DUMP=1` shows three new pairs naming `feed_self_movement` and the actionable total stays at 5581, so `UPDATE_ACTIONABLE_CEILING` is untouched. **The pair do not start or stop a look session, and that is a behavioural deviation rather than an absent value, so it is stated plainly.** benilla's mouse-look is not a mode the engine can be put into from outside: it is latched per frame from the buttons *the world owns* (`player/camera.rs`'s `latch_world_mouse` then `run_look_session`), the session begins on a world press and ends when that press is released, and a press captured by a frame is dropped before the latch sees it. A Lua `MouselookStart()` has no press behind it and therefore no release edge either, so forcing the flag on would leave the camera turning with nothing able to stop it — worse than doing nothing, and not what the reference does, where the call joins a session the hardwired `BUTTON2` binding owns and that binding's release ends. `bindings/commands.rs` records the same fact from the other side (`TURNORACTION` declared absent, "right-button mouse-look is hardwired, not routed through a binding"). The one corpus caller is pfUI's nameplate right-click emulator (`modules/nameplates.lua:1899-1934`), which calls `MouselookStart()` on a plate's `OnMouseDown`, polls `IsMouselooking()` to tell a drag-look from a click, and synthesises the click once the poll says no look is running. The nil global aborted that handler **before** it armed the poll, which is why right-clicking a pfUI nameplate did nothing at all; with these installed the poll runs and reads false, which is the truth, because the press that armed it was captured by the plate frame and so is a press the world never owned. 2 raises, and a feature back. Making the pair real needs a look session the VM can open and close — a camera state-machine change, not a binding. `script/tests/input.rs`'s `pfuis_nameplate_right_click_emulator_arms_and_then_synthesises_the_click` transcribes the emulator and fails against the absent global |
|
||
| `C_PlayerCache` (`RememberPlayer`, `GetPlayerInfoByName`, the four enable toggles) | done — the store's writer and its reader are the same addon | `compat/classic_api/player_cache.rs`, superseding this member's entry in the "ClassicAPI tail" row above, which classified it as needing a 1.12-absent subsystem. It needs no feed at all, and the reason is worth stating because it is the thing that makes the one unverifiable part unverifiable-but-safe: pfUI **fills** the cache itself from every roster, inspect and whisper scan it already runs (`libs/libunitscan.lua:42-47`, `RememberPlayer(guid, name, class, UnitRace(unit), UnitSex(unit))`) and **reads it back** by name to colour a player link in chat (`modules/chat.lua:830`). So every value here is the caller's own, round-tripped — nothing derived, inferred or invented. The one thing that could be wrong is which return slot carries the class token, and it cannot be, because that slot carries exactly the argument the caller named `classToken`. Return order is `GetPlayerInfoByGUID`'s and is pinned by that call site's destructuring rather than by memory: `local _, class, _, raceKey, sex = ...`, five values with the class token second and the race key and sex fourth and fifth. The two slots the store has nothing for answer **nil rather than a guess** — slot 1 is the *localized* class name and slot 4 the *English* race key, where `RememberPlayer` is handed a class token and `UnitRace`'s localized first return, and faking the other spelling of either means a reverse lookup through a localized string, wrong on any non-enUS realm. A miss answers **no values at all**, which is what lets the caller's `if not class then class = GetUnitInfo(real) end` fall through to its own scan instead of colouring with an empty string. The honest limits: benilla's `names::NameCache` is guid→name only, so a player this session never scanned is absent even if they are standing in front of you, and nothing persists across a logout where upstream's hooks the client's own cache and does. The four toggles are preference state with nothing downstream, exactly as `container.rs`'s bag-sort pair is (upstream's gate the DLL's background name scan; this client runs none), both defaulting **on** so an addon that gates on `IsEnabled()` is not told the store it can write to is closed. 7 raises. `GetPlayerInfoByGUID` and `UnitNameFromGUID`, the two bare siblings, stay unbuilt — neither is called, and a guid-keyed class/race lookup genuinely has no backing here |
|
||
| `C_Item.GetItemUniquenessByID` (restated) — the 404 was a wrong branch | todo — reason sharpened to something actionable | No change to the decision; the row above is still right that the *fact* is here (`ItemTemplateView::max_count`) and the **return shape** is not, and that guessing is invisible to the caller in both directions. What is new is that the prerequisite it named is half done. Upstream's `docs/API.md` does not 404 because it is gone — it 404s because the path cited there is on `main` and the repository's default branch is **`master`**. `brues-code/ClassicAPI` is public, its tree is readable, and the two files that answer this question outright are `src/item/Uniqueness.cpp` and (for the charges row above) `src/item/Charges.cpp`. It is still `todo` because the fetcher available in this pass could not read them honestly: `docs/API.md` resolves on `master` but is **truncated before the Item section**, and the summariser demonstrably confabulates past the cut — asked for the source tree it invented directories (`src/romance`, `src/witch`, `src/worship`) that cannot exist in a WoW API DLL. Anything it "quoted" past the truncation is therefore unusable as a signature, which is exactly the standard this row was written to protect. Next pass: read those two files directly (a git clone or an authenticated API read, not a summarising fetch) and the shape falls out of the `Push*` calls and the `return N`. One raise, `pfUI/modules/itemcount.lua:16` |
|
||
| `C_NewItems` (restated) — re-verified, unchanged | todo — the objection survives new information | Re-read against the live log rather than re-decided, and the two reasons the row above gives both hold. (1) benilla fires no `BAG_NEW_ITEMS_UPDATED`, and pfUI's module refreshes its glows from `EventRegistry:RegisterFrameEventAndCallback` on exactly that event (`modules/newitem.lua:60`), so a perfect `IsNewItem` would still never repaint. (2) The cheap version is a *positive* claim that a just-looted item is not new, indistinguishable by the caller from a correct one, which turns a loud failure into a feature that is permanently and silently off — the same objection that keeps `GetItemUniquenessByID` out. What the upstream tree would add, for whoever builds it: `src/item/NewItems.cpp` is the file to read, on `master`, and the event is upstream's own to fire — so firing it is part of the job, not an excuse not to do it. 3 raises, and they are low because the harness never opens a bag |
|
||
| `IconDataProviderExtraType` (restated) — re-verified, unchanged | todo — still fixes nothing on its own | Checked against the current surface rather than re-argued: the row above's claim still holds in full. `IconDataProviderMixin`, `IconDataProviderIconType` and `CreateAndInitFromMixin` are all still absent (the addon survey still lists `CreateAndInitFromMixin` among its missing globals), pfUI still only passes the constant *through* `CreateIconPicker` into `CreateAndInitFromMixin(IconDataProviderMixin, extraType)` (`api/ui-widgets.lua:1288`), and the mixin still needs an enumeration of every `Interface\Icons` texture in the patch chain that no feed provides. Installing the enum alone moves the raise from module scope into `EnsureProvider`, which is not a fix, and the values are still unavailable from the one call site, which reads `.Spellbook` and `.Equipment` as opaque seeds. 2 raises, both an icon picker neither call site can show anyway |
|
||
| `HEARTHSTONE_ITEM_ID` — **this, not the quest verbs, is why pfQuest's tracker and icons were still blank** | done — one global, and it turns the whole addon on | `compat/classic_api/container.rs`'s `install`, beside `PlayerHasHearthstone`/`UseHearthstone`, which is where modern FrameXML declares it; `reference/1.12-globals.tsv` has no row for it. **The seven stock quest-log verbs were never the problem.** Measured with the harness read-back probe (`--probe pfQuest --eval …`), which is the only instrument that could see this: `GetNumQuestLogEntries()` answered `3, 2` for the seated log, `GetQuestLogTitle(2)` answered `The Fargodeep Mine, 10, nil, nil, nil, nil`, and forcing `pfDatabase.localized = true` by hand made `pfQuest:UpdateQuestlog()` return `change=true` and fill `pfQuest.questlog` with `62=The Fargodeep Mine/2 176=Kobold Candles/3` — the whole chain below the gate was already correct. The gate: `database.lua:2215-2246`, the last block of the last file of pfQuest's `<Include>`, decides whether the server's locale matches its shipped databases by comparing the Hearthstone's live name against `pfDB["items"][loc][HEARTHSTONE_ITEM_ID]` — and `pfDatabase.localized = true` is set *inside* that comparison's `if`. With the global nil, `C_Item.IsItemDataCachedByID(nil)` is false, `Item:CreateFromItemID(nil)` builds an **empty** item whose `ContinueOnItemLoad` poll can never resolve, and the flag stays nil for the session. `quest.lua:352` — `if not pfDatabase.localized then return end` — is the first statement of pfQuest's `OnUpdate`, so every frame returned before `UpdateQuestlog`, before `SearchQuests`, before the queue drain that calls `SearchQuestID`: no tracker rows, no map pins, no minimap icons, for the whole session. **Silent by construction**, which is why it outlasted two earlier passes: reading `t[nil]` is legal Lua, a callback that never fires raises nothing, and `nil == nil` in pfUI's two comparison sites (`libs/libbagsort.lua:19`, `modules/itemcount.lua:16`) is a branch that is simply never taken. The value is not from memory — `container.rs` already held the same const for its two hearthstone verbs, and pfQuest's own shipped table is `[6948] = "Hearthstone"` (`db/enUS/items.lua:5642`), indexed *by* this global, so a wrong id would miss the key and leave the flag nil just as surely. The test transcribes that block whole (`pfquests_locale_check_sets_its_run_gate_from_the_hearthstone_the_bags_hold`) and fails against a nil global and a wrong id alike. **A note on the instrument**: the harness *survey* is blind to this entire class of fault and its table is byte-identical before and after the fix — 13/13 load, 13/13 session, 13/13 UI probe, 10/13 survived use, and the missing-globals ranking never listed `HEARTHSTONE_ITEM_ID` because the scan ranks names that are *called or indexed*, and this one is only ever compared or used as a table key. Only `--probe`'s read-back found it |
|
||
| `C_CreatureInfo` reaches every VM, not just the boot one | fixed | `ui_chr_races.rs`/`ui_chr_classes.rs` fed their tables from a **`PostStartup`** system — once, at app boot — so the VM that existed then got them and no other. World entry and `/reload` each build a *fresh* VM, which saw an empty `C_CreatureInfo`. Not cosmetic: pfQuest builds its race and class bitmasks at **file scope** (`database.lua:402-413`) and `GetBitByClass` has no fallback (unlike `GetBitByRace`), so an empty table made it answer nil and `bit.band(…, nil)` raise in `QuestFilter` (`database.lua:1707`) **every frame** — 380 raises in one real session, with `SearchQuests` aborting before its queue drain. That is the FPS collapse the user reported, the missing map icons, and the tracker that only filled when some other path happened to populate it. Each feed's body is now a `seed_*` function the VM-construction path in `ui_script/lifecycle.rs` calls directly — seeded **there** rather than by moving the systems to `Update`, because pfQuest reads the tables at file scope, so a push one frame later is a push too late. A test transcribes pfQuest's own file-scope loop and was verified to report an empty bitmask (`left: 0, right: 1`) without the seeding |
|
||
| Harness fixture: `C_CreatureInfo`'s race/class tables, the Hearthstone's item template, and a zone | done — three states the live client always has and the harness never did | `addon_harness/mod.rs`'s `seat_a_session`. Not client code; recorded here because each one was hiding a feature the harness therefore could not reach, and two of them looked like client faults. **Race/class tables**: the live client pushes them once in `PostStartup` off the player's own `ChrRaces.dbc`/`ChrClasses.dbc` (`ui_chr_races.rs`, `ui_chr_classes.rs`); the harness seated none, so pfQuest's `for id = 1, 32` walk (`database.lua:402-412`) built empty `pfDB.bitraces`/`bitclasses`, and `GetBitByClass` — which unlike `GetBitByRace` has **no fallback** — answered nil, which reached `bit.band(…, nil)` in `QuestFilter` (`:1707`). That raise aborts `SearchQuests` at `quest.lua:383`, which runs *before* the queue drain in the same `OnUpdate`, so it also cost every `SearchQuestID` map node, and `updateQuestGivers` is only cleared after the call, so it re-raised every frame. Seated from this crate's own `ui_unit::race_names`/`class_names`/`race_faction_group` rather than a second copy, so the fixture cannot disagree with its own `UnitRace`/`UnitClass`; the 1.12 class gaps at ids 6 and 10 stay gaps. **Item template for 6948**: a live bag push is itself what queries each slot's template (`ui_items/feed.rs` calls `items.template(entry, …)` per slot), so a logged-in character always has it; the harness held a Hearthstone in bag 0 with no template behind it, which made `IsItemDataCachedByID` take the 600-tick poll arm that a ten-tick session start never finishes. **A zone**: `area.rs` pushes four host globals on every area change, and pfQuest's default `trackingmethod` 5 matches a quest's log header against `pfMap:GetCurrentZone()`, whose fallback is `GetRealZoneText()` (`map.lua:678`) — with no zone its tracker lists nothing however full the log is. Elwynn Forest, to agree with the seated log's header row. With all three in place the probe now walks the whole feature end to end: `localized=true`, `questlog=2`, `nodes=194`, `trackerrows=2 [The Fargodeep Mine, Kobold Candles]`. The survey table is unchanged by all of it |
|
||
| Quest-watch persistence (`Model::quest_log_watched`) | todo — **buildable, but the reference behaviour is the unconfirmed part, not the plumbing** | The set lives only in the VM (`script/model.rs:977`); nothing in `crates/` reads or writes it to disk, so a relog or `/reload` — which rebuilds the VM — drops every tracked quest. Two facts are settled and worth recording so the next pass does not re-derive them. **It is client-side**: `reference/1.12-message-catalog.tsv` has no quest-watch opcode at all, and `reference/1.12-shapes.tsv` lists `AddQuestWatch` (`0x4e1930`), `RemoveQuestWatch` (`0x4e1970`), `IsQuestWatched` (`0x4e1890`) and `GetNumQuestWatches` (`0x4e1860`) as engine globals on one vtable (`0x84b0f0`), so no server round trip is involved either way. **And the implementation is cheap and safe**, correcting an earlier worry: `quest_log_watched` is a `Vec<u32>` of **quest ids**, not log indices (`script/quest_log.rs`'s `watch_id_at` resolves the index to `QuestLogEntryView::quest_id` on the way in), so persisting it restores the right quests even when the log order changes — a `benilla-config/` file through `local_state.rs` plus a resolve once the log lands. What is **not** established is whether the reference 1.12 client persists the array at all: nothing in this tree says so either way (`AUTO_QUEST_WATCH` is the auto-track CVar's label, not the list), and the only claim to that effect was an investigation note with no source. Building it on that would be a silent fidelity deviation in whichever direction the guess went, which this file's own rule ranks below an absence. Not the pfQuest symptom in any case — that tracker's inclusion is not gated on watch state (`tracker.lua:524` uses `watched` for ordering and styling only) |
|
||
| `QUEST_TURNED_IN` | **built** | `quest.lua:261` registers it and `:310` uses `arg1` as the questID to stamp `pfQuest_history[arg1]`, the authoritative turn-in record that keeps the questlog REMOVE path from having to guess turn-in vs. abandon by name. Not a 1.12 event — `reference/1.12-events.tsv` has no row for it — so it is declared in `reference_ui.rs`'s `ALLOWED_BEYOND` with its reason, beside SuperWoW's `UNIT_CASTEVENT` and the `DEBUFF_*_OTHER` pair. **The earlier note here turned it down on the grounds that the questID was not available at the moment of the turn-in. That was wrong**: `SMSG_QUESTGIVER_QUEST_COMPLETE` carries `quest_id`, `xp` and `money`, `messages/quest/giver.rs` already parses all three, and `ui_quest/net.rs`'s `quest_complete` already had the struct in hand to build its chat lines — the fact was sitting unused one field away. Queued there (`QuestGiver::push_turned_in`, since a net handler has no VM) and drained in `feed_quest` before the snapshot's early-out, so it fires even though the reward click has already closed the panel. Measured cost of its absence: a quest already handed in kept its map and minimap markers. The old note claimed `pfQuest-octo`'s addon-message sync covered this; it does not — that sync **replaces** `pfQuest_history` wholesale with whatever arrives inside a 3-second window (`patchtable.lua:196-219`), so a slow or absent reply empties the history rather than filling it, which makes `/db query` a way to lose the record, not restore it |
|
||
| `ItemMixin:ContinueOnItemLoad` — the wait now **asks** | done — a one-line correction to a function already built | `compat/classic_api/item_mixin.rs`. Found while closing `HEARTHSTONE_ITEM_ID` above and worth its own row because it is a second, independent way for the same pfQuest feature to stay off. Upstream's `Item:CreateFromItemID(id) … :ContinueOnItemLoad(cb)` is how a caller *requests* an item's data — `RequestLoadItemDataByID` is the same namespace's bare spelling of the same act — and this port implemented only the waiting half: a `C_Timer` poll on `IsItemDataCached`, because 1.12 has no `GET_ITEM_INFO_RECEIVED` edge. But `C_Item.IsItemDataCachedByID` records no ask *on purpose* (its own registration says so: a caller polling "is it here yet?" should not trigger the query), so the poll only ever resolved for ids something else happened to read, and ran its full 600-tick bound to no effect otherwise. Concretely: a character whose Hearthstone sits in the bank has no bag slot reading template 6948, so nothing asks, so pfQuest's locale check never completes and the addon is off for that character even with the global present. The fix is one read through the `*ByID` family, whose shared `template()` helper puts a miss into `Model::item_stat_asks` for `ui_items/feed.rs` to drain — one source of truth for item data, unchanged, and no new plumbing. Once is enough: the app's pending set keeps an unresolved id across frames. The test drives the whole arm — ask recorded, nothing fired while the answer is absent, callback on the first tick after `set_item_template` — and fails on the first assertion with the line removed |
|
||
| `C_Map.GetAreas` — **this is why pfQuest drew map icons but no minimap icons** | done — one member, and it is the whole of the minimap half | `compat/classic_api/map.rs`, fed by `ui_world_map::seed_area_names` over the `AreaTableRes` the map catalog is already built from; supersedes the `GetAreas` half of the `C_Map.GetAreas`, `C_Map.GetAreaTriggerInfo` todo row above, which still stands for the trigger member. **The asymmetry was the whole clue and it is not in the minimap code at all** — pfQuest's two passes resolve the player's zone *differently*. `UpdateNodes` (world map) takes `GetMapZones()`'s name and, on a miss, `customids[GetMapInfo()]`, a table built at file scope from `C_Map.GetMapAreaIDs` (`map.lua:632-660`), which has shipped here since that member landed. `UpdateMinimap` has no such fallback: one line, `pfMap:GetMapIDByName(GetRealZoneText())` (`map.lua:1365`), over `pfDB.zones.revloc` — and this install's pfQuest has **deleted** its shipped name table (`db/<locale>/zones.lua` is a five-line stub saying the names now come from `C_Map.GetAreas`), so with the member absent `pfDB.zones.loc` was empty, `revloc` was nil, the name lookup answered nil, and the pin loop's own gate `if data[mapID] and minimap_sizes[mapID] …` (`map.lua:1383`) indexed by nil and skipped. **Silent by construction**: `data[nil]` is a legal read, so there is no raise, no log line and no missing-global the harness survey can rank — the name is only ever a table key here, the same blind spot `HEARTHSTONE_ITEM_ID` sat in. Measured with the read-back probe before and after (`--probe pfQuest --eval`): `pfDB.zones.loc` 0 entries → 1,480, `revloc` nil → built, and `pfMap:GetMapIDByName(GetRealZoneText())` nil → **12**, the same id the world-map pass already reached through `GetMapAreaIDs`, which is why the two passes now agree on the key pfQuest's node tables are built under. Two further silent losses close with it: `GetMapNameByID` (the browser's and the tracker's zone labels, `UNKNOWN` for every row) and `GetZoneIndex`, which walks the keys of `pfDB.zones.loc` as the zones to ask `C_Map.GetMapOverlays` for (`database.lua:1104`) — with no keys the subzone index was empty and `SearchZoneID` returned early for every subzone-placed objective. **The shape is pfQuest's own generator, not a guess**: `toolbox/extractor.lua:2084-2104` writes `pfDB["zones"][locale][id] = name` straight out of `SELECT * FROM AreaTable ORDER BY id ASC`, so the answer is the **whole** table (1,480 rows here, 1,400 of them subzone rows with no `WorldMapArea` map — the catalog keeps only the ~80 that have one) keyed by id, ascending. What it cannot fix, said plainly: the caller's reverse `name → id` index keeps the *first* id it meets for a name (`database.lua:285-287`) and 70 of the 1,480 names are carried by more than one row (`Westfall` by both 40 and 206, both top-level), so those names resolve by Lua's own table order. That ambiguity is the shipped table's too and nothing in the DBC says which row a name means; picking one here would be this file inventing a fact. **One correction rode along, and it was forced**: `GetMapOverlays`'s `tiles` array is now resolved on **first read** through a per-VM row metatable instead of being built eagerly (`overlay_row_meta`). A cell is measured by *decoding* its file (`benilla-assets::sprite_dimensions`), and until this member landed nothing ever walked every zone's overlays — `GetZoneIndex` now does, and it reads only `areaID` and the four `hitRect*`. Measured on this install: **1,019 tile files across 707 overlay rows, 1.5 s**, which eager tiles would have spent inside pfQuest's `OnUpdate` at the first quest search. pfUI's reveal is the only reader of `tiles` and is itself gated on `C.appearance.worldmap.mapreveal`, so a player with reveal off now pays none of it; a read of `overlay.tiles` answers the same array it always did and is cached on the row after the first. Also seated in the survey fixture (`addon_harness/mod.rs`), beside the race/class tables and the zone globals and for the same reason: an in-world session with an empty `AreaTable` is a state only the harness reaches, and without it the harness is blind to this whole fault class. Tests: `pfquests_minimap_gate_opens_on_the_zone_the_area_name_table_names` transcribes `database.lua:283-289`, `map.lua:583-595`, the `minimap_sizes` metatable and the `map.lua:1383` gate, and asserts the gate is **shut** on an empty table and open on a fed one; `the_whole_area_name_column_reaches_the_vm_including_the_unmapped_rows` fails a feed narrowed to mapped rows; `an_overlay_rows_tiles_are_measured_on_first_read_and_cached_after` counts probe calls and fails an eager build. **One interaction left standing, with the reason.** pfQuest-octo knows about the duplicate names and dedups them — `overwrites.lua:922-963` deletes 16 "phantom" ids whose names collide with a functional zone's, its own comment naming the exact mechanism ("GetMapIDByName scans the locale table with pairs() (non-deterministic order), so it may return a phantom ID instead of the functional one, causing quests and minimap nodes to not appear inside those dungeons"). That deletion lands on `pfDB.zones.<locale>-turtle`, which `patchtable.lua:92` merges into `pfDB.zones.<locale>` — and then `patchtable.lua:150` calls `pfDatabase:Reload()`, which **re-points** `pfDB.zones.loc` at this call's table and drops the merged copy entirely. So the dedup no longer applies, and those seven dungeon names resolve by table order again. Left alone on purpose: which id a name "means" there is a fact about *pfQuest's database* (which rows carry NPC and object references — the fork says so in as many words), not about `AreaTable.dbc`, and the client has no way to know it. It is also not a regression in either direction — an empty `loc` resolved **every** name to nil, so all seven were already dead — and the custom zones the deleted `-turtle` names existed to supply are in this install's own `AreaTable.dbc` anyway (Dragonmaw Retreat 5600/5601, Hateforge Quarry 5098/5103, Gilneas City, Frostmane Hollow, The Rock of Desolation), which is the whole reason the fork deleted its shipped tables for this call |
|
||
| 3D unit portraits on an addon's own `PlayerModel` pane (`CreateFrame("PlayerModel", …)` + `:SetUnit`) | todo — **genuinely not built, not a bug**; the 2D path beside it works | What pfUI asks for: `api/unitframes.lua:1455-1456` creates one pane per unit frame (`pfPortraitModel<label><id>`, plus an off-screen `next` pane for change detection) and its `OnUpdate` drives `:SetUnit(unit)` then `:SetCamera(0)` (`:1222-1226`), with `:SetModelScale`/`:SetPosition`/`:SetModel` on the not-visible leg (`:2030-2033`). Every one of those verbs exists and raises nothing (`script/modelframe.rs`; probed: the pane is a live `PlayerModel` named `pfPortraitModelplayer`, `SetUnit` + `SetCamera` return clean, and `GetModel()` stays nil after `SetUnit` as it does for every unit pane here). **What is missing is on the app side and it is a feature, not a line.** A `<Model>` pane with a `SetModel` *path* renders generically — `ui_models/mod.rs` says so in its own first line, "to an addon's `CreateFrame("Model")"` — but a pane with a *unit* is routed instead to a pre-built **booth**, and the routing key is the pane's **global name**: `portrait::MODEL_PANE_BOOTHS` is a seven-entry table of the stock files' own names (`CharacterModelFrame`, `TabardModel`, `PetModelFrame`, `InspectModelFrame`, `PetStableModel`, `DressUpModel`, `AuctionDressUpModel`) whose doc says "A pane not in this table draws nothing", and the extract's unit leg is literally `let Some(slot) = name.and_then(model_pane_booth) else { return; }` (`ui_script/extract/mod.rs`). An existing test already states the behaviour with an addon-shaped fixture: `a_named_model_pane_samples_its_booth_and_an_unclaimed_one_draws_nothing`. So the pane is created, sized, shown and asked for a unit, and the app draws nothing — which is exactly "the frame is there and empty", with no error anywhere. **What it would take**, named so the next pass can cost it: a *pooled dynamic booth*, keyed by the pane's `FrameHandle` rather than by a name. Each live unit pane needs a render layer, a camera, a dressed `StageRig` for the unit and a bake target of its own — the four things `portrait::sync_portraits` builds today for each of the nine fixed `SLOTS`, which it walks as a `const` array with a layer per slot off `PORTRAIT_LAYER_BASE` and a block of `const _: () = assert!(…)` keeping every booth layer distinct. The precedent for the pooled shape is in the same file: `UI_MODEL_CAM_LAYERS = 8` already caps how many *file* panes get a perspective camera at once ("a ninth draws nothing"), so a unit-pane block alongside it, capped the same way, is the shape — pfUI alone would want up to ~48 panes (player, target, targettarget, pet, party1-4, raid1-40) and only the shown ones need a booth. The framing is already settled and already written down: a `<PlayerModel>` renders through the model's raw `cameras[1]` frozen at load (`portrait/framing.rs::body_frame`, `0x505890`), lit by `BoothLight::pane` with no glow. **Not faked in the meantime**, and the tempting fake is worth naming: an unclaimed unit pane could be pointed at the round 64² bust bake that `SetPortraitTexture` already produces for the nine slots, and it would look plausible in pfUI's square portrait box — but a `<PlayerModel>` is a live model through a model camera, not a circular bust, and a caller cannot tell a stand-in from the thing. **The 2D path is not broken**, which is the other half of the answer: `SetPortraitTexture(region, unit)` works for all nine round slots (`extract/mod.rs`'s `portrait_unit` arm, honouring the `SetTexCoord` crop pfUI applies), so a player can have 2D portraits today by ticking pfUI's "Always Use 2D Portraits" (`C.unitframes.always2dportrait`). It is off by default — pfUI ships `always2dportrait = "0"` with `portraittexture = "1"` (`api/config.lua:224-225`), and that second flag is only the *fallback* leg, taken when `not UnitIsVisible(unit)` or `not UnitIsConnected(unit)` — so for a visible target the 3D leg is what pfUI asks for and what the empty box is. A raid or party slot past the nine has no round bake either, so the 2D fallback is itself blank there; that is the same fixed-`SLOTS` limit and would close with the same pooled booth |
|
||
| 3D unit portraits on an addon's own `PlayerModel` pane — **built**: a pooled dynamic booth keyed by the pane's `FrameHandle` | done — supersedes the `todo` row above, which costed it exactly right | **This is the empty 3D portrait box in pfUI.** The row above was correct in every particular: the pane was created, sized, shown and asked for a unit, and the app drew nothing because `portrait::MODEL_PANE_BOOTHS` routed a unit pane by the pane's *global name* and `pfPortraitModelplayer` is not one of the seven stock names. Built as that row specified. **The pool** (`portrait::UnitPaneBooths`): eight dynamic booths, each a `Booths` entry like any other — render layer (`UNIT_PANE_LAYER_BASE`, a new block on top of the `UI_MODEL_CAM_LAYER_BASE` ladder), camera, root and 256² target, all spawned at `setup_booths` because a camera spawned mid-frame misses the render world's extract. Keying by `FrameHandle` rather than by name is the whole point: `pfPortraitModelplayer` and an anonymous `CreateFrame("PlayerModel")` are the same kind of caller. **Claim and release** are the UI extract's, in `convert_entry`'s `ModelPane` arm through the new `BoothBridge::model_pane_slot`: the stock seven-entry table is tried first and unconditionally, so **no stock pane changed behaviour**, and only a pane outside it claims a pooled slot. The liveness sweep (`sweep_begin`/`sweep_end`) runs on the extract's **full-conversion** path alone, the one pass that sees every drawn pane — so `QuadContent::ModelPane` carrying a unit is now **unspliceable** (`splice_simple`), exactly as a `portrait_unit` texture already was, and for the same reason: an entry whose side channel decides which booth renders must be seen by the pass that rebuilds the whole map. **Only a pane the extract actually draws** can claim, which is what keeps pfUI's cost at one booth per unit frame rather than two: `api/unitframes.lua:1456` also creates a parentless, size-less `model.next` as a `SetUnit` change detector, and probing it in the harness gives `shown=1` but `GetLeft()==nil` — no resolved rect, so the extract's `let Some(r) = eq.rect else { return }` drops it and it never costs a slot. **Past the cap a pane draws nothing**, the same answer `UI_MODEL_CAM_LAYERS`'s ninth perspective file pane gets, and deliberately **not** the round 2D bust bake the `todo` row named as the tempting fake. A slot freed by a pane that stopped drawing reaches a pane the cap refused through one extra full conversion (`UnitPaneBooths::take_handover`), gated on a slot actually freeing so that nine permanently drawn panes cannot pin the full-conversion path on. **`SetCamera` is honoured, and it is the difference between a portrait and a full body.** A `<PlayerModel>`'s camera could not ride `ModelState::camera`, which resolves against the path-keyed `ModelFileFacts` a unit's model never enters, so `camera_pending` would never clear; the new `ModelState::unit_camera` is that slot. `SetUnit`'s load installs the model's raw index 1 (`0x505890`, the characterinfo camera the paper doll frames through) and `SetCamera` overwrites the same `+0x31c` slot — so pfUI's `SetUnit(unit)` then `SetCamera(0)` (`api/unitframes.lua:1223-1224`) ends on index 0, the model's **authored portrait camera**, a bust. `framing::pane_frame_at` picks `PortraitAnchors::camera` for 0 and `pane_camera` for 1; any other index falls to the fixed rig rather than to the other authored camera (the reference installs the NULL camera there and draws orthographic, which no `<PlayerModel>` here does). Through index 1 a 36×36 pfUI portrait box — the probed size of `pfPortraitModelplayer` — would hold a whole figure a few pixels tall. **`SetModelScale` and `SetPosition` are ignored**, exactly as the four stock body panes already ignore them, and the reason is the reference's own: an authored camera is published through the same scaled model root (`0x71439b` → `0x718960`), so both cancel — that is why `framing::pane_root_scale_at` returns 1.0 wherever the installed index names an authored camera. pfUI agrees in practice: it calls neither on the unit leg, only on the *file* leg (`:2030-2033`), which `ui_models` already serves. The unauthored-camera branch, where they would **not** cancel, is not built; see the `todo` row below. **Deviation, taken for cost (`PaneLife::Still`)**: the reference's `<PlayerModel>` is live, but it drew one in the main pass with no second view, where a unit frame's portrait is on screen for the whole session and there can be eight. A pooled booth bakes a **still**, frozen at Stand with no item emitters, re-baked on the same `SnapKey` edges the body panes re-snapshot on (`SnapKey` gained the camera index, so a `SetCamera` re-aims); between bakes `gate_booth_cameras` sleeps the camera and parks the rig, and the target keeps the last render. The four stock window panes keep `PaneLife::Live`. **Measured** (release, Ryzen 7 7800X3D / RX 7900 XTX, 2560×1440): steady state, 8 live panes cost **9.0 µs of CPU a frame** (1.13 µs a pane — one descendants walk and one `SnapKey` per pane, no render); one bake costs **7.7 µs of CPU** (33-draw look, teardown plus respawn, commands applied) plus **one 256² pass for the 4-frame settle window** — eight pool cameras forced active measure **+0.11 ms a frame** over the same build with the pool not spawned (5.548 → 5.660 ms mean of 3×500 frames), and they are active only during a settle. **Ambiguity cost: zero.** `portrait::sync_paperdoll`/`sync_inspect_booth`/`sync_petdoll_booth`/`sync_stable_booth` were four identical wrappers around one `sync_body_booth` call, each paying its own ~130 `Transform` pairs; they are now one `sync_body_booths` that drives the pool too, and `UPDATE_ACTIONABLE_CEILING` **fell 5,581 → 5,194**. `UnitPaneBooths` names only pairs `PortraitImages`/`BoothPanes` already made, and `net::GuidIndex` names none. Tests: `a_named_pane_keeps_its_window_booth_while_addon_panes_fill_the_pool_and_the_first_past_the_cap_draws_nothing` (replacing the row above's `a_named_model_pane_samples_its_booth_and_an_unclaimed_one_draws_nothing`), `a_hidden_unit_pane_frees_its_pooled_booth_for_the_pane_the_cap_refused`, `a_pane_frames_through_the_raw_camera_index_set_camera_installed`, `a_unit_panes_camera_is_set_cameras_index_over_set_units_default`, `a_unit_pane_extracts_the_guid_it_pinned_or_rereads_a_token_that_named_nothing`. Image evidence: `WOW_PORTRAIT_TEST` now bakes into `UNIT_PANE_SLOTS[0]` through camera 0 beside the paper doll's camera 1, so `WOW_BOOTH_DUMP=unitpane0:<png>:6` dumps the two side by side — a bust and a full body off one model |
|
||
| 3D unit portraits on an addon's own `PlayerModel` pane — **animated**: the pool spends a shared render *rate* | done — supersedes the `PaneLife::Still` deviation the row above took, which is now withdrawn | **The row above shipped them frozen and said so**; this closes that deviation. A `<PlayerModel>` is a live model (`0x76d680`) and the reference's never stopped moving, so a still is visibly the wrong thing in a unit frame. Every pane booth now bakes `BoothMotion::Loop` (`portrait::PaneLife::motion`, which no longer has a frozen arm) and the nine round portraits keep their still, because a round portrait *is* a still (`0x524f60`). **What the still was bought with, measured rather than assumed.** The cost of a live pane here is not the draw the reference paid: every pane is its own off-screen camera, and bevy's `check_visibility` sweeps the whole visible population once per *active* view before the render world extracts, queues and encodes a pass for it. Measured at 256² against the `ui-unitframes` capture's 14.9 k-entity outdoor scene (release, Ryzen 7 7800X3D / RX 7900 XTX, `WOW_FPS_PROBE=3000` + `WOW_FPS_JOURNAL`, nine content booth cameras forced active against the same build with none): **+38 µs of GPU, ~0.4 ms of process CPU across all threads, ~60-90 µs of main-thread CPU and ~0.1 ms of wall frame time, per camera-frame**. The GPU figure is tight (0.570 → 0.910 ms, every repetition); the CPU figures scatter ±25-50 % run to run. Eight of those every frame — what `PaneLife::Live` for the pool would mean — is **~0.8 ms of frame time and ~3.2 ms of process CPU a frame at any frame rate**, and it grows with the frame rate, which is the wrong direction for a 36-point portrait box. **So the pool spends a rate, not a camera per pane per frame** (`PaneLife::Paced`): `boothPaneRate`, benilla's own CVar, is **60 pane renders a second shared between the panes actually drawn** — one portrait up animates at 60 Hz, the usual player+target pair at 30 Hz each, four at 15, the full eight at 7.5, and a pane can never render faster than the client draws frames. The whole feature therefore costs the same at 20 fps as at 300 and the same with one portrait up as with eight: **2.3 ms of GPU, ~24 ms of process CPU and ~4.5 ms of main-thread CPU a second**, which at 144 fps is **+0.016 ms GPU, +0.17 ms process CPU and +31 µs of main thread a frame** — about a twentieth of what full-rate live would have cost there. `boothPaneRate 0` freezes the pool back to the still. **Between renders the camera sleeps and the rig parks**, and that is what makes a coarse cadence look like animation rather than slow motion: `AnimParked` holds the *pose* while the sequence clock keeps running (`benilla_world::rig_anim`, whose `parked_rig_freezes_bones_only` is exactly this property), so each wake lands on the pose the absolute clock says. The clip plays at the rate the artist authored; only the sampling is coarse. The park and the wake are the same edge `gate_booth_cameras` already drove, so no second system and no new resource: **ambiguity cost zero**, `UPDATE_ACTIONABLE_CEILING` unmoved at 5,194. **The eight ticks are staggered** by each drawn pane's rank in the cadence (`portrait::pool_pace_phases`) — rank and not slot index, because the period shrinks with the pane count, so a fixed eighth-of-a-period offset would land three panes' renders inside one frame and idle the next five. Above the budget's own rate no frame ever wakes more than one booth camera. **Item emitters stay off a paced pane** (only `PaneLife::Live` spawns them): a booth that renders one frame at a time has its emitters frozen in between, so a weapon glow would crawl instead of running, and through the camera-0 bust a unit frame asks for it is out of frame anyway. The four stock window panes keep `PaneLife::Live` with their emitters. Tests: `eight_pooled_portraits_spend_the_same_render_budget_as_one_at_any_frame_rate` (the budget, the even split and the stagger, over 20/60/144/300 fps × 1-8 panes), `a_pooled_unit_pane_animates_at_the_pools_rate_where_a_window_pane_runs_live` (the policy: pooled ⇒ paced ⇒ looping and emitter-less; stock ⇒ live), `a_zero_pane_rate_freezes_the_pool_instead_of_running_it_slowly`, `a_stalled_frame_owes_the_pool_one_render_and_not_the_whole_backlog` (phase-locked, so a hitch is not paid back as a burst), and `cvars::tests::registered_defaults_mirror_the_code_truths` welds `boothPaneRate`'s registered 60 to `PaneRate::default()`. Image evidence: `WOW_PORTRAIT_TEST` now bakes its two *pane* slots looping, as both are live panes in production, so `WOW_BOOTH_DUMP=unitpane0:<png>:<secs>` at 6 s and at 12 s are two different poses of the same model (MAE 34.6 over the 256² target, 54 % of pixels moved) where before they were identical |
|
||
| A unit pane's `SetModelScale`/`SetPosition` on a display with **fewer than two cameras** | todo — the branch where the factors do not cancel | Named by the row above rather than guessed at. Where the installed camera index names an authored camera the reference publishes it through the same scaled root (`0x718960`), so `SetModelScale` and `SetPosition` cancel and ignoring them is exact. Where it does not — a display with no `cameras[1]`, or any index past the file's table — the fixed pane rig (`0x7ac930`) sits *outside* the model and would see both; `framing::pane_root_scale_at` already applies `pane_model_scale(display_aspect)` there, and the two Lua factors would multiply into it. Not built because nothing in the bundle exercises it: pfUI calls neither verb on the unit leg (only on the file leg, `api/unitframes.lua:2030-2033`, which `ui_models` serves), the four stock body panes call neither, and `SetPosition`'s unit ladder for a booth (`0x76d1a0`'s `pos · layoutScale`, in model units) is only written down for the *ortho* leg — adopting it for a perspective booth with no caller to check against would be a guess that looks like a feature. The place is `framing::pane_root_scale_at` plus a root translation in `sync_body_booth`'s pose block |
|
||
| `GetPlayerGuid()` / `IsPlayerGuid(guid)` | done | `compat/classic_api/unit.rs`, beside `UnitGUID`, off the same `Model::unit("player")` read so the three cannot disagree. **Not DLL exports**: ClassicAPI ships them as two lines of its own embedded Lua, which `strings ClassicAPI.dll` quotes verbatim — `function GetPlayerGuid() return UnitGUID("player") end` and `function IsPlayerGuid(guid) return guid == GetPlayerGuid() end`. Built in Rust rather than as a Lua chunk, since `benilla-ui` holds no persistent Lua `Function` handles (`LUAI_MAXCSTACK`). **Found by firing nampower's aura events into pfUI**: every handler of `AURA_CAST_ON_*` and `DEBUFF_*_OTHER` in this install calls one of them *unguarded* on the line it needs a caster — `libs/libdebuff.lua:1143`, `:1256`, `:1407` and `modules/innervatecall.lua:66` — so with these nil each event raised a Lua error and the handler's whole body was skipped. A dead feature made noisy instead of working is the same mistake in the other direction; the discrimination test for this renames `GetPlayerGuid` and watches the debuff-event transcription go silent. A unit *token* answers false from `IsPlayerGuid`, as upstream's `==` against a GUID string does |
|
||
| `GetInventoryItemRepairCost` (restated) — **built; the earlier decline rested on a false premise** | done | The row above declined it because "1.12 computes repair cost **server-side only** and the client never receives or derives a per-item figure". That is wrong twice over, and the second refutation is in the reference itself. (1) This tree already had the arithmetic: `ui_merchant::item_repair_cost` is the reference's `0x4faf30` over `benilla_formats::DurabilityTables` (`DurabilityCosts.dbc` × `DurabilityQuality.dbc`), and `repair_all_cost` already summed it across the equipped slots and the bags for `GetRepairAllCost`. (2) **`GameTooltip:SetInventoryItem(unit, slot)` returns the per-item repair cost as its third value in stock 1.12** (`0x532ee0`), and stock `PaperDollFrame.lua` prints the "Repair Cost" line off that return under `InRepairMode()` — no packet is involved at any point. What 1.12 lacks is only a *slot-keyed getter*; the figure itself was always client-side, and benilla's own third return had been hard-wired to 0 on the same mistaken belief (now live, so that stock line works too). Upstream confirms the shape and the one-helper design: ClassicAPI's `src/item/RepairCost.cpp` says in as many words "Matches the third return value of `GameTooltip:SetInventoryItem`", and the same helper backs `C_Container.GetContainerItemRepairCost` and the engine's `RepairAllCost` (`0x4fbd60`). **Shape:** one number, always — `0` for an empty slot, an item with no durability, a fully repaired one, or anything neither weapon nor armor, because upstream forwards 0 "because the caller asked 'what's the repair cost' and 0 is a meaningful answer". Deliberately *not* `GetInventoryItemDurability`'s three-case discipline: that one answers **nothing** for an empty slot because its callers branch on `if cur and max then`, where this one's callers *sum* (`pfUI/modules/panel.lua:417` adds `(GetInventoryItemRepairCost(id) or 0)` across the band). A non-number raises the usage error upstream raises; a slot below 1 answers 0. **Feed:** a new `InvSlotView::repair_cost`, computed in `ui_char.rs`'s `slot_view` by calling `item_repair_cost` itself rather than copying the arithmetic — a quote that disagreed with the vendor's bill would be a bug no caller could detect. **Cost:** nothing per frame. `feed_char` is edge-gated and a slot's cost can only move when its item, its template or the discount does; measured at **+2.0 µs per gate opening** in the worst case (12.9 µs vs 10.8 µs for a whole `feed_char` with all 19 slots worn and damaged, release). Outside the gate the only new work is the discount, which short-circuits on `MerchantOpen::vendor` being `None`. **Ambiguity: zero** — the one new pair (`feed_char` ↔ `close_npc_session_out_of_range<MerchantOpen>` on `MerchantOpen`) is declared away with `.after`, which is also the right order: walking out of a vendor's range must put the list price back in the frame the window shuts. **Discount:** the fed number carries the open merchant's price discount (`0x4faf8a`) and the full list price with none open — not a fallback but `0x4faf30`'s own behaviour, which takes a discount only once it resolves the merchant guid, and which upstream states for this very function ("outside a vendor interaction the engine short-circuits the discount path and we get the raw base cost"). So pfUI's panel tooltip, read away from a vendor, quotes the full price; read at a repair vendor the player is Honored with, it quotes the 10% less that vendor will actually charge, and the merchant window's own rows and `GetRepairAllCost` move with it because all three are this one number. The player is never under-quoted — a discount only ever takes money off — and the figure never disagrees with the bill. Not built alongside: `C_Container.GetContainerItemRepairCost`, which is now unblocked for the same reason and needs only the same field on `ContainerSlot` (it would feed `GameTooltip:SetBagItem`'s third return too, still 0) |
|
||
| `IconDataProviderMixin` + `IconDataProviderExtraType` + `IconDataProviderIconType` + the four `Get*MacroIcons` mutators + `CreateAndInitFromMixin` (restated twice as todo) — **built; the enumeration was reachable and cheap, and the enum values were readable** | done | The two rows above were right that the constant alone fixes nothing and wrong on both blockers behind it. **Blocker 1, "the mixin needs an icon catalog this VM has no feed for at all":** it has had one all along — `Model::macro_icons`, loaded once per VM by `ui_macro::load_icon_catalog` from `benilla_formats::load_macro_icons`, which walks the chain's `(listfile)` union. **Measured on this install:** the walk is 102,144 names in **471 ms** (release) and is *already paid* for 1.12's own `GetNumMacroIcons` chooser; classifying all three lists out of it is **1.4 ms**. The only half that was missing is the `INV_*` item bucket 1.12's `BuildMacroIconList` filter throws away — **2,384** entries against **670** spell ones, about **100 KB** resident and **~1 ms** of already-done I/O, in the same single walk (`load_macro_icon_catalog`). A picker therefore shows **3,054** icons on this install (1 question mark + 669 spell + 2,384 item), verified end to end against the real catalog: constructing the provider is **447 µs**. **One real cost had to be fixed, and it is upstream's own shape, not ours.** pfUI's search box re-walks the whole catalog per keystroke, calling `GetNumIcons` *and* `GetIconByIndex` per icon, and the mixin calls `table.getn` on both buckets inside each — where 1.12's `table.getn` is `luaL_getn` (`0x6f5050`, faithfully reimplemented in `lua50/table_size.rs`), which **counts `rawgeti` from 1 to the first nil** when no size is remembered. Measured: **95 µs** per `getn` on the 2,384-entry bucket, so a keystroke cost **315 ms** — about eight frames. The four mutators now record 5.0's remembered size with `table.setn` after appending, which is what 5.0's own `table.insert` does on every append and what `luaL_setn` exists for, so `getn` takes its first branch: the same keystroke is **3.0 ms**, a 103× drop, with the mixin's body unchanged and nothing visible added to the caller's table (the size goes to 5.0's weak registry store). `each_enumeration_records_the_remembered_size_so_getn_is_not_a_walk` pins it as a mechanism, not a duration — a stored size wins over the walk, so blanking the last slot must not change `getn`. **Blocker 2, "the values cannot be taken from the one call site":** true of the call site, but upstream publishes the file — ClassicAPI ships `IconDataProviderMixin` as *addon Lua*, `AddOns/!!!ClassicAPI/Util/IconDataProvider.lua`, whose own comment states `EnumUtil.MakeEnum("Spell", "Item")` is `{ Spell = 1, Item = 2 }` and which spells `IconDataProviderExtraType` as `{ Spellbook = 1, Equipment = 2, None = 3 }`. **Blocker 3, the `loose`/`mpq` axis** `macro_ext.rs` called unanswerable: answered rather than dodged. benilla reads only the archive chain and has no loose-file icon scan anywhere, so `loose` is empty **by construction**, not by under-reporting — and the one consumer cannot tell either way, because the mixin calls all four and concatenates (upstream's own note records that its tags were once inverted and nobody noticed for exactly that reason). All of it lives in `compat/classic_api/macro_icons.rs`: the four mutators in Rust over two new `Model` buckets, and the mixin as a Lua chunk over the existing `CreateFromMixins`, as `item_location.rs`, `item_mixin.rs` and `pools.rs` already do. **Shape, which a test caught:** bucket entries are **uppercase basenames** with no `Interface\Icons\` prefix and no extension (`"INV_SWORD_25"`), because the mixin concatenates `INTERFACE\ICONS\` itself — deliberately *not* 1.12's `GetMacroIconInfo` shape, which is the full path. And the bucket key strips at the **first** dot (upstream's `NormalizeIconKey`) where 1.12's chooser strips at the last: the first pass of this work got that wrong and `Ability_Druid_Mangle.tga.blp` came out as `ABILITY_DRUID_MANGLE.TGA`, which `the_icon_picker_buckets_are_bare_uppercase_stems_that_all_resolve` failed on. That test also checks every entry resolves through `benilla_assets::sprite_candidates`, so no grid cell draws as a white square. **Two deliberate deviations**, both documented in the file: the question-mark seed upstream hooks into 1.12's *engine* list is not copied (1.12's own `BuildMacroIconList` does not have it, and the mixin puts the question mark at index 1 itself), and the `Equipment` extras are the **worn** items via `GetInventoryItemTexture("player", slot)` — which is what upstream's own file header describes the type as — rather than upstream's broader `GetInventoryItemsForSlot` walk, which needs an `InventoryType`→slot mask, the `SMSG_SET_PROFICIENCY` table benilla does not track and packed `ItemLocation` keys. The extras are a deduped seed prepended to the full list, so the narrower set costs a caller nothing it cannot reach by scrolling, where the alternative was the Equipment branch raising on a nil global — the half-feature this row was declined twice to avoid. **Cosmetic gap, not built:** `CreateIconPicker` labels its filter dropdown with `ICON_FILTER_ALL`/`ICON_FILTER_SPELL`/`ICON_FILTER_ITEM` and `ICON_SELECTION_TITLE_CURRENT`, later-client GlobalStrings nothing here defines; they read nil, so those three labels draw blank and nothing raises. Inventing English for them is a GlobalStrings question, not this one |
|
||
|
||
## VanillaUtils.dll — inferred (bespoke OctoWoW build), lineage now partly confirmed
|
||
|
||
| Area | Status | Notes |
|
||
|---|---|---|
|
||
| `vanilla1121_*` function family | inferred, upgraded | `Vanilla1121_functions.cpp`/`.h` is a REAL file in the open-source `MarcelineVQ/UnitXP_SP3` repo — `vanilla1121_getVisiableObject`, `vanilla1121_inCombat` and others really exist there under those exact names, not a VanillaUtils-only invention. But in that repo they're native C++ helpers the hook calls internally (no `lua_register`/`luaL_Reg` for them found in its `dllmain.cpp`), not confirmed as Lua-callable globals there. This install's own export table showed a Lua-registration wrapper set (`lua_pushnumber`/`luaL_openlib`, …) alongside these names, which is still the best evidence they *are* exposed to Lua on this specific build — a different/extended fork from the mirror checked. Net: the names and rough purpose are now confirmed real; whether they're Lua globals here, and their exact argument order, are not |
|
||
| `UnitGUID(name)` | todo, inferred | directly exported in the mirror too, name→GUID; behaviour (what "name" matches — a currently-visible unit, a roster entry, something else) still unconfirmed |
|
||
|
||
## VanillaHelpers.dll (this build) — inferred (differs from upstream `isfir/VanillaHelpers`)
|
||
|
||
| Area | Status | Notes |
|
||
|---|---|---|
|
||
| `SetUnitBlip(unit[, texture[, scale]])` | done | `compat/vanilla_helpers.rs` queues a `BlipOverrideRequest` keyed by the unit's resolved GUID (so SuperWoW's raw-GUID and `markN` tokens work here too); `minimap::blips::drain_blip_overrides` folds it into the `BlipOverrides` resource, loading the named texture once through `minimap::blip_texture_path` (FrameXML-style `Interface\…` spelling → an `mpq://…blp` asset path); `emit_party_dots` draws the override's whole texture at its own scale instead of the party atlas cell. A one-argument call clears the override. 1.12 has no generic per-unit minimap blip at all — only party members, tracked/quest dots and landmarks — so party dots are the only unit blip there is to retarget |
|
||
| `SetObjectTypeBlip(type[, texture[, scale]])` | partial | the Lua side is built and queues an `ObjectType(u32)` override keyed by `AreaPOI` icon id (the only per-landmark key this client carries), and the override map stores it — but `emit_landmarks` does not read it yet, so the call is currently inert. What "type" actually indexes in the real DLL is unpublished (plausibly its own landmark-category enum, not `AreaPOI.icon`); wiring the draw site to a key whose meaning is a guess would make a wrong override silently paint the wrong landmarks, so the read is deliberately left for a pass that can check it against the real mod |
|
||
|
||
## Minimalist-AutoLogin-Plus (`Stormhand-dev/Minimalist-AutoLogin-Plus`) — a GlueXML addon, rebuilt natively
|
||
|
||
Not a DLL, and the reason it is in this document at all is the same as theirs: **it cannot be
|
||
installed.** It is Lua under `Interface\GlueXML\`, and this client runs no glue Lua. The login,
|
||
character-select and character-create screens are Rust (`login/`, `char_select/`, `char_create/`);
|
||
the only glue files read are read as *data*, not script (`glue_strings.rs` text-parses
|
||
`GlueStrings.lua`, `challenges.rs` text-parses `ChallengeList.lua`). There is no glue VM, no
|
||
`GlueXML.toc` loader and no glue widget surface to build frames against, so dropping the mod's
|
||
files into `Data/Interface/GlueXML/` leaves them inert — nothing opens them. Its behaviour is
|
||
rebuilt in `autologin.rs` instead.
|
||
|
||
| Area | Status | Notes |
|
||
|---|---|---|
|
||
| Remember the password | done | `autologin.rs` holds the store; `login::enter_login` fills both boxes when the screen comes up. **Fill only — deliberately not automatic.** The mod submits the form and enters the world for you; both halves of that were built here and then cut on the player's own call. An auto-login that lands you in the world is a client you cannot get a screen out of without knowing a trick, and the saving over a filled box is one click. The fill is gated on the tick as well as on the stored text, so unticking stops it in the same frame rather than waiting on the secret being cleared |
|
||
| Credential storage | **superseded: encrypted** | `benilla-config/autologin.toml`, written `0600` by `local_state::write_atomic_private` (asserted, including that it tightens a file that was already world-readable). Plaintext is the mod's own behaviour and its README's own warning; `0600` protects it from other users on the machine and from nothing else — anything running as this user can read it, and it lands in any backup of the folder. Its own file rather than a key in `config.toml`, because that is the file a player pastes into a bug report. Written **on the roster**, not at submit: a roster is the proof the credential was accepted, so a typo is never persisted and refilled on every later launch. Unticking the box **clears the password**, since that is the only way to get it off disk from inside the game. **Superseded at the player's explicit request:** the box now writes `password_encrypted`, the authenticated blob of `crate::credential`, through `AutoLogin::remember`; `migrate_plaintext` upgrades an existing plaintext file in place on the next launch, and `effective_password` still reads the old field so a file written by an older build logs in once before it is upgraded. What changed is the file; what did not is the threat model — see the credential section below for what the blob answers and what it cannot |
|
||
| Remember the last character | done, **selects only** | `char_select::apply_roster_policy`. The roster is always shown — nothing enters the world for the player. Remembered by **name**, and it outranks 1.12's own `lastCharacter` CVar, which is not a preference but a correctness fix: that CVar is a row *index*, and this screen now lets the player reorder rows, so after a reorder the index names whoever moved into that slot. A name survives reordering; an index does not. A remembered character who is gone (deleted, or another realm) falls back to the stock row. Recorded in `select_input` — the path that means *the player chose* — so a reconnect's re-pick and the env fast paths cannot rewrite it |
|
||
| Login screen cleanup | done | `cleanLoginScreen` (CVar, default **1**) drops the Blizzard logo, the `BLIZZ_DISCLAIMER` line and the version string. That is the whole of what this screen had left to hide: the rest of the mod's list — the Turtle website/armory/forum/Discord/Reddit buttons, Credits, Cinematics, Terms of Use, ESRB, Manage Account, Community Site, server news — **this screen never drew**, because `login/` keeps only what logging in needs (its module doc says so, and predates this work). Read once in `enter_login` and latched on `LoginForm`, not read per frame where it is used: the latter measured at six undeclared orders against every writer of the registry, for a value that cannot change while the screen is up (there is no console at the login screen to write it from) |
|
||
| Character row reordering | done | The mod's per-realm `order` + `char_buttons_locked`. `autologin::apply_order` is a permutation by construction: saved names first in their saved order, anything unmentioned keeping its server order on the end, so a character created since the order was saved appears at the bottom rather than vanishing and one deleted elsewhere is skipped rather than leaving a hole. Applied in `apply_roster_policy` *before* anything indexes a row, because the remembered-row CVar, `select_created_by_name` and `WOW_CHARSELECT_PICK` all mean rows as the player sees them. The arrows fold into `refresh_list`'s existing row loop (they are `Button`s carrying a `SelectAction`, so that query already walked past them) rather than a second `Visibility` writer. A move keeps the selection on the *character*, not the row — swapping rows under a highlighted name would silently re-aim Enter World at its neighbour |
|
||
| The button's label | deviation | The mod labels it `Unlock Characters` always and greys the caption while unlocked. Here it is **`Reorder Characters`**: a greyed caption is this screen's own word for *disabled* (`glue_hilights` greys exactly that way), and a fixed label is wrong in one of its two states. Naming the mode is accurate in both and needs no retitling — which would mean rewriting the caption's outline copies too. The arrows appearing on the rows are the state readout |
|
||
| Multi-account manager | not built | The account button column, per-account labels, autocomplete-by-letter on the username field, add/remove/reorder of accounts, and the per-account character memory that goes with them. One account is what the store holds; the rest is a second screen's worth of UI. Scoped out deliberately, not blocked |
|
||
| The mod's own persistence | n/a | It stores through SuperWoW's `ImportFile`/`ExportFile`, which are themselves unbuilt here (the SuperWoW "new functions" row above). This writes its own file instead, so the dependency never arises |
|
||
|
||
|
||
## pfUI-for-ClassicAPI — the measured gap, and what of it actually fires
|
||
|
||
This pfUI build is written **against ClassicAPI**, so "1.12 does not have it" is not by itself a
|
||
reason to decline a function: the realm ships that DLL and the addon is entitled to the surface.
|
||
Three earlier declines were wrong on exactly that ground and are now built (`UnitSpellHaste`,
|
||
`Item:CreateFromItemLink`, `C_ChatBubbles`).
|
||
|
||
**Method.** Every `C_<Namespace>.<Member>` and bare `Upper(` call across the installed AddOns
|
||
(1,784 candidates) was extracted statically and then resolved against a **loaded** VM — so what is
|
||
left is what an addon can actually reach and find missing, not what a grep imagines. 37 of the
|
||
misses are `C_*`, and **all 37 are in ClassicAPI's own surface** (`docs/classic-api-functions.md`;
|
||
`C_EncodingUtil` spells its pair `EncodeBase`/`DecodeBase`, which is why a naive match drops it).
|
||
|
||
**Reachability is the thing that matters, and most of the 37 are unreachable.** pfQuest guards
|
||
every one of its ClassicAPI calls (`if not (C_TaxiMap and C_TaxiMap.GetTaxiNodesForMap) then`,
|
||
and likewise for `C_SpellBook.GetSkillLineRank`, `C_Map.GetAreaTriggerInfo`,
|
||
`C_Item.RequestLoadItemDataByID`). Two whole pfUI modules gate themselves off at load and so can
|
||
never reach the members they would otherwise call: `equipmentmanager` returns at its first line on
|
||
`if not C_EquipmentSet`, and `loothistory` only runs `FullUpdate` while its frame is shown, which
|
||
needs `LOOT_HISTORY_FULL_UPDATE`/`ROLL_CHANGED`/`ROLL_COMPLETE` — three events this client does not
|
||
fire. **A note in an earlier revision of this file claimed `Item:CreateFromItemLink` "will raise on
|
||
a loot roll"; it does not, because that module never runs.** The call path was never checked.
|
||
|
||
| Area | Status | Notes |
|
||
|---|---|---|
|
||
| `C_ChatBubbles.GetAllChatBubbles` | done, **empty list** | The one genuinely hot absence the sweep found, and reproducible: `pfUI.bubbles:ScanBubbles()` raised `attempt to index global 'C_ChatBubbles'`, and pfUI runs it the frame after **every** `CHAT_MSG_SAY`/`YELL`/`MONSTER_SAY`/`MONSTER_YELL` within earshot. Answers an empty list, which is the truthful count rather than a stub: bubbles here are drawn natively by `chat_bubble.rs` as a 2-D world overlay, exactly as the reference's own `CGChatBubbleFrame` is, so there are no Lua frames to hand over and no regions a script can repaint. The cost is that bubbles keep this client's look instead of pfUI's; making them addressable (a frame + texture + `FontString` per live bubble, kept in step with the native spawn/stack/expiry rules) is the real fix and is not built |
|
||
| `C_Spell.GetSpellDispelType` | done | The other hot one: `libs/libdebuff.lua:379` calls it once per debuff while walking aura slots 33-48, so it runs for every debuff on every scanned unit — continuously, in combat, which is when the debuff display matters. Backed by widening [`SpellLookup`] with `Spell.dbc`'s `Dispel` column, the route this file already named for the `C_Spell` predicates; `SpellDisplay` carried it already, so nothing new had to be parsed. Answers the **raw id**, not `SpellDispelType.dbc`'s localized name, because the caller keys its own `dispelTypeMap` on the number and a name would make a localized realm answer something that table cannot match. `0` ("no dispel class") answers **nil**, which the call site's `if dispelId and dispelId > 0` already reads the same way. The book arm takes it from the probe alongside `reagents` — a spellbook slot carries neither column |
|
||
| `Item:CreateFromItemLink` | done | Keyed on the id inside the link, which is the same object here: every `ItemMixin` getter reads a `C_Item.*ByID`, and a 1.12 link carries nothing per-item beyond the id — no bonus ids, no modifiers, no scaling — so the link and id forms cannot disagree about anything the mixin exposes. A link naming no item (nil, a spell or quest link, a malformed string) yields an **empty** item rather than raising, which is what the call site's own `if item and not item:IsItemEmpty()` expects. Built for correctness, not for a live error: its only caller is the loot-history module, which never runs |
|
||
| `UnitSpellHaste` | done, **0** | 0 is the *answer*, not a stub: 1.12 has no haste stat anywhere — not a `UNIT_FIELD`, not an item mod, not a spell modifier — so every unit genuinely has 0% spell haste and the real DLL has nothing else to read either. pfUI's player module calls it unguarded into a `string.format("%.1f%%")` (`modules/player.lua:87`) whenever its haste readout is on, so the absence raised once per 0.25s tick and took the neighbouring "+N SP" line down with it |
|
||
| `C_EquipmentSet` (13 members) | not built, **unreachable** | 1.12 has no equipment sets at all. pfUI's manager is a backport of the 4.3.4 `GearManagerDialog` and gates itself off at its first line (`if not C_EquipmentSet or not C_EquipmentSet.CanUseEquipmentSets() then return end`), so the module never loads and none of the 13 is ever called — verified in a loaded VM (`pfUI.equipmentmanager` is nil). Building the read half to answer "no sets" would load a manager that can only ever show an empty list; building the write half means a persisted set store and an equip sequencer, which is a feature, not a compat shim |
|
||
| `C_LootHistory` (4 members) | not built, **unreachable** | Needs the three `LOOT_HISTORY_*` events as well as the namespace — the module's `FullUpdate` returns unless its frame is shown, and only those events show it. The data is on the wire and already parsed (`LootStartRoll`/`LootRoll`/`LootRollWon` carry the roll key, the roller, the dice value, the vote and the winner), so a real history is buildable: accumulate per `(looted_target, item_slot)`, resolve roller GUIDs to name/class, fire the three events. A feature worth doing on its own, not an error to patch |
|
||
| `C_TaxiMap.GetTaxiNodesForMap` — **the flight-master layer** | done | `compat/classic_api/taxi_map.rs` over a new feed, `ui_taxi/map_nodes.rs`; supersedes this member in the "not built" row below, which stands for the rest of its list. The guard that row cites is exactly why it mattered: pfQuest *is* guarded, so the absence produced no error at all — `pfDatabase:SearchFlightNodes` returned an empty table, the user's "flight masters" option stayed ticked, and nothing was ever drawn. Same silent-by-construction shape as `C_Map.GetAreas` above, and the same consumer. This install's pfQuest has **deleted** its shipped `pfDB.meta.flight` table for this call (`db/meta.lua:181` is a stub whose comment says the data "always comes live from the client (ClassicAPI)"), so there is no fallback behind it. **The contract is half read off the DLL and half off the caller**, and the file says which is which: `C_TaxiMap`, `GetTaxiNodesForMap`, `nodeID`, `faction`, `reachable`, `state` and the `FlightPathFaction`/`FlightPathState` enum names are all in the bundled `ClassicAPI.dll`'s string pool; the `areaID`/`mapX`/`mapY` triple is **not** separately visible there (the pool holds one copy of each string and `C_Map.GetAreaTriggerInfo` already uses all three), so for those the authority is `SearchFlightNodes` itself — it reads all six fields, gates on `if show and node.areaID and node.mapX and node.mapY`, and feeds the pair into `pfMap:AddNode` as `meta.x`/`meta.y`, which `UpdateNodes` divides by 100, so they are **percentages of the zone rect**. Faction comes from the two `MountCreatureID` columns, which is the reference's own gate (the taxi window asks for the mount of the player's half, `0x4a2650`): one column filled is that faction, both is neutral. A row with **neither** is reported neutral rather than dropped, deliberately and with the reason in the code — OctoWoW ships custom flight points and a custom row with unfilled mount columns is still a flight master standing in the world, so a stray icon is the cheaper mistake than a missing feature; the seed logs that count so the choice stays visible. Zone and percentage come from `WorldMapUiData::zone_at`, which asks `map_proj::zone_uv` for containment rather than comparing rects itself, so a node is only ever attributed to a zone whose own map the window would place it inside; where rects nest (every city sits inside its parent zone) the **smallest** wins, because a flight master in Ironforge belongs on the Ironforge map, which is also the map the player is looking at while standing there. 1.12 has no verb that asks this question, so that choice is ours and is labelled as such. **Seeded before the interface loads**, beside the world-map catalog and after it (it reads those rects), and the deadline is tighter than file scope: pfQuest replays its saved tracking selections once on `PLAYER_ENTERING_WORLD` and then calls `this:UnregisterAllEvents()` (`database.lua:131-151`), so a push one frame later is a push after the only read and the layer stays empty for the session. **Not built, each for the same reason**: `GetTaxiRoute`, `GetTaxiPaths` and `GetTaxiPathWaypoints` are `TaxiPath`/`TaxiPathNode` data, which `ui_taxi/routing.rs` loads for the window's own route search and has never pushed into the VM, and nothing in the corpus calls them; `GetAllTaxiNodes` is in the pool with no caller to read its shape against, and guessing it would be inventing a contract rather than matching one. Also absent from each node, with the reason stated in the file: `reachable` and `state`, which are session facts the open window owns (`model.taxi`, which exists only while a flight master has the frame up) — answering `reachable = false` for all 60 nodes because no window is open would be a confident wrong answer, where an absent field is a truthful "this build does not say" |
|
||
| `C_EncodingUtil` (8), `C_FriendList.SetFriendNotes`, `C_Item.GetCurrentItemLevel`, `C_Map.GetAreaTriggerInfo`, `C_SpellBook.GetSkillLineRank`, `C_TaxiMap.GetTaxiNodesForMap`, `C_Spell.IsSpellInRange`/`IsNextMeleeSpell`/`IsRangedAutoAttackSpell`/`ResetsMeleeSwing`, `C_Item.RequestLoadItemDataByID`, `C_LootHistory.*` | not built | Each either guarded by its caller (all of pfQuest's) or behind a user action not yet taken: `SetFriendNotes` on editing a friend note, `SerializeJSON` on sharing a config, `GetCurrentItemLevel` behind `if ShaguScore` (an addon not installed), the hunter range pair on a hunter's bar. None raises in ordinary play as installed |
|
||
|
||
|
||
## The offline UI-load harness — why 349 tests were failing, and what is fixed
|
||
|
||
Found while answering "is anything broken?": `cargo test` with `WOW_DATA` set had **349 failures**
|
||
that had been invisible, because every data-backed test silently *skips* without that variable and
|
||
no routine run set it. None was a product fault — the live client's own log has no loader errors at
|
||
all — but they meant a large slice of the integration suite was not actually exercising anything.
|
||
|
||
Two of the four causes are fixed, **349 → 286**.
|
||
|
||
| Cause | Count | Status |
|
||
|---|---|---|
|
||
| A harness loads one XML on its own, so a template an earlier TOC entry defines is simply absent. The loader only **warns** and builds the frame without its art, so the test passes against a different, artless frame than the client draws — a silent wrong answer, not a failure | 120 (`StaticPopup.xml` ← `MoneyInputFrameTemplate`, `ItemButtonTemplate`) + 52 (`PaperDollFrame.xml` ← `UIDropDownMenu_Initialize`) | **fixed** — `test_ui.rs`'s new `PREREQS` table, with the reference TOC's own line numbers as the proof of order, and a per-VM ledger so a prerequisite seated once is not built twice (these files are not virtual-only; each builds concrete frames too) |
|
||
| The realm's own FrameXML reads a **player at file scope** — `Turtle_ShopUI.lua:28` and `Turtle_TransmogUI.lua:17` both run `strlower(UnitRace("player"))` in their main chunk, which raises on nil and takes the file with it, leaving every `*_OnLoad` it defines undefined and each of its frames failing in turn. Six errors from one missing unit | 32 | **fixed** — `load_default_ui` seats a minimal `PlayerRecord`. Not a fiction: that function is only ever reached offline (tests and `addon_harness`), and in the live client FrameXML loads *on entering the world*, where a player always exists. The offline load was the one diverging from the running client |
|
||
| `FloatingChatFrame.xml` needs a deep chain — `ChatFrame`, `GameTooltip` (for `TOOLTIP_DEFAULT_COLOR`, which lives there, not in `Globals.lua`), `UIDropDownMenu`, `UIMenu`, `UIPanelTemplates`, `UIParent.lua`, `GlobalStrings.lua` for the `EMOTE<n>_TOKEN` sort | 137 | **not fixed.** The chain was walked to the end and *does* make the file load clean in isolation — but auto-seating it inside every harness made the suite **worse** (286 → 363): these are heavy files that build frames, and harnesses which deliberately use a minimal VM then fail different assertions (`SetFont` with a nil font). Reverted. The fix is per-harness seating, not a global table |
|
||
| Reference-vs-OctoWoW data assertions (`ChrRaces.dbc` row count, `AUTH_BANNED` not patched by `Localize()`, chooser catalog size) and knock-ons | the rest | **not fixed**, and arguably not bugs: the tests pin a stock chain, this install is a modified realm |
|
||
|
||
**The lesson worth keeping**: a loader that *warns* on a missing template rather than failing turns a
|
||
harness gap into a passing test that measures the wrong frame. `load_ui_strict` exists precisely to
|
||
catch that — the 120 were tests that already used it, which is why they showed up at all once the
|
||
suite was actually run with data.
|
||
|
||
|
||
## `AttackTarget` and the OctoWoW radio
|
||
|
||
| Area | Status | Notes |
|
||
|---|---|---|
|
||
| `AttackTarget()` | done | A **stock 1.12 engine global** (`0x489b50`, `reference/1.12-globals.tsv`), not a ClassicAPI backport — it was simply never built. pfUI's nameplates call it on a click that targets a hostile (`modules/nameplates.lua:1930`), so every such click raised: 48 in one logged session. Queued as `SelectionRequest::AttackTarget` and handed to **the binding's own system** rather than acted on where it lands — the reference reaches one body from both this global and the Attack action (`0x612df0`), and that body is `ui_action::drain::attack_target_binding`, which already owns the attack-actor refusals, the engaged/not-engaged toggle and the "no target, acquire the nearest" arm. A second copy beside it would be a second opinion about when a swing is legal |
|
||
| OctoWoW radio — the minimap button | done | The realm bakes **two** files into its FrameXML that benilla's manifest did not carry: `EverlookBroadcastingCo.xml` (its TOC 129) builds the `EBC_Minimap` boombox button, and `OctoRadioPlaceholder.lua` (146) hangs the station menu off it. The order is load-bearing, not cosmetic: the radio file's first line is `if not EBC_Minimap then return end`, so with only the second one the UI silently does nothing. Both added in the chain's own order; verified in a loaded VM that the button exists, is shown, and that the radio hooked its `OnClick` |
|
||
| OctoWoW radio — the audio | done | `sound/http_stream.rs`. The fetch is a **plain socket**, not an HTTP crate: the endpoint was probed first and answers `200`, `Content-Type: audio/mpeg`, `Connection: close`, no redirect, no chunked encoding, body a bare MPEG frame stream over plain `http://` — a dozen lines of `TcpStream` against a client that otherwise makes no network calls and has no HTTP dependency to reuse. `Icy-MetaData: 0` is sent so the server does not splice title metadata between frames, which would decode as a click every few seconds and look like a decode bug. **kira's own symphonia decoder cannot carry this**: it reads `n_frames` out of the codec parameters and fails `UnknownSampleRate` when absent (`streaming/decoder/symphonia.rs:45`), which is what a live stream reports — so `RadioDecoder` implements kira's public `Decoder` trait instead, keeping the sample rate (MP3 carries it per frame header) and answering the duration itself as a day at the stream's own rate. No kira patch. Verified against the live station end to end: 44.1 kHz, 46,080 real frames decoded, peak amplitude 0.486. Deliberately absent, each a named failure rather than a wrong stream: TLS (the `https://` DBC rows have plain-http twins), redirects, chunked bodies, reconnect-on-drop. One cost worth knowing: the connect is spent on the **main thread**, because kira probes the source while building the sound — bounded at 2s so a dead station is one stutter, not a freeze |
|
||
| OctoWoW radio — the Off button | done, **a deviation** | Off is a bare `StopMusic()` (`OctoRadioPlaceholder.lua`'s `TurnOff`). The reference's `0x460450` never touches the zone track `[0xb06cc4]` on its NULL arm, and ordinarily neither does this — a `StopMusic` must not silence zone music. But the radio arrives as `SMSG_PLAY_MUSIC`, which lands on *that* slot here, so Off stopped a slot the radio was not in. **Where the reference puts server-pushed music is recorded in `zone.rs`'s own header as untraced**, so the slot it lands on is this client's assumption rather than a transcribed fact, and the realm's addon is the evidence against it: its comment says zone music "stays on and resumes", which only holds if `StopMusic` reaches the radio and nothing else. Narrowed to a *live stream* on the slot, so an ordinary event track — which no Off button exists for — is untouched, and the zone pump is rearmed so zone music comes back. Both halves pinned by tests; reverting the narrowing fails the first and not the second |
|
||
| ~~OctoWoW radio — the audio~~ (superseded) | — | A station click sends `.radio <n>` as a SAY chat command; the server answers with `SMSG_PLAY_MUSIC` naming a `SoundEntries.dbc` row whose *file* is a live HTTP stream URL. Confirmed against the real DBC rather than taken from the addon's comment: seven of them, e.g. `http://radio.octowow.st/listen/booty_bay_pirate_radio/radio.mp3`. `SMSG_PLAY_MUSIC` is already handled and the mixer already decodes MP3 (kira's `mp3` feature, exercised by `mixer.rs`'s own test against a real chain file) — what is missing is only the *fetch*: every kit path today resolves inside the MPQ chain (`Sound\Music\...`), and nothing recognises an `http(s)://` file or streams one. Needs an HTTP client dependency and a progressive feed into the music slot, since a live stream has no end to read to |
|
||
|
||
|
||
## pfUI's options panel, and two more coercion gaps
|
||
|
||
Both of the panel's "it applied but it does not look applied" complaints were the same shape: a
|
||
1.12 binding where the reference **coerces** and this client did not. Neither lost a setting —
|
||
the values round-tripped correctly the whole time, which is what made them look like persistence
|
||
bugs rather than display ones.
|
||
|
||
| Area | Status | Notes |
|
||
|---|---|---|
|
||
| `CheckButton:SetChecked()` with **no argument** | fixed | The reference reads its argument through `0x6f1c10` with a **default of 1** (`0x799c77`), so a bare call *checks*. Ours unchecked — and `button.rs`'s own comment already said so ("here a missing argument unchecks… where the reference checks for a missing one"), a known divergence nothing had reached until pfUI's config panel did. It restores every ticked box with exactly that call (`modules/gui.lua:336`), so every setting the player had on showed as off after a reload, which then invites re-ticking, which sets pfUI's `settingChanged` and demands another `/rl`. mlua folds an absent argument and an explicit `nil` into one `Value::Nil`, so the binding now reads **arity** via `MultiValue`: `SetChecked()` checks, `SetChecked(nil)` still unchecks (FrameXML passes conditions that way). Both halves tested |
|
||
| `Texture:SetTexture("r","g","b","a")` | fixed | `lua_isnumber` is the reference's gate between the path and colour forms, and in 5.0 it is **true for a numeric string**. Ours matched only `Value::Number`/`Integer` on the first argument, so a numeric string fell into the path arm and loaded nothing — every colour swatch in pfUI's panel was black until clicked, the click being the one path that passes real numbers. The swatch stores a colour as `"0.05,0.15,0.15,0.75"` and hands `SetTexture` what `strsplit` returns, which is four strings (`modules/gui.lua:188`). The trailing channels already coerced (`coerced_opt_number`) — the inconsistency was the first argument alone. A numeric second argument is required too, so a one-argument path that happens to look numeric stays a path |
|
||
| Guild "N online" before the guild pane is opened | **deviation** | Not a bug: 1.12's FrameXML never asks for the roster at login. Its only unprompted `GuildRoster()` is on a `GUILD_ROSTER_UPDATE` *while the guild pane is visible* (`FriendsFrame.lua:705-711`), so a panel addon reads 0 until something opens that pane or its own poll comes round — 60 seconds for pfUI (`modules/panel.lua:396`). Faithful, and also a minute of wrong number on every login for a count the server answers immediately if asked. Now asked **once**, on the `GE_MOTD` that vmangos sends before any roster (`CharacterHandler.cpp:558`), through the same 10 s throttle every other request takes. Armed only when no roster is held, so a mid-session MOTD edit does not re-ask |
|
||
|
||
|
||
## Minimap player arrow scale, and ColorPickerPlus's TOC
|
||
|
||
| Area | Status | Notes |
|
||
|---|---|---|
|
||
| `Minimap` player-arrow scale | fixed | The arrow is engine child 9 (`minimapPlayerModel`, `0x4ee260`), a `Model` an addon can reach and scale — pfUI's minimap module finds it by walking `Minimap:GetChildren()` for the unnamed `Model` whose model names `MinimapArrow`, then calls `SetScale` (`modules/minimap.lua:41-54`). The search worked here; what did not is that **this client draws the arrow itself**, sizing its quad from the map's side length and two constants, and never reading that child. So the setting moved nothing. The factor now rides [`QuadContent::Minimap`] as `arrow_scale`, **relative to the map's own scale** so the map is counted once, and `minimap::player_arrow_geometry` applies it to both the quad and its authored centre offset — the offset too, or the art drifts off centre as it grows. Tested on both sides of the seam: the extract carries it, and the geometry applies it |
|
||
| `ColorPickerPlus`: two "not found" warnings | **not a bug** | `ColorPickerPlus.toc`'s `## Notes:` wraps onto two unprefixed continuation lines, and a TOC line that does not start with `#` is a file entry. The reference's parser does the same thing and fails to find them just as silently; the addon loads and runs regardless (`ColorPickerPlus.lua ran`). A malformed TOC in the addon, not a loader fault — worth leaving loud rather than teaching the parser to guess which unprefixed lines are prose |
|
||
|
||
|
||
## A wgpu validation error no longer ends the session
|
||
|
||
A `pbr_prepass_pipeline` failed shader-interface validation 42 minutes into a session, right
|
||
after a `/reload` queued a burst of live pipeline specialization, and **killed the client** —
|
||
wgpu's uncaptured-error handler panics by default. One bad pipeline out of 1,635.
|
||
|
||
`gpu_errors.rs` replaces that handler: the error is logged at `ERROR` with its full text, the
|
||
resource fails, and the draw that wanted it is skipped. A missing shadow instead of a lost
|
||
session — which on a hardcore realm is the difference between a graphical glitch and a dead
|
||
character. `WOW_WGPU_FATAL=1` restores the panic for a session spent hunting one.
|
||
|
||
Distinct errors are each reported once and then tallied on a widening scale (1, 2, 4, 8…), so a
|
||
per-frame repeat neither scrolls the log away nor disappears from it. That is the one property
|
||
of the fatal handler worth keeping: you noticed.
|
||
|
||
**The underlying mismatch is not fixed and is not yet diagnosable.** The failing variant is
|
||
unidentified because our pipeline watcher logs each pipeline the frame after it first appears,
|
||
and this one was queued and failed its async compile inside the same frame — the last one
|
||
recorded is `[1634]`, the failure is `[1635]` or later. A frame-based watcher always loses that
|
||
race. With the handler non-fatal the next occurrence logs the error text itself, which names the
|
||
pipeline label and the mismatched location; that is the missing half.
|
||
|
||
|
||
## `/cal` and the keyring toggle
|
||
|
||
| Area | Status | Notes |
|
||
|---|---|---|
|
||
| `/cal` (OctoCalendar) | fixed | Another pair of **baked FrameXML components our manifest did not carry**, the same shape as the radio: `OctoCalendar\*` (7 files, which define `/cal`, `/calendar` and `/octocal` at `OctoCalendar.lua:925-928`) and **AIO** (6 files, the realm's client↔server transport), in the chain's own order — the calendar registers an AIO handler at load and queues its guild-permission request until AIO is up (`:919-920`), so AIO has to come first. Verified in a loaded VM: the slash handler exists, `AIO` is non-nil, and invoking `/cal` opens the frame without raising. Harness 15/15 clean |
|
||
| pfUI's keyring show/hide button | fixed, **two bugs** | pfUI's bag module is `GetContainerNumSlots(-2)` slots normally and `GetKeyRingSize()` when showing (`modules/bags.lua:202-203`), so hiding is exactly "the container API reports nothing for the keyring". The feed reported a real count, both arms agreed, and the button did nothing. **`num_slots` is now 0** — nothing in stock FrameXML ever calls `GetContainerNumSlots(KEYRING_CONTAINER)`, because the keyring is sized by `GetKeyRingSize`'s scan everywhere, which is only worth writing if the container API does not answer for it. **And every addressable slot is now fed**, where the old code fed only as many as a level ladder allowed: `GetKeyRingSize` is FrameXML and it *scans* — 32 `GetContainerItemInfo` calls for the highest filled slot, rounded up to a multiple of four (`ContainerFrame.lua:816-844`) — so a key in a high slot was invisible to it. The ladder itself was removed: its doc claimed stock `GetKeyRingSize` reads `UnitLevel`, and **`UnitLevel` appears nowhere in `ContainerFrame.lua`** |
|
||
|
||
**Still missing from the manifest** after this, measured by diffing the realm's `FrameXML.toc`
|
||
against ours: 27 entries across `Turtle_ArenaUI` (5), `Turtle_GuildBankUI` (2),
|
||
`Turtle_BarbershopUI` (1), `OctoToolReqTip` (2), `OctoLocale` (5, non-enUS), and one file each for
|
||
`OctoChallengeTip`, `OctoGhostGate`, `OctoRestedTooltip`, `OctoWarModeOffer`,
|
||
`OctoWowWarModeConfirm`, `RestoreBugCommand`, plus six stock-ish ones (`Globals.lua`,
|
||
`Overrides.lua`, `Hooks.lua`, `StatsFrame.xml`, `ChronoboonTimers.lua`, `SpellChatLinks.lua`).
|
||
Each is a feature that is simply absent rather than broken, and they are worth adding as a batch
|
||
with the load checked after each.
|
||
|
||
|
||
## Video settings: the Apply that never committed, and the missing resolution row
|
||
|
||
| Area | Status | Notes |
|
||
|---|---|---|
|
||
| Graphics settings not persisting | fixed | `OptionsPage_Apply` wrote each pending row's CVar and stopped. For a **latched** row — `gxVSync`, `gxResolution`, `gxWindow` — that write is *staged* in `Row::pending` and only lands on `Cvars::commit_latched`, which runs on `RestartGx` (`video.rs`). Nothing in the options window ever called it, so a latched change stayed pending for the whole session, never reached the live value, and so was never written to `config.toml` either — vertical sync looked accepted and was back on after a relaunch. `OptionsPage_Apply` now calls `RestartGx()`, which is what the reference's own video options do on Apply |
|
||
| No resolution row in the options window | fixed | The CVar, the three verbs (`GetScreenResolutions`, `GetCurrentResolution`, `SetScreenResolution`) and the monitor enumeration all existed; the window simply had no row, so there was no way to resize from inside the game. Added between Display Mode and Vertical Sync, built from the real list at `OnLoad` and backed by the **index** verbs rather than `gxResolution` directly — `GetCurrentResolution` reports the live window where the CVar is the windowed size, and in borderless fullscreen those differ |
|
||
| "benilla is running at 1080p" | **not a fault** | It was already at the monitor's full 2560x1440: `video: BorderlessFullscreen(Primary), 2560x1440 logical / 2560x1440 physical (scale 1) [wayland]`. Worth recording because the absent resolution row made it impossible to confirm from inside the game, which is how it came to be doubted |
|
||
| World tooltip rebuild raises `OnShow` | **fixed** | A rebuild for a new subject ran `clear_content` — wiping every line, an addon's appended ones included — and then `show_or_hide_empty`, which is a **no-op on an already-shown frame**. So the addon was never told to put its lines back. The reference never meets this because 1.12's plates publish no tooltip; benilla's do (`target/hover.rs` feeds `Hovered` from a V-key plate, as the reference's plate `OnEnter` `0x7cb850` publishes the mouseover), so moving between a mob and **its own nameplate** rebuilds over a tooltip that never hid. Diagnosed from the player's own report that hovering from empty sky worked while mob ↔ nameplate did not — the sky case hides the tooltip first, which is exactly the missing transition. `world_tooltip_unit` now hides before rebuilding; it is already gated once-per-subject-change by `ui_tooltip`'s driver, so this is one hide/show pair per new subject and both land before the frame paints. Consumers: pfQuest's objective annotation (`pfQuest/map.lua:253`) and pfUI's `GetUnitGUID` reader (`modules/tooltip.lua:81`) both hang off `OnShow` |
|
||
| `GameTooltip:GetUnitGUID` on a **world hover** | **fixed** | The getter was built earlier and worked from a Lua `SetUnit`, but returned *no values at all* for the unit under the cursor — the case that matters, since every consumer is on the tooltip's own show. Cause: the mouseover snapshot carried `guid == 0`. `SnapshotCtx::full` assigns `s.guid`, but the world-hover path (`ui_tooltip/mod.rs`) builds its snapshot the long way — `snapshot()` then `enrich_unit()` — and `enrich_unit` **took a guid and never stored it**. `tooltip_unit.rs`'s `render_unit` records the guid only when non-zero, so the tooltip's `unit_guid` stayed `None` and the getter answered nothing. Assigned in `enrich_unit` rather than at the call site, so the two snapshot paths cannot disagree again. **Blast radius beyond the tooltip**: `UnitGUID("mouseover")` filters on `guid != 0` too, so it was nil for every hovered unit. Measured consequence: pfQuest's objective annotation (`pfQuest/map.lua:253-290` — a frame parented to GameTooltip whose `OnShow` keys on this getter's first return) silently never ran, so a hovered mob never showed which quest it counts for. Found only by instrumenting the live handler: it fired 3 times with the getter empty, which is also what made `tostring(GameTooltip:GetUnitGUID())` raise "value expected" |
|
||
| Secure-attribute click dispatch (`type1`/`type2`/click-cast) | **built** | `SetAttribute`/`GetAttribute` were built as a bare key/value store, with this file's own header calling that "honest" about not claiming modern WoW's restricted-execution subsystem. The line was in the wrong place: a frame that stores attributes and does nothing when clicked is not a documented limitation, it is an inert frame. **pfUI drives every one of its unit frames this way whenever Clique is absent** — which is the default — setting `type1="target"`, `type2="menu"` and `EnableClickCast()` and installing **no `OnClick` script at all** (`api/unitframes.lua:1320-1326`). So the player frame could not be clicked to target, right-clicked for its menu, or used for click-casting, and neither could any other unit frame. Dispatched in `frame.rs`'s `dispatch_attribute_click`, called from `button.rs` **after** the `OnClick` handler for the same reason the nameplate selection is — a frame may have both and a raising hook must not eat the action. Handles `target`, `menu` (through `ClassicAPI_ToggleUnitMenu`, the same entry pfUI's own Lua path calls, so the two routes open one menu), `focus`, `spell` (cast on the frame's `unit`, which is what click-casting means) and `macro`/`macrotext`, with `shift-`/`ctrl-`/`alt-` prefixes falling back to the unmodified binding as the reference's lookup does. **What is dispatched is the action, not the security model**: no taint, no protected call, no combat lockdown — none of which 1.12 has |
|
||
| nampower's `*_SELF` aura edges | **built** | `BUFF_ADDED_SELF`/`BUFF_REMOVED_SELF`/`DEBUFF_ADDED_SELF`/`DEBUFF_REMOVED_SELF`, over the diff that already produced the `*_OTHER` pair — the player was **explicitly skipped** there, with a comment saying this half was unbuilt. Its helpful half is diffed separately because the halves fire different events. Why it matters: DoiteAuras reads the whole aura block **once**, at `PLAYER_ENTERING_WORLD`, and then relies entirely on these four (`UpdateAuras` is called nowhere else), so with none fired its table froze at login and every buff gained afterwards read as missing. Upstream has **eight**; the `BUFF_*_OTHER` pair is deliberately not produced — it would mean diffing the helpful half of every streamed unit on every edge for no reader — and `AuraEdge`'s three variants say so rather than leaving a dead arm firing events the gate must then allow. **Gate note**: routing the names through a `&str` parameter compiled and left `reference_ui.rs`'s event walker blind (it reads the literal at the call site), including to the two `*_OTHER` names already declared; the literals are back at the fire sites for that reason |
|
||
| Aura feed ordered before the `PLAYER_ENTERING_WORLD` fire | **fixed** | `feed_auras` and `feed_units` both sat in `UnitFeed` with **no ordering between them**, and `feed_units` is what fires `PLAYER_ENTERING_WORLD`. Whether an addon reading the aura block in that handler saw it was down to Bevy's ambiguous order — which is why DoiteAuras worked on a fresh login and broke after a `/reload`. A new `UnitFeedDispatch` set wraps `feed_units` alone so a sibling feed can order itself ahead of the fire; `feed_auras` now does. Same reasoning `feed_units` already applied internally to the XP pair ("ahead of the `PLAYER_ENTERING_WORLD` fire so its first update reads it") |
|
||
| `PlaySoundFile` on an addon's `.ogg` | **built** (decoder) | Not an API gap — the binding and the loose-file read were already right; kira was built without the `ogg` container and `vorbis` codec features, so `sfx_from_bytes` returned an error for every Vorbis file and `sound/ui.rs` logged it at `debug!`. Silent alerts with nothing in the log. **75 `.ogg` files on this install**: DoiteAuras (57), BigWigs (11), unitscan (3), HCWarn (2), TWThreat and Cleanup — so every one of those addons' alerts was dead, including HCWarn's hardcore death warnings. AI_VoiceOver ships `.mp3` and always worked, which is what made it look like an addon-specific problem. Stock 1.12 plays wav and mp3 only; the Turtle/OctoWoW client these addons are written for plays ogg, which is why they ship it — the deviation is from 1.12, toward the client the player actually runs. Covered by `sound::mixer::tests::an_addons_ogg_decodes`, gated on the player's own addon folder the way the chain tests are gated on a WoW install |
|
||
| `LOCALIZED_CLASS_NAMES_MALE` / `_FEMALE` | **built** | Published by `script/class_info.rs` whenever the app feeds the class table, from the same two `ChrClasses` columns `C_CreatureInfo.GetClassInfo` answers from: keyed by `classFile` (`WARRIOR`), valued by the display name (`Warrior`). Half of this pair was already here and the other half missing — `ClassInfo::class_file`'s own doc names these tables as what FrameXML keys on, and `compat/classic_api/lua_ext.rs`'s `tInvert` exists *because* pfUI flips this one (`pfUI.lua:227`). With the table absent that flip inverted nil, so `pfUI.env.L["class"]` was **empty** (measured: 0 entries against the live chain) and every lookup through it silently found no class — the guild roster's name colours and class icons (`modules/socialmod.lua:18`), the friends list's, and `modules/panel.lua:359`. Nothing raised, because the module tests `if class then` and skips, which is why it read as "pfUI just doesn't colour those lists here". Both tables carry the same string: 1.12's `ChrClasses.dbc` has one name column, so the gendered pair later clients ship cannot be derived — publishing `_FEMALE` as a copy is what this data honestly supports, and omitting it would hand a caller nil where its sibling answers. Absent, not empty, on a run that never fed the table |
|
||
| `C_Spell.GetSpellMechanicByID` / `GetSpellEffectMechanics` / `GetSpellPowerCost` | **built** | One probe for all three (`SpellDbcExtras`): same `Spell.dbc` row, same install site, and a caller asking for either has already paid the lookup. Its own probe rather than columns on `SpellLookup`, which has eleven construction sites with no answer for them. **`GetSpellMechanicByID` is three-way** and the consumer spells it out: nothing for an id no spell owns, `0` for a real spell with no mechanic, the id otherwise — it replaces a hand-kept spellID → mechanic table, so collapsing "unknown" into "none" would reclassify crowd control as plain damage. The enUS name is the second return, from `SpellMechanic.dbc` (which this tree already parsed). **`GetSpellEffectMechanics` is the per-effect sibling**, `EffectMechanic[3]` as `{m1, m2, m3}`, and it is not the same question: vanilla puts some mechanics on an effect rather than on the spell, so Rake's bleed answers `{0, 15, 0}` where `GetSpellMechanicByID` answers 0 — a macro conditional asking whether its target is bleeding gets the wrong answer from the spell-level column alone. Same three-way shape (nothing for an unknown id, three zeros for a spell with no mechanic anywhere). `SpellDisplay` already parsed the column for the cast-immunity scan, so this cost a field on the probe payload and nothing else. Its absence was one raise a session from `SuperCleveRoidMacros/ClassicAPI.lua:267`, which calls it unguarded. **`GetSpellPowerCost` answers a LIST** (`costs[1].cost`), one row, since 1.12 has a single cost per spell; it is the flat `manaCost` column **without** the reference's per-level and percentage terms (`GetPowerCost` `0x6e31b0`), which need the caster's level and max power — stated because it is the base cost a macro reader wants, not what a scaling spell would actually spend |
|
||
| nampower `GetUnitField` / `GetSpellRecField`, version 4.1.3 | **built** | `GetUnitField(unit, field)` is nampower v3+'s window onto the raw `UNIT_FIELD_AURA` block, and the reason it exists is that its slots are **stable**: every 1.12 aura getter walks the *compacted* list, so each index shifts the moment an earlier aura drops and nothing can be keyed on it across frames. Two fields: `"aura"` → slot → spell id, `"auraApplications"` → slot → **stacks minus one** (the wire's own encoding; pfUI reads it back as `(apps[slot] or 0) + 1`, so returning `stacks` would show every debuff one stack high). **Slots are reported 1-based** — debuffs 33..48, which both consumers loop literally — over benilla's 0-based wire index. Needed new state: `AuraState::slot`, since the VM's aura list was compacted and had dropped the raw index; filled at both app-side builders. Accepts a token or a raw GUID (pfUI passes `GetDebuffSlotMap(guid)`, DoiteAuras passes `"player"`), folding a guid back through `token_for_guid`. Reads the same filtered list every other getter does — hidden-from-aura-bar and tracking auras are already gone, which is what a debuff tracker wants, and the slot keying is unaffected because a dropped aura reads nil at its slot rather than shifting its neighbours. `GetSpellRecField(spellID, field)` is **partial on purpose**: `name` and `rank` are answered, `stackAmount`/`manaCost`/`spellIconID` are **nil rather than a plausible zero** — this client reaches no other column of that row, and a fabricated `manaCost` of 0 reads as "free", which is exactly the test DoiteAuras' Clearcasting rule turns on. `spellIconID` is not derivable at all: the icon is carried as an MPQ path, not as the numeric id that names it. With all three in place `GetNampowerVersion` moves 4.0.0 → **4.1.3**, which is the gate DoiteAuras gates on — bumped only *after* the machinery existed, per that constant's own standing rule not to claim what is absent. Measured: DoiteAuras goes from refusing to open to **0 load / 0 session errors**; pfUI's `libs/libdebuff.lua` ("GetUnitField Edition") stops falling back to manual slot shifting |
|
||
| nampower's six spellbook/duration verbs | **built** | `GetSpellNameAndRankForId(id)`, `GetSpellIdForName(name)`, `GetSpellSlotTypeIdForName(name)` → `slot, bookType`, `IsPassiveSpell(index, bookType)`, `GetSpellDuration(id[, ignoreModifiers])` → ms, and `GetPlayerAuraDuration(auraSlot)` → `spellID, durationMs, expirationTimeMs` (`script/compat/nampower.rs`). Signatures are **not inferred**: `SuperCleveRoidMacros/NampowerAPI.lua` is a shipped, documented wrapper over this whole surface and states each one's arguments, returns and units, which is what these were built against. Why they matter beyond a missing-global warning: DoiteAuras takes each as a *fast path* and otherwise falls back to a 200-slot `GetSpellName` scan **per lookup, per tracked aura, per condition evaluation**. None is expressible in 1.12: that client walks a book by index and reads a name, but has no id for what it finds and no way back from a name to either, and it exposes a running aura's remaining time (`GetPlayerBuffTimeLeft`) but no way to ask a spell how long its aura lasts *before* casting it. The two name→X verbs answer the **highest rank known**, as `CastSpellByName` does. **The two slot conventions deliberately disagree**: `GetUnitField` is 1-based (debuffs 33..48) and `GetPlayerAuraDuration` is raw 0-based (0..31 buffs, 32..47 debuffs) — each is what its own consumers loop over, and matching them to each other would break both. `GetSpellDuration` reads `SpellDuration.dbc` through the same `ui_cast_events::aura_duration_ms` that answers nampower's `SPELL_START_*` `arg7`, so the event and the query cannot disagree; it keeps upstream's three-way answer (nil for no such spell, 0 for a spell whose length this client will not state, ms otherwise) because a consumer treats 0 as absent and falls back, while a number it trusts and draws as a timer. `ignoreModifiers` is accepted and ignored: it asks for the base duration, and the base is all this client has (`spell/mods.rs` stores the server's `SPELLMOD_DURATION` cells but has no reader) |
|
||
| nampower `GetSpellIconTexture` / `GetItemSpell` / `IsSpellInRange` / `IsSpellUsable` / `GetAmmo` | **built** — this row replaces the `todo` it was written as | The last five of DoiteAuras' eleven probes, so all eleven now answer. `IsSpellUsable(id|name)` -> `usable, oom` and `IsSpellInRange(id|name[, unit])` read a live per-spell map `ui_action::state` pushes over the player's **whole book**, computed by the reference's own usable walk (`0x6e3d60`) and range verdict (`0x4e56f0`) — the same computation the action bar greys its buttons with, so a spell on a bar and the same spell asked for here cannot disagree. That feed is the reason these are not a forward of `IsUsableAction`, which is keyed by *action-bar slot*: a spell the player knows but has not placed on a bar has no slot to ask through. Swept at ~10 Hz (a cooldown-generation change jumps the queue) because a book is ~150 spells and the walk reads equipment and reagents per spell. Range is measured against the **current target**, as the bar's own light is, so naming any other unit answers nil rather than quoting the target's distance under another unit's name. `IsSpellUsable`'s absence was the one with teeth: DoiteAuras' `_SafeSpellUsable` returns `1, 0` without it, so every usability condition passed and an aura gated on one showed **always**. `GetSpellIconTexture(spellIconID)` needed the pair's other half built first — `SpellDisplay` now keeps `icon_id` beside the path it resolves to, and the catalog keeps `SpellIcon.dbc` itself, so `GetSpellRecField(id, "spellIconID")` answers a real row id and this resolves it back (verified end to end: spell 6673 -> icon 456 -> `Interface\Icons\Ability_Warrior_BattleShout`). `GetItemSpell(itemID)` answers the **Use:** spell only (wire triggers 0 and 5), never an equip or proc one, or every stat trinket would read as an activatable. `GetAmmo()` is the doll's slot 0, whose count the app already sums across quivers for the low-ammo alert. Signatures throughout are **not inferred**: `SuperCleveRoidMacros/NampowerAPI.lua` is a shipped wrapper documenting each one's arguments, returns and units |
|
||
| `SetPortraitToTexture(region, path)` — the region form, and a nil path | **built** | It took only a texture *name* string, because both stock callers pass one (`ContainerFrame.lua:419`, `MailFrame.lua:174`) and this file's own comment recorded the object form as "untraced". OctoWoW's shop settled it: `Turtle_ShopUI.lua:893` passes the region and works on the real client. Refusing it raised **inside the shop's entry loop**, which aborted after the first card — and because the page count is written *before* that loop, the window read "Page 1/2" with one item and seven empty cells, which is the whole of the "can't see half the items" report. A nil path now clears rather than raising on argument #2: the same caller takes its texture from `GetItemInfo`, which answers nothing for an item the client has not cached |
|
||
| `OnHyperlinkEnter` / `OnHyperlinkLeave` | **built** | Fired by `pointer::update_hyperlink_hover` off the same rasterized link spans the click path reads, so a hover and a click can never disagree about where a link is. Not 1.12's — that client routes a link only on release (`OnHyperlinkClick`) — but TurtleWoW's adds them, and `SetScript` used to *raise* on the name under the standing rule that an accepted handler nothing fires is worse than a refused one. `ChatSuey-ItemLinkHover` is two handlers and nothing else, so the raise took the addon with it. The span is keyed by its markup, not its rect: a chat frame scrolls its lines under a still cursor, and re-entering the same link at a new y is not a new hover |
|
||
| A `<Script file=>` naming itself from the install root | **fixed** | Joining the value onto the including document's directory is benilla's own deviation (the reference uses it verbatim), written on the premise that nothing exists at the raw path. An addon's own `Interface\AddOns\<Name>\…` is exactly where that premise fails: the join doubled it into `Interface/AddOns/X/Interface/AddOns/X/…`. RoleplayingHelper writes all 22 of its locale files that way, so its entire word list was empty. A root-relative value that the provider can answer verbatim now wins; everything else still joins |
|
||
| The auction class tree at VM setup | **fixed** | `GetAuctionItemClasses`/`SubClasses` are fed by `feed_auction`, whose own comment notes the stock `AuctionFrameBrowse_OnLoad` reads them as the addon loads — but a system's first run is a frame *after* the addons, and an addon reading them at **file scope** is already past. Seeded in `lifecycle` beside `C_CreatureInfo`, which is there for the same reason. Tmog is the casualty: `Localization.lua` builds its whole locale table out of `GetAuctionItemSubClasses(1)`/`(2)`, so empty answers left every key nil and the next file died on `[L["Leather"]] = 1` — "table index is nil" — taking the addon with it, which is why `TmogTooltip` was nil on every hover afterwards |
|
||
| A tooltip for the player's **own** character | **reverted** — the ask was the unit frame, not the body | Built, then removed the same day: the request was for pfUI's *unit frame*, and 1.12's own behaviour on the 3D body (no tooltip, because your body is not a selectable pick) is the right one. `Hovered` is back to its original shape, so the self pick, the slot and the tooltip fallback are all gone. The unit-frame half is an addon patch, not a client change: upstream pfUI sets no `OnEnter` on its unit frames and never calls `GameTooltip:SetUnit` anywhere, so no pfUI unit frame has ever shown one — `pfUI.uf.OnEnter`/`OnLeave` now do what stock `UnitFrame_OnEnter` does, taking the unit from the frame's own label/id pair |
|
||
| `PlayerModel:SetUnit(<creature display id>)` | **built** | 1.12's `SetUnit` takes a unit token; a **number** is the realm's extension, meaning a `CreatureDisplayInfo` id. OctoWoW's shop draws every mount, companion and illusion card with it (`model:SetUnit(entry.modelid)`), and dropping the argument left those tabs showing empty cards while the texture-backed ones — skins, gameplay, miscellaneous — drew fine, which is exactly the split reported. **The skins load through the asset server, not inline.** The first cut resolved them with `WorldAssets::sprite_texture`, which locks the MPQ chain and decodes the BLP *on the calling thread* — and every UI path's calling thread is the main one. Measured at 6–10 ms per skin, a mounts tab of eight displays at up to three skins each spent a quarter of a second of main thread inside one frame, with the GPU idle at 3.9 ms through it: the client stopped dead while every meter reported a healthy 95 fps either side of it. The world's own `resolve_skin` always loaded these asynchronously; this now does the same, and a skin landing a frame late is what the reference does anyway. `WOW_SPRITE_TRACE=1` names any decode costing 2 ms or more, which is how this was found. The id resolves through a `CreatureDisplayInfo` → `CreatureModelData` map installed on the VM **with the display's three skin variations beside the path** — a creature M2 names no texture for its skin slots, so the first cut of this drew every mount and companion as a white silhouette; the skins now ride `ModelState` → `QuadContent::ModelPane` → `TileRequest` into `build_tile`, which applies the same rule the world path does (`entities::display::resolve_skin`): the batch's own texture when it names one, else the display's variation for its slot. The map (owned, not borrowed: the probe outlives the system run that builds it) and lands in the same `path` slot `SetModel` writes, through the same `set_file` + implicit-rect steps, so the loader, booth and camera are untouched. It returns before the dress-up redress, which would otherwise overwrite the creature with the player's own body. The fourth tab, Fashion, is a different path — `SetUnit("player")` + `TryOn`, gated on `GetItemInfo` answering an equip slot, which fills asynchronously after the scan tooltip's query round-trips |
|
||
| The dynamic unit-pane pool, 8 → 24 | **fixed** | `portrait::UNIT_PANE_SLOTS` sizes the pool of booths a `PlayerModel:SetUnit(<token>)` pane can claim. At 8 it covered unit-frame portraits and nothing else — Tmog browses transmog with a page of **15** `DressUpModel` cells (`Tmog.itemsPerPage`) plus its own full-body pane, so its grid drew a few models and then a page of empty squares. The engine said so plainly once per session and it was in the log the whole time: *"all 8 dynamic unit-pane booths are taken — a further PlayerModel:SetUnit pane draws nothing until one frees"*. Confirmed against the reference client side by side: there every cell holds a little dwarf wearing the item. The ceiling is the `RenderLayers` word (base 29, so 34 is the most that fits, and the existing assert holds the line); an unclaimed slot costs its render target and no per-frame work, since the booths are spawned inactive — 24 × 256² ≈ 12 MB of VRAM for grids that fill |
|
||
| A persistent item cache (`item_persist.rs`), the reference's `itemcache.wdb` | **built** | The reference writes every `SMSG_ITEM_QUERY_SINGLE_RESPONSE` to disk and reads it back at login, so `GetItemInfo` answers a known item **immediately**; benilla re-asked the server every session. One missing file, three symptoms that looked unrelated: OctoWoW's shop draws an icon from `GetItemInfo` (cold → the frame with no icon); its model cards branch on the same call (`elseif loc and loc ~= ""`), so an uncached item runs **neither** branch and the pane keeps the previous category's model — the Fashion tab showing companions; and Tmog queues a page of item ids and spins in an `OnUpdate` until every one answers, calling `EnableMouse(false)` on every preview button and re-showing its "Loading" overlay **each frame** while it waits (`Tmog.lua:1446-1481`), which on a cold cache reads exactly as the window flashing and going unresponsive. Measured first, not guessed: `WOW_UI_HANDLERS` showed 75–170 fps and 0.3–2.6 ms/f of Lua with Tmog open, and the flash tracer found **no** frame toggling — so it was never a renderer or a handler storm. Shaped on `name_persist.rs` (benilla's `creaturecache.wdb`): realm-scoped under `benilla-config/cache/` because the install is read-only and another realm may hold a different template under the same entry, written on a 10 s debounce and again at exit. The record is the parsed `ItemInfo` as JSON, not the reference's raw response block — the wire reader is the only parser, so re-encoding by hand would be a second copy of 52 fields free to drift from it. Both systems run in `Last`: in `Update` they added 78 undeclared orders against everything that reads `Items`, and neither is frame-critical |
|
||
| Per-pane dressing rooms | **built** (the state half; the booth half is the `todo` below) | `DressUpIntent` now carries **the pane it was called on**, and `ui_dressup` keeps a `DressUpRoom` per pane instead of one shared one. 1.12 has exactly one `DressUpModel`, so a single room was the whole model; Tmog draws a page of **15** preview cells (`Tmog.itemsPerPage`) and OctoWoW's shop a Fashion tab of them, and every one of their try-ons landed in the same room. The room's close rule changed with it: it used to read the two stock *windows* by name (`DressUpFrame`, `AuctionDressUpFrame`), which is the right test for the stock panes — each lives inside one — but an addon's pane is inside neither, so the rule closed its room on every frame it was being filled. **That fill-and-wipe fight, sixty times a second, is what made both windows flash.** Asking the pane's own visibility covers the stock panes identically (they are children of those windows) and ends the fight. Each open room now resolves to its own look, published per pane for the booth work below |
|
||
| A page of previews still shows one character | todo, and the last piece | With the rooms split, each pane has its *own* look — but a pooled unit-pane booth still **mirrors the live player's spawned tree** (`DressedLook`'s queries over the unit's children) rather than assembling a dressed one, so fifteen panes render fifteen copies of whatever the player is actually wearing. The dress-up booth knows how to assemble a look from scratch (`build_dressup_preview` → `DressUpBake` → `sync_dressup_booth`), but that path is built for **one** booth: a single `DressUpBake` resource, `Local` bake state keyed to nothing, and a hard-wired `DRESSUP_SLOT`. Finishing it means making the bake per pane, giving `sync_dressup_booth` per-slot state so it can write into a pane's claimed pooled slot, and skipping the mirror for slots that have a look. It is real renderer work — fifteen character assemblies, each with its own skin composite and item displays — and it can regress the paper doll, the inspect pane, the glue screens and the pooled portraits, all of which share that machinery, so it wants its own pass rather than a tired one |
|
||
| `Model:SetPosition`'s first argument is **depth**, not a screen offset | todo, root cause confirmed | The shop's mount cards draw far too close and cropped where the reference shows the whole mount with room around it. `SetPosition(a, b, c)` is stored verbatim and `ui_models` then reads `a`/`b` as a *screen* offset and `c` as depth. The convention is the other way round and three addons agree on it: the shop writes `SetPosition(entry.posz, entry.posx, entry.posy)`, Tmog writes `SetPosition(Z, X, Y)` from variables it names, and — the decisive one — Tmog's **mouse-wheel** handler changes only the first component (`Z = Z + 1`), which is zoom. So the first argument moves the model along the view axis and must change its apparent size; benilla shifts it sideways instead and never scales. Fixing it is two parts: the axis mapping, and making depth actually scale, which the orthographic leg (`camera: None`, every shipped pane) does not do at all. The second part needs the reference's own distance constants measured, not guessed — a half fix that only swaps the axes would shove every card sideways by its zoom |
|
||
| `GetActiveLevel` / `GetAvailableLevel` | **built** | 1.12's own greeting-row getters, which stock `QuestFrame.lua` prints beside each title. The level already rides the wire beside the title (`QuestOption::level`) and was parsed and then dropped, so both globals were simply absent — and a missing global is a **raise**, not a nil read. `_LazyPig` calls `GetActiveLevel` for every row of a quest giver's greeting list, so opening one spammed the error through `xpcall` and left the gossip window misbehaving, which is the "messed up quest ui" report. An index past the list answers **0, never nil**: the caller concatenates it straight into a string, so a nil would raise exactly the way the missing global did |
|
||
| `ClassicAPI_ToggleUnitMenu` opened nothing | **fixed** | Its initialiser passed `UIDROPDOWNMENU_OPEN_MENU` to `UnitPopup_ShowMenu`, which **indexes** its first argument — and in 1.12 that global holds a frame *name string*, which is why every stock initialiser wanting the frame writes `getglobal(UIDROPDOWNMENU_INIT_MENU)`. It raised at `UnitPopup.lua:136`, so right-clicking any pfUI unit frame did nothing at all. The closure already had the frame |
|
||
| `GameTooltip:SetUnitAura` | **built** | Raised on **every buff hover**: pfUI's raid and group frames tooltip each buff icon through it (`api/unitframes.lua:67`), so it repeated for as long as the pointer moved over a frame. The modern spelling of 1.12's own `SetUnitBuff`/`SetUnitDebuff` pair, choosing the list by its `filter` argument rather than by the verb's name, and forwarding through the same method table so those two stay the single implementation — a rename, not a second mechanism. `HARMFUL` **as a substring** selects debuffs (so the composite spellings a raid frame composes, `"HARMFUL|RAID"` and friends, land on the right list where an equality test would silently answer with a buff); everything else, an absent filter included, is helpful, which is modern's own default. Note it is not in `docs/classic-api-functions.md`'s GameTooltip list, so it is either newer than that capture or the list is partial; built anyway because the mapping onto two existing verbs is exact and the cost of leaving it was a raise per hover |
|
||
| `GameTooltip:SetHyperlinkCompareItem` | **built** | 12 raises in one session, from pfUI's equipment-compare tooltips (`modules/eqcompare.lua:21,24,72`). Not 1.12 — it is in ClassicAPI's surface, so the same family as `UnitSpellHaste` and `C_ChatBubbles`. The earlier note here guessed at a shim (`GetItemInfo`'s `itemEquipLoc` → `GetInventorySlotInfo` → `SetInventoryItem`) and called the equip-location table "real work" — but **that table already existed**: `tooltip_item/mod.rs`'s `equip_slots_for` was written for 1.12's own `SetMerchantCompareItem`/`SetAuctionCompareItem`, and its two-seat rows (finger → 11,12; trinket → 13,14; one-hand → 16,17) are exactly what `index` is for. So the verb is `compare_against_worn` — the same shopping-tooltip body, the same `CURRENTLY_EQUIPPED` latch — reached from a link rather than a list row, registered in `compat/classic_api/tooltip.rs`. `index` is 1-based over the *candidate* slots, so an empty second seat is not a candidate and index 2 answers nothing. Arguments 3 and 4 (`shift`, `comparisonTooltip`) are accepted and ignored: that module has already made the shift decision before calling (its merchant path passes `1` with no key held) and does its own `SetOwner`/`SetPoint` for the anchoring |
|
||
| SuperWoW's `LootSparkle` sparkled nothing | **fixed** — the decision was right, the switch feeding it was never on | Reported from play: *"Loot sparkle not working on a mining ore I'm looking at"*, with the unit arm visibly working on lootable mobs — which is the shape of the bug, since only the GameObject arm reads the switch. `LootSparkleMode` derived `Default`, so it booted `false` while its row registered `"1"`. A boot fires `CvarChanged` only for rows `config.toml` actually carries, and writing `"1"` over a value already `"1"` is a no-op that fires nothing either, so **nothing could ever tell the resource its real default** — not the boot, not the player toggling it. `vplates.rs`'s `VPlateMode::default` carries that warning in as many words, from `NameplateOcclusion` shipping inert the same way, and this is the same mistake one feature later. Fixed with a hand-written `Default` and the twin test `the_loot_sparkle_default_agrees_with_its_registered_cvar`, which reads the registered row rather than restating it. Swept the rest: of the 32 resources a `CvarChanged` observer writes, 6 derive `Default` (`AssistAttack`, `AuraCastFeed`, `AutoSelfCast`, `BlockTrades`, `FpsJournalSetting`, `GuildMemberNotify`) and **every one of their rows registers `"0"`**, so `LootSparkle` was the only one of this shape |
|
||
| `PlayerModel:SetUnit` tracked the token instead of holding the unit | **fixed** | unitscan's alert reads `SetUnit'target'` on the rare it just found by name, and the addon restores the player's own target on the next line — so a pane that re-resolved `"target"` per paint showed the card for one frame and then emptied. Reported the moment the by-name targeting stopped leaving the target on the rare: *"unitscan is not targetting anymore, very nice, but now it only shows the 3d model when I do target"*. The reference resolves the token in `SetUnit`'s own rebuild worker (`0x505b50`, the one `RefreshUnit` also runs) and nothing re-resolves it per paint — which is why FrameXML re-issues `SetUnit` from `PaperDollFrame_OnShow` and on `UNIT_MODEL_CHANGED`, and why pfUI can detect a portrait change with **no event at all** by pinning a scratch pane and comparing `GetModel` against the shown one (`api/unitframes.lua:2069`), a test that only says anything if a pane holds what it was given. `ModelState::unit_guid` is that pin, resolved at the call and read by one helper (`Model::unit_pane_subject`) for both the paint gate and the extract. A pane seated **before** its unit streamed in keeps re-reading its token, since our subject arrives asynchronously where the reference's is already in the object manager; `RefreshUnit` re-pins, as its worker does. Test: `a_unit_pane_keeps_the_unit_it_was_given_when_the_token_moves_on`. Still true and still not built: `GetModel()` answers nil on a unit pane here, so pfUI's eventless detection leg can never fire — its event leg does, and that is what draws its portraits |
|
||
| An item tooltip's rebuild was silent, so an addon's lines lasted one refresh | **fixed** | Reported from play with a video: hovering a bag item draws the enhanced tooltip — MasterTradeSkills' recipe block, AdvancedTradeSkillWindow2's `Bags/Bank/Total` — for about nine frames of sixty, and then the plate reverts to the bare one and stays bare while the cursor sits still. The loop behind it is stock: `ContainerFrameItemButton_OnUpdate` re-enters `..._OnEnter` every `TOOLTIP_UPDATE_TIME` (`ContainerFrame.lua:693` in the player's own `patch-4.mpq`, and pfUI's bag slots are `ContainerFrameItemButtonTemplate`, so they run it too), and each turn is `SetOwner` + `SetBagItem` — a clear that wipes every line an addon appended. What puts them back in the reference is a **hide/show pair**: the `SetOwner` binding hides the plate before it even reads the owner argument (`0x531179` zeroes the content gate at `+0xd0`, `0x531183` calls `[vtable+0x84]` = `0x530a60`, which is the `SetOwner(NULL)` core plus the base hide `0x76ad50`), and the content `Set*` re-shows it through the tooltip's own Show override (`0x530a80`), so every rebuild raises `OnHide` then `OnShow`. Ours cleared in place, and the base show early-outs on an already-visible frame (`0x76ae3b`), so the plate never hid and the `OnShow` never came. `LibItemTooltip-1.0` — the tooltip contract of MasterTradeSkills and Gromissingcrafts — hangs entirely on a scratch child frame of `GameTooltip` listening for `OnShow`, which is why exactly those two addons' lines vanished while pfUI's `ItemID` and sell-price lines, hooked on the `Set*` methods themselves, survived every refresh. All addresses read off the player's own `WoW.exe` |
|
||
| The object sparkle was armed but buried inside the object | **fixed** — the placement is ours | *"ore vein is still not sparkling"*, after the switch fix. Reproduced offline in the `fxview` GameObject lane (`WOW_CAPTURE=fxview WOW_FX_GO=310 WOW_FX_GO_TYPE=3`, a copper mining node): the gate fires, `attach_spell_fx` spawns the instance (`kit spawn … Particles\LootFX.mdl`), and `WOW_PARTICLE_CENSUS` lists all four of `LootFX`'s emitters at the node's own position, live and ungated — while the captured frame is **byte-identical** to one taken with the sparkle gated off. The attach cascade's last rung is the host's origin, which for a unit is right (`0x13` is attachment 19, Base, the ground point under its feet, and this is a ground twinkle); a 1.12 **GameObject** model authors no attachment points at all (`copper_Miningnode_01.m2`: 0), so the effect sat at the node's pivot, inside the rock — that model's own vertices run y ∈ [-3.83, +2.73]. A host with no attachment points now seats the effect at the top centre of its own box, and the capture differs; the same sparkle on a peacebloom, whose model is small, already showed at the pivot. The placement is ours and no more: SuperWoW publishes no source and the reference has no object arm to copy. It is also **subtle by design** — `SpellVisualEffectName` row 14 scales 1.0 and `Particles\LootFX.m2` is four emitters of 0.06–0.14 yd particles with ~0.9 yd of reach — so it reads as a few twinkles over the node rather than a beacon |
|
||
| 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`, `ChannelStopCastingNextTick`, `CombatLogFlush`, `CustomFileExists`, `EncryptPassword`, `EncryptedServerLogin`, `ExecuteCustomLuaFile`, `ExportFile`,
|
||
`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.
|
||
|
||
## The registration table — a complete list, where the pointer filter gives a candidate list
|
||
|
||
`scripts/dll-surface.py` above answers "what might this DLL publish". For `nampower.dll` there is a
|
||
**better** question available, and it has an exact answer: the names it registers sit **contiguously
|
||
in the string pool, in registration order, bracketed by the DLL's own log lines**.
|
||
|
||
```
|
||
$ strings -a -t x nampower.dll | awk '{a=strtonum("0x" $1); if (a>=0x7c6a8 && a<=0x7cb50) print}'
|
||
7c6a8 Registering Custom Lua functions
|
||
7c6cc QueueSpellByName
|
||
7c6e0 CastSpellByNameNoQueue
|
||
...
|
||
7cad0 ExecuteCustomLuaFile
|
||
7cae8 GetQuestLogQuestIds
|
||
7cafc GetQuestDialogQuestId
|
||
7cb14 Registering Glue Lua functions
|
||
7cb34 EncryptPassword
|
||
7cb44 EncryptedServerLogin
|
||
```
|
||
|
||
That is the whole Lua surface: **60 functions plus 2 glue-screen ones**, no inference. Immediately
|
||
before it, the same way, is the **event** table (`0x7c198`–`0x7c658`, after the `Added custom event
|
||
code:` line): 59 names, from `SPELL_QUEUE_EVENT` to `SPELL_DISPEL_BY_OTHER`. Immediately before
|
||
*that*, after `Registering/Loading CVars`, the CVar names.
|
||
|
||
Why this works where the pointer filter only narrows: a compiler emits one string literal per
|
||
`lua_register(L, "<name>", fn)` call and pools them in source order, so a single registration
|
||
function's worth of names lands in one contiguous run. The log line that opens the run is the
|
||
giveaway — it is the literal immediately before the first name, because it is printed immediately
|
||
before the first call. **Look for the log line, then read forward until the strings stop looking
|
||
like identifiers.** Nothing else was needed, and it is strictly better evidence than "the image
|
||
points at this string": a statically linked library's `BlueValues` cannot land inside the run.
|
||
|
||
Two other things fall out of the same pool, and both corrected code that was already written:
|
||
|
||
- **Usage strings are the argument contract.** `strings -a nampower.dll | grep '^Usage:'` returns 44
|
||
of them, each the exact spelling the DLL prints when a call's arguments are wrong — e.g. `Usage:
|
||
GetSpellModifiers(spellId, modifierType) - modifierType: 0=DAMAGE, 1=DURATION, …` enumerates all
|
||
26 modifier constants, and `Usage: GetSpellRangeData(rangeIndex)` says that verb takes a
|
||
`SpellRange.dbc` **row index** and not a spell id, which this file previously had wrong.
|
||
- **`GetItemIconTexture` took the wrong argument here, and the usage string is how that was found.**
|
||
`Usage: GetItemIconTexture(displayInfoId)` — an `ItemDisplayInfo` row id, the exact sibling of
|
||
`GetSpellIconTexture(spellIconId)`. Ours resolved an *item* id through the cache to its display id
|
||
first, which is a different function: upstream's addon layer keeps that hop in a wrapper of its
|
||
own (`NampowerAPI.lua:2648` is the pass-through, `:2665` is `GetItemIconTextureByItemId`). Fixed,
|
||
with the test now pinning that an item id answers **nothing**.
|
||
|
||
Three of the four field-name blocks are in the pool too, in table order, which is how
|
||
`GetCastInfo`'s ten fields and the cooldown table's twenty were read without guessing: `castId`,
|
||
`spellId`, `guid`, `castType`, `castStartS`, `castEndS`, `castRemainingMs`, `castDurationMs`,
|
||
`gcdEndS`, `gcdRemainingMs` at `0xa8854`, then `isOnCooldown` … `isOnGcdCategoryCooldown` at
|
||
`0xa88d4`.
|
||
|
||
## The DLL work list, name by name — `scripts/dll-surface.py`'s output, worked
|
||
|
||
The script above enumerates what the DLLs publish; this is what came of reading each name's call
|
||
sites in grom's own corpus. **The single most useful thing found was not a missing function.**
|
||
`SuperCleveRoidMacros/NampowerAPI.lua` is a capability registry: a `VERSION_REQUIREMENTS` table
|
||
naming every nampower function with its minimum version, reached through `API.HasFeature`, which
|
||
checks the version **and** `_G[name] ~= nil` (`:448-465`). So nothing on this list was *broken*, and
|
||
raising `GetNampowerVersion` would not have helped — the only honest way to make one of these live
|
||
is to define the global. Read a call site before implementing: the first pass of the script flagged
|
||
25 names as "unguarded" that were doc comments and table keys inside that registry, and pfUI's
|
||
`GetCastInfo` is its own file-local function (`modules/nameplates.lua:105`), not nampower's.
|
||
|
||
**Done (22 as of the round that wrote this, and the Built row below is the live count).** `PlayerIsMoving`, `PlayerIsRooted`, `PlayerIsSwimming`, `IsAuraHidden`,
|
||
`GetItemIconTexture`, `GetTrinkets`, `FindPlayerItemSlot`, `GetBagItem`, `GetItemIdCooldown`,
|
||
`UseItemIdOrName`, `CancelPlayerAuraSpellId`, `CancelPlayerAuraSlot`, `CastSpellNoQueue`,
|
||
`CastSpellByNameNoQueue` (nampower), `GetQuestIDForLogIndex`, `GetQuestLinkForLogIndex` (SuperWoW).
|
||
Plus the six `NP_EnableUnitEventsX` CVars, registered stored-only. Each has its row in
|
||
`reference_surface.rs`'s `allowed_beyond_1_12` saying why 1.12 has no spelling to prefer.
|
||
|
||
**Already present, counted as missing by the script.** `IsSwimming`, `IsMounted`, `IsIndoors`,
|
||
`SpellInfo`, `SetAutoloot`, `GetPlayerBuffID`, `SetMouseoverUnit`, `UnitExists`'s GUID return,
|
||
`UnitBuff`/`UnitDebuff`'s aura ids, `GetLootMethod`'s master looter, `UNIT_CASTEVENT`,
|
||
`RAW_COMBATLOG`. The script compares against `dump_globals` on a **bare** VM, so a verb installed
|
||
later and an *event* (never a global at all) both read as absent. Subtract those before counting.
|
||
|
||
**The nine file and credential functions — built, on grom's explicit say-so.** They were withheld
|
||
for a round and the reason recorded ("the right default for a client that did not have them
|
||
yesterday is not to grow them silently"); he said yes to all nine. Both halves of that decision
|
||
stay here, because the reasoning is what makes the second half a decision rather than a drift.
|
||
|
||
| Name | Where it lands |
|
||
|---|---|
|
||
| `ReadCustomFile`, `ImportFile` | one operation under two names (upstream registers both); the file's **bytes** as a Lua string, so a binary file round-trips |
|
||
| `WriteCustomFile`, `ExportFile` | `mode` `"a"`/`"append"` adds, anything else replaces; `ExportFile` has no mode and always replaces. Both answer `1` or nil, so a caller can report a failure the player can fix |
|
||
| `CustomFileExists` | `1` or nil, and `is_file` rather than `exists` — a directory of that name is not a file this family can read |
|
||
| `ExecuteCustomLuaFile` | loads and runs the chunk at global scope, named `@<filename>` so a traceback points at the player's file. A missing file is nil; a **broken** one raises, because that is the distinction worth the most on a file edited by hand |
|
||
| `StartItemExport` | writes the item cache as TSV to `ItemExport.txt`. **Deviation**: upstream's name implies a server sweep; this exports the rows the session already saw. A sweep is a few thousand `CMSG_ITEM_QUERY_SINGLE`s at the realm, which is not something a Lua call should start |
|
||
| `EncryptPassword` | the blob, from `benilla-app`'s `credential.rs` |
|
||
| `EncryptedServerLogin` | **upstream logs in; this remembers** — see below |
|
||
|
||
**The fence is app-side, and it is not a `..` scan.** `local_state::custom_file_path` admits one
|
||
relative name whose every component is an ordinary name, inside `benilla-config/CustomFiles/`, and
|
||
refuses a leaf that is already a **symlink**. The escapes a string check misses, each with its own
|
||
rule: an absolute path (`join` *replaces* the base rather than appending), a Windows prefix
|
||
(`C:\…`, `\\server\share`), a `..` nested inside a longer path, and a planted symlink — which is
|
||
the one that no amount of sanitising the string will catch. nampower writes relative to the
|
||
client's working directory, the install root, which is read-only here; a *subdirectory* of the
|
||
config folder rather than its root, because a verb that could create `config.toml` or
|
||
`autologin.toml` by name would let an addon rewrite the client's own settings. That is a different
|
||
power from "keep a file of your own", and it is not the one being granted.
|
||
|
||
**The credential pair's deviation is forced, not chosen.** Both are registered by nampower under
|
||
"Registering *Glue* Lua functions" — the login screen's VM — and this client has **no glue Lua VM
|
||
at all**: login, character-select and character-create are Rust (`autologin.rs`'s header sets that
|
||
out at length). `EncryptPassword` is a pure function and works the same wherever it is defined.
|
||
`EncryptedServerLogin` cannot mean "submit the login form" in the only VM there is, because in it
|
||
you are already in the world. What the pair is *for* is a password on disk as a blob rather than as
|
||
itself, so it stores the credential for the next login instead: `enabled` on, the plaintext field
|
||
**cleared**, the blob kept, and `AutoLogin::effective_password` reads it back at the next refill.
|
||
|
||
What that protects: an `autologin.toml` or a SavedVariables file read by someone else or pasted into
|
||
a forum. The blob is authenticated and keyed on 32 random bytes in a **different file**
|
||
(`benilla-config/credential.key`, mode `0600`). What it does not protect: anything running as this
|
||
user, which can read the key as easily as the blob — there is no user secret in the chain and there
|
||
cannot be, because the point is logging in without typing one. The construction is encrypt-then-MAC
|
||
with HMAC-SHA1 as the PRF for both halves, from the two primitives this tree already had; the HMAC
|
||
is tested against RFC 2202's own vectors, because a hand-written one still round-trips with itself
|
||
when it is wrong.
|
||
|
||
**Not wire-compatible with nampower's blobs**, and it cannot be: that scheme is undocumented and a
|
||
key file generated here would not decrypt one made there whatever the cipher. A blob copied from a
|
||
real install answers nil, which the drain reports rather than storing — a stored credential that
|
||
cannot be read back is a login box that silently refills empty.
|
||
|
||
**The tick-box now writes the blob** — asked for explicitly, a round after the machinery landed.
|
||
Storing the password in plaintext had been grom's own choice when the alternatives were a keyring
|
||
or nothing, so the round that built these two verbs left that path alone and said what switching
|
||
it would cost; this is the switch, not a side effect of the port. `AutoLogin::remember` is the one
|
||
write path, `migrate_plaintext` upgrades a file written by an older build, and the plaintext field
|
||
stays *readable* so that upgrade cannot lock anyone out of their own store.
|
||
|
||
**It cost grom his stored password once, and that is worth writing down.** While this was being
|
||
built, two unit tests ran concurrently: one redirected the config directory through
|
||
`BENILLA_HOME`, which is process-wide, and the other stood up `LoginPlugin`, whose `build` now
|
||
called `migrate_plaintext`. So his real `autologin.toml` was upgraded against a key file that was
|
||
not the one beside it — the plaintext was cleared and the blob it was replaced with does not
|
||
decrypt. He has to type the password once more; `remember` then stores it correctly, because a
|
||
blob that answers empty does not match and is re-sealed.
|
||
|
||
Two fixes, and the second is the one that matters: the migration is now `#[cfg(not(test))]`, so a
|
||
test cannot write to the player's own config at all; and **`sealed` encrypts and then reads the
|
||
blob back, refusing to clear the plaintext unless the round trip holds**. The second would have
|
||
prevented it on its own. The general shape of the mistake is the one `credential.rs` already
|
||
carries a note about — a scheme that round-trips with *itself* while being wrong — one level up:
|
||
here the cipher was right and the *key* was the thing that disagreed, and the only copy of the
|
||
password was thrown away before anyone checked.
|
||
|
||
Two decisions inside it worth naming, because both are places a convenient answer is the wrong
|
||
one. **A password that cannot be encrypted is not stored at all** — no fallback to the clear
|
||
field: the player cannot see the difference between a box that stored a blob and one that quietly
|
||
stored plaintext, so a silent downgrade is worse than a box that visibly did not take. And **a
|
||
stale plaintext field beside a blob is cleared rather than encrypted**: `effective_password` never
|
||
read it, so encrypting it would promote a password the player has already replaced.
|
||
|
||
**Everything below this line is the reasoning of earlier rounds, kept rather than deleted.** Each
|
||
section records what was missing *then*, what it would have taken, and — where a later round built
|
||
it — which of those reasons turned out to be wrong. That is the point of keeping them: three of the
|
||
"needs a feed" rows were answerable with data the tree already had, and a list of only the final
|
||
answers would hide that the first reading was the expensive mistake.
|
||
|
||
### The cast feed — `GetCurrentCastingInfo`, `GetCastInfo` and `SPELL_CAST_EVENT`
|
||
|
||
**Built**, and the reason this pair came first is measurement rather than taste: they are the
|
||
most-called nampower functions in grom's corpus (25 and 20 sites outside `NampowerAPI.lua`), and
|
||
one of those sites calls `GetCurrentCastingInfo` with **no existence guard at all** —
|
||
`local _,_,_,_,_,_,autoattack = GetCurrentCastingInfo()` (`SuperCleveRoidMacros/Core.lua:94`), in
|
||
the deferred-stop frame that keeps clearing the target while an autoattack is queued. On a client
|
||
without the function that line raises.
|
||
|
||
What the earlier "needs a feed" row said was true of `PendingCast` and wrong about the client as a
|
||
whole: this tree already folds a **cast window per caster** off the same packets (`UnitCastState`
|
||
in `ui_cast_events.rs`, behind `C_Spell.UnitCastingInfo`), with a start, an end, a channel bit and
|
||
the accumulated pushback. What was genuinely missing was the *caster's own machinery* — the
|
||
reference's cast globals — and the GCD, which `spell/cooldowns.rs` has run correctly all along and
|
||
never published.
|
||
|
||
| Piece | Where |
|
||
|---|---|
|
||
| `SelfCast`, eleven columns: the inflight spell `0xceca88`, the bar's spell `0xceac48`, the auto-repeat key `0xceac30`, four flags, the cast counter, the cast type, the target, and the GCD's start/end on the `GetTime()` clock | `benilla-ui`'s `script/model.rs` + `script/self_cast.rs` |
|
||
| The per-tick push, and `SPELL_CAST_EVENT` fired off the same commit | `benilla-app`'s `ui_self_cast.rs`, in `UnitFeed` after the cast-window drain so the two halves of `GetCastInfo`'s table cannot describe different casts |
|
||
| `Cooldowns::gcd` — the running global cooldown as one timer | `spell/cooldowns.rs`, the longest running GCD leg, which is the one a player is waiting on; `info` still answers the per-category question every cooldown swirl asks |
|
||
| `PendingCast`'s cast counter | `spell/inflight.rs`; the one place every class of commit passes through, which is why it is there and not in the send path |
|
||
| The commit rows (`castType`, target, item) | `spell/cast_send.rs`, at the two commits — the plain send and the targeting-cursor click, which is the only arm that produces `TARGETING`/`TARGETING_NON_GCD` |
|
||
|
||
Three decisions worth having written down, because each had a tempting wrong answer:
|
||
|
||
- **`GetCastInfo` is nil when nothing is casting, and an instant's GCD does not count.** The
|
||
shortcut is to answer a table whenever the GCD is up, so the consumer's fast path always hits.
|
||
But `API.IsCasting()` *is* `GetCastInfo() ~= nil` (`NampowerAPI.lua:1888`), and `[casting]` is a
|
||
macro conditional players gate abilities on — a table there would make it true for a second and
|
||
a half after every instant. The wrapper's own fallback comment says what to do instead
|
||
("`GetSpellIdCooldown` … works for instant casts").
|
||
- **`castId` means two different numbers under one upstream name.** In `GetCastInfo`'s table it is
|
||
a counter, beside a separate `spellId` column. In `GetCurrentCastingInfo`'s returns it is a
|
||
*spell id*, which is how both of its readers treat it (`Init.lua:89` stores it as
|
||
`castingSpellId`; `Core.lua:5427` hands it to `C_Spell.GetSpellName`). Both are implemented as
|
||
what their own consumers read.
|
||
- **`SPELL_CAST_EVENT` fires for commits, not for refusals.** Upstream's `success` column can be
|
||
0; this client's local refusals all funnel through `CastErrors`, which carries a spell id and a
|
||
reason byte and neither a cast type nor a target, so a row from there would invent two of five
|
||
fields. Nothing in the corpus reads the failing branch — `Core.lua:5177` is one
|
||
`if success == 1 then` with no else.
|
||
|
||
### nampower's cast events — the ten off the one queue
|
||
|
||
**Built.** All ten ride `ui_cast_events.rs`'s existing `CastEventFeed` rows — the same rows
|
||
`UNIT_CASTEVENT` and the cast windows are folded from — with `NpCastColumns` carrying the fields
|
||
nampower's shapes want and SuperWoW's word has no place for. One row per packet, folded once, which
|
||
is this file's own rule: an addon woken by one of these and then asking a getter must not be able
|
||
to get a contradicting answer, and a parallel queue reading the same packets is exactly how that
|
||
happens.
|
||
|
||
`NP_EnableSpellStartEvents` and `NP_EnableSpellGoEvents` are now **live** (they were stored-only),
|
||
gating the START and GO pairs; the other five have no switch in the DLL's own CVar table and fire
|
||
unconditionally, as upstream's do. Both are written to `"1"` at login by `NampowerAPI.lua:2894-2902`
|
||
and by pfUI's `libs/libdebuff.lua:56-88`.
|
||
|
||
The stored-only rows claimed their shapes wanted "itemId/spellType/corpseOwnerGuid columns this
|
||
client does not carry". Two of those three turned out to be a `Spell.dbc` lookup and a guid hop the
|
||
item-cooldown path already made:
|
||
|
||
| Column | Where it comes from |
|
||
|---|---|
|
||
| `itemId` (`arg1` of START and GO) | the cast item's guid through the same two hops `item_cooldown` makes (the guid index, then `OBJECT_FIELD_ENTRY`), now one shared `item_entry` helper. `SMSG_SPELL_START` carries the guid and `SessionEvent::SpellStart` was dropping it; it carries it now, as the GO's always did |
|
||
| `spellType` (`arg8` of START: 0 normal, 1 channel, 2 autorepeat) | the spell's own attributes — `AttributesEx & 0x44` for a channel, the auto-repeat bit for a shot. The wire says nothing about either |
|
||
| `channel_ms` (`arg7` of START) | `SpellDuration.dbc`, under exactly the conditions `aura_duration_ms` will answer one at all. Not on the wire: a channeled spell's START carries `cast_time_ms` 0 and the length arrives a round trip later in `MSG_CHANNEL_START` — which is the case pfUI's reader names ("only for true channels like Blizzard where arg6 is 0/nil") |
|
||
| `numTargetsHit`/`numTargetsMissed` (`arg6`/`arg7` of GO) | the packet's own two lists, capped at 255 because the server writes both counts as `u8`s |
|
||
| `spellResult` (`arg2` of `SPELL_FAILED_SELF`) | the failing `SMSG_CAST_RESULT`'s reason byte — the number 1.12 turns into a red line and discards |
|
||
| `corpseOwnerGuid` | **not sent**, and this is the one that really is absent. A trailing nil in Lua is indistinguishable from an absent return, and upstream sends nil for every cast without a corpse target. For the handful that have one the gap is bounded: `read_spell_cast_targets` reads the corpse guid and drops it, and the *owner* is a further hop through that corpse object's `CORPSE_FIELD_OWNER`. Nothing in grom's corpus reads the column |
|
||
|
||
Two subtleties worth having written down:
|
||
|
||
- **A channel's START is queued although its wire cast time is 0, and `UNIT_CASTEVENT`'s START is
|
||
still suppressed for it.** Without the row, `SPELL_START_SELF` would never fire for Mind Flay or
|
||
Blizzard; with the word, every cast-tracking addon on this realm would get a SuperWoW START whose
|
||
`arg5` is 0 — a cast bar told to run for no time. The suppression moved from the producer
|
||
(`cast_time_ms > 0`) to the fire, so SuperWoW's word behaves exactly as it did before the row
|
||
existed. An instant is the other zero case, and the wiki's own START/CAST split says its only
|
||
edge is the CAST.
|
||
- **The `_SELF`/`_OTHER` split is by caster here, and by *target* for the aura family next door.**
|
||
Neither is a guess: upstream's wording for the aura pair is "casts that land on the active
|
||
player", and for this one pfUI handles both names in a single arm and then reads `arg3` to learn
|
||
the caster — which only makes sense if the name already settled it.
|
||
|
||
**A gate hole closed on the way past.** `AURA_CAST_ON_SELF`/`_OTHER` were fired through one `&str`
|
||
variable, and `reference_ui.rs`'s beyond-1.12 event gate reads the string literal *at the call* — so
|
||
it had been blind to that pair for as long as they had existed. Both fire sites are literals now,
|
||
with their rows; verified by probe (renaming either row makes the gate name it).
|
||
|
||
### The bag, modifier and cooldown group — and two more shapes the string pool corrected
|
||
|
||
**Built: `GetBagItems`, `GetSpellModifiers`, `GetSpellIdCooldown`.** Plus two fixes to verbs that
|
||
already existed and had the wrong *shape*, both found the same way as `GetItemIconTexture`'s wrong
|
||
argument — by comparing what we answer against the DLL's own string pool.
|
||
|
||
| Verb | What it needed |
|
||
|---|---|
|
||
| `GetBagItems()` | every occupied bag slot as `{ [bagIndex] = { [slot] = row } }`. DoiteAuras asks seven times over and walks both levels with `pairs`, reading `itemInfo.itemId` and `itemInfo.stackCount` — it is how that addon answers "do I have this item" without 100 `GetContainerItemInfo` calls per condition evaluation. Empty slots and empty bags are **absent**, because a nil value in a Lua table *is* absence and `pairs` is how both levels are read |
|
||
| `GetSpellModifiers(spellId, modifierType)` | `flat, percent, hasModifier`, where `percent` is the **final multiplier** (90 means 90% of the original) — the consumer says so in its own words and multiplies by `percent / 100`. The 26 modifier types come from the DLL's usage string. The fold is `spell/mods.rs`' existing reader, reached through a new `Model::spell_modifier_probe` the lifecycle **reinstalls** whenever the wire moves the tables: a VM-side reimplementation of a four-gate 64-bit fold would be free to drift from the mana cost the action bar greys a button on |
|
||
| `GetSpellIdCooldown(spellId)` | the twenty-key cooldown table, from a new `Cooldowns::legs` — `info`'s three legs kept apart, same walk and same arms, so the folded number a swirl draws and the legs an addon reads cannot describe different timers. Fed beside the folded triple by the spellbook, the pet book and the bag feeds |
|
||
|
||
**`GetBagItem`'s row had the wrong key names.** It answered `bag`, `slot`, `count`, `texture`,
|
||
`quality`, `link`, `locked` — this client's own `GetContainerItemInfo` spellings. nampower's row is
|
||
`itemId, permanentEnchantId, tempEnchantId, randomPropertiesId, stackCount, duration,
|
||
spellChargesRemaining, flags, durability, maxDurability, tempEnchantmentTimeLeftMs,
|
||
tempEnchantmentCharges`, plus `bagIndex`/`slotIndex` — contiguous at `0xa8a68`, and the same block
|
||
`GetEquippedItem` already used. So a caller reading `.stackCount` got nil and no error. Now one
|
||
`bag_slot_table` serves `GetBagItem` and `GetBagItems`, with three columns **absent rather than
|
||
zeroed**: this client resolves a bag slot's enchants to `SpellItemEnchantment` *names* at the app
|
||
boundary and keeps no random-properties id for a container slot, so `permanentEnchantId`,
|
||
`tempEnchantId` and `randomPropertiesId` are not there. A zeroed enchant id would be a claim.
|
||
|
||
**`GetItemIdCooldown` returned the wrong *kind* of value.** It answered `start, duration, enable` —
|
||
the stock triple. At the version we report it is a table, and the addon layer is what settles it:
|
||
`API.GetItemCooldownInfo` and `API.GetSpellCooldownInfo` **return these calls' results directly**
|
||
(`NampowerAPI.lua:1950`, `:2035`) and their callers index them. `cd.gcdCategoryRemainingMs` is the
|
||
primary path of `API.GetGCDRemainingMs`, which the `[gcd]` and `[nogcd]` macro conditionals divide
|
||
by 1000 — against a triple that is `(0.0).gcdCategoryRemainingMs`, which raises. Nothing in the
|
||
corpus calls those two wrappers today, so the bug was latent rather than live; it was still wrong.
|
||
|
||
Two decisions written down at the code:
|
||
|
||
- **Every `*RemainingMs` is derived in the binding, from `GetTime()` and the leg's absolute
|
||
start.** A remainder is the one column that changes every frame, and pushing it would churn the
|
||
spellbook diff sixty times a second — the feed's own note says the absolute start is what keeps a
|
||
running cooldown out of that diff.
|
||
- **The reach is the player's book, pet book and bags, which is narrower than upstream's.** It
|
||
reads the client's whole `SpellHistory`; this reads the per-slot legs those feeds already carry.
|
||
For a spell the player has not learnt the answer is the same either way, and the one live
|
||
consumer asks about a book spell by construction (`API.GetGCDRemainingMs` probes with
|
||
`GetSpellName(1, BOOKTYPE_SPELL)`).
|
||
|
||
**One count the earlier sweep got wrong, worth correcting here**: `GetSpellPower`'s "7 call sites"
|
||
are `BetterCharacterStats`' own method `BCS:GetSpellPower(school)`, not nampower's global. Nothing
|
||
in the corpus calls the global at all — the only mention is a comment in
|
||
`SuperCleveRoidMacros/ClassicAPI.lua:272` saying it reads the same client field. A bare name match
|
||
is not a call site.
|
||
|
||
### The `Spell.dbc` row — `GetSpellRec` and seven columns `GetSpellRecField` answered nil for
|
||
|
||
**Built**, and this one is a measured gap rather than a completeness exercise. Counting what the
|
||
corpus actually asks `GetSpellRecField` for: `name` (4 sites), `school` (3), `stackAmount` (2),
|
||
`spellIconID` (2), then one each of `rank`, `rangeIndex`, `mechanic`, `manaCost`,
|
||
`effectMechanic`, `effectApplyAuraName`. **Seven of those ten answered nil** — the binding knew
|
||
three names. DoiteAuras caps its stack display on `stackAmount` (`DoitePlayerAuras.lua:539`) and
|
||
`SuperCleveRoidMacros` classifies a crowd-control effect off the two arrays
|
||
(`Utility.lua:7776-7794`), so each nil was a feature quietly off.
|
||
|
||
The names are the DLL's own, read off its pool in `Spell.dbc` column order (`0x91d68`–`0x92460`):
|
||
eighty of them. The mapping to `benilla_formats::SpellDisplay` lives in
|
||
`benilla-app`'s `ui_script/spell_rec.rs` — beside the struct it reads, because a copy on the VM side
|
||
would be free to drift — and `GetSpellRec(spellId)` is that same field list walked through that
|
||
same per-field reader, so the two verbs cannot answer differently for one name. A test asserts
|
||
exactly that, key by key, over one row.
|
||
|
||
**The array columns are a 1-based table of three, which is measured and not guessed.**
|
||
`SuperCleveRoidMacros` walks `GetSpellRecField(spellID, "effectMechanic")` as
|
||
`for i = 1, 3 do local em = effectMechanics[i]`. `totem`, `reagent` and `reagentCount` take the
|
||
same shape, two and eight long.
|
||
|
||
One new DBC column: `StackAmount`, column **39**, bracketed by two constants this tree already had
|
||
(38 `ModalNextSpell`, 40 `Totem[0]`). It is the only one added — fifteen further names upstream
|
||
answers are left nil rather than putting fifteen unused `u32`s on a struct the spell catalog holds
|
||
one of per row. They are listed at `FIELDS` with that reason, so the gap is a decision and not an
|
||
omission: `attributesEx4`, `targetCreatureType`, `procFlags`, `procCharges`,
|
||
`manaPerSecondPerLevel`, `maxTargetLevel`, `maxAffectedTargets`, `dmgClass`, `spellPriority`,
|
||
`spellVisual2`, `minFactionId`, `minReputation`, `requiredAuraVision`,
|
||
`effectPointsPerComboPoint`, `dmgMultiplier`.
|
||
|
||
`GetSpellRecField`'s three string columns keep the spellbook fallback the verb had before the
|
||
probe: a VM with a book and no catalog — a capture, or the boot VM before `ui_action`'s loader
|
||
runs — still answers a learnt spell's name and rank, which is what `GetSpellNameAndRankForId`
|
||
beside it does from the same `spell_display`. Every other column is a DBC row and has nowhere else
|
||
to come from.
|
||
|
||
### The trinket cooldown — and a stock verb that had been answering "cold" since it was written
|
||
|
||
**Built: `GetTrinketCooldown`, `UseTrinket`, `GetSpellRangeData`, `SetLocalRaidTargetIndex`,
|
||
`GetQuestLogQuestIds`, `GetQuestDialogQuestId`, `CombatLogFlush`.**
|
||
|
||
The find on the way through is not a nampower verb. **`GetInventoryItemCooldown` is a stock 1.12
|
||
verb and it answered `(0, 0, 1)` unconditionally** — the shape was right and the number was always
|
||
"cold", so nothing errored and no worn trinket ever drew its cooldown swirl on the character pane.
|
||
Stock `PaperDollFrame.lua:691` calls it on every slot update. Feeding an equipped slot's cooldown
|
||
for `GetTrinketCooldown` fixes that one too, off the same column, and the test runs
|
||
`CooldownFrame_SetTimer`'s own arithmetic against it.
|
||
|
||
The feed is `ui_char::slot_view`, keyed on the (use spell, item entry) pair exactly as the bag
|
||
feed's is — `Cooldowns::info` matches an item record on both — so one trinket reads the same
|
||
cooldown from the doll and from a bag.
|
||
|
||
**The risk in that change was the gate, and the first test for it was vacuous.** `feed_char` is
|
||
behind a change gate, and no descriptor moves when a cooldown is armed, so `Cooldowns::feed_epoch`
|
||
had to become a gate input or the swirl would appear only on the next frame something unrelated
|
||
changed. The first test passed with that input deleted: `run_system_once` builds a fresh system
|
||
each call, so the gate's `Local` memo resets and `vm_reset` opens the gate every time. A test for a
|
||
gate input has to run the system through the schedule (`add_systems` + `update`), or it proves
|
||
nothing. Rewritten that way, it fails with the input removed — verified by probe.
|
||
|
||
Two also worth recording:
|
||
|
||
- **`GetSpellRangeData(rangeIndex)` answers three values, not a table.** Every table-returning verb
|
||
in this DLL has its key names in the string pool; this row's are not there, so a flat row is what
|
||
it answers. `flags` bit 0 is the melee family, where the engine takes the combat-reach sum
|
||
instead of the authored pair — a caller that ignores the bit reads 0..5 for every melee ability,
|
||
which is why the binding says so.
|
||
- **`CombatLogFlush()` is a no-op, deliberately defined.** Upstream buffers its combat-log writes
|
||
and this empties the buffer, so what an addon asks for is "my lines are on disk now".
|
||
`ui_chat/logging.rs` writes each line to an **unbuffered** `std::fs::File` as the window shows
|
||
it, so that postcondition already holds at all times. Defining it is the honest answer: the
|
||
capability is present, and a missing global makes `HasFeature` report it absent when it is not.
|
||
|
||
**The four measured-out names — built too, on grom's "62/62".** Each had a reason for being out;
|
||
each reason turned out to be answerable, and where it was not the deviation is stated.
|
||
|
||
| Name | What it took, and what it cost |
|
||
|---|---|
|
||
| `GetSpellPower(school)` | **delegated to `GetSpellBonusDamage`**, which this client already answered. They are one number under two mods' names — `SuperCleveRoidMacros` says so itself ("Reads the same client field nampower's `GetSpellPower` does") — and delegating is the only way to guarantee they cannot drift. The earlier "needs a descriptor band fed" was wrong: it was already fed |
|
||
| `GetRaidTargets()` | the 8-slot **board**, now pushed beside the party roster. The earlier reason — "a scan over named tokens would silently omit a marked unit" — was right about the scan and wrong about the alternative: `GroupState::raid_targets` is the reference's own table and copying it costs eight words a push. A mark with nothing on it is **absent**, because a zero GUID is a string that passes `if t[4]` |
|
||
| `LearnTalentRank(page, index, rank)` | one `LearnTalent` press per missing rank, which is what a player clicking the button does. The earlier reason — the burst cannot be checked before it is sent — is true and is not a reason to withhold it: the check that matters is the server's and the client has never had another. What *can* be checked locally is, so a rank already held queues nothing and one past the maximum is clamped rather than sending presses the server will refuse |
|
||
| `GetUnitData(unitToken, [copy])` | the whole descriptor, from a new raw block on `UnitState` (`ui_script/unit_rec.rs`). The earlier reason — "a decision about which fields" — is now a table of seventy columns under the DLL's own names, and `GetUnitField`'s scalar half reads the same block, so the two cannot answer differently for one name |
|
||
|
||
**The indices for `GetUnitData` are derived, and the derivation is checked.** Only thirteen
|
||
`FIELD_UNIT_*` constants existed in this tree; the rest come from 1.12's `UnitFields` block sizes.
|
||
Those thirteen are spread from index 22 to 147, and every one lands exactly where the layout puts
|
||
it — thirteen independent confirmations, run as a test rather than asserted in a comment, alongside
|
||
one that no two columns overlap and one that nothing falls inside the aura blocks' 47..=124.
|
||
|
||
Three decisions at that mapping:
|
||
|
||
- **An unstreamed column is absent, not zero.** `at()` is `get_raw`'s strictness rather than
|
||
`get_u32`'s absent-is-zero convenience: `health = 0` for a unit whose health has not arrived is a
|
||
number a caller divides by, and nil is what their own guard already tests.
|
||
- **`aura` and `auraApplications` come from the filtered per-unit aura list**, not the raw slots —
|
||
the same place `GetUnitField` has always answered them from. The two verbs must not disagree
|
||
about which auras a unit has, and the filter drops exactly the ones a tracker does not want.
|
||
- **`status` and `zoneId` are left out** of nampower's seven computed columns. Nothing in the
|
||
string pool or the corpus says what `status` is a status *of*, and a zone is only knowable for
|
||
the player. The other five (`guid`, `posX`, `posY`, `power`, `maxPower`) are filled by the
|
||
caller, which knows them.
|
||
|
||
### The queue, and the one verb that needs it — 62/62
|
||
|
||
**Built**, on grom's "62/62": `QueueSpellByName`, `QueueScript`, `ChannelStopCastingNextTick` and
|
||
`DisenchantAll`.
|
||
|
||
> **Superseded by "The press-interception half, built" below.** The paragraph that stood here said
|
||
> the press half was deliberately unbuilt and its twenty CVars stored-only. It is built now, on
|
||
> grom's say-so and with a live test to follow; the slots are still the ones described in this
|
||
> section, because upstream has one queue.
|
||
|
||
**Upstream's queue has two halves, and both are built now.** The Lua surface — "remember this spell
|
||
and send it when the client will accept it" — is `spell/queue.rs`, below. The half that intercepts
|
||
**every ordinary press** is `spell/np_press.rs`, and the section after this one covers it. They
|
||
share the three slots and the one `SPELL_QUEUE_EVENT`: upstream has one queue, and a second set of
|
||
slots beside it would be a different feature wearing the same name.
|
||
|
||
**Three slots, because upstream has three and its own event says so.** `SPELL_QUEUE_EVENT`'s first
|
||
argument is `0`/`1` on-swing queued/popped, `2`/`3` normal, `4`/`5` non-GCD — so the queue is keyed
|
||
by what *kind* of cast a spell is, and the kind comes off its own `Spell.dbc` row through the same
|
||
three tests the send path makes. Each slot pops on its own condition: non-GCD and on-swing as soon
|
||
as no cast guards (neither uses the global cooldown), normal when the GCD has run out too.
|
||
|
||
Three decisions at the queue:
|
||
|
||
- **A replacement raises no popped event.** A second queue of one kind replaces the first — which
|
||
is upstream's own behaviour, its log line being `Replacing queued nonGcd spell X with Y` — and the
|
||
replaced entry was never sent. A consumer counting pops against queues would otherwise see one
|
||
too many.
|
||
- **An expiry raises nothing either.** `*_POPPED` means "this spell was sent"; a timed-out entry
|
||
was not, and upstream has no code for one.
|
||
- **The expiry is measured from the end of the cast in flight**, not from the moment of queueing,
|
||
so a player who queues early during a three-second cast is not punished for it. A lapsed
|
||
deadline cannot shorten the window below a fresh queue's.
|
||
|
||
**`DisenchantAll` is specified completely by the DLL's own strings**, which is why it is built the
|
||
way it is rather than designed here. `0x93087`–`0x932d0` carries the argument note
|
||
(`includeSoulbound: optional number - pass 1 to include soulbound items (defaults to 0)`), the
|
||
precondition (`You do not have the Disenchant spell`), the three quality keywords (`greens`,
|
||
`blues`, `purples`), every stop reason, and the three chat lines — one of them as a Lua chunk that
|
||
builds the item link with `GetContainerItemLink`.
|
||
|
||
**"move during cast to cancel" is the abort, and it needed nothing new.** Moving already cancels a
|
||
cast here (`inflight.rs`'s `local_self_cancel`), the cast then fails, and a failed cast stops the
|
||
run — so the one piece of machinery a sequencer usually needs is the client's own, and the player's
|
||
way out is the one upstream documents.
|
||
|
||
**How a step's outcome is read: the item is gone, or it is not.** There is no "disenchant
|
||
succeeded" packet; what happens is the item leaves the bag. Each step remembers the guid it cast at
|
||
and waits for it to vanish, with a ten-second deadline. Gone is success and the run continues;
|
||
still there past the deadline is a failure and the run stops with upstream's own line. An
|
||
observable test with no new wire plumbing — right for the same reason the cast bar's own edges are,
|
||
that the thing the player sees is the thing the client keys on.
|
||
|
||
### The whole registered list, accounted for
|
||
|
||
With the registration table read (the section above), the surface is no longer a count of what a
|
||
heuristic flagged. It is **62 names**, every one of which is now in exactly one of these buckets.
|
||
`comm -23` against `dump_globals` is the check, and it is reproducible:
|
||
|
||
```
|
||
strings -a -t x nampower.dll \
|
||
| awk '{a=strtonum("0x" $1); if (a>=0x7c6cc && a<=0x7cb50) {$1=""; print substr($0,2)}}' \
|
||
| grep -E '^[A-Za-z_][A-Za-z0-9_]*$' | sort -u > /tmp/np
|
||
cargo run -q -p benilla-ui --example dump_globals | cut -f1 | sort -u > /tmp/ours
|
||
comm -23 /tmp/np /tmp/ours
|
||
```
|
||
|
||
| Bucket | Count | Names |
|
||
|---|---|---|
|
||
| **Built** | **62** | `CancelPlayerAuraSlot`, `CancelPlayerAuraSpellId`, `CastSpellByNameNoQueue`, `CastSpellNoQueue`, `ChannelStopCastingNextTick`, `CombatLogFlush`, `CustomFileExists`, `DisenchantAll`, `EncryptPassword`, `EncryptedServerLogin`, `ExecuteCustomLuaFile`, `ExportFile`, `FindPlayerItemSlot`, `GetBagItem`, `GetBagItems`, `GetCastInfo`, `GetCurrentCastingInfo`, `GetEquippedItem`, `GetEquippedItems`, `GetItemIconTexture`, `GetItemIdCooldown`, `GetItemLevel`, `GetItemStats`, `GetItemStatsField`, `GetNampowerVersion`, `GetPlayerAuraDuration`, `GetQuestDialogQuestId`, `GetQuestLogQuestIds`, `GetRaidTargets`, `GetSpellDuration`, `GetSpellIconTexture`, `GetSpellIdCooldown`, `GetSpellIdForName`, `GetSpellModifiers`, `GetSpellNameAndRankForId`, `GetSpellPower`, `GetSpellRangeData`, `GetSpellRec`, `GetSpellRecField`, `GetSpellSlotTypeIdForName`, `GetTrinketCooldown`, `GetTrinkets`, `GetUnitData`, `GetUnitField`, `GetUnitGUID`, `ImportFile`, `IsAuraHidden`, `IsSpellInRange`, `IsSpellUsable`, `LearnTalentRank`, `PlayerIsMoving`, `PlayerIsRooted`, `PlayerIsSwimming`, `QueueScript`, `QueueSpellByName`, `ReadCustomFile`, `SetLocalRaidTargetIndex`, `SetMouseoverUnit`, `StartItemExport`, `UseItemIdOrName`, `UseTrinket`, `WriteCustomFile` |
|
||
|
||
|
||
**62 of 62.** The `comm -23` above is the check, and it prints nothing.
|
||
|
||
`CastSpellNoQueue` and `CastSpellByNameNoQueue` became meaningful with the press half: they take
|
||
the ladder entry that skips the queue (`CastLadder::send_now`), so a macro that has already decided
|
||
*when* to cast is not held by the client deciding to cast later instead. Before the press half they
|
||
were the plain cast, which was the honest mapping at the time and is no longer the whole story.
|
||
|
||
### The press-interception half, built
|
||
|
||
`spell/np_press.rs`. A press that the client would refuse for a **timing** reason is held in the
|
||
same slot an explicit `QueueSpellByName` would use, and the drain sends it the moment the guard
|
||
clears. No packet, no global cooldown, no error line while it waits.
|
||
|
||
**Where the rung sits, and the one deviation that comes of it.** Five rungs stay above it — the
|
||
profession opener, the auto-repeat re-press, the dead caster, the targeting cursor's clear and the
|
||
on-swing re-press — because each is what the press *means* whatever is in flight, and holding them
|
||
would change the meaning. Everything below it is a real refusal the player should read: no reagent,
|
||
no weapon, out of range, no power, stunned, moving, wrong form. Upstream hooks the client's cast
|
||
entry and so holds presses that would fail for those reasons too, which means its error arrives
|
||
when the entry pops, a second later, about something the player has stopped doing. Refusing them
|
||
immediately is the deviation, and it is the better half of the trade.
|
||
|
||
**What a window measures.** Upstream publishes four and no sentence saying. Two readings fit the
|
||
names: how long an entry survives, or how close to the guard's end a press must land. This takes
|
||
the first, for three reasons — `spell/queue.rs` already reads `NP_SpellQueueWindowMs` that way, and
|
||
one number meaning two things in one client is a trap nobody finds twice; upstream's own `Replacing
|
||
queued nonGcd spell X with Y` means the last press wins, so holding every press costs nothing in
|
||
staleness; and the admission reading would make the 250 ms cooldown window refuse almost every
|
||
press made during a global cooldown, which is the exact complaint nampower exists to answer.
|
||
|
||
Two rows do read a window as a threshold on remaining time, and both say so themselves:
|
||
`NP_InterruptChannelsOutsideQueueWindow`, whose name states one, and `NP_CooldownQueueWindowMs`,
|
||
which has to be one. A cast or a global cooldown ends in a second or two; a spell's own cooldown can
|
||
be half an hour. Holding that press means either firing it half an hour later or swallowing it with
|
||
no error line, and a Hearthstone pressed on cooldown has to still say "Item is not ready yet" — so
|
||
a quarter of a second, which is what the row defaults to, is an allowance for pressing a touch early
|
||
rather than a queue.
|
||
|
||
**The guard reopens early, and that is the headline.** `PendingCast` now keeps two timestamps where
|
||
it kept one. The **guard** reopens at the cast's own already-known length plus
|
||
`NP_MinBufferTimeMs`; the **record** still lives for the cast plus two seconds, so a `SMSG_SPELL_GO`
|
||
or `SMSG_CAST_RESULT` arriving a round trip later is still recognised as the answer to that cast.
|
||
Splitting them is not tidiness: the `modalNextSpell` chain that starts a hunter's Auto Shot reads the
|
||
record, and a record that died with the guard would break shooting.
|
||
|
||
The buffer is **not** a latency figure, which is why upstream's default is 55 ms and not a ping. Our
|
||
packet reaches the server about half a trip after the send; the server starts its cast then and
|
||
finishes half a trip after our own cast time elapses. A next cast sent at exactly `cast_time`
|
||
therefore arrives just as the server frees up, and the latency cancels out of the arithmetic
|
||
entirely. The buffer is jitter margin on top. `NP_NonGcdBufferTimeMs` is wider (100 ms) because a
|
||
non-GCD spell has nothing *but* the guard between two presses.
|
||
|
||
**`NP_RetryServerRejectedSpells` is the mitigation for that early guard**, and that is why upstream
|
||
defaults it on. If the margin is ever wrong — jitter, or a server cast time that differs from ours —
|
||
the server refuses the next cast and without a retry the press is simply gone. It goes back in its
|
||
slot **once**, held a buffer's worth, on exactly two reason codes: `0x3c` not-ready and `0x61`
|
||
another-action, the two an early guard can cause. Every other refusal is the server telling the
|
||
player something true, and re-sending one would be a packet a second that never succeeds. Upstream's
|
||
own set is not published and its log line (`" spell queued + ignored code "`) hints at a wider one;
|
||
a narrower set is the safe direction to be wrong in.
|
||
|
||
| Row | State | Note |
|
||
|---|---|---|
|
||
| `NP_QueueCastTimeSpells` | **LIVE** | Holds a press of a spell with a cast bar. Also the addon's presence probe |
|
||
| `NP_QueueInstantSpells` | **LIVE** | The same for an instant |
|
||
| `NP_QueueChannelingSpells` | **LIVE** | Whether a press made while a channel runs is held |
|
||
| `NP_QueueTargetingSpells` | **LIVE** | The terrain-cursor class, named by `cast_target::wants_terrain_click` — implicit target 16, or a location word with no unit bit. Deliberately *not* the item/locked/GameObject cursors, which want a click on a thing rather than a patch of ground |
|
||
| `NP_QueueOnSwingSpells` | **LIVE** | Default 0, as upstream's is "changed with 1.17.2 due to changes to on swing spells" |
|
||
| `NP_SpellQueueWindowMs` / `ChannelQueueWindowMs` / `CooldownQueueWindowMs` / `TargetingQueueWindowMs` | **LIVE** | See "What a window measures" above. The general one covers both a cast in flight and a global cooldown, which is what its unqualified name can mean |
|
||
| `NP_MinBufferTimeMs`, `NP_NonGcdBufferTimeMs` | **LIVE** | The guard's reopening margin |
|
||
| `NP_InterruptChannelsOutsideQueueWindow` | **LIVE** | A press while a channel has more than `NP_ChannelQueueWindowMs` left ends the channel instead of being held. Off by default |
|
||
| `NP_ReplaceMatchingNonGcdCategory` | **LIVE** | On, a non-GCD press takes a held slot only when the two share a `Spell.dbc` category — and a category of 0 means "no shared cooldown", so two of those do not match. Off by default, which is the plain always-replace |
|
||
| `NP_RetryServerRejectedSpells` | **LIVE** | Above |
|
||
| `NP_MaxBufferIncreaseMs`, `NP_OptimizeBufferUsingPacketTimings` | stored | Upstream's *dynamic* tuning of the buffer from observed packet timings. **Off in its own defaults**, so nothing changes by not having it; building it means a model of this realm's jitter, which is measurement rather than a binding |
|
||
| `NP_OnSwingBufferCooldownMs` | stored | A rate limit on re-queueing an on-swing strike. The slot is live; what is missing is anything to rate-limit, since a re-press of the queued strike already takes the reference's own silent bail (`6e4d43`) and so cannot queue twice |
|
||
| `NP_ChannelLatencyReductionPercentage` | **LIVE** | How much of the measured round trip comes off a channel's named end before the queue stops treating it as a guard, so a press held through a channel is not sent a round trip late. The arithmetic is the cast buffer's again, and worth writing down because "send early" sounds reckless: the server starts the channel at `T` and ends it at `T + duration`, we learn of it half a trip later, so our own end is already half a trip *after* the server is free — trimming 75 % of `L` puts a packet at the server a quarter of a trip after its own end. The named end, not the slack-inflated one, for the same reason. With no pong measured yet nothing is trimmed |
|
||
|
||
**A held entry's window is measured from the guard's *live* end, not the one stamped when the
|
||
press was made.** A fixed deadline reads right and has a hole in it: a cast pushed back twice
|
||
(`SMSG_SPELL_DELAYED`) ends after the window a press made at its start was given, so the entry
|
||
would time out while the thing it is waiting for is still running — and the press would vanish with
|
||
no packet and no error line, which is the one failure mode a player cannot describe. The same hole
|
||
swallows a press held through a cast whose global cooldown then outlasts the window. So the base
|
||
ratchets forward while anything still guards, and only ever forward.
|
||
|
||
**One behaviour worth knowing before testing.** Pressing a spell during a long cast holds it for the
|
||
whole cast and fires it at the end — the last press wins, so pressing three different spells during
|
||
a Pyroblast fires the third. That is upstream's `Replacing queued nonGcd spell X with Y` applied to
|
||
presses, and it is what "queue the next spell" means in practice. What it is *not* is a queue of
|
||
several casts in a row.
|
||
|
||
### The event table, accounted for
|
||
|
||
The same read gives **59 event names** (`0x7c198`–`0x7c658`). Their argument lists are upstream's
|
||
`EVENTS.md`, and for the cast family pfUI's `libs/libdebuff.lua:909-945` corroborates every column
|
||
from the consumer side — it destructures `arg2`=spellId, `arg3`=casterGuid, `arg4`=targetGuid,
|
||
`arg6`=castTime, `arg7`=channel duration, which is exactly the documented order.
|
||
|
||
| Bucket | Names |
|
||
|---|---|
|
||
| **Fired** | the aura eight — `BUFF_ADDED_SELF`, `BUFF_REMOVED_SELF`, `DEBUFF_ADDED_SELF`, `DEBUFF_REMOVED_SELF`, `DEBUFF_ADDED_OTHER`, `DEBUFF_REMOVED_OTHER`, `AURA_CAST_ON_SELF`, `AURA_CAST_ON_OTHER` (`ui_aura.rs`, `ui_cast_events.rs`) — the cast ten: `SPELL_START_SELF`/`_OTHER`, `SPELL_GO_SELF`/`_OTHER`, `SPELL_FAILED_SELF`/`_OTHER`, `SPELL_DELAYED_SELF`, `SPELL_CHANNEL_START`, `SPELL_CHANNEL_UPDATE` (`ui_cast_events.rs`), `SPELL_CAST_EVENT` (`ui_self_cast.rs`), and `SPELL_QUEUE_EVENT` (`ui_spellbook.rs`, off `spell/queue.rs`'s own rows — every mutation of the queue goes through `queue`/`pop`, so a code cannot be dropped by a caller that forgets to emit one) |
|
||
| **Deliberately absent** | `BUFF_ADDED_OTHER`/`BUFF_REMOVED_OTHER` — diffing the helpful half of every streamed unit, which nothing registers (the `AuraEdge` doc says so at the enum); `SPELL_DELAYED_OTHER` — upstream's own note is that the server only sends the packet to the affected player, so it never fires there either |
|
||
|
||
| **To fire, needs its own producer** | `KEY_DOWN`/`KEY_UP`, and nothing else — see "nampower's combat-log family" below for the other thirty-five, which are now fired. The pair needs the client's own numeric `KEY_*` enum, which is a table in `WoW.exe` this round did not read: the DLL's strings publish four of its values (`KEY_NONE=-1`, `KEY_SHIFT=0`, `KEY_A=65`, `KEY_ESCAPE=512`, `KEY_F1=768`), enough to see the shape — ASCII in bank 0, specials at `0x200`, function keys at `0x300` — and not enough to name a key in bank 2 past the first. A guessed code is worse than no event: an addon switching on `arg1` would silently never match. They are also nampower's **keyboard hook**, the same hook the press-interception queue uses, so they belong with that decision rather than ahead of it |
|
||
|
||
|
||
### nampower's combat-log family — 35 events off a decode that was already there
|
||
|
||
The whole of the "needs its own producer" bucket above, bar two keys. `ui_np_events.rs`.
|
||
|
||
**Why it was cheap, and why the earlier reasons were wrong.** The rows above said
|
||
`AUTO_ATTACK_*` needed "a melee-swing edge no path here raises" and `SPELL_HEAL_*` needed "the
|
||
heal amount off `SMSG_SPELLHEALLOG`, which nothing reads yet". Both were about the **cast** path,
|
||
and both were false about the tree: `combat_log/` has parsed `SMSG_ATTACKERSTATEUPDATE`,
|
||
`SMSG_SPELLHEALLOG`, `SMSG_SPELLENERGIZELOG`, `SMSG_SPELLLOGMISS`, `SMSG_SPELLDISPELLOG`,
|
||
`SMSG_SPELLDAMAGESHIELD`, `SMSG_ENVIRONMENTALDAMAGELOG` and `SMSG_PERIODICAURALOG` since it was
|
||
written — that is where the chat lines come from. This module is a second consumer of the same
|
||
`SessionEvent`, which is also what stops its arguments drifting from the chat log's.
|
||
|
||
That is the general lesson of the last three rounds, now three for three: of the reasons written
|
||
down for why a nampower feature was not built, the ones that turned out to be wrong were wrong in
|
||
the same way — they described what one subsystem could not do, when the answer was in a different
|
||
subsystem that already did it.
|
||
|
||
| Event | Source | Note |
|
||
|---|---|---|
|
||
| `SPELL_DAMAGE_EVENT_SELF`/`_OTHER` | `SpellDamageLog`, and a `PERIODIC_DAMAGE` tick | `arg5` is upstream's comma-joined `absorb,block,resist`; `arg8` its `effect1,effect2,effect3,auraType` off `Spell.dbc` |
|
||
| `AUTO_ATTACK_SELF`/`_OTHER` | `AttackerState` | `sub_damage_count` was added to the struct for `arg6`: vmangos writes one block, a server splitting a swing across schools writes more |
|
||
| `SPELL_HEAL_BY_SELF`/`_BY_OTHER`/`_ON_SELF` | `SpellHealLog`, `PERIODIC_HEAL` | A self-heal fires **two**, which is the only reading under which all three names mean something |
|
||
| `SPELL_ENERGIZE_BY_SELF`/`_BY_OTHER`/`_ON_SELF` | `SpellEnergizeLog`, `PERIODIC_ENERGIZE` | Same two-of-three shape |
|
||
| `SPELL_MISS_SELF`/`_OTHER` | `SpellLogMiss` | One event per row of the packet's own miss list |
|
||
| `DAMAGE_SHIELD_SELF`/`_OTHER` | `DamageShield` | `arg1` is the shield's **bearer**, which is the packet's first guid — nampower passes a packet's fields in wire order, which is what makes these argument lists checkable rather than guessed |
|
||
| `ENVIRONMENTAL_DMG_SELF`/`_OTHER` | `EnvironmentalDamageLog` | |
|
||
| `SPELL_DISPEL_BY_SELF`/`_OTHER` | `SpellDispelLog` | One per aura removed |
|
||
| `UNIT_DIED` | a `UNIT_FIELD_HEALTH` edge to zero | **The edge, not the dead flag**: the flag is also set by a create of an already-dead corpse, and `FieldChanged` deliberately raises nothing for a create — so the edge is what keeps "died" meaning died rather than "was first seen dead" |
|
||
| `BUFF_UPDATE_DURATION_SELF` / `DEBUFF_UPDATE_DURATION_SELF` | `SMSG_UPDATE_AURA_DURATION` | The packet carries a slot and a time; the spell id comes from the player's own aura block and the half from the slot, at the same 32 the block splits at. `arg3` is `GetTime()`×1000, **not** the reference's `GetTickCount` — that counter is unreachable from Lua, so an addon can only compare against `GetTime`, and the difference the argument is used for (`expiration − duration` = now) is the same either way |
|
||
| the thirteen `UNIT_*_GUID` / `PLAYER_GUILD_UPDATE_GUID` | `net::FieldChanged` | One shared producer over the descriptor-edge stream, one field to one name. `UNIT_NAME_UPDATE_GUID` is the exception: a name is not a descriptor field here, it arrives from `SMSG_NAME_QUERY_RESPONSE`, so it has its own handler on that packet |
|
||
| `UNIT_COMBAT_GUID` | `UnitCombatFeedback` | The same stream 1.12's own `UNIT_COMBAT` drains, so the action word is the reference's own classification rather than a second reading of the hit bits. Stock fires only for the tokens naming a unit, which is why the GUID form exists |
|
||
|
||
**A registration is the switch.** Upstream's README calls every `NP_Enable*Events` CVar
|
||
*deprecated* — "registering the event is preferred" — so `refresh_listeners` reads the VM's own
|
||
registration table (the same one `GetFramesRegisteredForEvent` reads) and a family is wanted when
|
||
its CVar is set **or** something is listening. Without that, three of the eleven families have no
|
||
CVar at all and would be unreachable for every addon, and `SPELL_ENERGIZE_*` unreachable for all
|
||
of them, since nothing installed writes its switch. It is also what keeps the cost honest: with
|
||
nobody registered, a damage packet queues nothing rather than building eight arguments and two
|
||
formatted strings for a fire that reaches no frame.
|
||
|
||
**The descriptor indices are written out and then cross-checked**, against `unit_rec::FIELDS`
|
||
(derived from 1.12's own block sizes) and the `FIELD_UNIT_*` constants `benilla-protocol` exports.
|
||
A hand-written descriptor index is exactly the number that is plausibly wrong and silently so: the
|
||
event simply never fires, which no amount of play distinguishes from "the server never sent one".
|
||
|
||
**Nothing installed registers one of the thirty-five.** They are built because the surface is the
|
||
deliverable, not because an addon here is waiting — so this round changes nothing visible in play,
|
||
and that is the honest expectation to set before testing it.
|
||
|
||
### The CVar table, 49/49
|
||
|
||
`strings nampower.dll | grep -oE '\bNP_[A-Za-z]+\b'` is the DLL's whole CVar table, and
|
||
`comm -23` against `cvars.rs`'s own rows now prints nothing. Fifteen names were missing, and the
|
||
reason is the same mistake the function list started from: the first pass took the list off
|
||
`NampowerSettings`' own writes rather than off the binary.
|
||
|
||
| Row | State | Note |
|
||
|---|---|---|
|
||
| `NP_ChatBubbleDistance` | **LIVE** | Writes the same `BubbleConfig::range_sq` SuperWoW's `ChatBubbleRange` writes — not a conflict to resolve, since both DLLs hook one cull and the last write wins there too. Ships **20** yd, the reference's own cull, not upstream's 60: a default is what a player who never touched the setting gets, and tripling the distance bubbles appear at is a visible change nobody asked for |
|
||
| `NP_ChatBubblesWhisper` / `Raid` / `Battleground` | **LIVE** | Three kinds the reference bubbles never. `Raid` is the raid-**leader** and **warning** kinds, which is upstream's own wording — reading it as "raid chat" would bubble every `/raid` line |
|
||
| `NP_EnableLocalSetRaidTarget` | **LIVE** | Solo, the wire leg marks nothing at all: the server answers by echoing to a group that is not there. This switch routes it to the local board instead — the path SuperWoW's `"local"` argument already built |
|
||
| `NP_EnableSpellEnergizeEvents` | **LIVE** | The seventh `NP_Enable*`; nothing installed writes it, which is why it was missed |
|
||
| `NP_SpamProtectionEnabled` | **LIVE** | A repeat press of the cast already in flight is swallowed rather than held — which is also the reference's own silent bail (`6e4d43`), so the two agree. Off, such a press may replace itself in its slot |
|
||
| `NP_QuickcastOnDoubleCast`, `NP_QuickcastTargetingSpells`, `NP_DoubleCastToEndChannelEarly` | stored | The double-press family, **all three off in upstream's own defaults**. Each needs the world point under the mouse *at press time*, which this client resolves in the click path (`spell/targeting/world.rs`) and not in the cast path, so wiring them means handing the ladder a camera ray — a real change whose feel wants its own test |
|
||
| `NP_PreventRightClickPvPAttack` | stored | Needs a hook in the right-click interact path, which decides target, interact and attack in one pass; its sibling `NP_PreventRightClickTargetChange` is stored for the same reason |
|
||
| `NP_PreventMountingWhenBuffCapped` | **live** | A mount press with all 32 positive aura slots taken is refused above the commit, so neither the packet nor the global cooldown is spent — `spell/np_press.rs`'s `mount_refusal`. The aura number (`SPELL_AURA_MOUNTED` = 78) is asserted against this realm's own `Spell.dbc` for three mount spells rather than taken from mangos' header, because a wrong one would make the refusal silently never fire. **One deliberate deviation, and upstream names the bug itself**: its settings label reads "Preventing mounting due to buff cap (breaks dismount)" — in 1.12 you dismount by re-casting the mount — so a press while `UNIT_FIELD_MOUNTDISPLAYID` is live is exempt here. Refused **silently**, as upstream's own report is a line in its debug log and 1.12's `SpellCastResult` has no code for "too many auras" |
|
||
| `NP_EnableEnhancedTooltips` | stored | Authored content rather than a binding: the numbers are in `Spell.dbc` columns this client reads, but what the lines say and where they sit is upstream's design, and guessing it produces a tooltip that is neither ours nor theirs |
|
||
| `NP_PreserveGreaterDemonAutocast` + the three `*AutocastData` | stored | Needs pet-autocast state keyed by demon rather than by the live pet guid. The three data rows are not settings at all — upstream's README calls them "managed automatically" — so they are registered to round-trip a write and nothing here will read one |
|
||
|
||
**Two upstream documents disagree** about the six `NP_EnableUnitEvents*` defaults: `EVENTS.md`
|
||
says 0, the README says 1. Those rows keep 0, and the disagreement costs nothing here, because a
|
||
default is not a behaviour under the registration rule above — every addon that wants the stream
|
||
either writes the CVar or registers the event.
|
||
|
||
|
||
**ClassicAPI's 70** are the remaining bulk and are tracked in `docs/classic-api-functions.md`
|
||
rather than here. The same caution applies twice over: its 999 pointed-at names include a great deal
|
||
that is not Lua surface at all, and the corpus intersection is the only part worth reading.
|