Files
benilla-octo/docs/ADDON_COMPAT.md
T
gromandClaude Opus 5 fcdf14c0dc app: a held press cannot time out while something still guards it
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
2026-10-25 00:30:46 -06:00

371 KiB
Raw Blame History

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 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_FIELDs); 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 `
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 SetPoints and Shows 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 …
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 Buttons 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
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
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 pcalls, 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 pcalls 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_SINGLEs 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 u8s
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 u32s 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.