Commit Graph
729 Commits
Author SHA1 Message Date
gromandClaude Opus 5 ff2cbdde67 ui: the HIGHLIGHT draw layer is the highlight state, and starts off
A texture an addon made with `CreateTexture(nil, "HIGHLIGHT")` drew all the
time. Only the region `SetHighlightTexture` installs was hover-gated, through
`ButtonState::region_visible`; anything else an addon left in layer 4 was an
ordinary region that nothing ever hid.

In the reference that layer is the highlight *state*, not a place to keep
highlight art, and four things move it:

  OnEnter  0x76b6a0  test [frame+0xf8]; push 4; call 0x76a710  EnableDrawLayer
  OnLeave  0x76b6f0  test [frame+0xf8]; push 4; call 0x76a730  DisableDrawLayer
  Lock     0x76c2e0  writes [frame+0xf8], then enables; on an unlock disables
                     only when this frame is not the mouse focus ([root+0x7c])
  Disable  0x7791bb  disables it outright

`0x77983c` settles what the layer means: a Button picks its label's font
instance by reading that layer's own enabled flag at [frame+0x1a8], which is
[frame+0x198] + 4*4, and takes the highlight font object at +0x3b8 when it is
set. So the layer starts off, and every path that can highlight a frame moves
it -- taking whatever the addon put there along.

What this was costing: hand-built column headers and list rows, which are
everywhere. The <Below Average> guild market's Browse page puts
UI-Character-Tab-Highlight in that layer on each of its nine sort headers, so
every header wore a permanent blue glow bar, and its Statistics page showed all
six filter buttons lit at once instead of the two that were selected.

The nameplate glow lives in layer 4 too, and its hover feed drove only the
region's shown flag. Its own OnEnter override chains back into the base notify
(0x7cb8a5 -> 0x76b6a0), so it now moves the layer with it; addons still read
mouseover from glow:IsShown().

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
2026-11-07 13:29:12 -07:00
gromandClaude Opus 5 50a229c202 The script log called 34 addon packaging bugs "problems" with this client
Every one of the 34 rows a play session reported was read back out of a
capture, and not one was ours: 16 are BetterCharacterPanel anchoring to
Blizzard_InspectUI frames that LoadOnDemand has not built yet, 7 are .toc
and <Script file> lines naming files their own package does not ship, 10
are TitanPanel/InfoBar/Outfitter integrations in addons whose optional
dependencies this player does not have, and 4 are CVars 1.12 never had,
which `cvars`'s own test says to leave unregistered.

The reference shows the player none of that. A missing parent, a missing
relativeTo and an unknown inherits are severity-2 lines in FrameXML.log,
and SetCVar on an unregistered name raises into the pcall every addon
that writes one already wraps it in. Our window was a deliberate superset,
which is right for a compatibility report and wrong for a line reading
"34 problems": a TitanPanel plugin with no TitanPanel is not a fault here.

So `error` (code raised) and `load` (an addon is not running) stay
problems and stay listed, and `warn` -- a call accepted that did not do
what it said -- becomes a note, counted beside them and listed behind a
new Show notes checkbox. Nothing is dropped; the log still carries every
one and the window is one click from all of them.

Also closes a hunt: the 283 `SetOwner: unknown anchor 'BOTTOMRIGHT'`
warnings that three static sweeps of every installed addon could not
place are the realm's own FrameXML, Shop_ProcessEntries forcing items
into the cache through a hidden scan tooltip.

The seven load failures and the two layout defects from the same session
are fixed in the player's own addon copies, mirrored to Addon_Ports; both
are written up in docs/ADDON_COMPAT.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
2026-11-06 15:30:55 -07:00
gromandClaude Opus 5 4974326381 Bring the nampower, UnitXP_SP3 and ClassicAPI ports up to the installed builds
The three DLLs beside the client were updated; re-measured each against the
thing with evidence behind it rather than against a wiki.

nampower's own string pool is exact — `^NP_[A-Za-z0-9]+$` is the CVar list and
`Usage:` the arity of every function — and both came back identical to this
tree: 49/49 CVars, 63/63 functions. The gap was events. Four built:

  BUFF_ADDED_OTHER / BUFF_REMOVED_OTHER   ui_aura.rs, AuraEdge::OtherBuff
  KEY_DOWN / KEY_UP                       bindings.rs, key_event_code

The buff pair was argued against here before, on the cost of diffing the
helpful half of every streamed unit for events nothing registered. Answered
rather than overruled: both are gated on `has_listener`, which is the test
upstream now recommends over its own deprecated NP_Enable*Events CVars. The key
pair uses the client's own code table, which `chord::key_code` already held with
a comment saying it was the fact these events were blocked on.

UnitXP_SP3 is packed, so its contract comes from the wiki plus the call sites in
the installed addons — and those call sites exposed a live bug. Every
`UnitXP("target", …)` queued a SelectionRequest, but the mod selects INSIDE the
call and SuperCleveRoidMacros steps the cycle and reads `"target"` back on the
next line, fifty times in one Lua frame. Queued, every pass read the same unit
and the scan reported one enemy however many were there — truthy return, no
error. The pick now resolves in-call from a pool the app publishes
(`feed_unitxp_scan`), exactly the bargain TargetByName already strikes. All nine
target modes are built where four were; `behindThreshold` is real (the reach
feed publishes the angle now, not a verdict); `timer` rides the one timer queue;
`notify` reaches winit's user-attention request; `version`,
`performanceProfile` and a non-raising `debug` answer.

ClassicAPI carries its whole Lua addon gzipped in `.rsrc` — 173 files, a length
header then the bodies, keyed by the paths in its string pool — so the spec for
the installed build is on disk. `scripts/dll-surface.py --unpack` writes it out.
Read against a LIVE VM (not `UiScript::new()`, which is engine-only), the gap is
898 globals, and almost all of it is either a locale string or a subsystem this
client models differently. What is between those is now in
`compat/classic_api/shared_xml.rs`: the pure Blizzard_SharedXML helpers, in
1.12's grammar, from the shipped addon's own source. `tostringall` stays absent
on `tests/stdlib.rs`'s own rule.

The native half is mostly widget methods; intersected with what the corpus
actually calls it is four, of which three are built (`SetResizeBounds` over
1.12's own resize pair, `GetNormalFontObject` — which needed 1.12's missing
`GetTextFontObject` implemented first — and a bare `IsEquippedItem`) and
`SetMotionScriptsWhileDisabled` deliberately is not, because both its call sites
guard on it and the behaviour behind it does not exist here.

Also measured and recorded: every addon-load warning in grom's own logs is a
third-party optional dependency he has not installed or an addon's own packaging
bug, not a defect on this side. docs/ADDON_COMPAT.md has the table.

Ambiguity ceiling 4,956 -> 4,966, measured: nine for `feed_unitxp_scan` against
transform writers on non-networked lanes, one for the attention drain against a
system that also reads WinitWindows and neither writes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
2026-11-06 09:28:56 -07:00
gromandClaude Opus 5 b39651d9b4 A <BarTexture name=…> never reached _G, which broke crafting in ATSW2
`apply_statusbar` read `file=` and `<Color>` off `<BarTexture>` and passed them to
`SetStatusBarTexture`, which builds the fill region itself. Every other attribute of
the element went unread, `name=` among them. In the reference `<BarTexture>` is a
Texture element and StatusBar's LoadXML (`0x782ef0`) builds the fill through the
shared texture ctor, which registers the name like any other region's.

A dropped `name=` is invisible until Lua reaches for the global, and then it raises
inside the addon. ATSW2's `SPELLCAST_START` arm shows the progress bar, tints it per
ore through `ATSWProgressBarTexture` — the raise — and only then calls
`ATSW_StartProcessing`. So every craft left the bar shown and running with nothing
able to stop it: `Processing.Active` stayed false, `SPELLCAST_INTERRUPTED` was never
registered, `ATSW_StopProcessing` and `ATSW_CompleteTask` were never reached, and the
guard `not Processing.Active` let the next craft re-raise. grom's log has seven in a
row and his screenshot has the stuck bar.

Named now, both ways, as Button state textures already were: the region registry for
`GetName()` and `_G` for `getglobal` and a sibling's `relativeTo`. A named
`<BarTexture>` with no paint still builds its region. Four of the five in this install
declare a name (`ATSWProgressBarTexture`, `ATSWRankFrameBar`, two QuickHeal bars); no
stock FrameXML status bar names its fill, so the base interface neither gains a global
nor collides with one.

Also in the skin: the reagent names under the icons take the same black-to-white
`SetTextColor` wrapper as the tools line. They measured 1/1/1 in a capture and read as
correct, but a capture has no item links and `ATSW_ShowRecipe` paints the title black
only inside `if RLink then` — in play it runs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
2026-11-06 07:25:15 -07:00
gromandClaude Opus 5 14a1a7179e A tooltip Lua sized itself kept being re-sized out from under it
benilla auto-sizes a GameTooltip in a pre-pass of every resolve, because
that is where the text measures are. The reference re-sizes one only from
its own content verbs and from Show: 0x530640 has six call sites
(0x52ad59, 0x52b029, 0x52e5ae, 0x52f869, 0x530615, 0x530aad), all inside
the tooltip code and none in the frame pump. So a SetWidth/SetHeight
written after that call stands, and ours was overwritten on the next frame.

ATSW2's recipe tooltip is the case grom hit: it Show()s, then sizes the
plate from its own line count and a column width floored at 350. Losing
that width collapsed the backdrop to the one blank line it pushes through
our AddLine and dropped every right column, anchored RIGHT -10 to the
plate, on top of its left column -- "Required reagents:" and
"Bags Bank Alts" sharing a pixel column in his screenshot.

TooltipState::authored_size: set by SetWidth/SetHeight through the
existing note_authored_size, cleared by any content change, by
SetMinimumWidth/SetPadding, and by Show. layout_tooltips skips a plate
that carries it.

Clearing it on Show matters: pfUI and pfQuest both SetWidth the shared
GameTooltip *before* Show, where the reference overwrites it -- honouring
those would pin every later hover at 180 or 100 px. Two tests pin both
halves.

Measured, driving ATSW2's own OnEnter: plate 350.0 x 81.7, the two
columns 232 px apart.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
2026-11-05 20:23:26 -07:00
gromandClaude Opus 5 082dd2ed0f The map's arrow, its stratum, and a fade while you walk
Three of grom's 2026-11-05 reports about the built-in map, all measured.

**The player arrow was the one piece of 1.12's map content left outside the canvas.**
*"my player arrow goes to the wrong spot at any zoom level, this needs to be accurate all
of the time."* `WorldMapFrame_OnLoad` builds it with
`CreateWorldMapArrowFrame(WorldMapFrame)` (`WorldMapFrame.lua:19`), so its parent is the
WINDOW, while `MapCanvas.lua` reparents everything it is anchored against into the canvas.
`SetPoint`'s offsets are in the positioned frame's own units, so the offsets the stock
update computes — `playerX * GetWidth()`, times `WorldMapDetailFrame:GetScale()`, which
`SeatOnCanvas` pins at 1 because the zoom lives one level up on `ScrollContainer.Child` —
are short by exactly the canvas zoom. Measured on a 1600x900 harness with the view centred
on the player: exact at zoom 1, 250.5 x 501.0 screen px out at zoom 2.

`WorldMapPlayer` — the invisible mouseover rect for the same spot, from the same two
FrameXML lines — has always been right, because 1.12's XML nests it under `WorldMapButton`,
which IS in the canvas. That makes it the oracle the test uses rather than a recomputed
expectation. Not reparented like the detail frame and the button: the arrow is a size-less
`Model` whose rect is the file's bounding box, so inside the canvas it would grow with the
zoom, and no frame scale fixes the size without undoing the position — both read the same
effective scale. The cost of staying outside is clipping, hence an explicit bounds test,
which is a hide the stock code never needed because a map that cannot pan cannot put the
player off-screen.

That re-seat runs every frame the map is open, so nothing in it may force a layout resolve:
the art's size is read off `WorldMapButton` (explicitly sized) rather than the detail frame
(`SetAllPoints`, so derived), and the bounds test is arithmetic on the controller's own
normalized scroll rather than a rect comparison — `GetLeft` on a dirty tree measures
30-370 us against 0.42 us settled, and a pan writes the scroll every frame.

While in there: `InstantPanAndZoom` set `Child:SetScale` from its raw argument while
`GetCanvasScale` clamps to the zoom ladder, so a scale past the ceiling drew the art at one
scale with every extent, pan clamp and pin placement computed for another. It clamps first
now, as the ease path already did by construction.

**The window fell out of its own stratum every time it opened.** *"the map needs to be on a
higher level as I can see my unitframe over it."* `WorldMapFrame.xml:5` declares
`frameStrata="FULLSCREEN"`; measured FULLSCREEN with no addons and MEDIUM at level 0 with
his, which is the bottom of the bucket pfUI's unit frames live in
(`pfUI/api/unitframes.lua:413`). Five visible frames drew over the map rect.

No addon does it. The new `$WOW_WATCH_STRATA=<FrameName>` instrument — every
`SetFrameStrata` AND every `SetParent` on a named frame, with the Lua caller site — found
one FULLSCREEN from the XML loader and then `WorldMapFrame:SetParent(UIParent)`, from the
realm's own `WorldMapFrame_Minimize` (`WorldMapFrame.lua:183`) by way of
`WorldMapFrame_OnEvent`, on the open path. A reparent forces the moved frame into its new
parent's stratum (`0x76ab5a`, pinned off the binary by `script::tests::setparent`), and
`UIParent` is MEDIUM. The reparent is right and stays; the window's own declared stratum is
re-asserted on `SeatLevels` instead, which already re-seats the frame levels on every show
for a cousin reason, plus hooks on Minimize/Maximize since the minimize runs while the
window is up. After: FULLSCREEN, 0 frames over the map.

**And the fade.** *"It would also be nice if the whole map window went to 80%
transparentcy when the player is moving."* `mapFadeWhileMoving`, percent, 0 off, default 80,
with a slider on the Options window's Interface page. Written as a FACTOR of whatever alpha
the window already has, because that alpha belongs to somebody else — pfUI sets it from its
own config and offers shift-wheel over the map — so the base is re-read whenever the live
alpha is not the one this last wrote, which is another writer's signature; their setting is
what gets faded, and closing the map restores it. `GetSpeed()` is the movement test: ours,
always present, and 0 standing still by construction, off the same move flags the engine
answers `IsSwimming` and `IsMounted` from.

Also `$WOW_CAPTURE_EVAL`/`_SETUP` take a path ending in `.lua` as the chunk. A probe worth
writing is longer than a shell argument survives, and a quoting error in a probe reads
exactly like the thing being measured not being there.

Verified: `the_player_arrow_tracks_the_hit_area_at_every_canvas_zoom` walks zoom 1, 1.5,
2.5 and 4 asserting the arrow and the hit area agree within a screen pixel, and that the
canvas applied the zoom asked for. In a real session with his addons: FULLSCREEN and 0
visible frames over the map (was MEDIUM/0 and 5). Options census 140 -> 141 on both counts.
benilla-app at its recorded baseline, benilla-ui 2128/0, clippy and the two lints clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
2026-11-05 10:38:31 -07:00
gromandClaude Opus 5 a7a54c6d02 Dismount and CancelShapeshiftForm, because our own pfUI port calls both
`pfUI/modules/autoshift.lua:45` answers `ERR_CANT_INTERACT_SHAPESHIFTED` with

    if IsMounted() then Dismount() elseif GetShapeshiftFormID() ~= 0 then CancelShapeshiftForm() end

and grom's 2026-11-05 log carries `attempt to call global 'Dismount' (a nil value)` from
exactly there. That is a port written against verbs this client does not have, with a
comment claiming both "do their own engine-side aura scan and send the cancel packet
directly" — which was the design and not the state.

Both were on `compat/classic_api/state.rs`'s not-built list for a reason that turned out to
be answerable. Each needs the SPELL ID behind a state the engine reports as a flag
(`UNIT_FIELD_MOUNTDISPLAYID`) or an index (`SelfMovement::shapeshift_form`), because
`CMSG_CANCEL_AURA` carries an id and nothing else; and the player's aura list carries spell
ids and nothing about what each aura IS. The missing half was one derivation —
`EffectApplyAuraName` 78 (`SPELL_AURA_MOUNTED`) and 36 (`SPELL_AURA_MOD_SHAPESHIFT`), now
`SpellDisplay::mount_aura`/`shapeshift_aura` and two fields on `AuraState` beside
`channeled`, which the feed derives the same way from the same row it already reads.

Each verb is then the aura it names cancelled by id, which is what the reference's own
right-click on the buff does. Neither is gated on `aura::cancel_authorized`, unlike
`CancelPlayerBuff`: that gate is the reference's check inside THAT verb, this is not that
verb, the server makes its own decision about a cancel it is sent, and a mount whose aura
is not flagged `AFLAG_CANCELABLE` would otherwise be undismountable with no way to tell
why.

Allowlisted in `reference_surface` with the reason, as the four taint globals were: 1.12
has no verb for this because 1.12's player has the buff icon under the cursor, and an addon
reacting to an error message has neither an icon nor a cursor.

Verified: `dismount_and_cancel_form_each_cancel_their_own_aura_by_id` seats a plain buff, a
form and a mount in one list and asserts each verb finds its own id past the others, and
that neither fires with nothing to cancel. benilla-ui 2128/0, benilla-app at its recorded
baseline.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
2026-11-05 10:38:02 -07:00
gromandClaude Opus 5 8fcab8517b The MasterSoundEffects cut has to ride the play queue, not an observer a frame later
grom, 2026-11-05: *"softspoken was repeating zone readouts over and over unless I manually
removed them from queue."*

1.12 has no verb that stops a playing sound, so every voice-over addon uses the one
primitive there is: `SetCVar("MasterSoundEffects", 0)` then `..., 1)`, whose ENABLING edge
walks the live instance list and stops each node (`0x457500` -> `0x7a6570` -> `0x7a67a0`).
SoftSpoken's `SoundUtils:StopSound` is exactly those two lines, and its `SoundQueue` spends
them twice over: to duck the game's NPC dialogue while it speaks, and — this report — to
stop its OWN clip before replaying one, which `StopKeeping` does and comments on ("it
replays from the start when Advance next reaches it, since the client has no seek").

`bb9eb084` had to exempt `PlaySoundFile` audio from the cut because stopping it silenced
SoftSpoken outright: the addon spends the toggle and THEN plays its own file, so a cut that
reached files took the very line the toggle made room for. Its doc said what that cost —
*"if the overlapping-replay symptom returns, this is why"*. It returned.

The ordering was the thing, not the exemption. `cut_effects_on_reenable` is already
`.after(UiInput).before(drain_ui_sounds)`, which looks like the right gap and is not: the
flag is armed by the `CvarChanged` observer, `CvarChanged` goes out through
`commands.trigger`, and so the observer fires at a command flush a frame AFTER the Lua pass
that wrote it — by which time the drain has started the new clip. The consumer's ordering
cannot fix a producer that is a frame late.

So the cut now rides the sound queue, in call order with the plays:

  * `SoundRequest::CutEffects`, pushed from `script::cvars::store` — the one write path
    every `SetCVar`, `ConsoleExec` and engine write goes through, and the only place that
    holds both the old value and the new, which an edge needs.
  * `drain_ui_sounds` walks the queue in order and spends it where the addon wrote it:
    stop, then play. Its catalog guard moved inside the loop, because the cut needs neither
    a catalog nor assets and a stop dropped for want of one leaves the clip it meant to
    stop playing under the next one.
  * `cut_effects` is now shared by both paths with a `files` flag. In order with the
    addon's calls it stops files, because the stop was written before the play; a frame
    late, from the observer, it still spares them, because by then the only file alive is
    the one the toggle made room for. Both answers were paid for by a report, and
    `stopped_by_reenable`'s doc says which is which.

Verified: `the_master_sound_toggle_queues_its_cut_ahead_of_the_file_that_follows_it` writes
SoftSpoken's own two calls and asserts the queue is `[CutEffects, File(...)]` — the stop
keeping its place in front of the play — with no cut for the disabling edge and none for a
re-assert. `only_the_in_order_cut_reaches_an_addons_own_file` pins both answers.
benilla-ui 2128/0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
2026-11-05 10:37:43 -07:00
gromandClaude Opus 5 fc78e52c25 A NAME = nil line in an addon's saved file deletes the default it just set
grom, 2026-11-05: *"I'd also like to get advanced trade skill window2 working … I can't
even get number 2 to load right now."* His log has three runtime errors from it, all the
same shape:

    AdvancedTradeSkillWindow2.lua:844:  attempt to index global `ATSW_Profession'
    AdvancedTradeSkillWindow2.lua:5931: attempt to index global `ATSW_Bags'
    AdvancedTradeSkillWindow2.lua:5990: attempt to index global `ATSW_Bank'

All three are assigned `{}` at FILE SCOPE — lines 294, 5929 and 5986 — and the chunk ran:
the traceback carries `ATSW_OnEvent` (2504) calling `ATSW_ShowTrainingPoints` (4269), both
past the first of them. So the tables existed and were taken away, and
`benilla-config/saved/AdvancedTradeSkillWindow2.lua` says by what: 19 of its 24 declared
names stood at `NAME = nil`.

An addon's saved file is executed AFTER its own Lua (`0x51f4b5`, "after the files set
their defaults"), which is what makes a nil line destructive rather than merely useless —
in a fresh VM a global is already nil, so the line carries nothing and can only delete.
And once written it is self-sustaining: the nil it forces is what the next shutdown reads
back and writes again, so one bad write is permanent. The reference does not write them,
which is why no real SavedVariables file has ever had a `= nil` line in it.

Both sides, because the write fix alone still costs a session:

  * `saved_variables_bytes_for` skips a nil global. The flat `RegisterForSave` file still
    writes them — it is read BEFORE FrameXML, where a nil line and an absent one mean the
    same thing — so the two paths split and `serialize_saved` takes the flag.
  * `addon::drop_nil_assignments` strips `NAME = nil` lines a previous build already
    wrote, matched narrowly against that addon's own `## SavedVariables` set, one
    assignment per line, logged once per file. A player's hand-written `X = nil` of a name
    the addon never declared is left alone.

It is not one addon: his install carried poisoned files for five — ATSW2 (20 lines),
BigWigs (4), ImprovedErrorFrame (2), MasterTradeSkills (1), SuperCleveRoidMacros (1).

Also the harness gap next door, which is why a capture could not have found this. A
capture answered `GetRealmName()` as "" and `UnitName("player")` as "Benilla" even with
`$WOW_CAPTURE_SAVED=C_Thun-Gromgosh` naming the character whose saved variables it had
just loaded — so `ATSW_Profession[realm][player] = nil` (line 296) raised `table index is
nil` and took 6,000 lines of chunk with it. That is a broken instrument that reads exactly
like the defect being measured, and it hit every addon keyed on `[realm][player]`.
`lifecycle.rs` now seats both from the capture identity, before the addon walk.

Verified: `a_never_assigned_saved_variable_writes_no_line_and_a_nil_line_is_dropped_on_load`
covers both halves. In a real session with his addons, all 24 of ATSW2's variables read as
tables at the first handler and `ATSW_ShowTrainingPoints()` — the function that raised
fifteen times — returns. Addon corpus unchanged at 86/87 clean loads, 86/87 UI probe.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
2026-11-05 10:37:20 -07:00
gromandClaude Opus 5 e15a415da9 securecallfunction, because modern libraries upvalue it at load time
The stitched map never said which zone you were looking at — from about zoom 3.5 to 10
there is no zone name anywhere on screen, which is the band a player pans around in.
Wiring up a port file that draws them took the addon corpus's "survived a UI probe" column
from 86/87 to 85/87, and the failure was not in the new file:

    OpenMapClassic/Libs/CallbackHandler-1.0/CallbackHandler-1.0.lua:19:
    attempt to call upvalue `securecallfunction' (a nil value)

Ace3's modern `CallbackHandler` takes `local securecallfunction = securecallfunction` on
its line 10 and calls it for **every handler of every event**, so the first callback any
addon shipping a post-2022 Ace fires raises. Nothing had fired one before, because
OpenMap's whole callback surface was dead for an unrelated reason until tonight.

1.12 has no taint, so there is nothing here to make secure — but the half of the contract
that is not about taint is the half library code leans on: it is a **protected** call, so
one raising handler does not stop the registry walking the rest. `securecallfunction`,
`securecall` (which also takes a global's name), `issecure` and `forceinsecure` go beside
`seterrorhandler` in `stdlib.rs`, with failures reaching `geterrorhandler()` exactly as the
reference's own secure dispatch does, so an addon bug still lands in the log rather than
being swallowed. Back to 86/87.

`the_rest_of_the_bare_globals` asserted these ABSENT, on the reasoning that the restricted
-execution system has no 1.12 counterpart and nothing here implements it. That was half
right; the half it missed is above, so the assertion is inverted with the measurement
written beside it — the same shape `hooksecurefunc` already has one line below.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
2026-11-04 21:56:17 -07:00
gromandClaude Opus 5 65520a4a58 The map's darkening was a grid of grey boxes, and a 12 px string was emptying the glyph sheet
grom, after a night's play: "make the map look a lot nicer, it would be really nice if the
darkened areas were not just massive squares on the map, and should fit into the actual
edges of the terrian if that makes sense, also I shouldn't see this dark squares on the
fully zoomed out world map, also make it so the world map can't move out of the frame."

**The darkening.** OpenMapClassic washed a never-visited zone through its land mask, and
those masks are Voronoi-dilated by construction — "so that the forty of them tile each
continent with no gap" — so for any zone that is not on a coast the mask *is* the
rectangle. Measured at zoom 2.5 over the Eastern Kingdoms: a grid of flat grey boxes.
Nothing could have been softened into a fix; it was the wrong mask. It was also drawn at
full strength from every zoom, and at the whole-world view the zones' own art is not drawn
at all, so the only thing on the world map was the fog's rectangles.

A land mask for every map now comes from the client's own data. `bake_zone_land` unions a
map's `WorldMapOverlay` alpha — the pieces the game reveals as you explore, cut to the
shape of the ground — into one 256×256 mask; `overlay-land-index.py` writes the list the
addon reads, because Lua cannot test for a file and a *missing* mask is dropped by the
engine so the whole rectangle draws, which is the defect itself. 53 maps have overlays
(the 40 stock zones, Hyjal, Alterac Valley and 11 of the realm's own); 76 have none and
keep a wide feather. `clip-zone-masks-to-coast.py` then takes the open sea off the dilated
sheet masks — the same dilation was painting parchment out on the ocean — by closing the
continent's own land by 500 yards (above the 466-yard inradius of the widest enclosed gap,
so seams fill and the sea does not) and keeping a 300-yard coastal margin. Twelve
landlocked zones lose 0.0%; Darkshore loses 53%, all of it water.

`tga.rs` gains the writer that needed, `script::uimap` and `ui_world_map::build_catalog`
become public so an example can reach the uiMapID graph, and `WorldMapUiData` grows the
accessor so the VM push and an outside caller cannot build different lists.

**The glyph sheet.** His four-minute session reset it 27 times, each dropping every cached
glyph. The producer is Atlas-CFM's cursor coordinate readout: a 12 px FontString on
`WorldMapDetailFrame`, rewritten every frame, and the canvas zooms by scaling that frame —
140 device px a digit at a canvas scale of 6.9. Nothing about the addon is wrong, so the
fix is ours: `raster_ppem` rasterizes exactly up to 64 device px and snaps to a
third-octave ladder above it, with `cell()` scaling the cached bitmap and metrics left
keyed on the true ppem, so advances and `GetStringWidth` are bit-for-bit unchanged. Same
1,200-frame reproduction: 3 resets and 6,623 rasters before, **0 and 1,424** after, with
occupancy climbing once to 30% and stopping. A reset no longer clears the metric cache
either, and `note_loud_cell` names the string that asked for an outsized cell.

**Four more from the same log.** `SetOwner`'s unknown-anchor line named a forwarding
wrapper rather than the addon that chose the anchor (two `debugstack` frames now); the nine
reference anchor names are written down with the binary evidence, because `ANCHOR_TOP` is
not one of them. The `CreateFrame(kind, …, inherits=<other kind>)` warning fired in both
directions and only one drops content — `kind_subsumes` makes it directional, and
`type_chain` moves out of `install` so `IsObjectType` and the loader read one table. A
string that is not UTF-8 now names the Lua line that cut it, and both glyph warnings quote
the string the character came out of. The three world-map CVars stay unregistered, with the
argument written down beside `REGISTERED` so it is not re-litigated from the warning.

**`World.zmp`.** The realm's `WorldMapArea` carries a third `areaID == 0` row, `694 World`,
with an all-zero rect; the reference ships no bitmap for it and neither the graph nor the
realm's own `Overrides.lua` lets it reach a player. The miss is a `debug!` line now, and
the `warn!` is kept for a continent that *has* a rectangle and is missing its bitmap.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
2026-11-04 21:16:20 -07:00
gromandClaude Opus 5 ee125519f1 The map arrow pointed the wrong way wherever the player was not
`UpdateWorldMapArrowFrames` (`0x4a8d10`, `0x4a7c20`) copies the camera-tracked object's
facing to both arrows and leaves them alone when there is no such object. Ours tested "is
there a player" with `WorldMapState::player_uv.is_some()` — a different question, because
that field is `GetPlayerMapPosition` on the **displayed** map. A view sitting on a
continent, on another zone, or on an addon's stitched world answers `None` on every frame,
so the arrow kept the heading it had when the window last sat on the player's own zone: the
right place, pointing the wrong way.

`player_facing_seen` is the real test, set the first time the feed pushes a facing — the
feed runs only where there is a player, and the facing it pushes is unconditional. The
existing test asserted the old behaviour on its second leg; that leg now asserts the
heading following off the displayed map, and a new test holds the other half (before any
feed, the login and loading screens, the arrow is left alone).

`scripts/soften-zone-masks.py` is for the OpenMapClassic port rather than the client: its
per-zone land masks are binary, so every map sheet ends on a crisp line against a
differently-lit neighbour. It writes a softened copy — dilate, blur, times a rect ramp that
reaches zero inside the sheet's own torn parchment border, which is the half that is not
obvious.

`docs/ADDON_COMPAT.md` writes up this round's five: the flashing arrow (two writers on one
frame, measured `h h S h h S`), why correcting after 1.12 rather than suppressing it cost a
full UI conversion every frame, pfQuest's pins being both behind the map and only ever for
one zone, the softened masks, and the cities.

Gates: fmt 0, clippy 0, benilla-ui 2121 pass / 0 fail, benilla-app 4233 pass / 12 fail (the
recorded data-golden baseline). Corpus 86/87 load clean, 86/87 survive a session start and
a UI probe, 0 raised on hover, click or drag — unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
2026-11-04 17:00:14 -07:00
gromandClaude Opus 5 841fd1a81f Two map verbs disagreed about where the player was
`UnitState::position` was fed from a streamed object's `Transform` read raw, and a
`Transform` is Bevy's frame (`wow_to_bevy` is `(-y, z, -x)`): the two map axes arrived
swapped and negated and the third was the player's height. Three compat verbs answer
from that field — SuperWoW's `UnitPosition`, UnitXP_SP3's `getUnitPosition`, and
`C_Map.GetPlayerMapPosition` — while 1.12's own `GetPlayerMapPosition` reads the
per-frame world-map feed, which converts. So the client sat in a state no real client
can be in: the 1.12 verb placing the arrow and the modern verb saying the player was on
no map at all.

Against a zone's few-thousand-yard rect the height lands outside 0..1, so the modern
verb answered nil — which is OpenMapClassic's "You aren't on the world map here" and
`Map/StockArrow.lua` declining to place anything, leaving 1.12's wrong placement
standing. Against a continent's sixteen thousand it lands inside, and answers a
plausible wrong fraction. Both of grom's reports, one cause. Distance was the one read
this never broke, the transform being rigid.

Measured in a capture with the player at Northshire: `UnitPosition` now answers
(-8980, -160, 110), `C_Map.GetPlayerMapPosition(1415)` and the 1.12 verb agree to seven
places, Elwynn answers a fraction and Dun Morogh correctly answers nil; and with the
view centred on the player's own world point the arrow lands on the container's centre
to a third of a pixel.

A capture could not have shown any of this — its synthetic player carried no position,
so every reader answered nil there. `demo_unit_feed` now publishes the capture's own
parking spot, at the cost of one ambiguous pair written up at the ceiling.

Also here, from the same play test:

  * A canvas zoom now tells the two 1.12 frames their scale moved, with their own scale,
    which is the only way a 1.12 map addon can hear it. pfQuest counter-scales its pins
    inside that hook; the canvas scales its child, so the hook never fired and the pins
    kept the size they were built at. Measured 0 -> 24 `pfMap.ResizeNodes` over one
    glide from 8x to 30x.

  * `CLASSIC_API_VERSION` says 1.18.0, at grom's ask. The DLL beside the real client
    reads 1.15.0.0 and nothing in his folder gates above pfUI's 11400 nag threshold, so
    the trade is the one that constant has always named and the floor is unchanged.

The addon-side half is in `Addon_Ports/openmap_port`: `unexploredMode` defaults to
`reveal`, with the one-shot migration this addon already uses for the kind. A 1.12 zone
sheet is blank parchment and every scrap of terrain is an overlay the client hands out
only where explored, while a city sheet has no overlays at all — which is the whole of
"it will only paint the actual map art for cities".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
2026-11-04 14:17:39 -07:00
gromandClaude Opus 5 d5beabab42 The map's mouse surface shrank with the zoom, and four things fell through the hole
The canvas gave the map no mouse surface of its own, on the reasoning that 1.12's
`WorldMapButton` covers the same rect and sits above it. The button is pinned INSIDE the canvas
child, so it scales with it: measured at the world view with OpenMapClassic driving, at grom's
own 2560x1440, a 23x16 px button inside a 701x468 px window. Every press on the other 99%
reached the next frame that takes the mouse — `WorldMapFrame`, which pfUI makes a
`RegisterForDrag("LeftButton")` handle for the whole window. So dragging the map moved the
window, and a click never reached `ProcessCanvasClickHandlers`, which is the only way a map pin
is ever dropped.

The container takes the mouse and the wheel now and drives the same controller. The frame level
mattered as much: the hit sweep probes one `(strata, level)` plane in link order and a parent is
linked before its children (`0x764aa0`), so a container tied with `WorldMapFrame` still lost
every press to its own parent. It is re-seated to `map:GetFrameLevel() + 1` on every show,
because the window is level 0 while the canvas builds and level 1 once `ShowUIPanel` has raised
it.

With the click arriving, a pin still stored nothing: `Map/Pins.lua:57` trims the note with
`strtrim`, which was deliberately absent. `script/stdlib.rs` still refuses it on the 1.12
surface, for the reason written there; this build claims ClassicAPI, whose client does have it,
so it lands in `compat/classic_api/string_trim.rs` with both spellings. Eight bare call sites in
the corpus, one of them in pfUI's search box on every keystroke.

`SCALE_RATE` 10 -> 24. A 0.7 s half-life was tolerable only while a wheel notch could not
accumulate; now that they add up the glide is what you feel ("scrolls past where I stop
scrolling").

`BenillaMapTextures(uiMapID)` is `GetExploredMapTextures` without the explored filter. Reveal
mode draws unexplored overlays out of a retail FileDataID table this client resolves none of —
captured with the fog default and with reveal, the same view came back pixel-identical, 0 by
`compare -metric AE`. The overlays were never missing, only their names: `Map/RevealFromClient.lua`
in `Addon_Ports/openmap_port` rebuilds the table from this, with paths.

Also in the port, not here: the arrow is placed through OpenMap's own coordinates, because
1.12 measures `GetPlayerMapPosition` against Blizzard's world and continent art and the stitched
map is a different projection.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
2026-11-04 09:34:00 -07:00
gromandClaude Opus 5 a32963b3f8 Find the frame that was eating every map click, and the realm LibStub behind it
A real pointer at the middle of the world map reached nothing: a hook on
`WorldMapButton`'s `OnMouseDown` counted 0 hits from a real click although the
button was shown, mouse-enabled and held the point. A Lua walk of every visible
mouse-enabled frame containing that point answered with exactly one frame, and
it was an unnamed 700x500 `FULLSCREEN_DIALOG` child of `UIParent` — AceGUI's
default container, carrying the title "SoftSpoken Stories" and no content. The
blank login popup grom has reported twice was parked over the centre of the
screen swallowing the map's clicks. One frame, both defects.

`hit_test_name` had been printing `<nothing>` there all along: it answers `None`
for an unnamed frame. Ask what holds the point, not what it is called.

The window is left shown because `AceGUI:Create` shows a container on acquire
and `self.frame:Hide()` is the last line of a `pcall`ed build step, so anything
raising in between leaves it up. The recorded reason was
`AceGUI-3.0.lua:282: attempt to perform arithmetic on local 'argc'`, and the
cause is the realm's own
`Interface\FrameXML\AIO\Dep_LibWindow-1.1\LibStub.lua`, loaded by its
`FrameXML.toc:162` before any addon, which hardcodes `minor = 2` where upstream
parses it — 1.12's Lua 5.0 has no `strmatch`. Every library therefore registers
as minor 2 and the first copy loaded keeps it, while `RegisterWidgetType` still
upgrades individual widgets by their real version: an Ace3v core
(`Fire(self,name,argc,…)`) hosting modern widgets (`Fire(self,name,…)`), whose
first payload value lands in `argc`.

Client side, one real divergence came out of it: `string.split` answered a table,
because BigWigs' `Plugins/SpellRequests.lua` defines `function string:split` and
never calls it, while Ace3's `AceConfigDialog-3.0` and `TreeGroup` both spell
`("\001"):split(uniquevalue)`. `string.split` is now `strsplit` — same function,
same argument order — in the beyond-1.12 string family, with a test pinning the
Ace3 call shape and the arity.

Also: `Step::Drag` moved the cursor eight times without advancing a frame, and a
canvas pans from its `OnUpdate`. It now ticks per move, and the same drag walks
0.5000,0.5000 -> 0.7582,0.3073 -> 0.5080,0.5161 — exactly what driving
`OnMouseDown` by hand produced. The frame between the moves is the gesture.

Four worth recording as dead ends, each of which looked like a client bug: a
verbatim replica of the LibStub file registers correctly; `strfind(34,"(%d+)")`
coerces the number and captures `34` as the reference does; the live function's
`getfenv` is `_G` with every global correct; and a known-good `NewLibrary`
installed on the live table stores the right value. `debug` is absent here, so
the way to ask a Lua function where it came from is to make it raise.

Addon-side edits (gitignored live copies, mirrored to Addon_Ports): the `Fire`
arity shim in both Ace3v AceGUI copies, `issecretvalue` guarded in
OpenMapClassic's vendored Heading widget the way its own Core.lua guards it, and
BigWigs' unused `string:split` removed.

Verified: SoftSpoken `initializationErrors` 0 and `shown=0` at login, nothing
over the map, `--drag` pans it, all three registered options tables open clean
(MasterTradeSkills 4 labels, OpenMapClassic 83, SpokenQuests 6), tabs switch
pages with no raise, session errors 0. fmt and clippy clean; benilla-ui 2119
pass; benilla-app 12 failures, the recorded pre-existing data-golden baseline.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
2026-11-03 20:33:53 -07:00
gromandClaude Opus 5 b7a8e1c406 ui: a Slider answers SetEnabled, because OpenMap's options tab called it unguarded
Rendering a panel is not the same as it working. OpenMap's options menu
opens 500x500 with 104 frames and 49 placed labels, and its Look tab
died on click: Map/Panel.lua:1084, attempt to call method SetEnabled (a
nil value) -- so a third of its options was unreachable.

SetEnabled is already composed here for Button, CheckButton, EditBox and
LootButton, and was deliberately withheld from a Slider, because addons
duck-type on the enabled family to tell a control from a bar. What they
duck-type on is the TRIO (if widget.IsEnabled then), that is what
tests/slider.rs pins, and the trio is still absent; SetEnabled is beyond
1.12 by construction, so a Slider answering it invents no 1.12
behaviour. OpenMapClassic calls it unguarded on every options row with a
condition, which is what settles it.

On a Slider it writes the mouse -- input refused, art not greyed, which
1.12 has no slot for; the caller dims it itself on the next line. The
argument goes through GetBoolOrDefault, not Lua truthiness, because
DoiteAuras writes SetEnabled(ok and 1 or 0) and 0 is truthy in Lua.

Also records a count that was wrong until checked: grep says 39 calls
across five addons, but AdvancedTradeSkillWindow2 and SoftSpoken define
their own SetEnabled and SoftSpoken's slider row is fully guarded. The
real unguarded widget calls are OpenMap's three and DoiteAuras' two --
and SoftSpoken's blank settings page is NOT this, and is still open.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
2026-11-03 19:34:52 -07:00
gromandClaude Opus 5 6c01be926d map: a map's rect on another map describes art, not a click box
C_Map.GetMapRectOnMap answered, for a continent on the world sheet, the
world-level CLICK box -- continent_sheet_rect over the WorldMapContinent
tile bounds, which trace the landmass. That is the reference's own
0x4a5d00 and right for what 1.12 uses it for; the two boxes are disjoint
so a world-level click can tell the continents apart.

It is the wrong answer for art. A continent's map is a 1002x668 sheet
with the land inside it and ocean around, so its art rect is 792x528 px
on the world where its click box is 300x564. Every 1.12 WorldMapArea
rect is 3:2, because every sheet is, and the click box is 0.355 of that.
A zone's rect on its continent was already a yard un-lerp and correct,
so the error entered once and multiplied through every zone: Dun Morogh
landed 42x79 where its art is 1002x668. A stitched world built on that
draws each zone a third as wide as it should be, which is why the canvas
was grey between the zoom steps.

map_proj::continent_art_rect carries the continent's own WorldMapArea
rect through world_uv, the projection a position on it already takes.
The view keeps both rects; the Era map graph publishes the art one.
Measured after: EK, Kalimdor, Dun Morogh and Elwynn all 1.500. Rendered:
the zone view stitches its neighbours in, zoom 3 is the continent with
its zones, zoom 1 is the world sheet. The stock 1.12 map is unchanged.

Three instruments came with it and are kept: $WOW_CAPTURE_EVAL, a chunk
run in a capture's VM at the shutter; $WOW_CAPTURE_EXPLORED, so a
stitched map's fog can be ruled out in one run; and a named warning for
a UI texture that is not in the chain, which is what refuted the other
hypothesis -- a grep said the addon's art was missing, a real render
asked for zero missing files.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
2026-11-03 16:42:09 -07:00
gromandClaude Opus 5 abb4dc1dd3 ui: a Slider's thumb had no rect, so no scrollbar could be skinned or seen
"Half of the info on the right is out of the frame" was a scroll frame with
699 of content in a 451 window, a scroll range of 228, and a scrollbar
with nothing on it. The thumb's rect was only ever computed inside the
draw pass and never published: the region sweep skips anchor-less regions
and a thumb is anchor-less by construction, because its slider places it.

That is invisible until something anchors to the thumb, and pfUI does --
SkinScrollbar clears the stock knob and hangs its own off the thumb's two
corners, so every pfUI-skinned scrollbar in the interface was a bare
track, which is a scroll frame that silently cannot be scrolled.

The thumb is on the layout roster now, survives the unanchored prune, and
carries its slider's (vertical, fraction) in its node hash -- without the
last one a named touch moved no hash and seeded nothing, which
WOW_LAYOUT_VERIFY caught as an incremental pass disagreeing with a full
derivation. The draw pass keeps computing its own rect: extract takes
&self and cannot resolve, so a SetValue with no resolve behind it still
has to draw where the value now is.

map: the window has two layouts and the canvas only knew the maximized one

Pressing maximize left the art a 1002x668 patch in a full-screen window
and minimize could not put it back, because the container was anchored
once at build to WorldMapPositioningGuide TOP -502,-69 -- 1.12's
maximized anchor against a fixed 1024x768 guide. Read as insets instead
(15/4/33/20 windowed, 10/12/69/31 maximized) the same numbers reproduce
1.12 at 1.12's sizes and fit any other window, which is also the left-edge
bleed: -502 against a guide stretched to pfUI's 1017-wide window starts
the art 6.5 px in, not 10.

The zoom ladder's floor follows: it was the constant 1, true only while
the container was the art's own size, and is the measured fit now. A view
outside a rebuilt ladder is pulled onto its nearest end, since ZoomIn and
ZoomOut walk it by value -- OpenMapClassic's whole ladder sits below ours.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
2026-11-03 14:27:51 -07:00
gromandClaude Opus 5 5e2f1f3ac8 ui: SetClipsChildren, the verb an addon asked for by name and was refused in silence
Era's map canvas is built on one verb this client did not have. Its `ScrollContainer` is a plain
Frame with `SetClipsChildren(true)`, and everything below it is cropped to that rect.
`assets/ui/MapCanvas.lua` substituted a real ScrollFrame, because this client crops a ScrollFrame's
scroll child and every descendant of it — which covers the canvas and nothing parented to the
container beside it. On Era that distinction does not exist, so an addon has no reason to respect
it.

OpenMapClassic parents its pin layer exactly there and asks for the verb by name, behind an `if`
(`Map/Stitch.lua:1070`), so the absence was not a raise. Measured on grom's own config with the map
open: the container sits at L=1151 R=2153 and its seventeen direct children span L=-765 R=2153 —
about 1900 units off the left edge, cropped by nothing.

Implemented as a frame flag with `SetClipsChildren`/`DoesClipChildren` and the `clipChildren` XML
attribute; `effective_clip` now intersects the rect of every ancestor that sets it alongside the
ScrollFrame rule, so the two nest and compose. An ancestor rather than the frame itself: the verb
clips a frame's children, and stock art overhangs its own frame on purpose. `MapCanvas.lua` sets it
on the container too, so a layer parked there is cropped whether or not its addon thought to ask.

Not claimed as the whole of grom's map report: the screenshot behind it has not been reproduced
offline, because a capture's view is not the view his session was in. This is the part that was
provably drawing uncropped.

Also here, both from the same play-test:

- `C_Spell.CancelSpellByID` answered with `SpellStopCasting` only. pfUI's buff bar cancels with it
  on a right click where 1.12's calls `CancelPlayerBuff`, so with nothing in flight the click did
  nothing and said nothing — grom had never been able to right-click a buff off on this client. It
  now finds the id among the player's own auras and queues it on the same CMSG_CANCEL_AURA path,
  behind the same `cancel_authorized` gate, and falls through to the cast only when there is no
  such aura.

- `$WOW_CAPTURE_SAVED=<Realm>-<Character>` points a capture at a player's own saved variables. A
  capture is hermetic by design, but with addons and no saved variables it renders an interface
  nobody is configured to be looking at — which is why the world map came out correct in a capture
  while being wrong on his screen. It doubles as the identity because a capture never logs in and
  `pfUI_config` is SavedVariablesPerCharacter.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
2026-11-03 09:46:32 -07:00
gromandClaude Opus 5 5ed9dded4c map: the Continent and Zone pickers were blank on the first map of a session
The realm's `WorldMapFrame.xml` fills them from exactly one place: the Zone
dropdown's `<OnEvent>` on `WORLD_MAP_UPDATE` (:321), which re-runs both
pickers' initialisers and then the two `WorldMap_Update*DropDownText`
functions. That dropdown registers the event in its own `<OnShow>` (:318 —
the realm commented the `OnLoad` out and moved it there), while
`WorldMapFrame`'s own `<OnShow>` fires it through `SetMapToCurrentZone()`
(:711), and a parent's OnShow runs before its children's. So the inline event
landed in a gap where its only listener did not exist yet. Measured through the
addon harness: the Zone dropdown answers `IsEventRegistered` nil before the
open and 1 immediately after, and the pickers come up blank on the first map
and correct on the second — which is what grom's recording shows, blank over
Dun Morogh and filled over Elwynn later in the same clip.

`SetMapToCurrentZone` now queues a second `WORLD_MAP_UPDATE` on the deferred
lane as well as firing the inline one. The inline fire stays: it is what
repaints the detail tiles inside the OnShow rather than a frame later (the
world-map flash). Only this verb doubles up, and only this verb needs to —
`SetMapZoom` is a click on a map that is already open.

The first attempt at this was reverted and the reason is in the test's own
comment: its pin opened the real map on the shipped interface and passed with
*and* without the fix, because the stock ordering happens to be fine and the
symptom needs the addons. The pin that replaced it asserts the engine half
directly — a handler that registers after `SetMapToCurrentZone` returns still
receives the event — and answers 0 without the change and 1 with it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
2026-11-02 21:57:07 -07:00
gromandClaude Opus 5 36f504820d ui: GetCursorPosition answered 0,0, so nothing could drag or size a frame
`Model::cursor_pos` is read by the verb in two places and was written by
nothing in the workspace: declared, initialised to the origin, never assigned.
So `GetCursorPosition()` answered `0, 0` for every call of every session.

That kills the universal drag-and-size idiom — stamp the cursor on
`OnMouseDown`, subtract it each `OnUpdate`, size by the difference. Every
delta came out zero, the frame was re-anchored to its own current size sixty
times a second while the player dragged, and the addon's own "that was a click,
not a drag" guard then declined to save. Nothing raises and nothing logs.

grom reported it twice as OpenMapClassic's map window refusing to resize; the
round before this one painted its invisible grips and left this underneath.

Fed in `ui_script/input.rs` beside `set_mouse_buttons`, for the reason that
one states: the pointer feed below is skipped for a cursor off the window and
for a UI hidden by TOGGLEUI, and the reference answers through both. UI units,
y-up from the bottom-left, undivided — UIParent is scale 1 here, so an addon's
`x / frame:GetEffectiveScale()` lands in the frame's own units as on the
reference. The test pins the whole idiom rather than the getter, because the
getter answering *something* was never the question.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
2026-11-02 21:15:38 -07:00
gromandClaude Opus 5 c93a0be93f ui: a nameplate now carries the unit token its own event names
`plate.namePlateUnitToken` was set on nothing. It is how every later-era plate addon gets from a
frame back to its unit — `plate.namePlateUnitToken or (plate.UnitFrame and plate.UnitFrame.unit)`,
six sites in `Cinematic/Effects.lua` and `PlateParts.lua` alone — so an addon walking
`C_NamePlate.GetNamePlates()` had a frame's rect and nothing about its unit, and could not tell a
friendly plate from a hostile one.

The measured symptom was grom's friendly nameplates switching themselves on the moment CineMode
started, on a profile whose every friendly-plate box is unticked. That CVar write is the addon's
and it is deliberate: outside instances Cinematic switches every plate kind on so visibility is
decided by *fading* instead, because fades can change in a fight and plate settings cannot. Having
switched them all on, it then faded none of them, because it could not identify one.

The token already existed — `script/nameplate.rs` has had the slot-bound `nameplate<N>` tokens and
the three plate events since the pool was built. All that was missing was publishing the slot's
token on the frame: set before `NAME_PLATE_UNIT_ADDED` and cleared after `NAME_PLATE_UNIT_REMOVED`,
so a handler for either event reads the field that event is about, and a pooled frame reused for
the next unit cannot answer the departed one's token.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
2026-11-02 14:51:37 -07:00
gromandClaude Opus 5 6e8c4488e1 ui: the override-binding family exists and says what it does not do
`ClearOverrideBindings` was absent outright, so `Cinematic/Camera.lua:470` raised in grom's chat on
every camera shot that ended — and a raise mid-handler costs the rest of the handler, not just the
line.

These are 2.0's secure-binding layer: a second, unsaved binding table a frame owns, outranking the
saved one while that frame wants it and dropped whole by `ClearOverrideBindings(owner)`. This client
models the saved table only (`script/keybind.rs`), so:

- the five setters are no-ops that **warn once each** through the script warning channel. Silence
  was the alternative and it is the worse one — a setter that quietly binds nothing is the same
  class of fault as a hook that quietly arms nothing, and the caller cannot find out.
- `ClearOverrideBindings` is a plain no-op and does not warn. Dropping a table that was never filled
  *is* nothing, and warning there would fire on every `ClearRecast()` while the player does nothing
  unusual.

The module header lists what a real layer needs — an override table keyed by owner in `Model`, a
generation bump for `bindings.rs`'s `sync_dispatch`, an override arm in `BindingDispatch::map` that
wins over the saved chord, and a `Bound::Click` — together with the `SecureHandler*` half in
`secure.rs`, which is the same table seen from inside a snippet and no-op for the same reason.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
2026-11-02 14:17:55 -07:00
gromandClaude Opus 5 bb2e40d1dd ui: GetDebugName, the name an unnamed frame still answers
`Cinematic/Frames.lua:3734` is `entry.name or entry.frame:GetDebugName()` — an addon that keeps a
list of frames reaches for this precisely because some of them have no name, which is the one case
`GetName` cannot serve. With the method absent, `/cine debug frame <name>` raised on the first
unnamed row in the list instead of printing the row the player asked for.

On all three method tables, since the era puts it on ScriptObject. A named frame answers its name,
so a feature test on it learns nothing a 1.12 client would answer differently. The unnamed leg
deviates in its fallback only: it walks the parent's own fields for the key the child is stored
under (frames here are plain tables, so `parent.icon = tex` is visible) and answers
`Parent.<unnamed>` when there is none, where the era answers an index among the parent's children —
an index here would be the order of a list this client does not keep, and a wrong number reads
worse than an honest absence.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
2026-11-02 14:17:44 -07:00
gromandClaude Opus 5 09aca72c46 ui: hooksecurefunc over a widget method has to read through the metatable
The table form read the field with `raw_get`, on the reasoning that a field
reached only through a metatable is not that table's to replace. A widget
carries none of its own methods — they live on the shared class table its
metatable's `__index` names (`object.rs`'s `frame_meta_for`) — so
`hooksecurefunc(frame, "SetAlpha", cb)` found nothing there and armed nothing,
silently. Measured: **0 fires across 2 `SetAlpha` calls**, while the plain-table
and global forms fired correctly.

That is not a corner of the form, it is what the form is for. 29 files in grom's
corpus hook a widget method, and the one that shows: `Cinematic/Frames.lua:459`
hooks each pfUI frame's own `SetAlpha` so that Cinematic can **follow** pfUI's
out-of-combat fade rather than fight it ("their frames have SetAlpha followed
straight away by ours, taking what it set as the new base"). With the hook
unarmed the two wrote a different alpha to the same frame on alternating frames
— which is grom's "still flashing my unitframe when cinematic is on".

The reference reads `table[name]` plainly and assigns the wrapper back, which
*shadows* the class method for that one object and leaves its siblings alone.
This now does the same: read through `__index`, write raw onto the host, which
is the same thing a plain assignment would do since frame metatables carry no
`__newindex`. A test pins both halves — the chain fires on the hooked frame, and
a sibling of the same kind is untouched, with nothing written to the class table.

Corpus roster byte-identical (87 ok / 1 fail) with all 29 files' hooks armed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
2026-11-02 11:10:57 -07:00
gromandClaude Opus 5 aa2ee515aa ui: MOD multiplies, and a gradient is not an alternative to a fill
Cinematic's colour tint painted the whole screen opaque white for as long as its
CineMode ran: `srgb(255,255,255)` across the world with the UI still drawn on top
of it, 16.8 s of it in grom's 2026-11-02 recording, starting the instant he picked
a profile. Three faults on the one path from the addon's call to the pixel, each
of them enough on its own.

`SetBlendMode("MOD")` parsed, answered `GetBlendMode` and then drew as straight
alpha. One pipeline served BLEND and ADD — the shader emitting `(rgb·a, a)` or
`(rgb·a, 0)` into a single `(One, OneMinusSrcAlpha)` state — because no scalar
source alpha can stand in for a per-channel destination factor, and the mode had
no caller worth a second pipeline. It had one: nine of the addon's ten tint
presets are MOD (`Tint.lua:21`), and a MOD colour is built to be multiplied —
`1 − (1 − c)·strength` at **alpha 1**, white at strength 0 — so drawn with alpha
instead it is an opaque near-white quad over UIParent. MOD now keys its own
pipeline, `(Dst, Zero)` with the destination's own alpha kept, off the same
`0x70c190` table the world's Mod batches already read; the shader returns the
multiplier un-premultiplied, since neither alpha appears in that formula, and runs
no longer merge across the flag.

The extract read `fill.or_else(|| gradient.map(midpoint))`, so a region that set
both drew the fill and dropped the gradient. They are not alternatives:
`<Gradient>` writes the four vertex colours (`+0xb8`, `0x77304d`), which
`loader/regions.rs` already reads as "unlike `<Color>` it tints the art". The tint
is exactly that shape — `SetColorTexture(1, 1, 1, 1)` once, re-gradiented a few
times a second — and so is its vignette, whose four edges were drawing as opaque
white bars.

And `SetGradient` took only 1.12's six loose numbers. This client answers
`CreateColor`, so an addon that feature-detects takes retail's colour-object form
and never tries the numbers; the two objects coerced to six zeros through
`as_f32`, the call **succeeded**, and the `pcall` that was the feature test
(`Tint.lua:546`) reported it understood. Both spellings now read a colour object
when the first stop is a table.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
2026-11-02 10:00:44 -07:00
gromandClaude Opus 5 33d0868009 ui: SetEnabled, the one setter retail put in place of Enable/Disable
A later-era addon writes `SetEnabled` **unguarded on its own controls**, even
where it guards on someone else's. `Cinematic/Options.lua:4176` is a tick's own
`Refresh`, greying it when a rule owns the row; `:305` twelve pages up is the same
file writing `control.SetEnabled and …` about a control it did not make. The raise
at 4176 unwound `RefreshOptions` → `SettingsChanged` → `SetProfile` →
`Welcome.lua:43`, so picking a profile from the addon's welcome window applied the
profile and then abandoned the panel refresh, leaving the traceback in chat and
the window still reading "Nothing runs until you pick one." That is the chat error
grom recorded on 2026-11-02.

Composed from the stock `Enable`/`Disable` pair (`0x780160`, `0x7801b0`) on
Button's table, which CheckButton and LootButton reach down the same chain the
pair travels. EditBox takes it too, written out rather than composed because the
reference gives a box no enabled state at all: it writes the mouse and keyboard
the frame already owns and drops focus, so input is refused and the text is NOT
greyed, which 1.12 has no slot for — `Options.lua:1629` is the call that needs it.

A Slider still answers neither this nor the trio: addons duck-type on them to tell
a control from a bar, which `tests/slider.rs` pins.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
2026-11-02 10:00:30 -07:00
gromandClaude Opus 5 c6b19c7926 lua: a size this library derived is a cache, and a # append invalidates it
This is what froze his client. The bound named `Cinematic/Camera.lua:2597`:

    while turnStarts[1] and now - turnStarts[1] > WINDOW do
        table.remove(turnStarts, 1)
    end

`remove` drains the table and remembers a size of 0 — faithfully, that is
`luaL_setn`. The addon then appends with `turnStarts[#turnStarts + 1] = now`,
which is a raw set this layer never sees, so slot 1 is full while the
remembered size is still 0. `remove` reads 0, takes its "nothing to remove"
path, changes nothing, and the caller spins forever. 15 s of Lua in every
frame, for the whole session, in a loop that in the reference cannot fail.

It cannot fail there because 5.0 has no `#` operator: every append goes through
`insert` or `getn`, so a remembered size is always the truth. The collision is
a later-era addon's `#` meeting 5.0's bookkeeping, and it also loses data the
quiet way round — `insert` after a `#` append writes *over* the appended slot
instead of after it, which is a test here now too.

So the two kinds of size are kept apart. One this library **derived** from an
`insert` or a `remove` is a cache: if `t[n + 1]` is not nil the table has grown
since, and the count wins. One an addon **declared** with `table.setn` is
honoured exactly as before, because `setn(t, 0)` as a logical wipe over full
slots is the entire point of it — AceLibrary and Mik's capture tables both turn
on that, and both still pass. An explicit numeric `t.n` field is a declaration
too, and is untouched.

Measured: the corpus roster is byte-identical (87 ok / 1 fail, the failure the
pre-existing AdvancedTradeSkillWindow2), and the drain loop that spun 44 times
in his log now ends in one removal a round.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
2026-11-02 08:03:09 -07:00
gromandClaude Opus 5 d1edd74cce ui: a runaway frame writes its own line, and stops costing the whole bound twice
Two of his sessions froze this morning with nothing in the log. The journal
says what happened: every frame 15,0xx ms, `main_ms` 14,970 of it, GPU 3 ms,
the main thread `state R` with 64 s of CPU in 68 s of wall. That dead-constant
15.02 s is `RUNAWAY_FRAME_SECS` — a Lua loop that never returns, re-entered the
next frame and the next.

The bound did its job and said nothing, because its raise unwinds to whatever
`pcall` caught the call. On this corpus that is always an addon's own guard, so
the trip became nobody's script error: `session errors: 0`, no log line, no
file, no line number. `WOW_UI_RAISES` would have caught it and is `eprintln!`
only, which is no use to someone who is in the game.

So the hook writes its own report before raising, and the host logs it at
`error!` beside the ordinary error drain. Each kept report carries a sixteen
frame traceback, because the innermost line is usually inside a library and the
handler that entered the loop is the name worth having.

And the bound tightens to a second once it has tripped. The first one has to be
far beyond any legitimate frame, which makes it far beyond playable: a loop is
re-entered every frame, so the player gets one frame every fifteen seconds —
indistinguishable from the freeze the bound exists to prevent, and that is
exactly what he got. One trip has already proved the call non-terminating; the
ones after it need only enough rope to say so. A second is still some 125M VM
instructions, two orders past the heaviest handler ever measured here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
2026-11-02 07:21:48 -07:00
gromandClaude Opus 5 75ba592fba ui: the Era map canvas over 1.12's own world map, and the Line region
What 1.12 withholds here is a framework, not a verb. Its world map is a fixed
1002x668 frame showing one zone — twelve tiles, a hit button, POI buttons, two
dropdowns — with no canvas, no pan, no zoom, no pins and no notion of where one
map sits on another. Every map addon written after 4.0 drives the canvas
framework instead, and there is no stock file to migrate from because there is
no stock feature.

The constraint that shaped every decision: `WorldMapFrame` already has eight
1.12 consumers on this install, all anchoring icons to `WorldMapDetailFrame`
and `WorldMapButton`. So nothing is replaced. The canvas is added, and those
two frames are reparented INTO it — at rest the rect they had, at scale 1 with
no pan, so the stock window is pixel-identical, and the payoff is that the 1.12
art and every icon on it now pan and scale WITH the canvas for free.

Clipping comes from a real ScrollFrame: this client clips a scroll child and
every descendant of it from one source, in the draw pass and the hit-test, so
drawing and hitting agree. The base-art layer a canvas addon hides is a proxy
that switches draw layers rather than the frame, because hiding the frame would
take the POI buttons and every addon's icons with it.

Three regressions, each found by measurement, each pinned: the anchor cycle
(container to detail frame to canvas to container — a cycle resolves to
nothing, and the symptom was `GetCenter()` nil inside the stock hover pass); the
base scale (a minScale below 1 opens the map zoomed out of its own window); and
`dataProviders` being a set rather than an array, because callers iterate it
with `pairs` and bind the key — an array handed that loop the number 1.

`CreateLine` is a Texture carrying two endpoints, drawn through the
explicit-corner quad path the cooldown wedge already uses. Nothing stands in
for it: a line between two moving points is a rotated quad whose angle and
length both change. `C_Texture.GetAtlasInfo` answers nil for every name, and
the nil is the answer — but the getter's presence is what makes the common
atlas guard take its fallback branch instead of raising.

The harness grew a second surface, and the finding is worth keeping: the full
screen map hides `UIParent`, so opening it in the ordinary render probe dropped
the corpus's draw column from 37/86 to 18/86 — nineteen unrelated addons. As
its own pass: no addon lost a quad and nineteen gained, pfQuest 374 -> 911,
pfUI 1063 -> 2259.

OpenMapClassic now reads 1/1 on all five columns, stitching 74 of the realm's
own zones. Corpus roster unchanged at 85 ok / 1 fail; the 12 data-half reds
byte-identical to HEAD's.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
2026-11-01 22:21:35 -07:00
gromandClaude Opus 5 b49856c5cb ui: texture masks, the later-era texture dials, and the pools a canvas map needs
The mask pair is the one that matters and nothing can stand in for it. 1.12 has
no texture masking at all — its single masked thing is the minimap disc, cut
engine-side — and no idiom reaches the same end, because the point is that a
rectangular texture draws as a non-rectangular shape. A seamless world map is
that operation and nothing else: a zone's art is a rectangle of parchment and
the map wants it cut to the zone's coastline.

It was affordable because the render pass already had the slot. `UiQuad::mask`
is a screen-anchored alpha mask built for the minimap's fixed circle with tiles
panning under it, which is exactly `AddMaskTexture`'s semantics — the mask is
another region with its own anchors, and the subject keeps the fragments its
alpha covers at that place. So this is plumbing, not a new material.

One mask per quad, said rather than hidden: widening it is a second texture
binding and a second uniform on every UI quad in the client. The list is kept
whole, because the order decides the winner — first added, which for a stitched
map is the land mask. Two traps are pinned by tests: a mask region is skipped at
extract rather than marked hidden, since its rect is what it covers; and a mask
whose art is missing drops the mask, not the quad, because zero coverage would
blank the art it was meant to shape.

`Texture:SetRotation` was nearly free — the renderer has carried the angle since
the minimap arrow, so this is the Lua setter for it. `SetDesaturation` is the
fractional form of 1.12's own flag, rounded onto the one flag this renderer has,
with the getter answering the caller's own number. The tile pair is applied: the
host computes the repeat count once the art has loaded.

`SetPropagateKeyboardInput` is stored, not applied: 1.12 already always
propagates, so there is no swallow mode for false to select. `EditBox:HasFocus`
publishes a fact this client already tracks and never exposed.

Corpus: 85 ok / 1 fail, roster and digest unchanged. The 12 data-half reds are
byte-identical to HEAD's.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
2026-11-01 21:15:55 -07:00
gromandClaude Opus 5 87f93fea46 compat: the uiMapID graph over the realm's own WorldMapArea, and C_Map's other half
1.12 addresses a map by a (continent, zone) pair of dropdown indices — a
position in two lists, which moves when a zone is inserted — and publishes no
map's rectangle on any other map at all, because its window draws one zone at a
time into a fixed 1002x668 frame and never needs to know where that zone sits.
A seamless, pannable map is exactly that missing relation.

`script/uimap.rs` builds the tree as a pure function of the catalog
`ui_world_map.rs` already pushes, rebuilt inside both setters so the graph and
the catalog cannot disagree. No new DBC read, so nothing here can contradict
what the stock map window itself draws.

It is built from OctoWoW's own rows, which is the point: 175 against stock
1.12's 51, with 124 maps added and 49 shipped rects re-tuned. Era's uiMapIDs
are reused where the map is the same map, joined on the WorldMapArea art folder
rather than the zone name, because the folder is the column a realm has no
reason to touch — this realm spells Azshara's `Aszhara` and Hillsbrad's
`Hilsbrad` exactly as 1.12 does. A row Era has no counterpart for is minted
from its own AreaTable id.

One realm quirk is handled with its measurement: a third `areaID == 0` row
whose art folder is `World` is the sheet's own row, which the reference's
catalog walk files beside the two continents — `SetMapZoom(3, 0)` then
`GetMapInfo()` answers `World`. Left in, it is a phantom continent carrying a
second copy of all 39 Eastern Kingdoms zones.

Art is answered as paths, so the chain serves the upscaled pack; the layer
geometry is the logical 1002x668 / 256x256, because normalized fractions are
invariant under a uniform upscale.

The harness now seats the player a world position and the per-frame map feed,
because every verb that answers *where* the player is reads the position, not
the zone name. Corpus: 85 ok / 1 fail, roster and digest unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
2026-11-01 20:38:22 -07:00
gromandClaude Opus 5 0886952611 ui: a numeric texture argument is a file id, not a colour channel
With this and the font-object rename below, **OpenMapClassic loads clean, survives a session
start and survives a UI probe** — from dying on its first file, inside Ace3, before a line of
its own code ran.

Later clients gave every shipped file a numeric id and addons pass those instead of paths. Both
of our texture setters read a number as the **colour** arm, and they failed differently, which
is why this is worth a module of its own:

- `region/paint.rs`'s `SetTexture` needs *two* numbers for a colour, so a lone
  `SetTexture(130763)` fell through to the catch-all and drew **nothing**.
- `button.rs`'s slot setters take one number as a colour with green and blue defaulting to 0,
  so `SetNormalTexture(130763)` painted a **solid block** where the button art belongs. That is
  the arm Ace3 calls, and it failed silently: nothing raised, the art was simply wrong, which is
  the worst way for this to go.

**The rule**: a lone numeric argument of 2 or more is a file id. A colour channel is 0..1 and
every id a client ever minted is six figures, so the two cannot collide and `SetTexture(0.5)`
keeps answering exactly as 1.12 does.

**The table is transcription, not invention.** Every numeric literal passed to a texture setter
anywhere in the installed corpus *and* in both `Addon_Ports` addons — 21, all from Ace3/AceGUI
— is annotated by its own author with the path it means:

    button:SetNormalTexture(130763) -- "Interface\Buttons\UI-DialogBox-Button-Up"

So the ids stay the real ones rather than something of ours, and the source is the call site. An
id with no row draws nothing and records a warning naming it, because a wrong texture is harder
to notice than a missing one — and that warning is what says which id to add. `GetFileIDFromPath`
answers only what the table knows and nil otherwise: minting a synthetic id would hand back a
number no other client agrees with.

**`SetNormalFontObject`** is the later-era spelling of 1.12's `SetTextFontObject` (`0x780400`)
— the one name this family left behind, since `SetHighlightFontObject` (`0x780700`) and
`SetDisabledFontObject` (`0x780580`) are 1.12's own. It hands out the same function value, so
the spellings cannot drift. Ace3 calls it on every button it builds
(`AceConfigDialog-3.0.lua:586`), one line above the `SetNormalTexture(130763)` above.

What openmap still cannot do is **draw**: it does not build a map, it re-skins Era's canvas
`WorldMapFrame` (`Map/WorldMap.lua:823`), and that canvas does not exist here. That is the map
stack, not this.

Corpus: 100 addons, 85 ok / 1 fail, roster identical. benilla-ui 2085 passed. Chain green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
2026-11-01 18:43:51 -07:00
gromandClaude Opus 5 4d19090259 compat: the cast-info pair as bare globals, and IsMouseButtonDown over a real feed
Cinematic's last two session errors. With these it loads clean, survives a session start,
survives a UI probe and **draws** — all four of the harness's runtime columns.

**`UnitCastingInfo`/`UnitChannelInfo` as bare globals** is an export, not a feature: the
namespaced pair was already built with the whole contract in `cast_info` (the two return
shapes, the millisecond unit, every position this client leaves nil and why), and these hand
out the **same function values** the `C_Spell` table holds, so the two spellings cannot drift.
Six calls of each in `Cinematic/Camera.lua` and its siblings, unguarded. `CastingInfo`/
`ChannelInfo` stay namespace-only: nothing on this install calls them bare, and pfUI reaches
the one it wants through the namespace (`libdebuff.lua:887`).

**`IsMouseButtonDown` needed a feed before it could be built honestly, and the reason it was
declined is why.** `compat/classic_api/input.rs` had said no on the grounds that the only
press state here is `Model::mouse_down_on`, which the pointer layer fills *only* for a press
that landed on a frame — and `Cinematic/Camera.lua:612` asks in order to spot a right-drag **on
the world**, which is exactly what that map cannot see. Answering from it would have been wrong
for the one caller.

So `Model::mouse_buttons` is new: `[Left, Right, Middle, Button4, Button5]`, pushed each frame
from `ButtonInput<MouseButton>` beside the modifiers. Placed there deliberately rather than
with the pointer feed below it, because that feed is skipped for a cursor off the window or a
hidden UI, and "is the right button still held" has to stay true through both — which is the
camera's question. Both spellings (`RightButton` and `Button2`), no argument means any button
as upstream's does, and a button no device has answers not-down rather than raising.

The reference capture of the real client's `_G` has no mouse-button getter at all, and
`ClassicAPI.dll` carries the name, so it is compat's rather than a 1.12 gap.

Corpus: 100 addons, 85 ok / 1 fail, roster identical. benilla-ui 2082 passed. Chain green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
2026-11-01 18:43:34 -07:00
gromandClaude Opus 5 482ad0bc5c sound: the later-era Sound_ CVar names, which three installed addons already wanted
`GetCVar("Sound_MusicVolume")` answered nil, so `Cinematic/Core.lua:3621` raised inside a
`format` and took the rest of its `PLAYER_LOGIN` handler — the music restore — with it. 2.x
renamed the sound CVars with a `Sound_` prefix and 1.12's names stayed what they are; Cinematic
is a multi-flavour addon and uses the modern spelling at **all 47** of its music sites, which
is the whole of its music-fade feature.

**This is a fix for the install, not a concession to the port.** Three addons already here ask
with the modern spelling and have been reading nil: DoiteAuras' editor and Tmog
(`Sound_MasterVolume`, `Sound_DialogVolume`, `Sound_EnableDialog`), SoftSpoken
(`Sound_EnableAllSound`, `Sound_SFXVolume`, `Sound_EnableSFX`).

Ten rows, each on the footing the `BackgroundSound` pair in this file already set: two names,
one knob, dispatched together in `sound/mod.rs`'s observer so the spellings cannot disagree
about a setting. Each default is its 1.12 sibling's, since they are the same setting twice.

Two honest compromises, labelled where they live: 1.12 has no dialogue channel, so
`Sound_DialogVolume` rides the SFX volume and `Sound_EnableDialog` the error-speech toggle; and
it has no SFX-only enable, so `Sound_EnableSFX` rides `MasterSoundEffects`, the only sound
enable it has. Riding what the mixer actually has beats inventing tracks it has not got.

**`GetCVarBool`** is absent from 1.12 on this repo's own evidence —
`reference/1.12-globals.tsv` is a capture of the real client's `_G` and carries `GetCVar` and
`GetCVarDefault` and nothing else of the family — and present in `ClassicAPI.dll`'s string
pool, so it belongs to the client this build announces itself as. It answers a real boolean, as
the name says, and **nil for a name with no row**, so "there is no such CVar" stays
distinguishable from "it is off". `Cinematic/Camera.lua:493` reads `ActionButtonUseKeyDown`
through it.

Not built, and deliberately: `IsMouseButtonDown`. `compat/classic_api/input.rs` already
declined it with the right reason — the only press state here is `Model::mouse_down_on`, which
the pointer layer fills *only* for a press that landed on a frame. `Cinematic/Camera.lua:612`
asks in order to spot a right-drag **on the world**, which is exactly what that map cannot see,
so answering from it would be wrong for the one caller. It wants a real mouse-button feed.

benilla-ui 2081 passed. Full chain green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
2026-11-01 17:14:50 -07:00
gromandClaude Opus 5 139965f415 ui: PreClick/PostClick, and a comma-separated template list
Two of the `Addon_Ports` pair's load blockers, both measured with `addon_harness` either side.

**`PreClick`/`PostClick`** follow the convention `SCRIPT_KINDS` already set for names that are
not 1.12's — `OnHyperlinkEnter`/`Leave` sit there with the same justification: a later client
added them, addons register them at load, and refusing the name raises and takes the rest of
the file with it. `Cinematic/MenuWheel.lua`'s wheel slots are the callers here. They arrived
with the secure template system so a button could act either side of the protected part;
nothing protected sits between them here, so the order is all they are.

`button::click_button` fires each of the three **separately**, so one that raises is recorded
and the next still runs: the pair exists to bracket the click, and a broken bracket must not
eat it. That is the same reasoning the attribute-click dispatch below it already carries.

**A comma-separated template list**, which 1.12 does not take: its `inherits` is one name, and
`object.rs`'s lookup is a transcription of that (`0x6ee6f0` at `0x7061dd`, no splitter). Era
takes a list and applies them in order, and that is what an addon written for Era passes —
`Cinematic/MenuWheel.lua:312` asks for
`"SecureHandlerShowHideTemplate, SecureHandlerAttributeTemplate"`.

**The evidence is weaker than `compat/`'s other relaxations and the code says so.** The
one-argument `SetPoint` rests on the real `ClassicAPI.dll` writing that form itself; this rests
only on Era doing it and the addon targeting Era — nothing in the DLL's own Lua passes a list,
and **no addon in the installed corpus does either**, which is also why it can break nothing.

Safe by construction: the split is attempted only **after** the verbatim name misses, so every
existing call takes exactly the path it did before and a template whose own name contained a
comma would still win. One unknown name anywhere in the list and the whole call misses,
reporting the literal **unsplit** and leaving no frame behind — so a typo reads as a typo
rather than as a half-built frame.

`a_comma_list_is_one_name_and_a_single_name_still_chains` was a deliberate, traced pin on the
old behaviour, so it is rewritten rather than deleted. It now pins the shape of the relaxation:
the verbatim name tried first, both templates applied in the order written with `$parent`
resolving against the instance, a list with one unknown name still missing unsplit and leaving
nothing behind, and a plain unknown name unchanged. Its doc states the weaker footing, which is
why the miss path is pinned that closely.

benilla-ui 2081 passed. Full chain green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
2026-11-01 17:14:32 -07:00
gromandClaude Opus 5 af823dd708 compat: the secure-handler family and the animation surface
Both modules land together because `classic_api/mod.rs` and `widget_surface.rs` carry rows for
each: split, the first commit named a module the second added, and would not have compiled.

## The secure-handler family, because 1.12 has nothing to be secure from

`frame.rs` declined it with a stated reason — "modern restricted execution, which 1.12 has no
equivalent of" — and that is right about the *sandbox* and is the whole of why it is now built.
A secure handler exists so an untainted snippet may call **protected** functions; 1.12 has
neither taint nor protected functions, so the restriction has no counterpart here, while the
plumbing underneath (a named frame store, a snippet on a child's handler) is ordinary
behaviour the snippets need. The body runs as plain Lua with the names the secure environment
would bind.

**The per-header shared table is the part worth getting right.** `MenuWheel.lua:484` writes a
bare `held = false` inside a snippet, and in the real secure environment that lands in a table
per header, not `_G`. `setfenv` onto one table per frame, `__index` to `_G` so the rest of the
API resolves; the test asserts `held` is **not** a global afterwards. My first test asserted
through `_G` and failed — the implementation was right and the test measured the wrong thing.

**`SecureActionButtonTemplate` is not empty**: reading your own attributes on click is plain
dispatch. `type="click"` clicks the frame in `clickbutton` (how the wheels reach the game's
micro buttons), and the lookup follows upstream's order (`type-<button>`, `*type*`, `type`).

**That exposed a real bug of ours**: `SetAttribute` silently dropped a frame.
`attribute_value` kept bool/int/number/string and sent everything else to `ScriptValue::Nil`,
so `SetAttribute("clickbutton", SomeFrame)` stored nothing — and upstream's `clickbutton` is
routinely a frame, which `Cinematic/MenuWheel.lua` relies on. A widget table is now kept as
`ScriptValue::Frame(id)`, the variant `NAME_PLATE_CREATED`'s arg1 already uses, which
round-trips through `object::frame_wrapper`. A non-widget table is still nil, documented.

**Not built, named rather than left to be discovered**: the binding half (`ClearBindings`,
`SetBindingClick`, …) are no-ops — no override-binding layer here to write into — so a wheel
opened with the mouse works and hold-a-key-and-release does not; and the `_onshow`/`_onhide`
attribute snippets are not run, since `SetAttribute` is a plain store with nothing to drive
them.

Templates in `assets/ui/CompatTemplates.xml`, listed on that wall with the reason: no stock
file to migrate from, and XML rather than `compat/` only because `inherits=` is loader-resolved.
A trap caught before shipping: `BackdropTemplateMixin.GetBackdropColor` forwarding to
`self:GetBackdropColor()` calls **itself** once `Mixin()` copies it onto the frame — the stock
getters answer, so there are no getter rows, and a test mixes in and reads back.

## `CreateAnimationGroup`: the surface, with nothing animated

1.12 has no animation system and this UI pass has no interpolation to hang one on. The cost is
the **absent constructor**: `Cinematic/Options.lua:7106` builds a rotating spinner and the nil
method aborted the whole options panel. Measured first — that is the addon's only animation in
55,661 lines, so this is a surface for a decoration.

**A non-looping group finishes the moment it plays**, firing `OnFinished`, because nothing
moves and that is the reading that keeps an addon's flow right; one that chains its next step
off `OnFinished` would otherwise wait forever. A looping group plays until stopped. Stated in
the header, since the name implies motion: a spinner does not spin, a fade appears at its
destination, a sequence lands on its last frame. Setters and getters round-trip.

Corpus: all 100 of grom's addons surveyed before and after — 85 ok / 1 fail, rosters
identical. benilla-ui 2081 passed. Full chain green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
2026-11-01 17:12:13 -07:00
gromandClaude Opus 5 64f07d9864 compat: SetPoint("POINT") with nothing after it, because ClassicAPI writes it
**A deviation, and the reasoning matters more than the code.** 1.12 raises `Usage:` on a
one-argument `SetPoint`, and that is traced rather than assumed: the `lua_type(L,3)` tag gate
at `0x7a25e6`..`0x7a2620` accepts four tags and an absent argument is none of them, which
this file's own header has said all along. Later clients read it as the default parent at the
same point with no offset, and that is what every Era-era addon writes —
`Cinematic/CameraWheel.lua:243` is `face:SetPoint("CENTER")`.

Allowed because **the real `ClassicAPI.dll` writes that form itself**: its embedded Lua
carries `f:SetPoint("CENTER");` and `tx:SetPoint(q[4]);`, so on the client this build already
announces itself as (`CLASSIC_API_VERSION` is set) the one-argument form has to work, or
ClassicAPI's own windows would not build. Claiming that client and withholding this is the
inconsistent position `stdlib_backport.rs` settled for the stdlib half.

It costs nothing in the ladder because of how it is spelled: the leg resolves to exactly what
the reference's own **three-argument** shorthand does (`RelTarget::DefaultParent`,
`0x7a270e`) — default parent, `rel_point` defaulted to `point`, offsets falling through to
(0, 0). No anchor shape is added; one spelling is mapped onto another the reference answers.

**It lives in `parse_set_point` and not in `compat/`, and that is load-bearing.** My first
attempt wrapped `SetPoint` per method table and `tests::region_map` caught it: the reference
registers it once in the Region method table, so every widget type runs the same function
object, and three wrapped copies broke an identity `region_map.rs` maintains on purpose (an
addon can pull `WorldFrame.GetHeight` off and apply it to a Texture). The header now says so,
so the wrapper route is not tried again.

Two tests pinned the old behaviour and are rewritten rather than deleted:

- The trailing-nil test gets **stronger**. It existed because `lua_type` tells `LUA_TNIL`
  (the screen root, `0x7a2710`) from `LUA_TNONE`, and the ladder must see the count Lua saw.
  Both forms resolve now, so it resolves a real layout and asserts they land in *different*
  places: the explicit nil at the screen's corner, the absent one on its parent.
- One genuine ordering change, asserted: with no third argument there is no tag to gate, so
  `SetPoint("MIDDLE")` reports "Unknown region point" where it reported `Usage:`. The
  tag-gate-runs-first claim is kept for a *bad* tag, `SetPoint("MIDDLE", true)`.

Movement: Cinematic's load `CameraWheel.lua:243` → `MenuWheel.lua:312` (the secure-template
family, which `compat/classic_api/frame.rs` declines with its own reason).

benilla-ui 2077 passed, 0 failed. Full chain green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
2026-11-01 14:46:07 -07:00
gromandClaude Opus 5 e731a77a42 compat: RegisterUnitEvent, the fixed-strata pair and SetWordWrap
The next three blockers of the `Addon_Ports` pair, each measured with `addon_harness` before
and after rather than picked off a list.

**`RegisterUnitEvent(event, unit1 [, unit2])`** — Cinematic's load blocker, and
`docs/ADDON_COMPAT.md` already had it on the "plausible and untraced" line. The registration
is the stock one: same `event_to_frames` list, same registration order, and
`Model::frame_unit_events` holds the units per frame per event. `tick.rs`'s walk skips a
frame whose row for this event does not name `arg1`; the map is empty on an install with no
such addon and the walk tests `is_empty()` first, so the common path costs one load.

All three unregister paths drop the row, including the one that is easy to miss: a plain
`RegisterEvent` after a unit register **widens** the registration back, as upstream's does —
the frame keeps its place and loses the filter. A row surviving an unregister/re-register
cycle would swallow that event forever, so the test walks the whole cycle.
`Cinematic/Frames.lua:2611` registers `UNIT_AURA` for `"player"` at file scope, so the nil
method stopped the file dead.

**`SetFixedFrameStrata`/`SetFixedFrameLevel`** are no-ops, and that is the honest answer
rather than a stub: 9.0 added them to tell the *frame manager* to stop re-deciding a frame's
strata and level, and this client has no such manager — both are whatever the setters last
wrote and nothing else rewrites them, so the guarantee the caller asks for already holds.
Ace3 calls both unguarded (`AceConfigDialog-3.0.lua:575-576`).

**`SetWordWrap`/`CanWordWrap`** is 3.x's whether-to-wrap switch; 1.12's `SetNonSpaceWrap` is
a different question (where a wrap may fall, not whether one happens). **Stored, not
applied**, on the footing `SetSpacing` and `non_space_wrap` already set in `types.rs`: the
text layout wraps to the declared width unconditionally and has no one-line-and-clip mode for
the flag to drive, so honouring it would mean inventing a layout mode. Kept because the pair
must round-trip and because the missing *setter* is what costs —
`Cinematic/Options.lua:4130` calls it on a label and aborted the options panel there.

All four are registered in `widget_surface.rs`'s `ALLOWED_BEYOND` with their call sites. The
first `RegisterUnitEvent` row said "Frame" and the wall rejected it: event registration lives
on the one shared frame method table, which is where 1.12 keeps `RegisterEvent` too, so the
two are registered alike on all twenty frame kinds or neither is.

Movement: Cinematic's load `Frames.lua:2611` → `CameraWheel.lua:243`, session
`Options.lua:4130` → clear; openmap `AceConfigDialog:573` → `:575`.

benilla-ui 2077 passed, 0 failed. Full chain green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
2026-11-01 14:45:36 -07:00
gromandClaude Opus 5 79e2494fc6 compat: math.huge and SetColorTexture, the two load blockers of the ported pair
Measured through `addon_harness` before anything was written, which is the only reason this
is two small changes rather than a guess at a list. Both `Addon_Ports` addons failed to load
at all; these are their first two stops.

**`math.huge` was nil, deliberately.** `stdlib_backport.rs` restores the 5.1 stdlib members
ClassicAPI backports, scoped to what the corpus calls, and `math.huge` was on the stated
"nothing calls them" list. `Cinematic/Core.lua:1103` is `local lastMovingOff = -math.huge`
at **file scope**, so the nil stopped the addon's main file dead ("attempt to perform
arithmetic on field `?`") and nothing after it ran — one site, but a load-time one, which is
the bar that table sets. The real `ClassicAPI.dll` carries `huge` in its own stdlib name
table beside `fmod`, `modf`, `gmatch`, `match`, `select`, `maxn`, `cosh` and `reverse`, so
this is what the client we already claim to be actually has.

Two tests pinned it absent and are updated rather than deleted: `lua50.rs`'s keeps asserting
this *layer* removes it (the registry stash is the proof), and `stdlib_backport.rs`'s keeps
the narrowness assertion for the four that stay gone. The new assertion is `-math.huge`,
because file-scope arithmetic is the shape that failed.

**`Texture:SetColorTexture`** is the modern spelling of `SetTexture`'s colour arm, which
1.12 already has (`0x770360`'s 8×8 solid surface, and `region/paint.rs` defaults the alpha
to 1 as it does). Retail split the names when `SetTexture` stopped taking a colour, so every
Era-era addon writes the new one. `Cinematic/Options.lua:701` paints its list border with it
and died at session start; OpenMapClassic its map chrome. Four lines of the same composition
chunk `SetShown` and the geometry pair use, and an `ALLOWED_BEYOND` row with both call sites
— `docs/ADDON_COMPAT.md` had it on the same "plausible and untraced" line as
`RegisterUnitEvent`, and this is that line's follow-through.

Movement, same harness both ways:
- Cinematic load: `Core.lua:1103` → `Frames.lua:2611` (~10 files further; wants
  `RegisterUnitEvent` next). Session: `Options.lua:701` → `Options.lua:4130` (3400 lines on,
  wants `SetWordWrap`).
- OpenMapClassic: unchanged at `DialogBorderOpaqueTemplate`, which neither fix touches.

benilla-ui 2076 passed, 0 failed. Full chain green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
2026-11-01 14:19:56 -07:00
gromandClaude Opus 5 e646b5a678 compat: the usable pair tells the truth — 1/0 from nampower's verb, the walk behind C_Spell's
Two defects in one pair of verbs, both found by reading what grom's addons do with what we
answer rather than from a report. A DLL whose source is not to hand still has its contract
written down: in the addons written against it.

**`IsSpellUsable(id|name)` answers two numbers.** Ours gave 1/nil for the first — transcribed
from `IsUsableAction`'s own convention — while already giving 0/1 for the second. Four
readings in the corpus say otherwise, and `AlternativeClassicActionBars/Button.lua:1907` is
the clearest: `elseif usable and usable ~= 0 then` is not a line anyone writes against a
function that answers 1/nil. `SuperCleveRoidMacros/Core.lua:1035` labels its own branch
"IsSpellUsable returned 0 = wrong stance/form, not enough power type", reading the value
straight out of `pcall(IsSpellUsable, …)`. And the one with teeth: DoiteAuras'
`_SafeSpellUsable` keeps a verdict only `if ok and u ~= nil` (`DoiteConditions.lua:970`), so
a nil sent it down a fallback chain ending at `:1022`'s "treat as usable so icon doesn't die
forever" — **every** Usable-mode aura lit on cooldown alone, stance, rage, range and reagents
ignored. Answering the wrong shape and not answering at all land in the same place.

0 is truthy in Lua, so that was checked before changing it: no caller tests the value for
truth unguarded, and the rest compare `== 1`. The DLL's string pool publishes the signature
and the name-lookup error but not the return shape, and the five mirrors of the fork's
`SCRIPTS.md` I could reach are each behind a Gitea login or 404 — the corpus was the better
source. A name it cannot resolve raises there, which is why every caller wraps the call in
`pcall`; ours answers `nil, nil`, and all four callers treat those identically.

**`C_Spell.IsSpellUsable` was computing the wrong thing and raising on its caller's
argument.** It was approximated as "known and not on cooldown" — the one reason upstream does
*not* count (its own list is "hasn't learned it, lacks mana or reagents, reactive conditions
haven't been met") and none of the ones it does. It now reads the same per-spell map the
global does, the reference's usable walk (`0x6e3d60`), which agrees about cooldowns: "a
running cooldown never greys here" (`spell/usable.rs` leg 11), leaving `GetSpellCooldown` the
question about cooldowns.

That makes it the honest answer for a reactive proc, which is exactly what its only caller
asks: Overpower greys on the caster's combo-point byte (leg 5, `PLAYER_FIELD_BYTES` byte 1,
the byte the server sets when the proc banks), so the walk answers it inside the proc window
and nowhere else, while "off cooldown" showed it nearly always. pfUI's reactive indicator
(`modules/nampower.lua:130`) passes a **name** against a `u32` signature, which mlua refuses
for a non-numeric string — a raise once per frame while the indicator was switched on.
`spell_ident` is shared now, so a name resolves through the book as the global's does.

Both returns there stay booleans, unlike the global's: the caller hands the first straight to
`SetShown`, where a numeric 0 would be truthy and show every icon forever. Two conventions,
each its own caller's, written down where they live.

Tests: the global's pair is asserted as `1|0` and `0|1`, with the callers' own accept test
(`pcall` + `u ~= nil`) quoted as an assertion; `C_Spell`'s is asserted over Overpower outside
and inside the proc window with nothing about cooldowns changing, by id and by name, plus the
not-in-book case answering false rather than raising.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
2026-11-01 13:11:00 -07:00
gromandClaude Opus 5 315ebe9138 ui: an <IMG> with an empty src names no file
`src=""` was stored verbatim, so the region carried `texture: Some("")`, the resolve asked
the size probe for a nameless file and the renderer searched the patch chain for one and
logged `texture miss: ''` — a defect-shaped warning that can name neither a file nor an
asker. `""` clears on every other texture setter here, as the reference's path arm treats
it like nil (`0x770200`), and `a9ed091eb` closed the three that had stored one; this was
the last route in the UI that still could.

Found by sweeping the rest rather than by a new sighting, and two of the leads this list
carried are now dead: the `CursorPayload` arm it called most likely is unreachable in a
live session (`extract/mod.rs:1020` guards the whole block with `if capture.is_some()`),
and `PortraitSource::File` cannot be empty — `temporary_portrait` always returns a `.blp`.
The M2 and WMO sides filter at parse, and `texture_url("")` would read `'.blp'` rather than
`''`, so the warning's own text says the UI sprite path is where it came from.

Whether this closes the one line in grom's log is unproven: it sat in `benilla-2.log` at
+20.717s, that run may predate `a9ed091eb`, and three measurement launches have since
rotated his logs out. The test is the next sighting: with the filter removed it reports
`texture: Some("")`, and in the fixture the nameless path also reserved the probe's 48 px
of flow under the image — in game the probe answers nothing for a miss, so only the chain
search and the warning were real.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
2026-11-01 12:59:29 -07:00
gromandClaude Opus 5 d2414d9f50 stable: the realm sells four pet slots, and our window drew two
Found by the `StableSlotPrices.dbc` golden: the table has four rows where 1.12
had two, and `NUM_STABLE_SLOTS` was 3 (the current pet plus two). Three
independent sources in the install agree on four, so this is not a guess:

- `StableSlotPrices.dbc` ships 4 rows — 500c, 5g, 250g, and a fourth at one
  copper — against 1.12's two.
- the realm's own `PetStable.lua` (patch-4) declares `NUM_PET_STABLE_SLOTS = 4`.
- the realm's `WoW.exe` caps the purchase at four, and `0x4cb0bf` reads straight
  across: `cmp $0x4,%eax; je refuse` on the bought count, then `inc %eax` and
  `cmp 0xc0d728,%eax; jg refuse` against the price table's max id — which is
  exactly `num_stable_slots != NUM_STABLE_SLOTS - 1 && next_slot_cost != 0`.

So the window drew two slots the server would happily sell, and
`GetStablePetInfo(3)`/`(4)` answered nothing: the last two buttons were dead.
`NUM_STABLE_SLOTS` is 5 now, the gate's comment cites the realm's binary rather
than 1.12's 2, and the test fixture helper takes however many leading slots a
case cares about so the existing three-slot fixtures still read.

Also five goldens re-recorded, two of them findings:

- **The spam filter is one line now.** patch-4 ships a 93-byte
  `SpamMessages.dbc` of one row where `patch.MPQ`'s 2161 bytes held 28, and the
  chain takes the whole winning file rather than merging ids — so Blizzard's 28
  gold-seller patterns are gone and what is left matches a rival server's
  address, whitespace-tolerant between every letter.

- **`md5translate.trs` stopped hashing.** 9794 rows against 8401, and an Azeroth
  tile now resolves to the readable path `Azeroth\map32_48.blp` rather than
  `ea283abc0bf9637c3fad5e840a65b38b.blp`. The realm ships the tile at that path
  in patch-4; 1.12's hashed copy is still in `texture.MPQ` at the same 33940
  bytes, so only the table moved and the lookup still has one job.

- ItemSet 172 -> 363 rows, and Defias Leather (161) gained a sixth item, 81007,
  one of the realm's own. Every one of 1.12's 172 sets also gained a name in
  five more locales — the same five-locale fill the realm gave `EmotesText`.
- NPCSounds 156 -> 247, purely additive; the one pre-existing row patch-4
  touched is 90, which gained two kits.

benilla-formats: 21 -> 16 failures. `cargo fmt` and
`clippy -p benilla-formats -p benilla-ui -p benilla-app --all-targets` clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
2026-10-31 19:36:30 -06:00
gromandClaude Opus 5 a9ed091ebe texture miss: '' was three setters storing an empty path
`Texture:SetTexture("")` already cleared, and so did a button's slot setters — the reference treats
nil and `""` alike on the path arm (`0x770200`). But `SetStatusBarTexture`, `SetThumbTexture` and
the two `ColorSelect` setters stored the empty string, so the region reached the extractor as
`path: Some("")`, and the renderer asked the patch chain for a nameless file.

What that cost is less than the warning suggested, and the comments say so rather than the opposite:
both the decode and the warning are cached per key, so it was one chain lock and two candidate
reads, once. The damage was the line itself — a defect-shaped warning with nothing under it, in a
client where a real `texture miss` is worth reading. pfUI blanks textures this way in a dozen
places, which is why it showed up at all.

Measured both ways: the new test reports `[""]` with any one guard removed, and three UI captures
(`ui-char`, `ui-options`, `ui-actionbar`) now log zero `texture miss` of any kind. His own
2026-10-28 log has the line once, at 20.7 s.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
2026-10-31 17:48:09 -06:00
gromandClaude Opus 5 3d6306f828 Five stock options checkboxes were inert, and GetCVar raised where it should answer
Two product fixes behind the last three red UI tests.

**`GetCVar`/`GetCVarDefault` no longer raise on a nameless ask.** The realm's own options window
depends on it: the checkbox loop calls `GetCVar(value.cvar)` for every row in its table
(`UIOptionsFrame.lua:265`), and the rows driven by a saved variable carry no `cvar` at all — so a
nil reaches the verb every time the window loads. The reference reads the argument with
`lua_tostring`, which answers NULL for anything but a string or a number, and a NULL name matches
no row, so the push is nil. `SetCVar` is deliberately left raising, and that is not a guess:
`0x488c10` checks `lua_isstring(1)` and falls into
`luaL_error("Usage: SetCVar(\"cvar\", value [, \"scriptCvar\")")`, with `0x6f4940` being `luaL_error`
itself (`where` + `pushvfstring` + `concat 2` + `lua_error`). A write with no name is a mistake; a
read with no name is a miss.

**The action-bar block now loads above the stock interface-options window**, which is the inverse
of 1.12's order and has to be. Stock patched the five multibar rows of `UIOptionsFrameCheckButtons`
from `MultiActionBarFrame_OnLoad`, under a comment calling itself a "Hack to get around load order
dependencies" — that hack is what let the window load first. The realm gutted the function
(`MultiActionBars.lua:7-12`) and wrote the readers into the table literal instead
(`UIOptionsFrame.lua:58-62`, `func = MultiBar1_IsVisible`), where they capture whatever the globals
are at the window's own load. Loaded above the bars, all five captured nil, those five checkboxes
read and wrote nothing, and `UIOptionsFrame_Save` fell through to `SetCVar(nil, ...)` and raised —
so pressing Okay abandoned the rest of the save. `reference_ui`'s shadowing gate is what kept this
honest: the first arrangement I tried let the stock window overwrite our own `UpdateNameplates`.

Also: the stock video-options window is gone as 1.12 knew it. The realm rewrote `OptionsFrame.xml`
into a searchable category panel — `OptionsFrameCategoryList*`, two scroll frames, a search box,
twenty result rows — keeping only the frame, its header and the three footer buttons. Gone with the
old page: `OptionsFrameDisplay`, `WorldAppearance`, `Brightness`, `PixelShaders`, `Miscellaneous`,
the three resolution/refresh/multisample dropdowns, `OptionsFrameSlider1..9` and
`OptionsFrameCheckButton1..18`. Worth stating rather than asserting around: **pfUI's options-video
skin walks those names and finds none of them**, so on this realm it skins nothing there.

And `MULTI_ACTION_BAR_STAND_INS` is retired — it existed to pre-create the rows that hack needed,
and nothing indexes the table before `UIOptionsFrame.lua` declares it.

All seven gates green. `ui_script` is 1113 green, 0 red.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
2026-10-31 15:25:36 -06:00
gromandClaude Opus 5 3f29824458 A padded reward row is counted out by the reference, not painted — match it
grom opened the quest I had photographed on the reference client and counted
two reward buttons where we paint three. Yesterday I told him the reference
draws the same empty button, citing the DETAILS publisher. That reading was of
the wrong layer: `0x500ef0` really does copy `count` entries in with no test on
the item id (`500f41`-`500f58`) and zero-fill the rest — but the drop happens
one layer up, in the count.

`GetNumQuestChoices` (`0x501f00`) is a wrapper over `0x5016c0`, which walks the
six 36-byte records at `0xbdf020`, tests the record's item-id dword and `je`s
out of the loop on the first zero. It is a take-while, not a filter: a zero row
hides every row behind it too, and `GetNumQuestRewards` (`0x501ec0` →
`0x5016a0`) is the same loop on the reward triple's own first dword.

The record layout is pinned off `0x501700`, the shared row reader, which
string-compares the Lua type argument against 'reward' / 'choice' / 'required'
and reads `+0x00/+0x04/+0x08`, `+0x0c/+0x10/+0x14` and `+0x18/+0x1c/+0x20`
respectively; the first dword of each triple is the item id — it is what gets
pushed into the item-cache query at `0x55ba30` — and the third is the count. So
the publisher's pad is `(id 0, display 0, count 1)`, which is exactly the
`item 0 x1, displayId 0` the instrument printed on his session.

`GetNumQuestItems` keeps `.len()`: its counter (`0x5016e0`, from `0x501f40`)
tests `+0x1c`, the *display* id, not the item id at `+0x18` — the one counter of
the three that reads the wrong dword of its own triple — and QuestItemView
carries no display id to reproduce that from.

The instrument stays, narrowed to the case that is genuinely ours: a wire row
that names a real item and still resolves to neither a name nor an icon.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
2026-10-31 08:08:41 -06:00
gromandClaude Opus 5 89dc99f4d1 What the second player actually saw: four panel entries and no nameplates
A second player's four `/errors` entries, all from one session, none of them a
fault in the two addons he was reporting:

  1. `FloatingChatFrame.xml: SetScript("OnInputLanguageChanged") unsupported`.
     Ours. The slot is real 1.12 and the *realm's own* FrameXML sets it on
     `ChatFrameEditBox`, so every player met a red error on every login for a
     line none of them wrote. `SCRIPT_KINDS` now accepts it and never fires it,
     which is also what the reference does — a client with no IME never fires it
     either. The rule the old test stated is unchanged and restated: a 1.12 slot
     benilla neither fires nor finds in a shipped file still raises.

  2. `bindings.xml: root element is <Bindings>, not <Ui>; tolerated`. Every
     `Bindings.xml` in the corpus has that root and `bindings_xml` reads it
     correctly. No longer said.

  3. `bindings.xml: Unknown frame type: Binding`, counted 69. Faithful — the
     reference logs the same line (`0x871124`) for every row while `0x51f400`
     registers them separately — and ours registers them too:
     AlternativeClassicActionBars' 69 bindings all answer `GetBinding`. Now a
     trace, so the terminal keeps it and the panel does not.

  4. `SetOwner: unknown anchor 'BOTTOMRIGHT' on owner 'WorldFrame'`, counted 292.
     The hunt for that caller ended a while ago: it is `Shop_ProcessEntries` in
     the realm's own `Turtle_ShopUI`, off the player's patch chain, and the
     reference accepts an unknown anchorType silently (mode 0, ANCHOR_LEFT), as
     we do. The diagnostic has done its job; it is now host-log-once
     (`Model::warn_host_only_once`) and not retained.

The panel is where someone looks to find what is broken in *their* interface.
A line that is faithful, or names a file they cannot reach, crowds out the ones
that are theirs.

----

A second player sent a video: Options → Nameplates with all six boxes ticked,
the V binding set, and no plates at all — while the overhead *names* drew fine.

`VPlateMode::default().range_sq` was `0.0`, and the gate is
`distance_squared > range_sq`. Zero is not "no cull"; it is "cull everything, at
any distance". `NameplateRange` is a registered CVar with default `"80"`, and a
registered default is not written to `config.toml`, so no `CvarChanged` ever
fired and the field never left its derived value.

This is the trap the paragraph three fields above already documents, and which
`occlusion` and `spread` were fixed by writing their registered defaults into
`Default`; `range_sq` was left behind. It stayed hidden because
`NameConfig::default()` — the overhead names — already carried `80.0 * 80.0`, so
names drew and only plates were missing, which reads as a nameplate-addon
problem rather than an engine one. It could not be reproduced here either:
grom's own `config.toml` carries `NP_NameplateDistance = "62"`, which seeds the
same field through the other observer arm.

`the_plate_range_default_agrees_with_its_registered_cvar` now pins the default
to `registered_pairs()`'s row and pins the plate and the name culls to each
other, since a disagreement between those two is what made this read as
somebody else's bug.

It also answers the other half of his report. The gate `continue`s before the
plate is seated at all, so with `range_sq` at 0 no plate frame ever exists —
and UnitPlates works by adopting the plate frames under WorldFrame. One bug,
both symptoms: "nameplates will not turn on" and "UnitPlates does nothing" are
the same line.

And a packaging answer that comes before both, for UnitPlates specifically:
GitHub's "Download ZIP" unpacks `Redbu11dev/UnitPlates` as `UnitPlates-main`
with `UnitPlates.toc` inside it. The client looks for `<Folder>/<Folder>.toc`,
so the addon never loads — here or on the real client. The harness says it in
as many words: "no manifest under other-addons/UnitPlates-main — is that an
addon folder?". It has to be renamed `UnitPlates`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
2026-10-30 09:02:47 -06:00
gromandClaude Opus 5 5751857890 The realm's client is not stock 1.12, and its Lua grammar is not 1.12's either
SoftSpoken (z1xel/SoftSpoken, a 1.12 narration port) failed to load: nine files
across six addons would not compile, and the three that did then raised at
runtime indexing namespaces the failed chunks never built. Every one of the nine
is valid Lua 5.1 and invalid Lua 5.0 — `#`, `%`, `0x3040`, `local NAME = ...`.

`third_party/lua-src` is a fork that deliberately 5.0-ifies the grammar: three
arms deleted from `lparser.c` (`simpleexp`'s `TK_DOTS`, `getunopr`'s `'#'`,
`getbinopr`'s `'%'`) and `read_numeral` replaced with 5.0's, which has no hex.
Every deletion is byte-read out of `WoW.exe`'s own parser and every one is right
about stock 1.12.

No OctoWoW client runs stock 1.12. ClassicAPI.dll is in the realm's bundle and
rewrites every chunk at the `luaL_loadbuffer` chokepoint — file scripts,
`loadstring` and XML `OnLoad` alike — adding exactly five constructs: `#`, `%`,
`...` as an expression, `0x` hex and `[=[ ]=]`. Confirmed three ways: the repo's
own highlights line, PR #28, and the DLL's string pool, which twice says "Our
5.1 syntax backport makes the probe pass". `docs/ADDON_COMPAT.md`'s row for this
said "free — benilla's VM is mlua/Lua 5.1 already". That was the error.

So the target grammar is the union, and the fork now carries it:

  * the three parser arms restored, as stock 5.1 writes them;
  * `read_numeral` grown an explicit `0x` arm. It could not take 5.1's version
    back wholesale: 5.1 reached hex through a trailing `isalnum` slurp, and that
    slurp has to stay gone or `1then` breaks again
    (`AdvancedTradeSkillWindow2.lua:3878`, which the client compiles). The
    prefix arm gives both;
  * addon file chunks now called with `(addonName, addonPrivateTable)`
    (`UiScript::run_addon_chunk`, four call sites). Taking `...` without passing
    the arguments only moves the failure one line later, which is what
    ClassicAPI's own notes record;
  * `string.reverse` added to the stdlib backport — SoftSpoken calls it.

1.12's own dialect is untouched. `LUA_COMPAT_VARARG` withholds the `arg` table
only from a function that *uses* `...` as a value, so the shipped FrameXML's
`function f(...) … arg.n … end` still gets `arg`: the two dialects coexist one
function at a time, which is what the client-plus-ClassicAPI does.

The cost is the era probe. 170 corpus `loadstring` sites ask
`loadstring("return function(...) return ... end")` and 92 library files in 24
folders branch on the answer; they now take the 5.1 branch, as they do on the
real client. Two branch wrongly there and ClassicAPI ships a compensation for
each — Compost-2.0 rev 17406+'s no-argument `Erase` stub (bundled by BigWigs and
QuickHeal; it silently stops clearing in place) and Cartographer_Notes'
`WorldMapDeathRelease`. Both are now carried in `lua51_probe_fallout.rs`, probed
by behaviour rather than by revision and re-checked on every `ADDON_LOADED`,
because AceLibrary can upgrade a library mid-session.

Measured on grom's own AddOns folder: SoftSpoken, SoftSpoken_Stories,
SoftSpoken_Places, SoftSpoken_Writings, SpokenZonesAudio, SpokenBooksAudio,
SpokenQuestsAudio{Horde,Alliance,Gossip,Shared}, SSS_VOFallback and
AI_VoiceOverData_Turtle all load with zero load errors and zero session errors,
98 `Spoken*` globals, `/softspoken` registered, and PLAYER_ENTERING_WORLD,
QUEST_DETAIL, GOSSIP_SHOW and ITEM_TEXT_BEGIN reaching 108/108, 1/1, 6/6 and 2/2
handlers clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
2026-10-30 09:02:27 -06:00
gromandClaude Opus 5 7a3ab5e7bf A tooltip raise cost 6.6 ms because every frame it minted re-derived the layout graph
`WOW_UI_VERBS=1` over the hover bench answered it in one line: the rebuilt frame's
cost was **`frame:GetLeft`, 3 calls at 2188 us each — 6.56 of the 6.6 ms** —
inside `UIParent:OnUpdate`. `GetLeft` forces a resolve, and each of those
resolves was a whole-graph derivation of ~19,000 frames and ~44,000 regions,
because `Model::frame_id` gave up naming the graph on every mint. OctoPawn mints
two frames on every `GameTooltip` OnShow purely to own a one-shot `OnUpdate` —
the 1.12 way to write `C_Timer.After` — so a raise dirtied the graph twice and
the three `GetLeft`s each paid for it.

A just-minted frame is now **appended to the cached graph as a leaf**
(`LayoutScope::append_frame_leaf`). Sound because the id was minted this instant,
so no anchor anywhere can name it: nothing cached reads the new node and no
cached edge moves. What it reads is its own anchors' targets, which the append is
handed — a mint is not always a creation, since `frame_id` mints on the first
*fetch* too, and a frame built in Rust (a nameplate, a chat bubble) is first
fetched when Lua asks for its wrapper with its anchors long written. `last` stays
`NO_NODE` so the incremental seed sees the hash move; `commit_incremental` counts
the slot, which keeps the next full pass's `matched != last_count` node-death test
honest. Refused when the frame already carries regions: a region is a graph node
without needing a minted id — the derivation mints region ids inside its own walk
— so only a derivation can add those rows.

**Measured, same bench, same interface: the rebuild costs -0.08 ms/frame under
forty churning plates and +0.14 ms with none up, from 6.63 and 6.78.** The
rebuilt windows now report no derivation sites at all.

Two things about how this was found, both in the bench's doc now:

- Its own earlier conclusion was that the cost was "the plate's line walk and the
  hooks hanging off it, flat, and not the graph it dirties", because the crowded
  and the empty shape cost the same. They cost the same because **both** were
  paying the full derivation. A fair experiment that was blind to its own answer,
  and one env var would have named it.
- `WOW_LAYOUT_VERIFY` over the real interface is red on this change — and it is
  the documented pre-existing 48-region shortfall, reproduced identically with
  the whole change stashed: `frames 13544 -> 13544, regions 29481 -> 29529`
  either way. All 2069 benilla-ui tests pass, and they run that verifier forced
  on after every resolve.

189 workspace failures, unchanged, no regressions.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
2026-10-29 18:49:58 -06:00
gromandClaude Opus 5 3bbd99ebaf The slam macro, end to end through the path the game takes
He re-tested and it still never casts Slam, so I drove the whole thing rather
than the conditional: `UseAction` on a macro slot → `run_macro` fires
`EXECUTE_CHAT_LINE` per line (`0x4f14e0`) → the stock ChatFrame sends it through
its edit box (`ChatFrame.lua:1343`) → `ChatEdit_ParseText` finds
`SlashCmdList["CAST"]` → CleveRoids' hook → `GetParsedMsg` → `DoWithConditionals`
→ `Keywords.noslamclip` → `ValidateNoSlamClip` → `CastSpellByName`. On his own
interface, with his own 2.70 s weapon and Battle Stance, the line casts **Slam**
at the top of a swing and **Heroic Strike** 1.6 s in — which is the assertion the
old test never made, and the reason "the conditional answers true" meant nothing.

Three things came out of the drive.

**The load-time latch is worse than I wrote this morning, and it is what he is
seeing.** `GetSlamWindowPercent` reads SP_SwingTimer's `st_timerMax` cache, not
`UnitAttackSpeed`, so the `ADDON_LOADED` latch decides the window for the whole
first fight: the stand-in gives (2.0 - 1.5)/2.0 = **25%** against his weapon's
44%, while `st_timer` is still 0 so the swing reads **100% elapsed** — and
`[noslamclip]` is false with nothing said about it. Before the stand-in it was
false *and* printed "requires SP_SwingTimer" once; the fix traded a visible
failure for a silent one. Both states are pinned in the test now.

So the sheet is **seeded before the addon walk**, beside the roster record and
unit snapshot that are already seeded there for the same reason
(`ui_char::seed_combat_stats`). A `/reload` — and anyone mid-fight — now latches
the real weapon. A fresh login still has no self store (the create block has not
arrived, which is why the record comes off the roster), so the stand-in stands
there and heals on the first swing.

**And the stand-in now says so in the log.** One line per VM per token, through
the channel `/errors` lists: a combat-stats verb answering for "player" before
anything pushed that unit's sheet. One such read is expected at load; what the
line is for is the case where it never stops, which no test of the verb can see
because every one of them seats the sheet first. `UnitAttackSpeed` reaching Lua
with the store's own `UNIT_FIELD_BASEATTACKTIME` is now asserted through the real
`feed_char`, which is the test that was missing underneath all of this.

The dual-wield road is pinned too, because it is the one shape that can strand
the main-hand timer: `CheckDamageSource` returns "MAIN" on line one for a
two-hander and never runs its arithmetic, but dual-wielding it weighs the hit
against `UnitDamage`'s ranges scaled by the target's armour — and
`UnitArmor("target")` is 0 on any 1.12 client, ours included, because another
unit's resistances are not public. Every hit then reads UNKNOWN and falls to
`ClosestSwing == true or HeroicStrikeQueued()`, whose first half compares against
a *function* and whose second is true whenever this very macro has queued a
Heroic Strike — and `ResetTimer(true)` refills the **off-hand** timer while
`st_timer` runs to 0, where the elapsed read is 100 for good. That loop is the
addon's own and reproduces on any client; what we must not do is enter it when
the player is two-handed, which is `has_offhand` alone, and his combat log says
two-handed: 2.70, 2.71, 2.72, 2.68 between consecutive hits, with no 0.2 s
off-hand pairing anywhere.

189 failures, unchanged, no regressions.

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