Commit Graph
1564 Commits
Author SHA1 Message Date
gromandClaude Opus 5 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
2026-10-21 17:46:55 -06:00
gromandClaude Opus 5 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
2026-10-21 14:47:57 -06:00
gromandClaude Opus 5 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
2026-10-21 06:43:01 -06:00
gromandClaude Opus 5 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
2026-10-20 20:57:15 -06:00
gromandClaude Opus 5 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
2026-10-20 19:17:44 -06:00
gromandClaude Opus 5 4393bde4ec sys-timing: a stall report names the thread and how long it was stuck
Two durations were being conflated. How long the thread has not moved is the defect; how long the
span it is inside has been open is only where it is stuck, and for an enclosing span that is the
whole session — which is why the live log said "has been running 360380 ms" and then "was in there
at least 360406 ms" about a 150 ms hiccup.

So both, labelled, and the thread's name beside them: the span it is stuck in may be unnamed, since
an enclosing `info_span!` carries no `name` field for `on_new_span` to record, and then the thread
is the only thing the report can name. Before this, such a stall printed `span #2473901162497` and
nothing else.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
2026-10-20 19:17:44 -06:00
gromandClaude Opus 5 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
2026-10-20 18:17:01 -06:00
gromandClaude Opus 5 21c0befdca sys-timing: a stall is a thread that stopped, not a span that is open
The watchdog keyed on "a span has been open past the threshold". Spans nest, and `on_exit`
republishes the enclosing one deliberately — so between any two inner spans the slot shows a span
that has been open since the first frame, and the watchdog said so.

In a 105-second play session that was 13,658 of the log's 15,335 lines: `STALL: span
#2473901162497 has been running 209147 ms`, alternating with `STALL ended`, every ~75 ms, each one
re-reading /proc for 39 threads — inside the session it was there to measure. The instrument whose
whole job is to be trustworthy when nothing else is was the loudest liar in the log, and it could
not even name what it was accusing, because an enclosing span has no entry in `names`.

So `Slot::beat`, bumped on every enter and every exit. A stall is now a thread that has not entered
or exited anything for the threshold — which is what "wedged" means and what the module header
already claimed to be reporting. The end line also gets the real total rather than the duration at
first sighting, which is why both lines used to print the same number.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
2026-10-20 18:16:52 -06:00
gromandClaude Opus 5 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
2026-10-20 06:19:20 -06:00
gromandClaude Opus 5 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
2026-10-20 06:19:20 -06:00
gromandClaude Opus 5 a8b6fc8ad6 perf(layout): the resolve's seed pass walked the graph, not the change
Each solve chooses which nodes to solve and which clean rects to seed it
with. It did that by asking **every id in the graph** — 18 203 frames and
42 284 regions on the player's interface — "is one of your dependents
dirty?", for a dirty closure whose steady-state median is 2 frames and 4
regions.

And nothing was measuring it. `[layout-pre]`'s last lap ends at the change
gate; `[layout-prof]`'s `frame_us`/`region_us` start at the round loop. This
pass sits between them, so neither line charged it — which is how a
`frame:GetLeft` that measures 17.5–18.4 us a call in a live session (six a
frame, through stock `MouseIsOver`'s four edge reads) could force a resolve
the profiler called 3 us. A `seedsel_us` column now closes that gap, and it
stays: a phase between two timers is a phase nobody owns.

Measured on the player's own 80-addon interface, last-quarter solves:

              median   p75   p90   mean    max
    before       458    498   607    488    882
    after          0      3     9     38   3788

`solved` and `swept` come out identical to the digit (median 2/4, mean
156.3/408.3, max 18183/41415): the same work is selected. Before, 202 of 549
solves cost over 400 us and the dearest of them had dirty sets of 0–13 nodes,
which is the pathology itself; after, 29 of 549 exceed it and every one is
solving 14 000–16 600 frames at 0.48 us a node, against a `frame_us` of
10 170 us on the same pass. The cost moved from the size of the graph to the
size of the change, which was the point; the higher max is that, not a
regression.

**What it is not: an fps fix.** The bench's per-frame totals do not move —
1.294 -> 1.293 ms/frame grouped, `vm-tick` 0.667 -> 0.676 (noise, single
runs). The one column that shifts is `resolve`, 0.011 -> 0.006 ms/frame. The
458 us is per *solve*, and a steady frame does a fraction of one; the live
bound is the whole of `frame:GetLeft`, 105-110 us/f, against a 7.6-9.5 ms
frame. So this removes real waste and buys about 1%, which is worth having
and is not what grom asked for. Chasing it at all was a misread of mine:
`GetLeft` is 54% of the *binding* bill, and the binding bill is ~200 us of a
~9000 us frame.

The same set, by the same relation read the other way round: a clean node is
seeded iff some dirty node reads it, and a node's dependencies are exactly
the three `scope.edge` arguments in the roster walk — its anchors' targets,
plus a region's owner frame, whose scale it reads. `LayoutScope::dirty_ids`
carries the dirty set as a list, written wherever `dirty` turns true so the
two cannot disagree, and `seed_dep` enumerates the dependencies. A
`full_scope` pass skips the walk: everything is dirty, so there are no seeds
to find.

Order is not a property here, which is what makes the rewrite safe. Both
rosters are built from `HashMap` iteration (`frame_to_id`, `region_data`), so
no pass ever had a canonical order, and the region sweep is a fixpoint whose
round cap covers any chain — order moves `rounds`, not rects. Only the seed
lists are sorted, to drop duplicates a shared dependency would otherwise
re-`set_external` every round.

`verify_selection` is the falsifier: under `WOW_LAYOUT_VERIFY` it recomputes
all four lists the old way and requires the same sets. That is a tighter
question than `resolve_layout`'s rect comparison — it isolates this pass, so
a selection bug cannot hide behind an unrelated red, and an unrelated red
cannot be pinned on the selection. Verified non-vacuous by dropping the
region-owner dependency and watching it fail (`0 vs 1 entries`).

Note for whoever runs it: `WOW_LAYOUT_VERIFY=1` over `tick_bench` is **red at
HEAD**, independently of this change — an incremental pass ends with 48 fewer
region rects than a full derivation of the same frame (`regions 29597 ->
29645`), byte-identical with and without this commit. That is a missing
`touch_layout()` somewhere, not a selection bug, and it had never been run
before because `tick_bench` is `--ignored` and the flag is in no gate.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
2026-10-19 22:05:01 -06:00
gromandClaude Opus 5 7322a95a9d fix(ui): WOW_UI_VERBS must not wrap setfenv — it unloaded a third of the interface
A wrapper puts its own frame between the caller and the callee, which
silently changes the meaning of every stack-level-relative call.
`verb_prof::install` wraps every function in `_G`, and `_G` at that point
holds Lua's own stdlib as well as our bindings — so it wrapped `setfenv`.
pfUI's `api/api.lua:4` is `setfenv(1, pfUI:GetEnvironment())`, and level 1
from inside a wrapper is the wrapper, a C function, so Lua raises
``setfenv' cannot change environment of given object`.

One armed session logged **91** of those and lost pfUI, WhatsTraining_Turtle,
AI_VoiceOver, MasterTradeSkills, pfUI-addonskinner, pfUI-locplus and
PizzaSlices: an instrument that unloaded a third of the interface it was there
to measure, and whose numbers were therefore of a client without them.

`STACK_RELATIVE` names the four `wrap_table` must leave alone. `setfenv` and
`getfenv` take a level; `error(msg, level)` positions its message by one; and
`debugstack([start])` is the reference's traceback by level, which
`FuBarPlugin-2.0.lua:752` asks for as `debugstack(6, 1, 0)` and
`AceDB-2.0.lua:742` reads a specific line of. `pcall`/`xpcall` stay wrapped:
no level argument, and an armed session shows them working and worth seeing.

The test wraps `_G` and then runs pfUI's line, so it is the bug rather than a
restatement of the list; deleting the skip reproduces grom's exact error. The
module doc also claimed `_G` "holds our bindings and nothing else" at install
time, which is what made this easy to miss.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
2026-10-19 20:58:03 -06:00
gromandClaude Opus 5 5764837e35 perf(ui): give WOW_UI_VERBS a live window, so the instrument exists in a session
`verb_prof` wrapped every global and every widget method and charged each call
two `Instant::now`s — and the only caller of `log_report` was the
`addon_harness` **example**'s `--together` load. Armed in the client it paid
for every binding call and printed nothing at all. The one question it answers
— which binding a handler's self time went into — is exactly the open one:
`WOW_UI_HANDLERS` names `UIParent:OnUpdate` at 503 us/f in combat against 48
idle, and stops there.

So `WOW_UI_VERBS=<secs>` now prints a window from `report_window`, called at
the end of `UiScript::tick` beside the handler and raise reports, one level
down from them. The bare `=1` the harnesses already pass reads as one second,
which is why `period()` parses the same variable rather than taking a new one.

A window is a **delta** against a snapshot (`PREV`), not a `reset`: `ROWS` is
the one table and `log_report`'s callers read it whole at a moment of their
own choosing — `--together` ticks ten times before printing its load bill, so
a window that cleared the table would have handed it whatever happened after
the last window instead of the load. The arithmetic is `diff`, split out and
tested on its own because `window_delta` reads two process-wide statics and
`diff` is the whole of the logic.

It earns its keep immediately. `tick_bench` over the player's real 94-addon
folder, 121-frame windows: `frame:GetLeft` costs **0.42 us/call** on a settled
layout and **29.95 us/call** the moment anything moved — the same two calls a
frame, 55% of the frame's whole verb bill — because the four edge getters call
`settle()` and a resolve past tier 1 walks every live frame. During the load
burst the same call is **370 us**. `GetWidth`/`GetHeight` already carry an
authored-span shortcut for precisely this; the edge getters never got one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
2026-10-19 20:35:07 -06:00
gromandClaude Opus 5 df72d823cd perf(static-gx): budget the bake, 43 ms off a world-load frame
`flush_static_gx` bakes a cell once it has been dirty and *quiet* for its
window, which staggers cells whose changes are staggered — and a world load
dirties hundreds within a few frames of each other, so their windows expire
together and the whole burst baked in one flush. A cold Elwynn vista measured
one run of 43.2 ms against 2.6 ms for the other nineteen in that second
(`WOW_SYSTEM_TIMING=1`): a visible stall, and a scheduling artefact rather than
work, since the same bakes cost the same total spread over later frames.

`BAKE_BUDGET` spends 4 ms of wall clock per flush — the figure
`terrain_stream::SPAWN_BUDGET` already paces placement spawns by, and the two
passes share a frame — and hands the rest to the next flush. A region that
yields stays `dirty` with its `last_change` untouched, so `bake_due` says yes
again next flush: yielding is the absence of a decision rather than one, which
is what keeps the budget to *when* a region bakes and never *whether*. The three
exemptions say the same thing. Nothing yields until one region has baked, so a
region larger than the whole budget cannot stall behind itself. `flush_now` gets
the full flush it asked for. The `MAX_DIRTY_FRAMES` age cap outranks the budget,
so the backstop that already bounded a region's wait stays absolute instead of
becoming advisory. And an empty region is an unload — free to publish, and
deferring one would leave dead geometry on screen — so removals are never
budgeted at all.

Measured after, same vista: every budgeted flush landed 4.0–4.5 ms, draining a
backlog that peaked at 398 held regions at ~30 a frame over 16 consecutive
flushes. Worst visible flush 43.2 → 4.5 ms.

One flush still runs 58.4 ms and should. `terrain_stream.rs:908` asks for a
`flush_now` while `!focus.paced`, because a region that cannot draw yet is "a
hole in the reveal", and `ViewFocus::paced` is false only "through entry, a
teleport and a world swap, while the loading cover absorbs the burst". So the
budget moves that stall out of play time and behind the cover, which is the
point; it is not a leftover to fix.

`WOW_GX_BAKE_MS=<ms>` overrides the budget and an explicit `0` retires it, so
the A/B runs in both directions.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
2026-10-19 20:33:00 -06:00
gromandClaude Opus 5 d8d8f4fc59 physics: retire avian's collider mass-property pass, 181 ms off a world load
`WorldPlugins` has disabled `MassPropertyPlugin` for as long as the world has
had colliders, with the reason written beside it: there are no dynamic bodies,
so no mass is ever read. The pass kept running anyway. `ColliderBackendPlugin`,
which we keep for `update_collider_scale`, registers
`update_collider_mass_properties` itself (`collision/collider/backend.rs:232`),
and that system's query filters `Without<Sensor>` and nothing about the body
type — so every collider that *changed* paid `Collider::mass_properties`, a
full parry volume integral over the trimesh, for a value with no reader.

Terrain streams its colliders as trimeshes by the tile, so the cost lands in a
world load. Measured on a cold Elwynn vista with `WOW_SYSTEM_TIMING=1`, before
and after, same viewpoint:

  before  run_fixed_main_schedule  worst 183.0 ms   200.9 ms/s
  after   run_fixed_main_schedule  worst   1.3 ms    25.9 ms/s

`update_collider_mass_properties` no longer appears in the report at all, and
its 181.3 ms worst run was the whole of that schedule's spike — the nesting is
what identified it.

A run condition rather than another `.disable()`, because the system cannot be
removed individually and shares a `.chain()` with `update_collider_scale`.
`MassPropertySystems` is `pub` but not in avian's prelude, hence the full path.

The invariant the whole physics trim rests on now has a wall of its own:
`tests/no_dynamic_bodies_wall.rs` fails if `RigidBody::Dynamic` appears in the
workspace. This bug was exactly an invariant that lived only in prose — the
`.disable()` comment stated it correctly while a system slipped through the gap
between two plugins — so the assert is the point. Verified by planting a
dynamic body and watching it fail, then reverting.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
2026-10-19 17:22:59 -06:00
gromandClaude Opus 5 d06bb9fae9 options: Interior Darkness says what it does not scale
grom turned this dial down, hit snow in Dun Morogh, and got rooms that read
"really grey and flat" — then spent his own time hunting it before settling on
19% by trial. The dial was not lying, it was just silent about the half of the
picture it does not own.

A storm's fog reaches indoors at full strength, which is faithful: the MFOG
selector bails on a one-record root (`0x69de4b`) so the room keeps the SCENE
fog, and the crossfade staged at `0x6cef43` carries it in "storm veil included".
Measured at his own spot, snow against clear: the fog start goes 0 -> -105 over a
350 end, which is 23.1% fog at ZERO distance — the wall in front of you. That
predicts [36.0, 50.2, 57.8] against a measured [36.9, 50.3, 58.0], so it is the
whole effect. Turn the room light down under that and the veil is most of what
is left.

So the tooltip now says it, in both locales, and points at the dial as the answer
instead of leaving the next player to re-derive it. No behaviour changes — the
fog is the reference's and stays.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
2026-10-19 13:40:26 -06:00
gromandClaude Opus 5 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
2026-10-19 13:16:02 -06:00
gromandClaude Opus 5 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
2026-10-19 12:39:10 -06:00
gromandClaude Opus 5 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
2026-10-19 10:54:56 -06:00
gromandClaude Opus 5 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
2026-10-19 08:53:12 -06:00
gromandClaude Opus 5 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
2026-10-19 08:53:12 -06:00
gromandClaude Opus 5 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
2026-10-19 07:59:45 -06:00
gromandClaude Opus 5 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
2026-10-19 07:59:45 -06:00
gromandClaude Opus 5 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
2026-10-19 07:59:28 -06:00
gromandClaude Opus 5 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
2026-10-18 19:42:39 -06:00
gromandClaude Opus 5 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
2026-10-18 19:33:11 -06:00
gromandClaude Opus 5 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
2026-10-18 18:51:45 -06:00
gromandClaude Opus 5 c5911ccd6f verbs: an instrument that charges a load's time to the binding it went into
`WOW_UI_HANDLERS` names the handler and `WOW_UI_TICK` names the tick phase, and
between them they left the question this answers. A reload of the player's
interface spends **4.47 s inside handler bodies**, and single calls in there cost
hundreds of milliseconds — `pfBag:OnHide` 394 ms in ONE call — which is not Lua
arithmetic. A handler's self time includes the engine work it provokes, so that
time is ours: it is inside the verbs the handler called, and nothing charged it to
them.

`WOW_UI_VERBS=1` wraps every global and every widget method at the end of
`UiScript::new` — before FrameXML or any addon runs, so everything it finds is
ours — and times each call. Off, nothing is wrapped and nothing is paid.

It works offline, which matters more than the live case: the addon harness's
`--together` mode loads the player's whole folder into one VM in load order, which
is the closest thing to a login burst that needs no server. The first run over his
91 addons, three passes:

```
[ui-verbs] 519 verbs, 17,966,414 calls, 9,148 ms in all
    4384     6393    685.8 us  pcall            (addon code, nested)
    1498 16071556      0.1 us  type             (5.4M calls a load, Ace's)
    1241     1950    636.2 us  frame:GetWidth   ← 650 calls a load at 0.6 ms each
     548    33093     16.6 us  CreateFrame
     219    37524      5.8 us  fontstring:SetPoint
      95       99    960.3 us  frame:GetHeight
      67      123    545.9 us  frame:GetLeft
      17        3   5628.6 us  frame:GetCenter
```

A geometry read costing 636 µs is the whole finding, and the fix is the next
commit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
2026-10-18 18:51:45 -06:00
gromandClaude Opus 5 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
2026-10-18 17:34:17 -06:00
gromandClaude Opus 5 6affd61c35 doc: the model pane's skins link points at the struct that has them
`doc-links` was the last red gate, on one link dating from the fork's import
(`81d96af3`): `script::types.rs`'s `QuadContent::ModelPane::skins` cited
`widget::kinds::ModelState::skins`, a field that never existed on that struct. The
three creature skin variations live on `script::CreatureModelView`, which is what
a display id resolves to for a pane, so the link now goes there — by the public
re-export rather than through the private `model` module.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
2026-10-18 16:14:22 -06:00
gromandClaude Opus 5 eb891f4862 wall: the world API ratchet counts from where it actually is
`world_api_wall` has been failing with 174 crossings against a ceiling of 107, and
a ratchet 67 below the count is not a ratchet: it cannot tell a fresh leak from the
standing debt, which is the only thing it is for. The ceiling is re-baselined to
the 174 that are really there, dated and labelled a recorded debt rather than an
achievement — `LEAK_SLACK` still drags it down as closes land, and the next
crossing fails again.

Re-baselining is not the remedy the test offers, so the note says why it was
taken: of the two it does offer, closing 67 crossings is weeks of architecture and
publishing them as engine API would make the test lie about what the engine's
surface is.

What the number is actually telling us is in the dump now. `WOW_API_DUMP=1` prints
the per-file inversion beside the per-item list — how many engine internals each
game file names, and how many of those it is the only namer of — because that is
the view a close is planned from, and it says the debt is not spread over the game:
`entities/wmo_props.rs` 27 (16 named nowhere else), `torch_shadow.rs` 23 (8),
`entities/carried_light.rs` 20 (6), `entities/equipment/spawn.rs` 16,
`entities/attach/mod.rs` 14, `player/camera.rs` 13, `aura_visual.rs` 12,
`entities.rs` 12. Renderer and model-spawn files on the game side of the line,
which is why they name so much of the engine. So the disposition for most of this
is `move-engine`, one cluster at a time, in the round that is already in that file
— and each is real work rather than a file move, because `carried_light.rs` also
reaches into `entities::spell_fx` and `torch_shadow.rs` into `video` and
`char_select`, so a cluster closes only once its gameplay edge is cut.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
2026-10-18 15:59:41 -06:00
gromandClaude Opus 5 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
2026-10-18 15:55:24 -06:00
gromandClaude Opus 5 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
2026-10-18 15:18:10 -06:00
gromandClaude Opus 5 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
2026-10-18 14:59:22 -06:00
gromandClaude Opus 5 089990a929 chain: the icon catalog lists its own directory, not all 102,144 names
Profiled off the player's own 2026-10-18 session log, where one blocked frame
ran 7,725 ms: the file loading is 4,505 ms of it (pfQuest's `init\data.xml`
alone 695 ms, pfQuest-octo's 311 ms, all of FrameXML 693 ms), then
`load_icon_catalog` 396 ms, the random-property table 259 ms, and the first
VM tick the remaining ~2.3 s.

This takes the icon catalog. It walked `Chain::list()`, and the walk is what
the catalog costs — the module says so itself: classifying the three buckets
out of the names is 1.4 ms. Per name a full list allocates a normalized dedup
key, hashes it into a 102k-entry set, resolves the winning archive and asks it
for a size; the prefix this caller wants was tested *after* all of that, on
every name in the chain.

So `Chain::list_under(prefix)` tests it first, with the same case-insensitive,
separator-blind compare the caller was doing for itself. Measured on this
install, and the test prints the pair it quotes: 102,144 entries in 471 ms
listed whole against 51 ms for the 3,205 under `Interface\Icons\`, the same
entries and the same sizes either way. 420 ms off every login, and off every
`/reload`, which rebuilds the catalog for the new VM.

Nothing else in the client lists the chain; `benilla-extract` does and wants
the lot, so `list()` keeps its name and its body, now shared.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
2026-10-18 12:35:33 -06:00
gromandClaude Opus 5 101ddc3d97 tests: the UiScript wall's ceiling follows the four files that argued past it
`ui_script_wall.rs` has been failing since `2af91704`, 888 commits ago: 43
gameplay files name `UiScript` against a ceiling of 40. A ratchet that cannot
pass notices nothing, which is the one thing it is for — so the ceiling
follows the count, with each newcomer argued in place, as the test's own
message asks.

Three of the four do not cross the line the wall draws:

- `spell/net.rs` names the identifier only to say it holds none — three doc
  comments explaining why its work is queued instead. The count reads doc
  comments by design, so documenting the line reads as crossing it.
- `target/by_name.rs` and `target/scan.rs` hold the VM because `TargetByName`
  and the scan must select inside the Lua call as `0x489d60` does; that is
  `bbe2e3aa`, without which an addon's own target restore cannot see what it
  selected.
- `debug_panel/{mod,lighting_controls}.rs` write CVars through
  `UiScript::set_cvar_engine`, the queue Lua's `SetCVar` feeds, so the dev
  panel cannot hold a different value than the interface.

Nothing moves and nothing is excused wholesale: the next file to learn that
the VM exists still fails this test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
2026-10-18 12:26:23 -06:00
gromandClaude Opus 5 2a51c9817a frames: EnumerateFrames, the walk over every frame the client has
A real 1.12 global we did not have (`reference/1.12-globals.tsv`), and the
natural way for an addon to ask the live UI which frames carry some script
or property — the player's backlog item, raised because nothing answered.

Read off `0x705f60`. The frame manager owns a Storm list (`[0xcf0bd8]+0xcc0`,
link offset `0x304` written by its ctor at `0x7641b8`) and the `CSimpleFrame`
ctor links every new frame through `0x7646a0`, which puts the newcomer in the
sentinel's own slot `[0xcc4]` and the frame it displaced in the newcomer's
`+0x308`. So the list is walked **newest to oldest**: with no frame the
binding answers `[0xcc4]`, with one `[frame+0x308]`, and the first frame ever
created holds the tagged sentinel `&head|1` there, which is the `test $0x1,%al`
that ends the walk with nil.

Three details that are not guesses:

- The only type test is `lua_type(L, 1) == LUA_TTABLE` (`cmp $0x5`), so a
  number, a string or a boolean is not an error — it falls through to the head
  and starts the walk over.
- A table is resolved through `T[0]` exactly as our `decode_id` does, and both
  rejections raise through `luaL_error` (`0x6f4940`, which never returns) with
  the client's own wording: `0x872f08` for a table with no object behind it,
  `0x872ecc` for one that is some other kind of object — a region, which is
  not in this list at all.
- Regions and the walk's order aside, hidden and anonymous frames are in it:
  the link is the ctor's, not the draw list's.

The arena grows the list it already implied. `Frame::insertion_seq` could not
serve: that is the draw-order stamp, and a `Show` or a strata change restamps
it (`resequence_to_tail`), where creation order never moves. So `created` is
the handle list in creation order with `created_seq` the frame's index into
it, and a step is one lookup. A destroyed frame's slot is kept, not
compacted — its handle stops resolving and the walk skips it — because an
index shift would break every other frame's `created_seq`, and nothing in
play destroys a frame anyway, as nothing in 1.12 can.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
2026-10-18 12:25:57 -06:00
gromandClaude Opus 5 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
2026-10-18 12:05:47 -06:00
gromandClaude Opus 5 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
2026-10-18 11:46:02 -06:00
gromandClaude Opus 5 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
2026-10-18 09:02:19 -06:00
gromandClaude Opus 5 e527931885 worldmap: WORLD_MAP_UPDATE is signalled inline, so the map opens on the player's zone
The world-map flash, and it is one frame of the wrong paint rather than a wrong selection.

`WorldMapFrame.xml`'s OnShow runs `SetMapToCurrentZone()`, and `WorldMapFrame_OnEvent` answers
`WORLD_MAP_UPDATE` with `WorldMapFrame_Update()` **only** `if ( this:IsVisible() )`. Our
`store_selection` pushed that event on the deferred lane, so it was heard a tick later — by which
time the panel was already on screen, still carrying whatever tiles the last `WorldMapFrame_Update`
left. Those are the World sheet's: `WorldMapFrame_OnLoad` runs its own `WorldMapFrame_Update`
before the player's zone is known, and a nil `GetMapInfo()` takes FrameXML's own
`-- Temporary Hack` branch, `mapFileName = "World"` (`WorldMapFrame.lua:47-55`), painting
`Interface\WorldMap\World\World1..12` into the twelve detail tiles.

Signalled inline instead, as the setter `0x4a67a0`'s own `SignalEvent` is, so the repaint happens
inside the OnShow and the first frame drawn is the player's zone. Signalling moves out of
`store_selection` — the writer cannot do it, only a caller holding the Lua state can — and each of
the four call sites does it: `SetMapZoom`, `SetMapToCurrentZone`, `ProcessMapClick` (only when the
click actually moved the selection, since the setter is the only signal site) and the engine-side
world-enter sync.

`worldmap_update_event_fires_on_next_tick` asserted the deferred behaviour deliberately, on the
reason "a binding cannot re-enter dispatch". That was not so: `UpdateSpells` had already been doing
exactly this with the same `fire_event_into`, with its own test pinning the synchronous dispatch.
Replaced by one that asserts the inline fire and one that covers the two other legs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
2026-10-18 09:02:03 -06:00
gromandClaude Opus 5 8e0d1bae8d lua: a numeral ends where its digits do, so 1then is two tokens
`AdvancedTradeSkillWindow2.lua:3878` is `if Item.ReplaceWords == 1then`, with no space, and it
compiles on the reference. Its `read_numeral` (`0x6ffee0`) is 5.0's: digits, at most one `.` and its
digits, then — given an `e`/`E` — the marker, an optional sign (the `0x2b`/`0x2d` test at `0x6fff04`,
reached with the marker already saved) and its own digit loop. The only character-class test in the
whole routine is the ctype digit mask at `0x6fff47`; past it the buffer is terminated and handed to
`luaO_str2d` (`0x6f5900`). 5.1 instead ends with
`while (isalnum(ls->current) || ls->current == '_') save_and_next(ls);`, which swallowed `1then`
into the numeral and failed it — so we raised `malformed number` and lost a 4000-line file and every
global it defines, and its own XML then raised four more times calling them. The addon was dead.

The ninth hunk of the `lua-src` fork, which is where a grammar difference belongs: `BENILLA.md` is
explicit that rewriting the chunk source means parsing Lua with a regex. A replacement, not a
deletion — 5.1 had no exponent-digit loop of its own, so deleting the slurp alone would have cost
`1e2`, which is what the new test caught.

The same boundary is the deliberate half: 1.12 has **no hex literals**. The digit loop stops at the
`x`, so the reference reads `0xFF` as `0` and the name `xFF` and cannot parse the statement around
it. A comment- and string-stripping scan of the installed corpus finds hex in code in exactly two
files, `pfQuest/toolbox/pngLua/deflate.lua` and `numberlua.lua`, which no `.toc` row reaches — zero
loaded sites, the same standard the fork's three other deletions were held to. Our own shipped Lua
and his saved variables have none either. Two of our own test chunks did, and are now decimal:
no chunk of benilla's Lua may use a construct the client's parser rejects.

Verified: the whole chain still loads with no failure, the `WOW_DATA` failure set is unchanged, and
`AdvancedTradeSkillWindow2` now publishes `ATSW_OnLoad` and `ATSWTypeColor`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
2026-10-18 08:20:02 -06:00
gromandClaude Opus 5 4764e4fb2d framexml: the twenty-one rows of the chain's own toc the manifest still lacked
benilla loads `assets/ui/benilla.toc`, not the player's `Interface\FrameXML\FrameXML.toc`, so a row
the chain carries and the manifest omits is silently absent — no read attempt, no warning. Six went
in with the arena templates; this is the rest of the diff but one, each with a comment on what it
is for and in the chain's own order.

The six baked Octo extensions are the player-visible half. `/pvp` now asks before a War Mode
character spends its permanent, persisted opt-out. The exhaustion tooltip reads this realm's 175%
instead of 1.12's baked 150%, and `/rested` answers. A player's hover shows the realm's own two
challenges, Samurai and Together Forever: the server was already setting bits 10 and 11, and
`TargetFrame_UpdateChallenges` walks a list that stopped at 10. The one-shot War Mode offer on
arrival from the hardcore realm renders instead of being dropped. `/bug` and `/suggest` exist again
— Turtle's FrameXML dropped the BugReport frame, and this rebuilds the dialog over chunked `.bug`
SAY commands. And on C'Thun the Ghost debuff's tooltip carries the hardcore afterlife line, which
is why the realm reverted that string in Spell.dbc.

With them: `OctoToolReqTip`, the realm's own 40 KB recipe-to-tool table wrapping
`GetTradeSkillTools`, so a recipe whose tool OctoWoW changed names its real one; six `OctoLocale`
rows, each gated on `GetLocale()` and inert here; and the three Turtle windows the manifest said
needed verbs built first — arena (4 rows), barbershop, guild bank (2). They need exactly one,
`CameraZoomOut`, now built. All three publish their frames and open only on an exact gossip trigger
string, so answering nil for `GetGossipText` cannot hide ordinary gossip.

`StatsFrame.xml` is the row left out, and the manifest says so where it would sit: the debug-build
overlay whose OnUpdate is the only reader of `GetDebugStats`, a binding we do not have, so the row
would publish a `ToggleStats` that raises twice a second once pressed.

`UNLEARNED_SPELL` joins `UNPRODUCED` rather than getting a producer. It is the only entry there that
is not a 1.12 event at all: 5875's event-name pool carries `LEARNED_SPELL_IN_TAB` and
`SPELLS_CHANGED` and no unlearn sibling, so the reference ignores that registration exactly as we
do, and the War Mode re-scan rides PLAYER_LOGIN and LEARNED_SPELL_IN_TAB, which both fire.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
2026-10-18 07:52:12 -06:00
gromandClaude Opus 5 f185f3b549 camera: CameraZoomIn and CameraZoomOut, and a bare one is a yard
The first two rows of the reference's camera table `0x84f7a0` (`0x50b400`, `0x50b450`), and the one
verb the chain's own barbershop, arena and guild-bank windows call that we did not publish —
`Turtle_Barbershop.lua:211` zooms the world camera out two yards as its preview opens.

Read off the binary rather than guessed, because the argument handling is not the one every sibling
in this file uses: both thunks test is-number (`0x6f34d0`), take `tonumber` (`0x6f3620`) when it
holds, and otherwise store `1.0f` outright (`0x50b42d`, `0x50b47d`). So `CameraZoomOut()` steps a
yard out, where `SetView()` and `FlipCameraYaw()` return silently. A test pins that, since it is
exactly what reading the callers would have got wrong.

Deviation, named in the module: the reference turns the yards into a timed glide — divide by the
rate at `[0xbe0fbc]+0x24`, scale, truncate (`0x40a2b0`) into a step count — and a call during a
running glide adds into its pending-zoom field (`0x50fca9`). Our rig holds one target distance and
glides to it every frame, so a second call re-aims the same target instead of queueing behind the
first. The clamp is the rig's own, the one the wheel and the zoom keys already share.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
2026-10-18 07:51:53 -06:00
gromandClaude Opus 5 cbcaef264e target: a new target is answered for before anything reads it
Two orderings let the inspect window re-show itself on a creature and then raise inside
`InspectPaperDollFrame_SetLevel`, where TurtleWoW's three-spec `PLAYER_LEVEL` meets a creature's
nil `UnitRace`.

`feed_unit_reach` now runs ahead of `feed_units`, not behind it. `fire_event` dispatches into Lua
synchronously, so a handler of `PLAYER_TARGET_CHANGED` that asks `CanInspect("target")` was being
answered off the previous frame's reach map — the old player target, in range and inspectable — and
`InspectFrame_Show` showed the window for the creature that had just replaced it.

And `PLAYER_TARGET_CHANGED` now leads the token `UNIT_*` fan-out. The reference has no token diff at
all: re-pointing `"target"` writes no descriptor field, so its watches stay silent and this is the
only event a new target raises. Behind the fan-out, `InspectPaperDollFrame_OnEvent`'s `UNIT_LEVEL`
arm ran against an `InspectFrame.unit` the next event was about to clear. Every snapshot is pushed
before either fires, so a handler reads the new target either way.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
2026-10-17 22:13:14 -06:00
gromandClaude Opus 5 981e2ee498 framexml: six rows of the chain's own toc the manifest never had
benilla loads `assets/ui/benilla.toc`, not the player's `Interface\FrameXML\FrameXML.toc`, so a row
the chain carries and the manifest omits is silently absent — no read, no warning, just a missing
global or template surfacing as somebody else's error. Diffing the two found 29 such rows.

Six of them land here. `Turtle_ArenaUI\Turtle_ArenaUITemplates.xml` is the one that was costing
four errors per inspect: `Blizzard_InspectUI\InspectArenaFrame.xml` inherits `ArenaTeamTemplate`
from it and then reads `$parentText`, `$parentDetails` and nine `$parentDetails*` children by name,
so all three team-button OnLoads and `InspectArenaFrame_OnShow` raised on nil. `Globals.lua`
publishes `print`, `TURTLE_WOW_VERSION`, `TW_CLASS_TOKEN`, `Turtle_AvailableChallenges`,
`GAME_YELLOW` and the realm's three `ADDON_MSG_*_DELIMITER`s. `Overrides.lua` is Turtle's
two-continent map. `Hooks.lua` is the realm's tooltip layer: the guild line, the shift-click hint
and `FixSetInspect`. `ChronoboonTimers.lua` and `SpellChatLinks.lua` are self-contained.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
2026-10-17 22:13:14 -06:00
gromandClaude Opus 5 a1359d8df1 gameobject: a nameless or modelless object says so, for the hardcore graves
"note that they are not corpses but actual grave stones from hardcore deaths,
custom twow stuff" — so the corpse plate was never the path, and the three
corpse legs added last commit cannot see this at all. A hardcore gravestone is
one of the server's own GameObjects, which reaches a plate through
`go_inputs.templates` and a mesh pick, not through `CORPSE_FIELD_OWNER`.

Two lines, one per failure shape, each said once:

  - a display id that names no model (`entities::display`, built once per id and
    cached). This is the quiet one: a GameObject with no model draws nothing
    *and* cannot be hovered, because the pick set is built from its mesh parts —
    so a custom display whose `GameObjectDisplayInfo` row this chain's DBC does
    not carry has no tooltip for exactly the reason it has no art.
  - a hovered object with no template yet (`ui_tooltip`), the plate's only
    silent exit. A line with no sequel means the
    `SMSG_GAMEOBJECT_QUERY_RESPONSE` never came, and a server's own entry is
    where that is most likely.

Neither line on the next grave he walks past would mean the object is picked and
named, and the fault is in the plate itself rather than upstream of it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
2026-10-17 19:00:59 -06:00
gromandClaude Opus 5 02265cc224 compat: nampower's equipment and item-cache getters, for the character panel
"then we gotta fix bettercharacterpanel, that one is driving me nuts."

BetterCharacterPanel is built on five nampower getters and had none of them.
`GetEquippedItem` was 8 raises in one session of his log; the other four failed
inside the addon's own `pcall`s, so its item-level column, quality colours,
missing-enchant warning and two-hand test were all dead at once. DoiteAuras
reads the same getter in six places.

The spec is the installed `nampower.dll` itself rather than inference, which is
what unblocked this — the earlier note here said to find the upstream spec
first, and the DLL *is* the spec:

  - its usage strings give the signatures,
  - "Slot must be a number between 1 and 19" gives the band, so the slots are
    1.12's own `GetInventoryItemLink` numbering and `NampowerAPI.lua`'s
    `slot - 1` in `IsItemInSlot` is that shim's bug, not the contract,
  - its string pool gives the key names in order, and
  - "Item not found in DBCache" is a raise, which is exactly why the addon
    `pcall`s these three and calls `GetEquippedItem` bare.

`itemLevel` is the one field 1.12 cannot reach at all: `GetItemInfo`'s fourth
value is the *required* level, and later clients are the ones that insert
`itemLevel` there — so the panel's item-level column had no possible source
before this. The cache row now carries it, with `buyPrice` and `displayInfoID`
beside it, and an equipped item carries the raw descriptor numbers the tooltip's
resolved enchant lines could not answer (an id whose `SpellItemEnchantment` row
hides its name is still an id).

Fields left absent rather than guessed are listed in code and in
`docs/ADDON_COMPAT.md`; nothing installed reads any of them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
2026-10-17 18:57:12 -06:00
gromandClaude Opus 5 994da2ba85 mesh_tag: an interior prop with no probe slot is still indoors
The SH-probe table is 13 tag bits wide and nothing can widen it — the probe
field is the last of the u32 — so a dense interior can ask for more slots than
exist. His session hit it (`live 8192, peak 8192`), and every prop that missed
out was then tagged as plain exterior: furniture lit by the sky, which in a Dun
Morogh snowstorm reads as the weather coming indoors.

A prop that misses a slot is still *indoors*, and saying so costs no bits.
`PropSeat` names the three states a spawner can be in — `Exterior`, `Probe(slot)`
and `Matte` — and `Matte` is the exterior payload with `MATTE_INDOOR_BIT` and a
saturated lane: the room's day/night law, which is exactly what
`interior::AppliedLaw::Matte` already gives an entity whose classification finds
the table full. A dim room instead of a sunlit one.

The seat also replaces the bare `Option<u16>` the assembler took, so a caller
now says which of the three it means rather than leaving `None` to stand for
both "sky-lit" and "wanted a probe and lost".

Still true: the table can fill, and the props past it lose their room's *baked*
light. Lowering that demand (sharing by a quantized fold, or scoping the
allocation) is the next lever and wants a measurement first.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
2026-10-17 18:35:43 -06:00
gromandClaude Opus 5 4e92fcf1a1 corpse: each way a grave can draw no plate now says which one it was
He hovered a grave for the instrumentation and the log has **no** corpse line at
all — which, as the earlier note said, means the question is now a different
one: either no corpse object arrived, or the pick never landed on it, or the
plate refused it silently.

Two of those were still silent. The bone-pile refusal now logs too, though it is
the reference's own answer (`0x5d76d0`: bones with `CORPSE_FIELD_DYNAMIC_FLAGS`
bit 0 clear shows no name) — from the player's chair it looks identical to a
miss, and it is the likeliest shape for a grave. A picked corpse with no fields
logs as well.

`log_new_corpses` is the third leg: one line per corpse object the server sends,
with its flags, owner and chosen model, on `Added<ObjectStore>` so it touches
only the frame's new arrivals. It answers whether the grave he hovered was a
body or a bone pile without asking him to hover anything, and separates "nothing
arrived" from the two plate-side misses.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
2026-10-17 18:14:17 -06:00