78e4e02cfe52cd5fbb8c2880a81348c5cd931a00
1130
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
f87ef148b8 |
app+ui: the trinket cooldown — and a stock verb that always said "cold"
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** — right shape, 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 off the same column, and the test runs `CooldownFrame_SetTimer`'s own arithmetic against it. The feed keys its query on the (use spell, item entry) pair exactly as the bag feed does, so one trinket reads the same cooldown from the doll and from a bag. **The risk was the gate, and my first test for it was vacuous.** `feed_char` sits behind a change gate and no descriptor moves when a cooldown is armed, so `Cooldowns::feed_epoch` had to become a gate input. 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 the way the schedule does. Rewritten with `add_systems` + `update`, it fails with the input removed — verified by probe, and the reason is written at the test. `feed_char` also hit Bevy's sixteen-parameter ceiling, which was a fair signal its parameters wanted grouping by what they are for: `ItemCooldownInputs` and `RepairInputs` replace six. Two decisions recorded at the code: - `GetSpellRangeData(rangeIndex)` answers three values, not a table. Every table-returning verb in this DLL has its key names in the string pool and this row's are not there. `flags` bit 0 is the melee family, where the engine takes the combat-reach sum instead of the authored pair — a caller ignoring the bit reads 0..5 for every melee ability. - `CombatLogFlush()` is a deliberate no-op. Upstream buffers its combat-log writes and this empties the buffer, so what an addon asks for is "my lines are on disk now" — and `ui_chat/logging.rs` writes each line to an unbuffered File as the window shows it, so that holds at all times. Defining it is the honest answer: the capability is present, and a missing global makes `HasFeature` report it absent. Also fed, both one wire column each: `QuestState::quest_id` (the wire names the quest on all three giver panels and 1.12's Lua names it nowhere — a title is not a key) and `SpellDisplay::stack_amount`'s neighbour work from the previous commit. Five names stay out, each now with the field list that makes it a decision rather than a shrug — `GetUnitData` (80 raw descriptor columns, read off the pool, zero call sites, and a partial answer is worse than none), `GetRaidTargets` (the reference keeps a board; this client feeds the mark per unit, and a scan over named tokens would silently omit a marked unit), `DisenchantAll` (a server-paced sequencer, which is the queue), `GetSpellPower`, `StartItemExport` (writes a file — it belongs with the withheld seven). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn |
||
|
|
f06caa9968 |
ui+app+formats: the Spell.dbc row, and seven columns that answered nil
`GetSpellRecField` knew three names. Counting what the corpus actually asks it 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, and each was a feature quietly off rather than an error — 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). The eighty field names are the DLL's own, read off its pool in Spell.dbc column order. The mapping to `SpellDisplay` lives in `ui_script/spell_rec.rs`, beside the struct it reads — 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, not guessed: SuperCleveRoidMacros walks `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`, 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 rather than an omission. `GetSpellRecField`'s three string columns keep the spellbook fallback the verb had before the probe, so a VM with a book and no catalog — a capture, or the boot VM before the catalog loads — still answers a learnt spell's name and rank. Every other column is a DBC row and has nowhere else to come from, which is why the fallback is exactly three names wide and says so. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn |
||
|
|
cf0e157f34 |
ui+app: the bag walk, the modifier fold, and two shapes the DLL's pool corrected
Built: `GetBagItems`, `GetSpellModifiers`, `GetSpellIdCooldown` — plus
fixes to two verbs that already existed and had the wrong *shape*,
found the same way `GetItemIconTexture`'s wrong argument was: by
comparing what we answer against nampower's own string pool.
`GetBagItems()` hands back every occupied bag slot as
`{ [bagIndex] = { [slot] = row } }`. DoiteAuras asks seven times over
and walks both levels with `pairs`, reading `itemId` and `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)` answers
`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 `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. The third return is the point — the
consumer tests it before touching either number, and the duration path
multiplies, so `0, 0` for an unmodified spell would zero every debuff
timer that asks.
`GetSpellIdCooldown(spellId)` answers the twenty-key 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.
**`GetBagItem`'s row had the wrong key names** — `bag`, `slot`,
`count`, `quality`, `link`, `locked`, this client's own
`GetContainerItemInfo` spellings. nampower's row is contiguous at
`0xa8a68` and is the block `GetEquippedItem` already used, so a caller
reading `.stackCount` got nil and no error. One `bag_slot_table` now
serves both, with three columns absent rather than zeroed: this client
resolves a bag slot's enchants to names at the app boundary and keeps
no random-properties id for a container slot, and a zeroed enchant id
would be a claim.
**`GetItemIdCooldown` returned the wrong kind of value** — the stock
`start, duration, enable` triple. At the version we report it is a
table, and the addon layer settles it: `API.GetItemCooldownInfo` and
`API.GetSpellCooldownInfo` return these calls' results *directly* and
their callers index them. `cd.gcdCategoryRemainingMs` is the primary
path of `API.GetGCDRemainingMs`, which the `[gcd]`/`[nogcd]` macro
conditionals divide by 1000 — against a triple that is
`(0.0).gcdCategoryRemainingMs`, which raises. Latent rather than live
(nothing calls those wrappers today), still wrong.
Two things the schedule gates caught and improved:
- The probe refresher started as an exclusive system, which is
ambiguous against everything in Update it is not ordered against:
+274 pairs over the ceiling. `NonSendMut` is all it needs.
- Its memo started as a bare `Local`, which `session.rs`' own gate
refuses — a memo about what this VM has been told must die with the
VM. As a `VmMemo` it now reinstalls on the first frame after a
`/reload`, which is better than the reasoning I had written for the
`Local`: a fresh VM has no closure at all until something installs
one.
One count the earlier sweep got wrong, corrected in the doc:
`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. A bare name match is not a call site.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
|
||
|
|
dc59594389 |
app+protocol: nampower's ten cast events, off the queue that already had them
All ten ride `ui_cast_events.rs`'s existing `CastEventFeed` rows — the same rows `UNIT_CASTEVENT` and the cast windows are folded from — with a new `NpCastColumns` carrying the fields nampower's shapes want and SuperWoW's one 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 over the same packets is exactly how that happens. `NP_EnableSpellStartEvents` and `NP_EnableSpellGoEvents` go from stored-only to LIVE. Their rows said the 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. `itemId` is the cast item's entry through `item_entry` (now shared with `item_cooldown`, which had the same two hops inline) — `SessionEvent::SpellStart` was dropping the guid the GO always carried, so it carries it now. `spellType` is `AttributesEx & 0x44` for a channel and the auto-repeat bit for a shot. `channel_ms` is `SpellDuration.dbc`. The GO's two counts are the packet's own lists, capped at 255 because the server writes both as `u8`s. `SPELL_FAILED_SELF`'s result byte is the failing `SMSG_CAST_RESULT`'s reason — the number 1.12 turns into a red line and then discards, so no addon on that surface can see why a cast failed. `corpseOwnerGuid` is the one column that really is absent, and it is not sent at all: 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 and written down — the targets block reads the corpse guid and drops it, and the owner is a further hop through that corpse's own CORPSE_FIELD_OWNER. Two subtleties, both pinned by tests: - 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 — pfUI's reader expects exactly that row and falls back to arg7 "only for true channels like Blizzard where arg6 is 0/nil". With the word, every cast-tracking addon on this realm would get a SuperWoW START whose arg5 is 0, which is a cast bar told to run for no time. So the suppression moved from the producer to the fire, and SuperWoW's word behaves exactly as it did before the row existed. - 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 are literals now, with their rows, and the probe confirms it: renaming either row makes the gate name the event. Tests read pfUI's own `local` bindings back out rather than a list of values in push order, as the aura-cast block beside them does. A positional shape got backwards here fails in silence — the handler binds arg3 to casterGuid whatever arg3 happens to be — and this family is where that would be most expensive, since pfUI's swing timer resets off the START and its whole debuff bookkeeping gates on the GO's two counts. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn |
||
|
|
ecee994ab6 |
app+ui: the player's own cast state, and SPELL_CAST_EVENT off the commit
`GetCurrentCastingInfo` and `GetCastInfo` are the two most-called
nampower functions in grom's corpus — 25 and 20 call sites outside
`NampowerAPI.lua` — and one of them calls with no existence guard at
all: `local _,_,_,_,_,_,autoattack = GetCurrentCastingInfo()`
(SuperCleveRoidMacros/Core.lua:94), in the frame that keeps clearing the
target while an autoattack is queued. On this client that line raised.
The earlier doc row said the client had "a spell id, a deadline and a
guard bit — no start instant, no channel state, no GCD end". That was
true of `PendingCast` and wrong about the tree: `ui_cast_events.rs`
already folds a cast window per caster off the same packets, with a
start, an end, a channel bit and the accumulated pushback, behind
`C_Spell.UnitCastingInfo`. What was actually missing was the caster's
own machinery — the reference's cast globals, which have no descriptor
field anywhere — and the GCD, which `spell/cooldowns.rs` has run
correctly all along and never published.
So `GetCastInfo` folds two halves: its timing columns come from the
player's own window, its identity and GCD columns from a new per-tick
`SelfCast` snapshot. The feed declares itself after the window drain, so
the two halves can never describe different casts. Added:
- `SelfCast` (benilla-ui) — the inflight spell `0xceca88`, the bar's
spell `0xceac48`, the auto-repeat key `0xceac30`, casting/channeling/
onswing/autoattack, the cast counter, the cast type, the target, and
the GCD's start and end on the GetTime clock.
- `ui_self_cast.rs` (benilla-app) — the push, plus `SPELL_CAST_EVENT`.
- `Cooldowns::gcd` — the running global cooldown as one timer. `info`
still answers the per-category question every cooldown swirl asks;
this answers "when does the GCD end", which has no spell to key on
because its consumers divide it (`API.GetGCDRemainingMs`) or compare
it against seconds (`[gcd:<1]`).
- `PendingCast`'s cast counter, in the one place every class of commit
already passes through.
Three decisions where the tempting answer was the wrong one:
- `GetCastInfo` is **nil** when nothing is casting, and an instant's GCD
does not count. Answering a table whenever the GCD is up would make
the consumer's fast path always hit — and `API.IsCasting()` *is*
`GetCastInfo() ~= nil`, so `[casting]` would read true for a second
and a half after every instant. The wrapper's own comment says what to
do instead ("GetSpellIdCooldown … works for instant casts").
- `castId` is two different numbers under one upstream name: a counter
in `GetCastInfo`'s table, beside a separate `spellId`; a **spell id**
in `GetCurrentCastingInfo`'s returns, 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). Each is implemented as what its own
consumer reads, and both are spelled out at the binding.
- `SPELL_CAST_EVENT` fires for commits, not refusals. Upstream's
`success` can be 0; this client's local refusals funnel through
`CastErrors`, which carries a spell id and a reason byte and neither a
cast type nor a target — 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). Written
down at the type rather than left to be found.
Tests run the consumers' own bodies transcribed, as `cast_info.rs` next
door does: `API.GetCastInfo` with its `GetCurrentCastingInfo` fallback
arm, `API.IsCasting`, `API.GetGCDRemainingMs`, `API.GetCastRemainingMs`
and `CleveRoids.UpdateCastingState`. A positional seven-return shape and
a ten-key table are only correct relative to the code that reads them.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
|
||
|
|
5555bf9c89 |
ui+app: nampower's bag group, aura cancels, no-queue casts, unit-event CVars
Second tranche. Eight more verbs and six CVar rows, all from state this client already owns. The bag group — `FindPlayerItemSlot(idOrName)`, `GetBagItem(bag, slot)`, `GetItemIdCooldown(itemId)`, `UseItemIdOrName(idOrName)` — shares one lookup, deliberately: the three that search must agree about *which* of two identical stacks they mean, and they answer the first, reading bags and slots ascending, as the reference's own auto-store search walks (`0x4f8030`) and as a player reads their bags. A name matches through the item cache, case-insensitively, since a bag slot carries no name of its own. `GetItemIdCooldown` answers the stock `0, 0, 1` for a cold or uncarried item rather than nil, because the consumer does arithmetic on the triple. `UseItemIdOrName` calls `UseContainerItem` itself rather than reaching past it, so the cursor-clear and repair-mode rules the stock verb applies still happen. `CancelPlayerAuraSpellId(spellId, [ignoreMissing])` and `CancelPlayerAuraSlot(auraSlot)`: the two keys into the `CMSG_CANCEL_AURA` queue `CancelPlayerBuff` already fills, so the three cannot disagree. The slot is the **raw 0-based** descriptor slot upstream documents and the buff events report, not the Lua index. `ignoreMissing` is accepted and changes nothing — it asks upstream not to complain, and nothing here would have complained. `CastSpellNoQueue(spellId, spellBook [, unit])` and `CastSpellByNameNoQueue(name [, unit])`: the plain cast, which is the honest mapping and not a stub — benilla has no spell queue to bypass, so "cast this now, do not queue it" is exactly what happens. The point of them is the first argument: a **spell id** where stock `CastSpell` takes a book slot. `"mouseover"` routes to the queue SuperWoW's `CastSpellByName(name, "CLICK")` already uses. The six `NP_EnableUnitEventsX` switches are registered stored-only, one step further back than the four `NP_Enable*` rows beside them: benilla raises no GUID-keyed unit event at all, so there is no stream for a token filter to narrow. Registering them stops the unknown-CVar warning and lets `SuperCleveRoidMacros`'s writes persist; it claims nothing about the events arriving, and `cvars.rs`'s own reader gate carries that reason. All nine gates green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn |
||
|
|
f120e8003d |
ui: six of nampower's reads, and the item-icon probe behind two of them
First tranche off the DLL work list. The method that produced it is worth recording with the code: `SuperCleveRoidMacros`'s `NampowerAPI.lua` is a capability registry — a `VERSION_REQUIREMENTS` table of every nampower name with its minimum version, reached through `HasFeature`, which tests the version **and** `_G[name] ~= nil`. So none of this was broken, and raising `GetNampowerVersion` would not have helped: the only honest way to make one of those features live is to define the global. - `PlayerIsMoving()`, `PlayerIsRooted()`, `PlayerIsSwimming()` — `1` or nil, as upstream's own doc lines state, never a boolean. All three read the per-tick posture snapshot `SelfMovement` already carries, which gains `rooted` (`MOVEFLAG_ROOT`) and `moving` (any of the six movement bits, the keyboard turns included — they are input to the same word the client tests, and a turn in place does interrupt a cast). - `IsAuraHidden(spellId)` — from a new `SpellLookup::hidden_aura`, filled with exactly `ui_aura::shown_in_aura_ui`'s predicate inverted, so it cannot contradict the aura list an addon reads beside it. An unknown spell is nil: "not hidden" is the honest answer about an aura this client knows nothing about, and the lists show a catalog miss too. - `GetItemIconTexture(itemId)` — two hops, because an item template carries a display id and not an icon: the cache row's `displayInfoID`, then `ItemDisplayInfo`'s `Icon` through a new `ItemIconProbe`. A known item with no display row is nil (a data gap); an item the cache has never seen raises and records the ask, as every `item_record` verb here does. ClassicAPI's `GetItemIcon` wants the same probe, which is why it is a probe and not a closure. - `GetTrinkets()` — slots 13 and 14 in `GetEquippedItem`'s own row shape, an empty slot absent rather than nil-valued, since the consumer walks it with `if t[i] then`. Each is in `reference_surface.rs`'s `allowed_beyond_1_12` with why 1.12 has no spelling to prefer — the client reads its own movement word and tells Lua nothing, applies the aura attribute test inside `UnitBuff`/`UnitDebuff` without exposing either, and can only reach an item icon through a bag slot it already holds. All nine gates green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn |
||
|
|
ada08720bb |
ui+docs: the GUID token's real spelling, 40-yard interact, and the whole patch map
Working down the 29 `.tdata` hooks from the realm's patched `WoW.exe`.
Most turned out to be already ported; two were wrong here, and one of
those was wrong in a way that mattered.
**The GUID token was the wrong string.** `unit::guid_token` wrote 16
lowercase hex digits and no prefix. The realm writes `0x` + 16 upper-case
digits: its formatter at `0xd06500` — which `UnitExists`'s second return
goes through — lays down `0x30 0x78` then sixteen nibbles each `+0x37`
above 9, and its parser at `0xd06650` demands the `0x` and takes upper
case only. grom's own addons agree from the other end: BigWigs decides a
string is a GUID with `string.sub(guidOrName, 1, 2) == "0x"`
(`MageTools.lua:963`), and AI_VoiceOver reads fields from position 3 and
mints its own with `format("0x%04X%06X%06X", …)`.
Same number, wrong string, and wrong where it mattered most:
`UNIT_CASTEVENT` and the aura events carry these tokens, so every such
test read our GUIDs as unit *names* and every `sub(3, …)` field read came
out two characters off. Now written the realm's way; both spellings and
either case are accepted on the way in, since refusing one buys nothing.
Pinned literally rather than by round trip — a round trip held fine
through the whole bug.
**`CheckInteractDistance` type 4 is 40 yards, not 30.** `0x48bafd` jumps
to `0xd0686c`, which tests the type against 4 and compares against the
float the section carries at `0xd06868`: `1600.0`, squared thresholds.
Stock 1.12's `900.0` is still what the other three types use.
`docs/ADDON_COMPAT.md` now carries all 39 hook sites of both sections
with a status each: 10 in `.octoql` (one feature, the quest log, ported),
and 29 in `.tdata` — the aura ids, the master looter, `UnitExists`'s
GUID, the char-create mask and `CHAT_MSG_HARDCORE` already done; the two
challenge-mode functions needing nothing, because the realm's own
`ChallengeList.lua` defines them in Lua for exactly our case; four read
but unported with the reason (unnamed struct fields, no consumer in the
corpus); and three that cannot apply to a client that loads no DLLs.
Also `scripts/dll-surface.py`, which asks the injected DLLs the same
question. Their export tables are one `DllMain` each, but a DLL that
registers a Lua function must point at the name: collecting NUL-delimited
identifier strings the image holds a pointer to cuts `ClassicAPI.dll`
from 2872 CamelCase candidates to 706, and subtracting the 1.12 census,
Lua's own names and benilla's real surface leaves a candidate list the
addon corpus can rank. It is a list to read, not a specification —
statically linked libraries contribute their own strings, and
`UnitXP_SP3.dll` yields nothing. It found 38 names in `nampower.dll` that
grom's addons call and this file did not know about; they are written
down rather than guessed at.
269 data-gated failures, identical to the baseline, all nine gates green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
|
||
|
|
cae59cdc11 |
app+protocol: fire CHAT_MSG_HARDCORE, the realm's own chat type
`reference_ui`'s `UNPRODUCED` carried `CHAT_MSG_HARDCORE` as a chain event nothing here fires: the window registered for it and benilla routed no such message, so the realm's hardcore-death broadcasts simply never arrived. Three of grom's addons listen for it too. The missing piece was one number, and the realm's patched `WoW.exe` has it in the PE section it names `.tdata`, beside the string `"HARDCORE"` at `0xd06000`. Two detours do the whole job: `0x49f7aa`, in the chat type-name lookup, jumps to `0xd06020`, which sets the name pointer to that string and the type to `$0x5e`; and `0x49b193`, inside the `CHAT_MSG_*` fire helper `0x49b0b0` whose format string this file already cites, jumps to `0xd06040`, which tests the type against `$0x5e` and swaps in the event-name index `0x21f`. So wire type `0x5E` is `ChatEventKind::Hardcore`. Nothing in 1.12 claims it — its last type is `CHAT_MSG_BATTLEGROUND_LEADER` at `0x5D` and its `CHAT_MSG_BG_SYSTEM_*` block sits at `0x52`-`0x54` — so there is no collision to arbitrate, and the test pins that adjacency along with the decode, since a wrong id would look exactly like the silence it replaces. The chain's `ChatFrame.xml` registers the event and its `GlobalStrings.lua` has the `[Hardcore] %s: ` format, so the window needed nothing from us but the fire; it takes the window's default colour, as `FILTERED` does, the realm adding no `ChatTypeInfo` row. Not covered: sending. The ruRU locale carries `SLASH_HARDCORE1 = "/h"` and `CHAT_HARDCORE_SEND` where enUS has only the receive format, so the send side needs its own look. 269 data-gated failures, identical to the baseline, all nine gates green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn |
||
|
|
02e6fc4208 |
formats+app: a stale snapshot archive mounts below the realm's patches
grom is keeping `patch-A.mpq` — it is almost certainly textures — so the shadowing it causes is handled in the chain instead of on his disk. `mount_order` stays the reference's law, letter above digit and all, and `demote_stale_snapshots` runs after it: an archive whose `FrameXML.toc` is a **strict subset** of another's is an earlier edition of the realm's manifest, so it mounts just below the patch set — above `patch.MPQ` and the base archives, so anything only it carries still resolves. A toc that differs by *removal* is a different manifest rather than an older one and is left exactly where its name puts it. `WOW_CHAIN_DEMOTE=0` keeps the reference's order for comparison. On his install `patch-A.mpq` (Aug 7) now loses to `patch-5.mpq`, so the realm's current data is what loads: its `FrameXML.toc`, its `GlobalStrings.lua` and a `Spell.dbc` with **28019** rows against the 27992 his last session reported — 27 spells the update added that were invisible. Reading the realm's real files then broke ten tests, all of which had pinned August's behaviour, and each is now pinned the realm's way: - `AIOHandlers.lua` claimed the `OctoRadio` block as a no-op on the evidence that the radio file carried no AIO call. The update built the radio out into a file twice the size whose `RegisterNowPlayingFeed` registers that name at `VARIABLES_LOADED`, and `AIO.RegisterEvent` asserts on a duplicate, so our shim raised through seven tests. The row is gone, and the shim now claims at `PLAYER_LOGIN` through `pcall` so a realm that implements one of these takes the name and we step aside. `no_aio_block_we_shim_is_one_the_realm_now_handles` fails a row the realm has implemented — verified by putting `OctoRadio` back. - `BATTLEFIELDS_CLOSED` no longer hides the battlefield list, by the realm's decision: the server stamps `SMSG_BATTLEFIELD_LIST`'s battlemaster guid with the player's own, so the client anchors the window to the spot it opened and closes it when you step away, on foot or on a boat (bug tracker #330 and its follow-up). Pinned that way, with the close button proving the list still closes. - `MAX_QUESTLOG_QUESTS` is 25, not 1.12's 20. The test reads the realm's global now, so the next bump is data and not a failure. - The realm's `OctoQuestLogPoller` redraws the log every 0.2 s, which legitimately costs a layout gate walk. `POLLS_ON_A_TIMER` stands it down by name before the quiet-frame bench measures, rather than raising the threshold and blunting the one instrument that catches an every-frame writer. A timed realm handler that is not listed fails. 269 data-gated failures, identical to the baseline at HEAD name for name, and all nine gates green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn |
||
|
|
73bc9a9e8e |
app+formats: name the realm files a shadowing archive holds stale
The archive found holding an old copy of the realm's own `FrameXML.toc` is holding an old snapshot of the realm's interface, not one old file. `Chain::copies` answers which archives hold a name and at what size off the hash and block tables alone, decompressing nothing, so the boot log can now name every row of the realm's toc whose winning copy comes from that archive while the realm's own has a different one. On grom's install that is ten, and it is the better half of the finding: `GlobalStrings.lua`, five `OctoLocale` files, `AIO.lua` — the client and server's whole transport — the calendar, the challenge tips and the radio all load as an August `patch-A.mpq` has them, because a letter patch outranks the realm's `patch-5.mpq`. `Spell.dbc` is shadowed the same way, 20 KB behind. Reported and not repaired, deliberately: shadowing a file with different content is what a patch archive is *for*, so outside the toc — where "holds every row the winner does, plus more" is a decidable relation — this client cannot tell a stale copy from an intended override. The real 1.12 client reads the same copies, so this is a fix on disk, and the line is what makes it findable instead of invisible. `richest_toc` splits the superset rule out of `realm_toc_rows` so it is testable without client data, which is where it ships from: the winner stands unless a shadowed copy drops nothing, a longer copy that drops a row is passed over, and the caller is told which archive it read so it can say so. The staleness report is checked against the real chain and every row it names must really differ in size between the two archives, so it cannot be noise. The 269 data-gated failures remain identical to the baseline at HEAD, name for name. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn |
||
|
|
eaadc9f73b |
app+formats: take the realm's own FrameXML list, not a curated copy of it
`benilla.toc` enumerated every interface file, so a row the realm adds in a client update was *silently* absent: no "not found", no warning, just a global nobody defined. That is what `/xpboost` was, and the only way to find it was to diff two MPQ archives by hand. The manifest now decides order and substitution, and the realm's own `FrameXML.toc` decides membership: every row it carries that the manifest does not name loads after it, in the chain's order, and the boot log names them. `DECLINED` is the short list of rows this client must not run (`StatsFrame.xml`, whose OnUpdate is the only reader of a `GetDebugStats` we do not have), each with its reason; a row we merely have no verb for still loads and raises where it is used, which beats silence. `WOW_FRAMEXML_TAIL=0` opts out and says what it is leaving out. Finding that out turned up the reason the row was missing in the first place, which is worse and is now fixed too. `Chain::open` mounts `patch-?.MPQ` in the reference's order — case-folded, last wins — so `patch-A.mpq` mounts **above** `patch-5.mpq`. The realm ships its interface in `patch-5`; this update added six rows to that toc; and the 140-row copy inside an older `patch-A.mpq` kept winning. The interface was loading August's list of files. So `Chain::read_all` returns every archive's copy of a name in ascending priority, and `realm_toc_rows` reads a shadowed copy when it holds every row the winner does *plus* more. That case is unambiguous — nothing was removed, so nothing can be lost — and it is a packaging accident with no intent behind it either way. A copy that disagrees by removal is a real change of mind we cannot arbitrate: the winner stands and the log says what was passed over. Both paths say which archive, so the clean fix on disk is one line of log away. Verified against grom's own install and his addon corpus: with the `OctoXpBoost` row deleted from the manifest — tomorrow's update, simulated — the tail finds it, resolves it to a readable chain path, and `/xpboost` is a live slash command with a function behind it. The tail is empty with the manifest as it stands, which is the point: the mechanism is armed and changes nothing today. Tests: the merge takes both lists as arguments so it runs where there is no client data, since that is the machine where a regression in it would otherwise skip unseen; the letter-above-digit mount order is pinned on its own, because that fact is what cost the six rows; `read_all`'s ordering contract is checked against a real chain; and a `DECLINED` row that the realm stops shipping now fails as stale. The 269 data-gated failures are the same 269, name for name, as at HEAD. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn |
||
|
|
ef7f823d9a |
ui: teach the producer gates the reach-reporting fire
`every_event_a_chain_file_registers_has_a_producer` finds producers by scanning our own `.rs` for `fire_event(`, `fire_event_into(` and `pending_events.push((` followed by a string literal. `49b4f7b5` moved `area.rs`'s minimap fire to `fire_event_reach(`, which matches none of them, so the gate began reporting that `Minimap.xml` listens for `MINIMAP_ZONE_CHANGED` and nothing fires it. Both scanners here now match that spelling too. The break rode green through a full gate run and a commit because this test and its sibling need `WOW_DATA`, which `scripts/gates.sh` does not set — on a machine without the client data 1609 tests skip silently. Found by running `WOW_DATA=<Data> cargo test -p benilla-app --lib` and diffing the failures against a baseline at HEAD: 3 before, 2 after, and the two that remain fail at HEAD as well. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn |
||
|
|
d3d2103919 |
app: publish the world-map feed from the frame's own area
`SetMapToCurrentZone()` answers out of the `(continent, zone)` pair `feed_world_map` pushes into the VM, because the VM cannot query the world — the reference's resolver `0x4a6650` reads the player's area live. That makes the schedule part of the verb's contract, and only half of it was declared: the feed ordered `.after(UiInput)` and nothing else, so it ran before `terrain_stream::update_current_area` wrote the area under the player and published the previous frame's zone. At a crossing the feed therefore named the zone just left, `area`'s `feed_zone_events` fired `ZONE_CHANGED_NEW_AREA` on the new one, and both addons that answer it while the map is closed reselected where the map already was. From grom's 03:57 log at the Ironforge gate: `ZONE_CHANGED_NEW_AREA 10/10 handler(s) · MINIMAP_ZONE_CHANGED 8/8 handler(s) · the UI asked to follow the player 3× · WorldMapFrame shown=Some(false)` — every handler reached, the verb called three times, the selection unmoved. Every map-derived overlay then sits a zone behind, `GetPlayerMapPosition` and so pfQuest's minimap pins included, until the player opens the world map by hand (pfQuest reselects on close), which is the workaround he reported. Ordered after `AreaAuthoritySet`, whose own doc already required it of every consumer of the area, and pinned by a schedule test over the declared graph: remove the edge and it fails naming both systems. The watchdog's zero-ask arm said "nothing has called `SetMapToCurrentZone()` since the player crossed" through all of this. The count was right and the window was not: it starts where this feed first publishes the new zone, which the stale feed made *later* than the addons' answer, so three real calls read as none. That one wrong fork cost two sessions; the wording now names the window it measures. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn |
||
|
|
49b4f7b55e |
ui+app: say what a fired event reached, and whether the UI answered
grom's stale-minimap report survived the same-interior-skip fix, and his log says why that fix was the wrong suspect: at the gate the zone-text interior claim reads `outdoors`, so the skip was never consulted there. The module doc no longer claims otherwise. What his run leaves is sharper and stranger. The election cannot have declined — leaf 1537 → 809 "Gates of Ironforge" takes the zone from 1537 to 1 and the subzone text with it, so both `ZONE_CHANGED_NEW_AREA` and `MINIMAP_ZONE_CHANGED` fire — and two separate addons call `SetMapToCurrentZone()` on those while the map is closed (`pfQuest/map.lua:1466`, `pfUI/modules/minimap.lua:183`). Zero calls arrived, with nothing raised anywhere in the session. Offline, against the same addons and saved variables, both events reach every registered handler and are answered every time, so the difference is live-only and the engine cannot currently see which of three things it is. So it can now. `UiScript::fire_event_reach` answers an `EventReach` — frames registered when the dispatch began, frames whose handler ran, and the name of the frame a cut-short walk stopped on (our documented deviation: a handler that unregisters the walk's saved next ends the dispatch, and every later registrant silently never hears the event). `UiScript::frame_shown` reads the shown bit addons branch on, which no amount of engine-side logging could otherwise explain. `fire_event` is unchanged for its nineteen callers. `area::feed_zone_events` logs the whole handshake at each fire: what was elected, reached/registered per event, the `SetMapToCurrentZone` delta, and `WorldMapFrame`'s shown bit. `0/0` is nothing registered, `reached` below `registered` is a cut-short walk, and every handler run with `0×` asked is the guard both answerers share. `addon_harness --fire` reports the reach too. Gates green (459 s). His corpus offline: 10/10, 8/8 and 4/4 handlers reached. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn |
||
|
|
38fd2543b4 |
app: the same-interior skip must not swallow a zone change
grom's minimap report, found. Walking out through Ironforge's gate tunnel fires no `ZONE_CHANGED_NEW_AREA`, so nothing tells pfQuest to call `SetMapToCurrentZone()`, so `GetPlayerMapPosition` keeps projecting onto Ironforge and every minimap pin sits wherever that lands — until the world map's `OnShow` calls the verb itself, which is exactly "it fixes itself when I open and close the map". The zone updater's same-interior skip (`[0x868608]`, `[0x86860c]`) returned out of the whole function while the player stayed inside one group of one building. But the zone it elects on comes from the **terrain leaf**, and that can change underneath an unbroken group: the gate tunnel spans the boundary, so the leaf goes 1537 → 809 "Gates of Ironforge", whose zone is Dun Morogh, with the group never changing. The election never ran, and the cache kept saying Ironforge for as long as he stood there. So the skip now covers the naming only: it carries the previous frame's names forward explicitly and falls through. The texts then compare equal, so the only thing that can elect is the zone id — which is the case that was being lost, and nothing else changes. Three tests pin it, including the gate crossing itself and that a different group of the same building still renames. Read as a skip of the whole updater the reference would lose the "Dun Morogh" splash on the way out, which it plainly does not, so this is recorded in the module doc as a deviation chosen on that evidence rather than one traced out of the binary. The dedup key is a separate question left open there: the doc claims `(WMOID, MOGP uniqueID)` and the code has only the group's `WMOAreaTable` key. Two log lines, because this was invisible from a log twice over: the zone-text interior claim now prints on change (`sound::interior` prints the *render* claim, a different ray, and the two disagree exactly at the aprons where it matters), and an election that happens inside one unbroken group says so. Measured first, against grom's own addons: `--fire ZONE_CHANGED_NEW_AREA` in the probe harness has the loaded corpus call `SetMapToCurrentZone` 1× and move the selection, which is what ruled the addon side out and left the client. `--fire` is new to the harness for that. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn |
||
|
|
9e13856cfd |
app: say in the log when the world map stops following the player
The stale-minimap report needs one fact: did the selection follow the player into the new zone? Everything map-derived rests on it, because `GetPlayerMapPosition` projects onto the **displayed** map — a selection left a zone behind seats pfQuest's minimap pins against the wrong rect, and plausibly rather than obviously wrong wherever two rects overlap, as Ironforge's does inside Dun Morogh's. The `zone:` line could not answer it even after it started printing both pairs, because the pairs disagree constantly and mostly correctly: for a frame or two after every crossing, since this feed publishes the new zone and the `ZONE_CHANGED*` an addon answers by reselecting fires after it; and for as long as the map is open on another zone, which is what browsing is. A marker on that line would have cried wolf on both. So the rule is its own thing, `SelectionLag::step`: an episode starts only on a frame where the *player* moved to a zone the map is not on, and only an episode outliving a 1 s grace is reported — once, at WARN, with the catch-up line when something finally reselects. A selection that wanders under a standing player is never an episode. Five tests, including the 600-frame browse that must stay silent. The line also forks the cause, which is the part that saves a round trip: `UiScript::world_map_resolver_calls` counts Lua's `SetMapToCurrentZone` calls, so the report can say whether the UI never asked to follow — the event did not reach the addon — or asked and still landed wrong, which is the verb reading a zone the feed had not published. Same symptom, different fixes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn |
||
|
|
e282410938 |
Seat the map catalog in the harness, and print the real map selection
Two instruments, both because a diagnosis went wrong on this data today. The zone line said "world map shows" while printing the player's *resolved* zone, not the selection. Those are not the same fact, and conflating them cost a whole diagnosis: GetPlayerMapPosition projects onto the **displayed** map, so a selection sitting a zone behind is exactly what leaves every map-derived overlay stale — and from the log it looked identical to a selection that had followed. Both pairs are now printed, with an explicit "STALE: the map is not on the player's zone" marker, so a plain run answers it with no flags. The harness never seated the world-map catalog, so C_Map.GetMapWorldSize had no rects there. pfQuest's minimap_sizes metatable caches a false per zone on a miss, so a probe of its pin gate reported "no zone sizes" for Dun Morogh, Elwynn, Orgrimmar, Thunder Bluff, Darnassus and Loch Modan — and the four zones that did answer were the four whose sizes the addon still ships. Every number was real and the conclusion was false. With the catalog seated (and the C_TaxiMap table beside it, for the same reason) all six answer and SearchFlightNodes adds 69 nodes. seat_a_session already carried the area-name column against exactly this fault class; this is the next feed in the same wall. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn |
||
|
|
ce8992bc3e |
Draw flight masters: C_TaxiMap.GetTaxiNodesForMap over TaxiNodes.dbc
grom: "can't see flight master location marks, even tho I have them on."
Nothing was broken — the layer was silently empty. pfQuest-octo has deleted its
shipped pfDB.meta.flight table in favour of this call (db/meta.lua:181 is a stub
whose comment says the data "always comes live from the client (ClassicAPI)"),
and SearchFlightNodes opens with
if not (C_TaxiMap and C_TaxiMap.GetTaxiNodesForMap) then return maps end
so with the namespace absent the option stays ticked, no error is raised, and no
icon is ever drawn. Same silent-by-construction shape as C_Map.GetAreas, same
consumer.
The contract is half read off the bundled ClassicAPI.dll's string pool
(C_TaxiMap, GetTaxiNodesForMap, nodeID, faction, reachable, state and the
FlightPathFaction/FlightPathState enum names) and half off the caller: the
areaID/mapX/mapY triple is not separately visible there, since the pool holds
one copy of each string and C_Map.GetAreaTriggerInfo already uses all three, so
for those SearchFlightNodes itself is the authority. It reads six fields, gates
on `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. Both files say which half is which.
Faction comes from the two MountCreatureID columns, the reference's own gate
(0x4a2650 asks for the mount of the player's half): one column filled is that
faction, both is neutral, and neither means the row is not a flight master at
all — on this install those are exactly the eight Transport/Ferry/zeppelin
endpoints, and passing them on would put "Flight Master" pins on the Menethil
docks.
Attributing a node to a zone needed the file's own names. Geometry over
WorldMapArea boxes proposes — these are axis-aligned boxes around irregular
zones, so a node near a border sits inside two or three of them — but no purely
geometric rule works: "smallest box" misfiled a dozen of ~70 rows (Crossroads
under Mulgore, Aerie Peak under Alterac Mountains), and "most central box",
much better and what zones_at now sorts by, still loses Talonbranch Glade to
Darkshore by about one percent of inset. TaxiNodes.dbc names its rows
"<place>, <zone>" and a city row leads with the city, which is itself a zone, so
trying each comma-separated part against the candidates gets Ironforge over Dun
Morogh *and* Felwood over Darkshore from one rule. zones_at also reaches the
instance-map list, so Alterac Valley's two nodes place on the battleground map
instead of nowhere.
Seeded before the interface loads, after the map catalog it reads the rects
from. That deadline is tighter than file scope: pfQuest replays its saved
tracking selections once on PLAYER_ENTERING_WORLD and then unregisters every
event (database.lua:131-151), so a push one frame later is a push after the only
read.
Verified against the real addon offline, not just by unit test:
SearchFlightNodes now adds 69 nodes through the probe harness. Three tests pin
the rule against the DBC itself — the capitals and the border nodes by hand, and
every named row held to the zone its own name claims, which is the test that
rejected both earlier geometric rules. It also pins the four rows that name no
zone, three of which are this realm's own additions (Northshire Abbey and two
Darkshore canoe routes), so the next custom node shows up as a failing
assertion rather than a pin in the wrong zone.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
|
||
|
|
f434cc9916 |
Survive a net entity despawned before attach's queue is applied
grom's 06:23 session died 183 s in on first world entry (Diagnostics/crash-1792671984.txt): insert::<GroundShade> on 4431v0, "its index now has generation 1", one frame after a 4301 ms world-entry hitch during which the server sent a burst of creates and destroys. The mechanism is structural, not a one-off. A command queue is applied at the schedule's next sync point, not when the system returns, so a destroy handled by a system that ran *earlier* in the same frame is still only queued while attach_entity_visuals' query runs: the row looks alive, the whole visual is built for it, and the despawn lands first at the sync point. The panicking command form then kills the client for an entity the server had already taken away. So every write this system makes onto the net entity root is now a try_ command. add_child was the one that needed more than a rename: it queues its command on the *parent*, so the conform node and RigPose::anchor_for's bone anchors are related at spawn with ChildOf instead — the relationship hook tolerates a missing target where a command on it does not. The test builds a CommandQueue the way the existing rig test does, despawns the root before applying it, and asserts the entity stays gone. It fails with grom's exact panic text when any single write is put back, which is how I know it bites. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn |
||
|
|
b4a5d3b38f |
ui: "do I know this spell?" asks the known set, not the spellbook
Every "Already known" check read `spellbook.slots`, and `SpellDisplay::in_spellbook` filters out `SPELL_ATTR_IS_TRADESKILL` — correctly, since recipes live in the trade-skill window, not the spellbook. So the question "do I know this spell?" answered **no for every crafting recipe in the game**: a recipe item never showed its `ITEM_SPELL_KNOWN` red line, and nothing stood between the player and consuming it a second time. grom learned Plans: Copper Chain Vest (item 3609) twice. `PlayerReqState` gains `known_spells`, filled from `PlayerActions::spells` — `SMSG_INITIAL_SPELLS` plus every learn, which is the set the reference's own check reads (`0xb700b0`). It belongs there: that struct is already "the player state the tooltip's red lines and the usable gate compare against", already change-gated on the action/spell inputs, and already pushed by the same feed. Both readers move over: the taught-spell line and `item_usable_by_id`'s spell requirement, which had the same hole. The existing `verified_families_signable_locked_resists_known` only passed because it made the recipe known by putting it in the spellbook, which no real session does — it now sets the known set instead, and that is the change's own regression story. The new test leaves the spellbook deliberately empty, which is the real shape and what fails if the check ever reads it again; it asserts the not-yet-learned case too, so it cannot pass by always reading red. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn |
||
|
|
a2da3e4da7 |
ui: a skill lock you can open stops reading red
The GameObject tooltip's skill-lock line took the opener-*unknown* arm unconditionally — `LOCKED_WITH_SPELL` in red — whatever the player's skill. The known arm was simply not built, and the code said so where it was missing. The reference forks on whether any known spell opens the slot's LockType (`0x52abcb`): known takes `LOCKED_WITH_SPELL_KNOWN`, which carries the requirement as a `%d` and the colour of the skill-difficulty ramp `0x529fa0`. Built now, both halves. grom reported it on a copper vein at Mining 70, which under the ramp is +69 over the vein's requirement of 1 — the green rung, not the grey one he expected; grey starts at +100, so a copper vein greys at Mining 101. Either way not red, and red is what it showed. The resolve is hoisted out of the `flag_locked` branch, because the "Locked" line's colour and this line's known arm are two readings of one answer and must not disagree with the click. `lock::skill_margin` recomputes the two numbers from the opener the resolver already matched, with the same two functions its own value test uses (`0x5f850f`), so hover and click cannot diverge on which opener is in play. Deviation, recorded at the constants: the three new rung RGBs are the standard trade-skill palette, not values read out of the binary's table. Only the ramp's shape is traced — five rungs, bands at +0/25/50/100 — and the rung a line lands on is what the player reads. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn |
||
|
|
92b2e31577 |
app: order the zone events after the world-map feed
`feed_zone_events` fires `ZONE_CHANGED`, `ZONE_CHANGED_NEW_AREA` and `MINIMAP_ZONE_CHANGED` inline into the VM. pfQuest answers all three by calling `SetMapToCurrentZone()` whenever the map is closed (`pfQuest/map.lua:1466`), and that verb reads `worldmap.player_zone` — which `ui_world_map::feed_world_map` publishes, in a system that was ordered only `.after(UiInput)`, exactly as this one was. Nothing ordered the two against each other. Run the wrong way round, `SetMapToCurrentZone()` reselects the zone the player has just left, because the feed has not published the new one yet. Every map-derived overlay then stays a zone behind until something else reselects — opening the world map, say, whose `OnShow` calls the same verb a frame later when the feed has caught up. There is nothing to order in the reference: `SetMapToCurrentZone` calls the resolver `0x4a6650`, which reads the player's current area live. Reading a per-frame feed instead is ours, so the ordering is ours to state. Stated as a set rather than a bare `.after`, since the next consumer of the published zone will owe the same edge. Found while chasing grom's report that pfQuest's minimap icons do not re-seat on a zone change until the world map is opened — which is this shape. Not confirmed as that bug's cause: it is an unpinned order, so which way it fell before this commit is not something the logs record. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn |
||
|
|
cd6731799b |
ui: memoise the texture-existence probe, as its two neighbours already are
`set_texture_probe`'s closure answers every path form of `SetTexture`, and it was the only one of the three probes installed beside it without a cache — `set_texture_size_probe` and `set_font_probe` both memoise, and the font one's comment already gives the reason that applies here verbatim: a path's answer cannot change, and an addon may ask every frame. A miss is the expensive answer. An addon texture is never in the MPQ chain, so both candidates fall through to `loose_addon_file`, which walks the path one component at a time and, whenever a component's real spelling is not the lower-cased one it was handed, enumerates that whole directory to compare case-insensitively. `Interface\AddOns` holds 94 folders here and `pfquest` never matches `pfQuest` on a case-sensitive filesystem, so an uncached call read that directory twice over, on the main thread, between two systems. grom's 2026-10-21 17:08 session priced it: `[ui-verbs]` reports `texture:SetTexture` at **45-59 us a call across all 33 windows** — stable enough to be a fixed charge rather than a tail — against the ~0.2 us a cached existence answer costs. 1.75 ms a frame over 34 calls while the world map was up, because `WorldMapFrame_Update` re-textures every overlay tile and POI on each `WORLD_MAP_UPDATE`. Replaying the same walk over the real AddOns tree reproduces 26-42 us for a single candidate. Misses are cached too, as the size probe's are: an addon probing for optional art is the common case and it is the one that pays for the walk. This is the smaller half of the map's cost — `scrollframe.rs`' derivations were the larger — and it is a ~1-2% of frame fix, not the dip. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn |
||
|
|
e6ae2e0f67 |
ui: a scroll names the child it moved instead of giving up on the graph
`SetVerticalScroll` and `SetHorizontalScroll` called `Model::touch_layout` on the line before `reanchor_child`. That gave up naming for the whole graph and then *erased* the precise touch that followed: `reanchor_child` re-anchors the scroll child through `write_anchor`, which reads an offsets-only change (same point, same target) and names the child node — but `touch_layout_node` will not un-say an imprecise touch, so the give-up won on every call. Naming the child is the whole truth here. A scroll offset reaches the layout only as that one anchor: anchor targets are ids fixed at `SetPoint`, so no edge moves and the roster is unchanged, and the clip a scroll child draws within is the ScrollFrame's resolved rect rather than the offset — `clip::scroll_clip_sources` rebuilds it per call and caches nothing. Measured on grom's 2026-10-21 17:08 session, which was a map-and-panel run. These two lines were **472 of the run's 806 whole-graph derivations**, the largest single source by a factor of two, and the cost lands on the Lua that reads a frame edge afterwards rather than on the pass's own `resolve` phase: | | n | fps | UI pass | `frame:GetLeft` | |---|---|---|---|---| | windows with >= 10 scroll derivations | 12 | 68.5 (min 53) | 5.93 ms/f | 2.18 ms/f | | windows with no derivations at all | 11 | 92.4 (min 77) | 1.62 ms/f | 0.05 ms/f | `r(scroll derivations, GetLeft us/f) = +0.907` over 57 in-play windows, against `+0.014` for every other derivation site. `[ui-verbs]` has `frame:GetLeft` at 94-376 us a call in those windows and 0.6-1.5 us in the quiet ones, on ~14 calls a frame, because stock FrameXML reads frame edges constantly and each read past the clean epoch re-derived. That is why a scrolling panel — the world map, the quest log, a profession list — was the expensive thing on screen. The test scrolls both axes on the shipped interface and asserts zero derivations, with a positive control so it cannot pass on a dead counter. Put one `touch_layout` back and it reads exactly 10 for 10 scrolls. `WOW_LAYOUT_VERIFY=1` over the real 92-addon interface gives `frames 13379 -> 13379, regions 29597 -> 29645` — identical to the recorded pre-existing baseline, so this narrowing introduced nothing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn |
||
|
|
754d577eaf |
ui: a trade-skill list mutator signals inside its own call
`ExpandTradeSkillSubClass`, `CollapseTradeSkillSubClass`, `SetTradeSkillSubClassFilter` and `SetTradeSkillInvSlotFilter` fire `TRADE_SKILL_UPDATE` from inside the call now, as the one helper all four tail-call in the reference does (`0x4fd710`, `0x4fd730`, `0x4fd750`; `reference/1.12-verb-events.tsv`). It used to be a `trade_skill_touched` flag drained by `ui_tradeskill::drain_trade_skill` and answered the following frame. Inline is load-bearing. These four verbs are called *from* a `TRADE_SKILL_UPDATE` handler: a profession library reads the recipe list by unfolding every header, walking it and folding back what was folded. The signal is therefore re-entrant by construction, and every addon that does it carries its own guard — LibCraftingProfessions has `trade_skill_scan_in_progress`, ATSW an `atsw_scans < 2` counter. Those guards are written against an inline signal and only an inline signal lands inside the window they cover. Deferred to the next frame it landed after the guard had cleared: the library rescanned, the rescan called the verb, the verb re-armed the flag, and the two kept each other alive at one fire a frame for as long as the window was open. grom's 2026-10-21 session shows it directly — `[ui-events]` reporting `TRADE_SKILL_UPDATE · 1.01 fires/f · ~900 call(s)` across 37 consecutive windows, three unbroken minutes, at 0.58-0.84 ms a frame in handlers over four addons rebuilding their lists for nothing. It is the 118-second and 61-second stretches at 55-70 fps in a session whose in-play median was 86. Two tests: each mutator's fire is read with its own `run` still on the stack, so a deferred signal fails; and a library-shaped in-progress guard turns one real update into exactly one scan, with nothing owed to a later tick. The bench's `[events]` block also fires the five non-quest events the live session named. They cost ~0.2-0.5 ms there against 12-72 ms live, because this harness has no NPC in front of it, no trainer list and no open map — recorded as a gap, not an answer. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn |
||
|
|
e269e4dbb4 |
ui: name the event, not the library frame that dispatched it
`WOW_UI_EVENTS=<secs>` (`script/event_prof.rs`): per event, what one fire
costs across every handler it reaches, and which frame took the most of the
worst fire.
The gap it fills is a dispatcher. `WOW_UI_HANDLERS` keys on `(frame, script)`,
which is the right key for an `OnUpdate` and the wrong one for an `OnEvent`,
because every addon library registers *one* frame for *every* event it wants and
fans out in Lua. So the largest identifiable cost in grom's 2026-10-21 session,
eleven separate times, read as
[ui-handlers] 312.5 312.5 0.00 72522.3 AceEvent30Frame:OnEvent
— a true row that names a library's shared frame and no addon, no event and no
cause. `[sys-timing]` saw the same cost as `ui_quest::feed_quest` at 85.7 ms for
one run in 76, because every handler runs synchronously inside whichever system
fired, so the system holding the stack gets the bill.
Two conventions the report sticks to:
- `self` is nest-adjusted, in both columns. A handler's own time has any event it
fired taken back out (`event::fire` differences `event_prof::nested_ns` across
the call), or every event in a cascade reports the cascade and the row naming
the real cost is the one that looks innocent. `total` keeps it, which is what a
`[sys-timing]` span sees.
- A fire is one `fire_event`, however wide it fans out, because that is the unit
a dropped frame is measured in. `calls` is beside it for the width.
Measured on the player's own 92-addon interface (`tick_bench`'s new `[events]`
row, three fires per event from a settled start):
us/fire worst event
5068 14759 QUEST_DETAIL <- HCQuest.lua:139 took 14,097 us
764 1324 QUEST_LOG_UPDATE
662 1564 QUEST_FINISHED
Read `us/fire` against `worst` and that 14.8 ms is **one** fire of three: the
other two cost ~0.1 ms, so it is HCQuest building something once and keeping it,
not a cost paid per quest. With that read correctly, no quest event costs more
than ~1.5 ms recurring offline — so the bench does not reproduce the live 57-73
ms and says so, rather than offering a number that looks like an answer. Hence
the instrument, armed in `./measure.sh`.
Also adds `./measure.sh --frames`, which arms two meters that already existed and
have never been armed: `WOW_FRAME_PHASES=50` and `WOW_RENDER_MS=1`. A frame's
wall time is `max(main app, render app)` since bevy pipelines the render app onto
its own thread, so when the render app is the longer, `wall` climbs while `cpu`,
`main`, `gpu` and every per-system `worst` all read healthy — exactly what that
session's **161 ms frame in a second whose costliest system worst was 2.9 ms over
105 runs** looks like, and what 43 of its 64 spike-seconds look like. Its own
mode because both perturb what they measure.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
|
||
|
|
0c862838da |
ui: a SetScale names its subtree instead of giving up on the graph
`[ui-phases]`'s new write-site tally answered the combat-fps question on its
first armed session: 463 whole-graph layout derivations across 34 windows, and
**232 of them** at `object/frame_state.rs:427` — the `touch_layout()` inside
`SetScale`. Splitting the per-window count by site and regressing against
resolve time over 43 in-world windows, resolve correlates with `SetScale`
derivations at r = +0.996 and with every other derivation at r = -0.041. The
four costliest windows of the run are 36-64 `SetScale` derivations each and
almost nothing else.
That also corrects the flat "~15.5 ms per derivation" of the round before: a
mint-triggered derivation costs ~1.1 ms, a `SetScale`-triggered one 12-13 ms.
The difference is that `SetScale` gave up *two* ledgers, not one.
The caller is pfUI's action-button press animation
(`pfUI/modules/actionbar.lua:182`, `:207`, `:233`, `:259`): four styles that all
run `this:SetScale(this:GetScale() + fade)` on `OnUpdate` for the ~60 frames a
button's fade lasts. Pressing an ability starts it, which is why a settled
interface never reproduced this and a fight always did.
Both give-ups had precise versions already, written for `SetParent`:
- `touch_layout()` -> `touch_layout_subtree_scale(h)`. A scale write moves the
subtree's `effective_scale` and nothing else: anchor targets are ids fixed at
`SetPoint`, so no edge moves and the roster is unchanged.
- `touch_measure_all()` -> the same walk now names the subtree's regions on the
measure ledger. This was the bigger half. `measure_dirty = None` makes the
next `fontstrings_needing_measure` walk all ~43,000 regions of a real
interface to issue, typically, zero requests; `stale_measure_key` reads only a
region's own data plus *its owner frame's* `effective_scale`, so the subtree's
regions are exactly the ones that can go stale.
Measured on the player's own 92-addon interface, 43,195 regions, one `SetScale`
per frame for 240 frames (`tick_bench`'s new `[scale]` row):
before changing resolve 0.601 ms/f · 0.996 derivations/f (239/240)
after changing resolve 0.005 ms/f · 0.000 derivations/f ( 0/240)
control same resolve 0.005 ms/f · 0.000 derivations/f ( 0/240)
`changing` is now indistinguishable from a control that writes the value it
already holds. The harness understates the live cost (its measure answers are a
6px-per-char stub), so the live saving is to be confirmed in a play-test, not
predicted from that row.
Correctness is left to `WOW_LAYOUT_VERIFY`, which `cfg!(test)` forces on for all
1975 benilla-ui tests: it re-derives the whole graph after every `resolve()` and
panics on any rect the incremental pass got wrong, and runs the full measure
roster beside the ledger drain and panics on any stale region the ledger missed.
So the two new tests assert only that the cheap path was taken, on a graph three
frames deep with a FontString on the leaf. The real-interface verify run fails
identically to its documented pre-existing red (`frames 13379 -> 13379, regions
29597 -> 29645`), so this change adds nothing to it.
Also repairs an assert message in `layout_gate.rs` whose line continuations had
been eaten by a heredoc, leaving runs of spaces mid-sentence.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
|
||
|
|
c1a56f98ab |
layout: name the write that asks for a whole-graph derivation
`[ui-phases]` could say a window paid for 61 whole-graph derivations. It could not say which of the forty-odd `touch_layout` sites asked for them, and that is the half a fix has to be aimed at. Measured on the player's interface, one derivation costs ~15.5 ms — from two directions: the derivation count explains total resolve time at r = +0.977 across twenty windows (solve count: r = -0.33, so solves are free and derivations are everything), and 26 of 78 worst-single-call figures over 1 ms land in the 15-16 ms bin across eleven unrelated handlers by six authors, two of which fired exactly once for their whole window. Eleven handlers cannot each independently be 15.5 ms slow; it is one shared charge landing on whoever reads the graph first, which is also why the same session shows `resolve` p99 at 14.4 ms and worst frames "mostly tick". One defect, two accounts. So `#[track_caller]` down the naming chain and a tally of `file:line`, printed in the report. Not a backtrace: `WOW_LAYOUT_DERIVE_TRACE` already captures those and has to be rationed to `n` prints because of what they cost. This is a `(&'static str, u32)` key off `Location::caller()`, cheap enough to leave armed, and take-and-rearm so a window reports its own rows and a VM replaced by `/rl` re-arms without anybody noticing it was replaced. Also a `[churn]` window in tick_bench, which records a negative: a plate roster turning over 4-in-40 every ten frames costs **zero** derivations on the player's own interface. `sync_nameplates` pools the plate frames, so a new key rewrites an existing plate and mints nothing. With `resolve_bench`'s hover-sweep, tooltip-content and moving-region tests all asserting zero, four candidate triggers are now ruled out offline and the live tally is what names the real one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn |
||
|
|
e4851df7df |
ui: WOW_UI_PHASES — the UI pass's phase split, windowed
`WOW_UI_TICK` measures `UiScript::tick`. `[sys-timing]` measures the Bevy system around it, `ui_script::extract::tick_script`, which also runs the FontString measure round-trip and `UiScript::resolve`. In a combat session the first read 38 ms/s — its lowest of the whole run — while the second read 157 ms/s. Neither instrument was wrong, and 76% of the cost was in the space between them, which is where the stutter players report lives. `UiFrameCost` has carried the whole split since the hover recorder needed it, and `WOW_UI_COST=1` prints it one line per frame: at this client's framerate, twenty thousand synchronous stderr writes a minute from the thread being measured, which is the trap `sys_timing`'s header records costing 15 ms a frame. The split existed and was unusable in exactly the situation it was for. So a window, and a worst column — a phase that is ruinous on one frame in a hundred has a mean near nothing and is the only thing a player feels. `derives` is in the per-frame line because `UiFrameCost::derives` is documented "expected zero", so a nonzero one names a whole-graph re-derivation on sight. `UiFrameCost::stamped` comes with it: the paint can return before any of its three arms wrote this frame's phases, and a reader that cannot tell counts the previous frame twice. Armed in ./measure.sh at 5 s, with the per-frame form left alone for the hover recorder. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn |
||
|
|
112647b919 |
perf(ui): the handler profiler names the worst single call, not only the mean
`WOW_UI_HANDLERS` ranked handlers by self time over the window, which is the right ranking for what costs framerate and the wrong one for what costs a frame. A handler that stalls once every four seconds has a mean near zero over five hundred frames and never reaches the table at all. The player's session had a 15-28 ms tick in nearly every five-second window — one dropped frame every few seconds, which is the kind of thing a player reports as "it hitches" and no average will ever show. The table said `UIParent:OnUpdate 60.8 us/f` and nothing about it. So each slot keeps its costliest single call, the table gets a `worst` column, and the handlers whose worst call cost a frame are listed again underneath — but only the ones the table left out, so the block says something new or nothing. `tick_bench` prints the same ranking. It names the stall on the first run, offline, in this interface: ``` 15768.7 us once (self 17080.8 us over 240 calls) pfQuestMapTracker:OnUpdate 12339.4 us once (self 13042.0 us over 240 calls) AceEvent20Frame:OnUpdate 5380.9 us once (self 16166.0 us over 240 calls) pfQuest\quest.lua:348:OnUpdate ``` 92% of the tracker's whole cost over 240 frames is one call. Both of the top two are steady per-frame allocators, which made the collector the first suspect; the bench's own heap line refutes it — 222.8 MB live against 29.1 kB/frame of churn is ~131 s between 200%-pause cycles, not the ~4 s the stall recurs on. What the per-binding bill says is in there instead: one `pcall` of 7.5 ms, and `frame:GetLeft` at 92 us a call, which is a layout resolve against a dirty graph. `measure.sh` comes with it. The set of instruments worth arming for a play-test is long enough that a run with one of them missing is normal, and such a run looks exactly like a run that found nothing — the session this column came from was missing `WOW_SYSTEM_TIMING`, so it could say what the UI cost and not what the frame cost. The script arms the set and prints what it armed. The test asserts the property the column exists for and not a duration: a handler called ten times for 50k iterations each outranks one called once for 150k by self time, and the second still has the larger worst call. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn |
||
|
|
3ebf432ebd |
harness: ask the whole interface a question, not one addon at a time
`--probe <Name>` loads one addon and asks it things. What it cannot see is an interaction between two addons, and a real interface is mostly interactions: the player's chat frame is docked by pfUI, re-anchored by the shipped `FCF_DockUpdate`, and read by ChatSuey, and no single-addon probe reproduces any of that. `--probe-all` is the same read-back against the walk `--together` already does — the whole folder in one VM, in load order, each `ADDON_LOADED` at its own position, then one session start — with the VM left standing for `--eval`, `--tick` and `--mouse` instead of thrown away. The walk itself is now one function both callers share, so the survey and the probe cannot drift. Two things the walk was missing, both of which decide what the interface looks like: - **Saved variables.** `register_addons` was told where they live and nobody read them, so every `--together` run measured a default-config interface. pfUI's panel sizes, module enables and font sizes all live there. With `--saved C_Thun-Gromgosh` the probe reproduces the player's own geometry to the digit — the chat frame's 25.12 px panel gap matches what was measured in play, where a default-config VM says 23.62. Loaded before `ADDON_LOADED`, as the reference sets them (`0x51f6c0`), because reading config on that event is the usual shape; after it is a default-config interface with extra steps. Dependencies get theirs too: a library pulled in under its first dependent never gets a turn of its own in this walk, where the live client gives every enabled addon one. - **The screen.** `--screen 2560x1440` instead of a hardcoded 1024x768, because pfUI's pixel-perfect scaling reads `GetScreenHeight` and a player's interface is not laid out at the harness's resolution. What it answered first time out: the player's short pfUI chat frame does **not** reproduce offline. With his real saved variables, at his real resolution, with all 80 addons in one VM, `ChatFrame1` is docked correctly — `TOPLEFT` and `BOTTOMRIGHT` into `pfChatLeft`, height 129.76 of a 180 panel — so whatever shortens it live needs state this walk does not have, which is worth knowing before reading another line of `layout.rs`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn |
||
|
|
9d16c200a6 |
cvars: a capture can boot on a player's setting, not only the defaults
A capture reads no `config.toml` — that hermetic boot is exactly what keeps an A/B exact — so there was no way to ask offline whether a report still reproduces at the player's own numbers. `WOW_CVAR=<name>=<value>[;…]` applies after the file and the preset, so it wins; unset it moves nothing and every capture keeps booting on the registered defaults. An unknown name or a malformed step is reported and skipped, because a typo in a measurement should not take the run down with it. Written for grom's report that interiors go "really grey and flat" when it snows with Interior Darkness turned down. That is `interiorGain`, and reproducing it needed his 1.25 in a capture that boots at 0.5. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn |
||
|
|
948373f7a4 |
probes: a prop shares a slot with one that agrees, not only one identical
The interior prop-probe table holds 8192 and cannot be widened — 13 mesh-tag bits, with every bit of that u32 already spoken for. It keyed on exact coefficient bits, so two props shared a slot only when no MOLR light reached either *and* their baked MODD colours matched to the last bit. Measured in Ironforge at grom's login spot: 3,883 distinct probes standing still, 5,543 at a far clip of 777, and his session saturated during the world load — after which every further interior prop falls back to the room's flat day/night law. The distinctness is the per-prop baked colour, not the prop's position: folding the probe on a 16 yd grid instead of each prop's own centre removed only 4.5% of the slots. Rounding the *key* onto a 1/32 step removes 35%. A shared slot keeps the first claimer's exact rows, so the quantization decides who shares and never reaches the light the shader reads. Verified by render, not by argument: that Ironforge storeroom at the new default is byte-identical to exact keying across 1.44M pixels, while starving the table outright (the new `WOW_PROBE_CAP=0`) moves 109,830 of them by up to 14/255 — so the lane does drive this view, and the sharing costs nothing measurable in it. The table now also counts the distinct probes it refused, and all three overflow warnings report it. That number is the whole question when the table saturates: a small overflow is answered by sharing harder, a large one only by more slots, and until now the log could not tell them apart. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn |
||
|
|
497570a68c |
addons: an on-demand load says what it loaded
The startup walk names every addon it loads; `LoadAddOn` named none. So when the client froze on 10-19 while learning recipes, the session log could not say whether the trainer window was even open — and `Blizzard_TrainerUI` is LoadOnDemand, so that was the whole question. `benilla-ui` has no logger, so the line travels the way its errors and warnings already do: a `notices` channel on the model, drained to `info!` beside them in the same pass. Silent for a redundant load, because the stock `*_LoadUI` glue calls `LoadAddOn` on every open and a line per call would bury the one that did the work. A refusal carries the reference's own reason token, and a name that is not installed says so. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn |
||
|
|
9782b07fd4 |
lua: a frame that never leaves the VM fails its call, not the client
The load has had an instruction bound since `3b6975fd` — "so a hung load reports instead of freezing the loading screen" — and the end of the load disarmed it, "so no handler is killed for being slow". Right about slow, and it leaves a handler that never returns at all able to freeze the client until the player kills it, which is what happened to him this morning. So the bound does not go at the end of the load, it changes shape. Wall time rather than instructions, because the number has to be obviously beyond anything legitimate and a count is not: a frame still inside Lua after fifteen seconds is not slow, it is stuck. `tick` restamps every frame, so no frame is charged the one before it and a handler reached later in the frame — a click, a fired event — measures from that mark, which can only make the bound tighter. The raise names the chunk and the line, so his log says which addon rather than that something somewhere looped. It shares the one count hook, which costs 5-13% of pure Lua execution time (measured: 30M instructions, 235-260 ms bare against 267-274 ms hooked, which also puts his machine at ~125M VM instructions a second — what sizes the load's 200M at about 1.6 s). Lua is a small part of a play frame, so that is a fraction of a percent of frame time, and the load was already paying it. `WOW_LUA_RUNAWAY=<secs>` moves the bound, `0` disarms it. Two tests: a loop with no exit fails its own call, names its chunk and leaves the VM usable; and a frame is not charged the wall time that passed before it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn |
||
|
|
af4bb2dd22 |
stall: the Linux client watches its own main thread
It froze on him this morning while learning recipes at a trainer, and he had to kill it. Both logs end on an ordinary line; the fps journal's last sample is a second earlier with normal numbers; `journalctl -k` holds nothing but firewall noise between 07:30 and 08:20, so no GPU reset and no OOM kill; and the two crash reports in Diagnostics are from the 11th and the 15th by their own embedded server-time lines. A frozen frame writes no line of its own, and a force-kill writes no crash report, so the session left no evidence of where it stopped. There was a watchdog for exactly this, with a heartbeat, a threshold set, an `_exit` backstop and two injectors to test it — registered `#[cfg(all(feature = "dev", target_os = "macos"))]`, because its diagnosis is `/usr/bin/sample`. On Linux nothing was watching at all. So the sampler grows a Linux arm instead of a second watchdog beside it. `/proc` already knows: each thread's scheduler state, the kernel function it sleeps in, and its CPU time in clock ticks. That is less than a backtrace, but it settles the first question — climbing ticks is a loop, `S` or `D` with flat ticks is a wait and the wchan says what for — and it is free, where a real backtrace of our own process needs ptrace, which yama's `ptrace_scope=1` refuses between siblings and which this client has no business asking for on a player's machine. The report says so, and names the pid for `sudo eu-stack -p <pid>` by hand. Verified with the injector that was already there, `WOW_STALL_INJECT=20:14000`: stall: no frame finished in 10072 ms — main thread tid 2793492 state S wchan hrtimer_nanosleep cpu 667 ticks benilla; 39 thread(s) in all `S` on `hrtimer_nanosleep` is the injected sleep, read correctly as a wait. The full report lists all 39 threads by CPU. The in-frame verdict goes to the log as well as the file, because the log is what gets read after a freeze and a file nobody knows about is not evidence. Per-platform where the platforms differ: Linux waits ten seconds rather than 600 ms, since this client has honest frames of several (a world entry's UI load, a `/reload`), and takes more reports, since reading `/proc` suspends nothing — where a `sample` suspends the audio thread, which is itself a crackle, and still declines for that reason. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn |
||
|
|
b7ddc18782 |
load: a reload says what it cost and what the chunk cache did for it
Both lines sat on the world-entry path, so the load the player actually waits through — the `/reload` — reported nothing at all. His 2026-10-19 session has two of them, 4,981 ms and 6,276 ms against the 7.5-9 s he started from, and the log cannot say where either went or whether the bytecode cache was hit, although the cache directory proves it was: 1,585 entries written at first load, 14 added across both reloads. `report_load` now runs on both roots and names which one. The reload's clock starts before the saved-variable writes, because those are part of the one blocked frame he sees, which is also what the `frame hitch` warn measures. `chunk_cache_line` resets its counters on read, so each line is that load's own work rather than a running total in which a warm reload hides behind the cold login before it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn |
||
|
|
a3334b279e |
gameobject: a template is keyed by the object's own entry, not its guid's bits
A hardcore gravestone gave no tooltip, and his log had the whole answer: `go plate: 0xf112aea6e34d2828 has no template yet — asked, drawing nothing`. The pick landed perfectly; the plate's only silent exit is the template. And the guid says why — high `0xF112` names no 1.12 object family (`HIGHGUID_GAMEOBJECT` is `0xF110`), so `guid::entry` answered `None`, `request` returned at its first line, and the query was never sent. The word "asked" in that line was a lie. Two graves differ in the entry slot by 77 (`…aea6e3…`, `…aea730…`), so on this realm that slot is a counter, not an entry. `OBJECT_FIELD_ENTRY` is the reference's own cache key (`0x60b160`) — the lesson `names.rs` and `ui_pet_stats.rs` already carry for creatures and pets, where a guid's middle field holds a pet number instead. So `request` takes the descriptor's entry, and remembers the guid only when the two disagree: a 1.12 guid costs nothing, and all sixteen lookup sites keep taking a bare guid, which is all a hover has. `ui_item_text`'s is-a-GameObject test goes the same way — `OBJECT_FIELD_TYPE` bit `0x20` first, guid bits only when no descriptor arrived — because reading a custom object as an item asks the wrong query entirely. If a grave still draws nothing, the plate's line now prints the entry it asked under, which says whether the server refused it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn |
||
|
|
c13c85615c |
pipes: a warm budget is not charged the frame it starts in
The pipeline warm pass held a 10 s budget and timed out 5.2 s in, leaving pipelines to compile live — a visible stall each. `Time<Real>` is sampled at the top of the frame, and the frame the menagerie spawns in is the frame that loads the in-game UI: his login's 3.9 s (4.8 s the day before) came out of the budget before the first rig was ever revealed. His own log says so without a bisect: "2069 rigs drained in 9.75s" printed 5.84 s after "menagerie up", and 9.75 - 5.84 = 3.91 s, exactly that session's `in-game UI up in 3907 ms`. It cleared the timeout by a quarter of a second. The stamp is now an `Instant` read in the system, taken *after* `spawn_menagerie`: building the rigs is the frame's own cost, which no timeout can cut short, so charging it to the reveal only shortens the reveal. The spawn line prints that build cost and the timeout line prints how far it got, so the next failure reads as "too slow" or "budget stolen" with no further instrumenting. And a live compile now names the near miss instead of just its own identity: every variant that compiled behind a cover is kept as a `Variant`, and an uncovered one logs the warm variant it is closest to and the single field that differs — `cull=None (warm Some(Back))`, `vs_defs +WOW_RIG_SKIN` — or says no warm variant carries its label at all, which is a missing menagerie arm rather than a missing variant. One field is an edit; a 900-character pipeline identity is a research project. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn |
||
|
|
a48dc95586 |
lua: the cache says what it did, and takes an off switch
Two small things the chunk cache needed to be trustworthy on a machine I cannot
watch. `WOW_LUA_CACHE=0` turns it off without a rebuild — a cache earns a kill
switch, and it is also how its own saving is measured. And every in-game UI load
now logs what it did, beside the load's own cost:
ui_script: lua chunks: 0 compiled in 0 ms, 1591 loaded from cache in 157 ms
ui_script: in-game UI up in … ms
because "the cache is installed" and "the cache is working" turned out to be
different claims, and the first build of it satisfied only the first.
A capture run resolves no state folder, so it has no cache and compiles
everything — which is right (a capture writes no player state) and worth knowing
before reading one of those logs as a regression.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
|
||
|
|
d5ddaa1286 |
lua: a compiled chunk is kept, so a load parses only what changed
The compile is a UI load's biggest removable cost and the only half of it that can be kept — running the chunks is what an addon *is*. Measured through the addon harness's `--together` load of the player's own 91 addons, three passes: ``` no cache 4,788 chunks compiled 3,324 ms cold 1,592 compiled, 3,196 from cache 1,482 ms warm 0 compiled, 4,788 from cache 471 ms (+24 ms of keys) ``` **About 940 ms off every login and every `/reload`**, and the first load after an addon changes pays only for the files that changed. `benilla-config/cache/lua/`, one file per chunk, keyed by a hash of the chunk's own bytes and its name. Both chunk paths go through it — `run_chunk_named` and the XML loader's own runner, which is how the biggest files in the corpus arrive (pfQuest's database is 33 MiB of `<Include>`d Lua). It cannot go wrong quietly. A key is a property of the bytes, so an edited file is a different entry and a stale one is never asked for; an entry that is truncated, corrupt or from another Lua build fails `lua_undump`'s header check and the source is parsed instead, so a bad entry costs the parse it meant to save. The dump keeps its debug info — `debugstack` is an addon-facing verb and a stripped chunk would lie about its own lines — which the test asserts by raising from a cached chunk and demanding its file and line back. The folder is capped at 512 MiB (one load's worth is 159 MiB) and emptied wholesale when it is over: a cache that needed an eviction policy would need bookkeeping that costs more than it saves. The first build of this looked like it worked and saved nothing, because `ahash::AHasher::default()` is seeded per process: every session hashed the same file to a different name, found nothing and rewrote the whole cache (4,776 entries, 477 MiB, a steady 1,592 misses a run). Fixed seeds, and the instrument that caught it — `benilla_ui::script::chunk_cache_line()`, hits and misses with the time each spent — stays, because "the cache is installed" and "the cache is working" turned out to be different claims. Verified: the harness's verdicts over all 91 addons are **identical** with the cache and with `WOW_LUA_CACHE=0`, the full gate chain is green, and the `WOW_DATA` failure sets are unchanged (benilla-app 270, benilla-formats 80). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn |
||
|
|
0fe0a99d8a |
layout: a size the author set is not a question for the solver
`GetWidth`/`GetHeight` answer against current layout, so they resolve first — and a resolve that follows any `CreateFrame` re-derives the whole layout graph, because minting a frame id drops the cached one (`Model::frame_id` → `touch_layout`). A UI load is nothing but creation interleaved with reads, so the player's interface paid **839 whole-roster derivations in one load, 2.84 ms each: 2.4 s**, against 650 `GetWidth` calls. `WOW_LAYOUT_DERIVE_TRACE` named the giver-up: every sampled one was `create_frame`. Most of those reads cannot learn anything from a resolve. An edge takes its anchor scan first and only then `combineEdge(center, opposite, size · scale)` (`resolve_left`, `resolve_right`), so an authored size IS the span unless the anchors pin **both** of that axis's edges. `LayoutInput::authored_span` is that rule, written beside the solver whose precedence it mirrors, and it refuses the two cases where only the solver knows: both edges anchored, and a clamped frame, whose rect the screen can squeeze. Measured on the player's own 91 addons through the harness's `--together` load, three passes each: ``` derives per load 839 → 235 preamble per load 2,414 → 560 ms frame:GetWidth 686 µs → 60 µs per call (1,241 ms → 118 ms over three loads) ``` **1.85 seconds off every login and every `/reload`**, and the steady state gains the same way: an addon reading a size it set in an `OnUpdate` no longer walks the roster for it. Proven as well as measured: six UI captures (character, bag, merchant, quest, quest log, tooltip) are **pixel-identical** before and after, MAE 0.000, max 0; the layout suite passes under `WOW_LAYOUT_VERIFY`, which re-derives every frame from scratch and demands the same rects; and `resolve_bench` now asserts both halves — an authored, singly-anchored span answers with no gate walk, while one whose anchors pin both edges still forces the resolve and reports the anchors' distance, not the authored number. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn |
||
|
|
3369d525cb |
log: the client keeps its own log, and says which zone and room it thinks it is in
A player sees something wrong for two seconds and quits. What the client said at that moment lived only in the terminal it was launched from — which, for a player who starts it from a desktop, is nowhere. So three things, all aimed at the next report being answerable from the machine it happened on. **The session log.** `benilla-config/Logs/benilla.log` now holds every line that goes to stderr, with the previous two runs kept beside it as `benilla-1.log` and `benilla-2.log`. The ring that feeds the crash report already formats these lines ([`log_ring`]), so the file is one more sink on it: the ring's backlog replays into the file when it opens, so the boot lines are not lost to the attach being late, and writes are unbuffered because the point of the file is to survive the crash that made someone read it. 64 MiB cap, then one line saying so. A capture run resolves no state folder and writes none. Player chat is logged at `debug` and so is not in it; server notices are. **The zone line.** One line per change naming the whole chain: the leaf area id the terrain chunk under the player carries, the zone it rolls up to, and what the world map selected from that. This is unanswerable otherwise — the ids live for a frame, and the map only shows the end of the chain. It is already earning itself: the player reported the map naming a far-away zone in a custom area, and the terrain says why. `Azeroth_36_43.adt` (the server's own, out of `patch-A.mpq`) tags 44 of its 256 chunks `5599 "Rugford's Mountain Rest"` — the custom area the quest is in — and **53 of them `3 "Badlands"`**, in a block that starts a few chunks south of it. Walk off the rest and the client reads Badlands off their terrain and moves the map there, exactly as the reference would. **The interior line.** One line per room change saying whether the camera is inside a WMO group and which, because that claim decides whether the open world is drawn at all: inside, the exterior is culled to what the portal flood reaches. "It all went grey but I could see at some angles" is a description of that state, and from the outside a wrong claim and a right one look identical. `log_ring::attach_file` and `FILE_CAP_BYTES` are published engine API: the engine owns the log, the app owns where this player's state lives. The wall caught both the moment they crossed, which is what re-baselining it was for. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn |
||
|
|
e95de4a5ad |
log: the session's warnings are 330 down to 129, and the 201 that went were ours
A full inventory of the player's 2026-10-18 session: **330 WARN lines, 67 distinct
shapes, and not one ERROR**. Read one at a time against the code and the
reference, most of what remained was the client complaining about itself. Three
causes, measured by replaying the new rules over that same log: **330 → 129**.
**A label is not a key (33 lines).** `text=` resolves as a GlobalStrings key and a
miss shows the raw value, which is faithful; the warning on a key-shaped miss is
ours. But a miss is already a string no locale defines, so a single all-caps word
is a label the author typed — `OK`, `UP`, `DOWN`, `MT`, `TPS`, `TOP`, `BOTTOM`,
`WIM`, `TWA`, 42 lines of them in one session, every one rendering exactly as the
reference renders it. The predicate now wants an underscore, which keeps the shape
that cannot be anything else: `ATSW_ALLREAGENTLISTFRAME_CHN`, an addon's own locale
row missing from the locale it shipped, which really does paint its key name on
screen. The shipped-XML sweep keeps its own broader rule, where `text="DELETE"`
must resolve and a collision is a bug either way; its comment now says so.
**Prose is not a missing file (12 lines, and phantom load failures).** A `.toc` is
read line by line, `#` opening a metadata line and everything else a filename, so
a wrapped `## Notes:` paragraph and the `--` section headers many addons write
become entries that no file answers. The reference opens them, fails, and says
nothing. We called each one a load failure, which put it in `/errors` and counted
it in the "N addon load failures" line at every login — ColorPickerPlus shipping
"two files it does not contain" that are really one two-line sentence. A line that
cannot name a file is now a debug line; one that looks like a file and is absent
stays a warning, because then the package really is short a piece (BigWigs'
`Raids\Other\Nerubian.lua`). The addon harness shares the predicate, so its
package audit stops making the same claim.
**One cause speaks once (168 lines → 9).** The stock inspect panel's sixteen slots
each name a frame `Blizzard_InspectUI` has never been asked to build, and
QuickHeal's one undefined template is inherited ten times — per UI load, so 78
lines a session for two causes. A document's log warnings now collapse by shape:
the first two keep their detail, the rest become one "N more like …" line, and
`/errors` still gets every one, because the panel is where detail belongs.
Three shapes were checked and deliberately left, because the client is right:
`SetScript("OnInputLanguageChanged")` must keep raising (an unfired script kind
raises by this project's own rule, and nothing can fire an IME edge here); the
`SetOwner: unknown anchor 'BOTTOMRIGHT'` line is TurtleWoW's own
`Turtle_ShopUI.lua:530`, where our mode-0 fallback already matches the reference
and the line is already once per session; and a `.toc`-listed `Bindings.xml` walks
as FrameXML with `<Binding>` an unknown frame type, which the reference logs too
(the bindings themselves load by their own path and work).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
|
||
|
|
56fb28fa2b |
gates: clippy clean again, and the mail repro that never was for commit
`scripts/check.sh` has been failing before it ran a single test, on 62 clippy findings in code none of the recent rounds touched — 32 in `benilla-world`, 30 in `benilla-app` — so the in-loop gate could not tell anyone anything. Every one is a newer clippy noticing older code (`as_chunks` and `is_multiple_of` are both lints younger than the lines they fire on), and they are fixed, not silenced, except where the silence is the point: - **Argued allows, the house idiom.** `clippy::type_complexity` on ten Bevy system signatures and two inline caster queries, each with the one-line reason beside it, as the other 67 in this workspace have. `clippy::approx_constant` on `liquid/waves.rs`'s `TWO_PI`, whose own doc already says why it is spelled `6.2831853`: it mirrors a shader line, and `f32::consts::TAU` would stop reading against the file it copies. - **Two runtime assertions became compile-time ones** (`EXT_SEL_EMPTY is index 255`, the endless-stream frame count): a claim about constants should fail the build that breaks it, not a test run after it. - **Three real, if harmless, redundancies gone**: `len > MAX_TILT_TAN && len > 0.0` (the constant is `tan(4°)`, so the second test never spoke), `u32::from(..) + 1 | flags` now parenthesised where `+` binds tighter than `|`, and `palette.get(0)`. - The rest is mechanical: `chunks_exact(3)` → `as_chunks::<3>()`, `map_or(true, ..)` → `is_none_or`, two `Default::default()`-then-assign test fixtures, a doc line that began `1.` and so turned its own paragraph into a list item, and `local_utc_offset_secs` moved above the test module it had drifted below. `tests/repro_mail.rs` is deleted. Its first line says "TEMPORARY repro harness — not for commit"; it reads a loose mirror of FrameXML out of `/tmp/opencode/ vanilla-ui`, which no longer exists, so it has been failing on a missing directory rather than telling anyone about mail. What is left of the gate's red is one test: `world_api_wall` counts 174 crossings of the `benilla-world` boundary against a ceiling of 107 — 90 of them already filed with a close plan, 84 unsorted. That is not cleanup; the wall's own message says the remedies are to close the crossing or to publish it as engine API with the record that says so, and 84 of those is a design pass on what the engine's public surface is. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn |
||
|
|
3e616e5cb3 |
reload: one VM and no flush storm, 615 ms off every rebuild
Two costs read straight out of the player's own `WOW_UI_HANDLERS=1` session log (2026-10-18 20:33), where a `/reload` blocks the main thread for 7.84 s. **The saved-variables flush, 305 ms.** The reload's first act is to write every loaded addon's saved variables, one file each, and `write_atomic_bytes` ends each with `sync_all()` — a disk round trip per file, 60 of them, inside the one blocked frame. Measured on this project's own btrfs home: **327 ms flushed, 6 ms not**, which is the +27→332 ms the log spends between `ReloadUI — ending the UI session` and the last `wrote …/saved/*.lua`. `write_atomic_bytes_bulk` keeps the temp file and the rename (so a file is whole or absent) and gives up only a power loss in the seconds after; the reference writes these with plain buffered CRT writes and a `.bak` rotation whose result it discards, so this is still the stronger of the two. **A whole VM built and thrown away, 310 ms.** `end_ui_session` installs a fresh boot VM, which is right for the roots that stop there — the character screen runs on it — but `run_pending_reload` then calls `load_ingame_ui_on_world_entry`, which mints its own in `mint_entry_vm`. So every reload ran `UiScript::new`, `GlobalStrings.lua`, the emote table and the font registry twice and let the first one go without running a line: the log prints `GlobalStrings loaded` at +350 ms and again at +662 ms. `end_session` now takes what to leave behind, and the reload leaves the dying VM until the mint replaces it — safe because the two calls are one exclusive system with nothing in between, the old VM is still dropped before any file loads, and `fold_dying_vm_cvars` drains what it folds, so the mint's second fold of the same VM finds nothing. Only when the rebuild is certain to mint: with no UI wanted (a capture) the load returns early and the world would be left with no VM at all. `chunkbench` is the instrument the next item needs, and the one that priced this one's neighbours: it runs a file list three ways — a VM per file, one VM with the stock collector, one with it stopped — so parse, execute and the GC's share are separate numbers. Over his enabled addon set (1,471 files, 70 MiB) the parse is 703 ms against 74 ms to compile the same chunks from bytecode, which is what a bytecode cache would be worth; the collector is not the problem (829 ms against 731 ms stopped), and pfQuest's 23 database files are 417 ms and 110 MiB of heap. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn |
||
|
|
780ca25833 |
npc: the interaction token reaches the VM before the window's own event
HCQuest raised `bad argument #1 to 'strupper' (string expected, got nil)` at `HCQuest.lua:16` once in the player's 2026-10-16 session, from `questDetail` on `QUEST_DETAIL`. Its three arguments are `GetTitleText()`, `QuestFrameNpcNameText:GetText()` and `GetObjectiveText()`, and the `and` short-circuit means the giver is only read for a quest whose title matches one of the addon's 49 — which is why it fired once and not on every quest. So the nil is the name string, and `QuestFrame_SetPortrait` fills it from `UnitName("npc")` (`QuestFrame.lua:63`). `feed_interact_npc` seats `InteractNpc` before every feed, but the `"npc"` token only reaches the VM in `feed_units`, and `feed_units` and `feed_quest` are two systems in one `UiFeed` with no edge between them: the order was declared by nothing, so `QUEST_DETAIL` could be signalled into a VM whose `"npc"` was still absent. `SetText(nil)` then leaves the string empty for the life of the panel, and the stock quest frame shows no giver name either. Five window feeds were in that position; each now declares `.after(crate::ui_unit::UnitFeed)`, as `feed_auction` and `feed_trainer` already did, with the stock line that reads the token in the comment: quest, gossip (where the chain's own `Turtle_ArenaUI.lua:285` and `Turtle_GuildBankUI.lua:378` gate on `UnitName`/`UnitExists("npc")`, so a late token means the window simply never opens), bank, taxi, tabard. The reference needs no such edge: `CGGameUI::SetInteractNPC` writes `[0xb4e2d0]` on the interact itself, so the pointer is live before any window's `SignalEvent`. The test reads the declared graph and names the FrameXML site per window, so a new window that reads the token and forgets the edge fails with the line it reads it at. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn |
||
|
|
a2909fb0df |
ui_text: a font file answers to a family of its own, so no sibling stands in
Naming a face to `cosmic-text` is a family query, and `fontdb` matches a family by exact name alone: two files whose name tables declare the same family are one family to the shaper, and whichever it reaches first answers for both. The player's AddOns folder is full of that — four addons ship the same `Myriad-Pro.ttf` and three the same `Hooge.ttf`, which is where our own substitution warning came from three times a session — and in one pair it is not even the same design: `pfUI-CustomMedia\fonts\NiceDamage.ttf` and `MikScrollingBattleText\Fonts\pepsi.ttf` both declare `! PEPSI !`, so one of them draws the other's letters at the other's widths. So each file is re-registered under `benilla:<lowercased path>`, unique for the same reason `path_to_face` is keyed that way. The face's own axes are kept, and the shaper's per-character fallback is untouched — it walks the whole database and needs no family match — so the substitution warning now means only what is left: this face has no glyph for that character, and it says so. The test registers one real file at two paths and asserts each answers for itself; its second half is the behaviour that makes the rename necessary, `fontdb` answering with the first face for both. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn |
||
|
|
df921cdb47 |
creature: a display that names a model is drawn, whatever its scale column says
A screenshot of the Gnomeregan Reclamation Facility (OctoWoW's own area 5211) with its NPCs replaced by flat grey boxes, and the target frame filled — `M. C. Volterwhite`, 57, 4.13k — behind an empty portrait. The boxes are `entities::attach`'s model-less debug cube, and the empty portrait is the same cause: the display resolved to nothing to draw. `new_creature_display` took a row only `if m.scale > 0.0`, where `scale` is `CreatureModelData.ModelScale × CreatureDisplayInfo.CreatureModelScale`. That column does not scale a spawned unit: the server folds it into `OBJECT_FIELD_SCALE_X`, which is why `Creatures::model_scale` says in its own doc that the glue screens' pet is its only reader. So the gate was testing a number nothing downstream consults, and a `0.0` in it meant "no model". His chain's `CreatureDisplayInfo.dbc` (the effective copy, `patch-A.mpq` — patches sort ascending by lowercase name, so A wins over 5) carries a contiguous custom block, **20256-20356, 100 rows, every one a character model**: 27 HumanMale, 16 HumanFemale, 16 TrollMale, 12 GnomeMale, 10 NightElfFemale and the rest, all authored with that column left at `0.0`. One batch of custom NPCs, one zone's worth of grey boxes. Stock 1.12 ships three of its own that way too (displays 16, 17 and 19: HumanThief, HUMLBlackSmith, HUMLMerchant), which is the proof the column was never a visibility switch. The gate is gone. The one shape left that earns a cube — a display id with no row in either DBC — now says so once per id, as the GameObject leg already does, so the next zone full of cubes names the ids instead of needing a screenshot. A test over the real chain pins the data: every zero-scale row still names a real model, and the only row that answers `None` does so for want of a `CreatureModelData` row (display 185, `ModelID = 0`), not for its scale. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn |