Commit Graph
459 Commits
Author SHA1 Message Date
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 524e500d43 The map's glide is a setting, because two reports bracketed one guess
grom, 2026-11-05: *"its the maps motion that is laggy."* That is `SCALE_RATE`, and it is the
same constant he reported from the other side a day earlier — *"scroll is better but feels pretty
slippery, like scrolls past where I stop scrolling"* — which is what took it from 10 to 24. At 24
the half-life is 0.29 s, so at 60 fps a wheel notch is still closing 167 frames later: "it
overshoots" and "it lags behind" are two readings of one glide, and a third guess would be a third
guess.

`mapMotionSmoothing` is a percentage of a 0.30 s half-life, 0 is no ease at all, and the slider is
on the Options window's Interface page. The top of the range is the 0.29 s he called laggy,
because that is the slowest anyone could want; the default is a quarter of it. Measured over a
full rung (scale 1 -> 1.6) at 60 fps, frames to settle within 0.001: **0% = 1, 10% = 17, 20% = 34,
25% (shipped) = 42, 50% = 84, 100% = 167** — so the default is four times snappier than what he
just played, and still eased.

One rate drives both the pan and the zoom ease, read once a frame: the two are one gesture to a
player, and a zoom that settles while the pan is still drifting reads as the pan being broken. A
drag still writes the scroll directly and never eases, as before.

Verified in a live session with his addons: `mapMotionSmoothing=25`, one notch settles in 42
frames (0.70 s), map still FULLSCREEN, fade still 80. Options census 141 -> 142 on both counts.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
2026-11-05 11:48:43 -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 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 c92f078c39 A wheel notch stepped from the easing scale, so notches did not accumulate
`ZoomIn`/`ZoomOut` walked the zoom ladder from `GetCanvasScale()` — the live value the smooth
zoom is still easing — so the notch after the first re-read a scale that had barely moved, found
the same rung still under it, and re-set the target it already had. The ease closes about 63% of
the gap a second, which is slow enough that a player never gets a second rung.

Driven at grom's own 2560x1440 with his addons loaded, over OpenMapClassic's 27-step ladder
(0.0330 .. 2.3734): four wheel-down notches at the map's centre all set target 0.9030 — one rung
— and two more at the far right set nothing. That is his "it will only scroll very little out …
and will only very little scroll in period".

`GetZoomStepScale()` steps from `targetScale` instead, so notches accumulate and either end of a
long ladder is reachable. The same measurement rules out the position dependence he saw: the
wheel's target over the centre and 19 px from the right edge is `WorldMapButton` both times,
because `wheel_target` walks past frames that take no wheel the way `0x7664f0` does.

`docs/ADDON_COMPAT.md` also records the two addon-side causes of "I can't see any buttons but I
can use them", both fixed in `Addon_Ports/` rather than here: OpenMapClassic's rail tabs are never
dressed because `mod.FaceSideTab` ships in a file the release omits, and the OpenMap tab's own
icon is absent from this patch chain. And pfQuest's minimap icons now fade with the minimap.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
2026-11-04 07:53:50 -07:00
gromandClaude Opus 5 3e8c995e27 map: clamp the canvas onto whatever zoom ladder is in force
CreateZoomLevels is the extension point a canvas addon replaces
wholesale, and OnLoad seeds a scale (1) before any addon has run.
OpenMapClassic's ladder is 27 steps from 0.0125 to 0.899 -- its whole
range below this file's own 1, because its stitched world is reached by
zooming out past a single zone -- so the canvas sat above its own
ceiling, and ZoomIn, which walks the ladder by value, found no step at
all above it.

ClampZoomToLevels is called from GetCanvasScale rather than from
CreateZoomLevels, because CreateZoomLevels is exactly the function the
addon will not have.

Also records where OpenMapClassic stands, measured: 808 textures in the
canvas subtree, 166 placed, spanning ~4600x3270 units of a 1002x668
canvas, with a ladder floor implying a world ~80000 units wide. The Era
map tree under it answers correctly; GetMapArtLayers answering one
constant for every map is the thread to pull next.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
2026-11-03 15:05:14 -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 41512a04ef map: 1002x668 is a contract, and the canvas counted the windowed scale twice
Every 1.12 map addon places its art in absolute pixels against
WorldMapDetailFrame's 1002x668 — pfUI's reveal does it one SetPoint per
tile — so that size is a contract, not a size. 1.12's windowed mode puts
WORLDMAP_WINDOWED_SCALE (0.7) on the frame and on WorldMapButton; under
the canvas the container already carries the window, so that scale was a
second authority on the same rect and, with the frame SetAllPoints to the
canvas, came back as unit size: 1002 / 0.7 = 1431. Everything absolute
inside it landed at 0.700 of where it belonged, which is grom's
screenshot drawing Coldridge Valley twice, and the mouse surface kept its
XML 1002x668 beside 1431 units of art, so clicks read 70% of the map.

SeatOnCanvas makes the canvas the only authority: both frames to scale 1,
the frame filling the canvas, the button given the art's rect — explicitly
sized, because pfUI reads WorldMapButton:GetSize() to size the whole map
window. Re-run after Minimize/Maximize and on every canvas resize.

Rescaling the children this file knows the names of could never have
fixed it; the children it does not know the names of are the point.

saved-variables.lua now goes through saved_home, so a capture with
$WOW_CAPTURE_SAVED opens the player's windowed map — the only mode the
bug exists in, and the reason neither harness had ever seen it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
2026-11-03 13:03:17 -07:00
gromandClaude Opus 5 d11b9ac3ae map: the art was scaled by the canvas while living in the detail frame
Grom's report was "textures are not where they should be", and still wrong with OpenMapClassic
switched off — which is what made it ours rather than the addon's. His own session log had both
states 1.4 s apart, same map, same frame: detail 1431x954 with tiles 366x366 (correct, 1431/1002 =
1.428), then the same frame with tiles back at the XML's raw 256x256.

`ScaleDetailArt` was being handed the CANVAS's size by `OnCanvasSizeChanged`, on the assumption
that the detail frame is `SetAllPoints` to the canvas. 1.12's own windowed mode breaks that:
`WorldMapFrame_Minimize` puts `WORLDMAP_WINDOWED_SCALE` (0.7) on `WorldMapDetailFrame` and
`WorldMapButton` and insets the frame to TOPLEFT 15,-33, so the same screen area is a different
number of units in each — his trace reads `detail 1431x954 scale 0.56` inside `WorldMapCanvas
1002x668 scale 0.80`. Scaled by the canvas's number every tile came out 1.43x too small: the art
covered 1024x768 of a 1431x954 frame, the rest bare parchment, and the exploration overlays scaled
to match landed nowhere near their terrain.

The detail frame's own size is the authority, since the tiles are its children. `GetLeft()` first
to settle the layout, because that size is derived and an unforced read answers the size from
before the last change; the caller's w,h stay as the fallback for what cannot be measured.

Reproduced and controlled in the harness with the windowed scale applied as the stock code applies
it: tile 362 where 362 is wanted, against the control's 256 — the number his log recorded. Two
earlier attempts are written up in docs/ADDON_COMPAT.md as dead ends, because both looked right:
caching the canvas size prefers the wrong quantity, and converting it by the effective-scale ratio
gives 366 against a true 362, since Minimize also insets the frame.

`$WOW_CAPTURE_SAVED` now reaches the enable-state file too, so a capture reproduces which addons
the player has turned off — without which "I turned it off and it is still wrong" cannot be
answered offline at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
2026-11-03 10:54:54 -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 7f97777597 ui: the stock map art has to follow the detail frame it is drawn into
`WOW_MAP_TRACE=1` named it on grom's first run, in one line:

    detail 1431x954 @scale 0.70 | 1 256x256 @(10,1122) | 2 256x256 @(266,1122) | ...

1.12's `WorldMapDetailFrame` is always 1002x668, so everything drawn into it is placed in absolute
pixels against that: twelve 256x256 tiles anchor-chained from its TOPLEFT, each exploration overlay
at the offset `WorldMapOverlay.dbc` gives it. The canvas breaks that the moment it sizes the frame
to anything else, and it does — `OnCanvasSizeChanged` falls back to the container's own rect
whenever it has no art layer to read, which is every moment the canvas has no `mapID`, including
the world-view blip the same log shows on every map open.

So: a 1024x768 sheet of art pinned to the top-left of a 1431x954 frame, covering 72% of the width
and 80% of the height, with the rest of the map drawing nothing. That is the grey he reported,
with the art magnified relative to what was left of the frame.

The art is now rescaled to whatever size the frame has. The tiles need only their size set, because
the XML chains them TOPRIGHT-to-TOPLEFT and BOTTOMLEFT-to-TOPLEFT. The overlays carry absolute
offsets, so they are captured from what the stock repaint last wrote — hooked on
`WorldMapFrame_Update`, the only moment those base values are readable — and scaled from that base,
so a second pass cannot compound. Measured: a 1431x954 frame now holds a 1462x1097 grid, the same
1024/1002 and 768/668 proportion the reference frames.

Two smaller faults fell out and are fixed with it: the container resizing was not treated as a
canvas size change at all (the canvas was re-sized only on a map change), and a derived resize
fires no `OnSizeChanged` here, so the rescale takes the size its caller just set rather than
reading the frame back, which would answer the old one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
2026-11-02 16:35:53 -07:00
gromandClaude Opus 5 70e4278c79 ui: the map's hovered-zone label is chrome, not map content
The canvas reparents 1.12's `WorldMapButton` into the zooming child so that every
1.12 addon's icons pan and scale with the map. `WorldMapFrameAreaFrame` hangs off
that button in the client's own XML — it covers the art, so that is where the
label that names what the cursor is over was hung — and it went along with it.

Its label is a 102 px THICKOUTLINE FontString. Inside the canvas it scales with
the zoom, so at zoom 2.5 every letter is a 261 px glyph, and one zone name's worth
of those is most of the 2048² glyph sheet. Measured in grom's 2026-11-02 session:
one `ui_text: a 261.10907 logical-px request … past the 256 ceiling`, then 20
`glyph sheet full … resetting at the frame boundary`, one per hover, each of them
a frame in which every string on screen is missing — the map "flashing the
interface". Frame cost with the map open went 11 ms → 16-20 ms over the same
window.

So it goes back on the window, anchored to the container's top, which is where the
art's top edge is now; at rest that is the pixel it was already at. Measured
through the harness: the biggest text under `WorldMapFrame` was 102 px at zoom 1
and 1632 px at zoom 16, and is now 102 px at every zoom.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
2026-11-02 10:00:20 -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 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 164eb7e624 ui: the ClassicAPI-era templates addons inherit by name
`CreateFrame(): Couldn't find inherited node "DialogBorderOpaqueTemplate"` is where
OpenMapClassic stopped — on its first file, inside Ace3, before a line of its own code ran.
It is not that addon's problem: `BackdropTemplate` is named **unguarded** by Ace3 itself
(`AceGUIContainer-Frame.lua:179` and `:206`, `AceGUIContainer-DropDownGroup.lua:128`), so
every Ace3 addon on this install hits it.

`assets/ui` does not grow, so this file carries its reason on that wall's own list: there is
no stock 1.12 file to migrate from, because 1.12 has neither template. They are the same kind
of surface as `script/compat/`'s verbs and live in XML only because `inherits=` is resolved by
the loader and cannot be answered from Lua.

**`BackdropTemplate` is correctly empty.** 9.0 moved the backdrop off the C side and invented
`BackdropTemplateMixin` to put it back; 1.12 still has `SetBackdrop`/`GetBackdrop`/
`SetBackdropColor`/`SetBackdropBorderColor` as real frame methods, so a frame inheriting the
template already answers all four. The mixin table exists for the
`BackdropTemplateMixin and "BackdropTemplate" or nil` idiom and for `Mixin()`, its rows
forwarding to the stock methods. **No getter rows**: a forwarding `GetBackdropColor` would
call itself once `Mixin()` had written it onto the frame, and the stock one already answers.

The dialog border is one `<Backdrop>` over this client's own dialog art — the shape every
stock 1.12 dialog draws, and the same block `GameMenuFrame.xml` uses — rather than a
transcription of Era's nine `SharedUIPanelTemplates.xml` textures. The opaque variant differs
only in background alpha, which is the one thing the two names disagree about. `enableMouse`
stays off: it is a decoration over a frame that handles its own input, and a border that ate
clicks would make an Ace options window unusable.

Movement: openmap `AceConfigDialog:573` → `:586`, now wanting `SetNormalFontObject`.

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:49 -07: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 3a89f825bf The realm ships its own ArenaFrame, so our stand-in only shadowed it
`CharacterFrameAdapters.xml` existed on one premise: "There is no `ArenaFrame.lua` or `.xml`
anywhere in the player's chain". That is no longer true — the realm's FrameXML carries
`Turtle_ArenaUI\Turtle_ArenaFrame.xml` (toc rows 106-110, which `benilla.toc` already loads at
270-274), and it defines `ArenaFrame` with `enableMouse="true"` and `id="5"`, sharing the PvP tab
rather than inventing a sixth. Our empty stub loaded 100 rows earlier and was overwritten, and
wherever it loaded last it replaced the realm's real arena window with a frame that has no
content. `frame_flag_gate` had both halves of that pinned as divergences (mouse false, id 6).

Twelve more red UI tests, each one a file the realm rewrote under an assertion written against
stock 1.12:

- `inspect_tests` (4): all four died in `InspectArenaFrame.xml`'s own OnLoad, which indexes
  `_G[this:GetName().."Text"]` — a child of `ArenaTeamTemplate`, which lives in FrameXML and so is
  always there live. The rotate buttons are `InspectModelFrame…`, not stock's `InspectModel…`.
- `character_tests` (4): `HonorLevelText` keeps its authored placeholder because the realm
  commented that `SetText` out and hides the string instead (`PaperDollFrame.lua:126-127`);
  `PaperDollFrame.xml` builds 77 frames, not 75, the two being the realm's player-titles dropdown
  and its template's `$parentButton` (read off `FrameXML_Debug(1)`'s own trace, which also proved
  each of the 77 is created exactly once — a grown file, not a double load);
  `CHARACTERFRAME_SUBFRAMES` has six names; and one sound at load is the reference's own —
  `UIDropDownMenu_Initialize` runs its init function on the spot and `UnitPopup_ShowMenu` ends in
  `PlaySound("igMainMenuOpen")`.
- `loot_tests` (4): same load-time blip, drained and named; and the master-loot menu is rewritten
  end to end — the party arm drops any candidate it cannot resolve to a party unit, the raid arm
  groups by class with `hasArrow` submenus instead of by subgroup, and the event is now a toggle
  whose memory `DropDownList1`'s own OnHide clears. It also needs `MAX_RAID_MEMBERS`, so the loot
  window's prereq row names `RaidFrame.lua`.
- `dressup_tests` (2): `CharacterFrame_OnShow` gsubs `UnitPVPName("player")` unguarded, so the
  fixture's player needs the name a real one always has.

The one accepted flag difference is now written down with its reason: our options window keeps
1.12's `toplevel` where the realm's rewrite of `OptionsFrame.xml` took `movable` instead.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
2026-10-31 12:09:51 -06:00
gromandClaude Opus 5 f4960efc53 The realm update lands clean, and three of the "269 known red" were real
His 2026-10-28 20:18 update rewrote `Data/patch-4.mpq` (1.37 GB) and
`patch-5.mpq`
(230 MB). Verified rather than assumed, and nothing of ours broke: the realm's
`FrameXML.toc` went 146 → 151 rows and patch-5 holds the richest copy, so
`chain::demote_stale_snapshots` fires and it wins the manifest; the five new
rows
are realm modules (`OctoChallengesSession`, `OctoHardcoreBGMenu`, `OctoReplay`,
`OctoReplayBar`, `OctoReport`) that `unnamed_chain_rows` picks up by design and
that load with **0 load errors and 0 session errors**, every global they name
resolving; DBC field counts are unchanged everywhere, so no parser is at risk,
while record counts moved (Spell 27992 → 28026, CreatureDisplayInfo 13423 →
13451), which is the proof the new data is live; a `WOW_CAPTURE=vista` at
Ironforge prints **0 ERROR lines**; and both golden baselines held exactly, 269
with an identical failing name set and 80, `goldshire_cell` still at 1874.

Two examples answer "did it land", because "the file is in the archive" never
does. The reference mounts `patch-?.MPQ` by case-folded name, so every
`patch-A..Z` outranks `patch-4`/`patch-5`, and the toc-subset demotion cannot
see
an archive that carries no toc at all. `chain_report` prints the `FrameXML.toc`
copies in priority order and, with `--toc`, the winner of every row of the
realm's manifest with a tally — 68 to patch-4, 43 to patch-5, 32 to `patch.MPQ`,
7 to `interface.MPQ`, 1 to patch-3, and **none to a stale letter patch**.
`chain_shadow` answers from the archive's side: patch-4 carries 20326 paths of
which 10220 are shadowed, patch-5's 1494 are not shadowed at all.

Then, grouping the red bucket by panic *site* rather than trusting its label:
252 of the 269 are shipped-UI tests and **136 share one site**. Three of the
rest
were real, and are fixed here.

Six `OptionsFrame_*` enable/disable helpers were missing outright. `benilla.toc`
is the only thing that loads two of the reference's settings windows — the
realm's own toc lists neither `SoundOptionsFrame.xml` nor `UIOptionsFrame.xml` —
stock 1.12 keeps those six beside them in its own `OptionsFrame.lua`, and the
realm replaced that file with Turtle's rewrite, which drops them. Checked
against
all 151 interface rows, Lua and XML: nothing defines one. So those windows held
controls that could not grey out, with the first raise waiting on the click that
reached one. `assets/ui/OptionsFrameAdapters.lua` carries the reference's own
bodies, read out of `patch.MPQ` rather than from memory.

`CHARACTERFRAME_SUBFRAMES` was asserted to hold exactly five. The realm's
`CharacterFrame.lua` carries a sixth, `ArenaFrame` — which this client already
stubs (`CharacterFrameAdapters.xml`), so the count was the only thing wrong. It
now checks the reference's five by membership and order, which is what the test
is for; a realm may append.

And the kit's stand-ins were anonymous. `CreateFrame("Frame")` with no name
answers `GetName()` nil and `GetID()` 0, the publish is `setglobal` either way,
so naming them costs nothing and the absence cost two of the 136's three errors:
`FCF_SetWindowName` concatenating a nil name and `SetChatWindowDocked` refusing
window 0. `ChatFrame1`/`ChatFrame2` are no longer seated at all — every row of
`UIPARENT_MANAGED_FRAME_POSITIONS` is read through `frame = _G[index]; if
( frame )`, so a missing one is skipped, and seating them only shadowed the real
windows for good. The pass runs at load, from `ShapeshiftBar_OnLoad`, twelve toc
rows before the chat files, which is why the stand-ins were always first.

That leaves 136 on one cause, and the two tests added to `loader/tests.rs` are
what ruled out the first hypothesis: a template's `parent="UIParent"` does reach
the frame that inherits it, one level and two, which is how `ChatFrame1` is
built. The next suspects are written down in the gates memory.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
2026-10-28 21:15:41 -06:00
gromandClaude Opus 5 ee9cfdcacf Two settings for things arriving: a foliage distance row, and a ramp for lights
**Foliage Distance** (`foliageDistance`, Advanced Graphics, 70..300 yd). The reference
draws ground clutter to a hardcoded 70 yd (`[0x867958]`) and we inherited it, so grass,
reeds and small bushes arrive at a fixed ring around the player — which is exactly where
the eye is drawn to them appearing. The row moves the ring; it changes nothing about what
grass looks like.

Measured on an Elwynn fence line (`fence-shadowline-day`, clutter density 2), p50 frame
time / main thread: 70 yd **3.25/3.26 ms**, 140 **3.32/3.24**, 200 **3.48/3.38**, 300
**3.69/3.42**, with the GPU flat throughout. Doubling it is free; past 140 it costs main
thread, which is the budget this client is short of. The registered default IS the
reference's own 70 and the row is in no preset ladder, so nothing moves until a player
asks it to.

It commits on Apply: `fade_far` is baked into the clutter material key, so a change drops
every built patch to be re-cut (338 meshes on one measured write) and a drag would do that
thirty times a second. `clutter::remesh_on_cutout_change` is now
`remesh_on_material_change` and watches both the alpha ref and the fade for that reason.

What it buys in a STILL is small — 70 -> 300 moves 0.6% of a frame, the far tufts being
sub-pixel — and that is the wrong instrument: the complaint is grass ARRIVING, which no
static capture can show.

**Light arrival fade** (`lighting::global_light`). No setting: a correction, not a taste.

A fixture enters the packed light table for several unrelated reasons — the portal walk
reaching its room, its WMO group streaming in, the camera crossing `INTERIOR_NEAR_ADMIT`
or `POINT_PACK_RADIUS`, the 255-entry capacity cut re-sorting — and every one of them was
a hard edge that switched a light on at full brightness between one frame and the next.

Measured in Ironforge by diffing the admitted set between static vantages: walking **3 yd
exchanged 2 of 172 admitted lights, and 10 yd exchanged 8** — about one arriving per yard,
which at a walk is several a second. The table ran 110-196 across five vantages and never
reached the cap, so this is admission churn and NOT capacity; the cap does bind
transiently during a world load (305 candidates seen once), which is why the boundary fade
below is kept.

Arrivals now ramp over 0.35 s, whichever rule admitted them. The scale lands on the
light's colour before the pack, so the GPU table, the CPU mirror, the fog lamps and the
froxel volume all follow from one write. Deliberately NOT symmetric: a departing fixture
still goes at once, because keeping it alive to dim means keeping its room claim alive
with it, and a stale claim gates a live light with a dead building's identity.

**Spell lights are exempt**, and that is the exemption that matters: a fireball's light
lives about as long as the ramp does, so ramping it would spend the whole effect at half
strength.

Beside it the capacity cut itself is softened — the last 24 admitted ranks scale toward
zero across the distance band they span — and nothing fades while the table fits, which is
what makes the control possible: with the ramp on against off, the Goldshire inn and the
Great Forge both settle BYTE-IDENTICAL.

Also recorded in docs/GRAPHICS.md: captures force `WOW_CLUTTER_DENSITY=0` for byte-stable
baselines, so a run that sets only `WOW_CLUTTER_FADE` measures a scene with no grass in it
and reports every horizon as free. Three rounds of this measurement were worthless before
the starved control caught it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
2026-10-28 08:58:08 -06:00
gromandClaude Opus 5 33fc0c2023 Froxel volumetrics: the haze stops painting shadowed air the colour of sunlit air
`volumetricFog` gains tier 3, "Volume", with a "Shadowed Air" dial beside it; the Ultra
rung of Lighting Quality takes it. A view-space 160x90x64 froxel grid — a fixed BUDGET,
so every resolution costs the same — marched one compute thread per screen COLUMN, front
to back, with a reprojected eight-frame history. Inject and integrate in one pass, because
a froxel past the last shadow cascade has no evidence either way and the honest answer is
the mean of what the rest of its column could see; a thread that owns the column can carry
that and a froxel-per-invocation dispatch cannot.

It publishes two things:

- **the lit share** — how much of the air in front of each pixel the sun actually reaches,
  weighted by each slice's own contribution to what the eye sees through it.
  `volumetric_fog`'s haze multiplies its colour by it. The haze below this rung is the
  reference's: a zone colour mixed in by distance, the SAME colour whether the air it tints
  stands in open sun or a mountain's shadow, and no screen pass can tell it otherwise
  because a shaft march only ever ADDS light. On an Elwynn overlook at noon, 74% of the
  frame is air the sun does not fully reach.
- **the lamps' in-scatter**, free once a volume exists: the phase lobe evaluated at every
  point of the air rather than once per ray at its closest approach, attenuated by the
  medium it crosses and strengthened when the medium is. `lampFog`'s analytic halos retire
  into this solve at tier 3, and that row still chooses which lamps and how many. The gain
  is derived, `0.9 / density(clear night)`, so the two lanes match on a clear night. At the
  Goldshire inn, isolating each by rendering lampFog 0 against 2 on both (the bases are
  byte-identical, which is what makes it a comparison): the volume's are 1.30x and wider.

The sun's SHAFTS deliberately stay on `volumetric_light`'s per-pixel march, and that is a
measurement. A volume bands in hard horizontal steps at 160x90, 320x180 and 534x300 alike,
because one froxel column hands the same value to about a hundred pixels and a canopy's
shadow is finer than any grid worth paying for. Spreading each column's samples across its
own screen footprint (R2 down the column) turns the steps into a gradient and is worth
knowing for any volume, but the march still wins on detail. docs/GRAPHICS.md has the sweep.

Safety is one number: fully lit air multiplies by EXACTLY 1, so at Shadowed Air 0 the lane
renders byte-identical to High over all 1,440,000 pixels with every pass still running.
Getting that exact needed `1 - (1-floor)*(1-lit)` and not the `mix(floor, 1, lit)` it
equals — `mix` is a fused multiply-add and lands a last ulp below 1.0, which moved five of
nine tier 0-2 control pairs with the rung switched OFF. The shipped floor, 0.85, is
deliberately half the depth of the first one tried, and the dial reaches twice it.

Control: nine tier 0-2 pairs against the previous build, five byte-identical and four
differing by one or two PIXELS out of 1,440,000 at 1-2 of 255 — every tier-0 pair identical,
and tier 0 is the tier that switches the fog pass off, so the remainder is the fog shader's
instruction schedule changing because the shader did. Cost is under the noise of both
instruments: the frame probe puts Volume inside High's own run-to-run spread, and the GPU
pass timer puts `gpu_other` at 0.75 -> 0.76 ms.

Also recorded: `water-lake` leaves the deterministic row once shadows are forced on — it
differs from a build of ITSELF by up to 88/255 on 0.03% of pixels.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
2026-10-28 06:40:48 -06:00
gromandClaude Opus 5 848c840ecf SSILVB: the AO lane stops only darkening and starts bouncing light
`ambientOcclusion` 3 (Indirect Light), the Ultra rung. Tier 3 of the AO row
rather than a row of its own, because it is one trace: the bitmask that
measures occlusion properly is what produces the bounce, so a player choosing
between them would be choosing between two halves of the same thing.

Classic SSAO sums a cosine term per tap, so two occluders covering the same
direction count twice and a cluttered corner over-darkens. A bitmask counts
DIRECTIONS: each slice owns a 32-bit word of angular sectors, a tap sets the
run it spans, overlap is a no-op. And the bits a tap sets that were NOT already
set are exactly the share of the hemisphere it is the nearest thing in — i.e.
the fraction of the receiver's light it alone is responsible for. Its own pixel
colour, weighted by that, IS the one-bounce indirect term. No second trace, no
probes, no extra pass.

## The lane was already shaped for it

- `STAGE_AO` already bound the resolved scene colour, for the bright-pixel
  protection mask. That buffer is exactly what a screen-space gather reads.
- The apply needed no new pass and no dual-source blending: visibility in
  `src.a`, bounce in `src.rgb`, blend `One, SrcAlpha` = `bounce + dst*vis`.
- Only the half-res stages grew, by one attachment each, and the uniform's two
  spare floats carried the strength and thickness, so its layout never moved.

## The one part that is ours and not the paper's

**The bounce is scaled BY THE OCCLUSION.** A physically-measured bounce is
wrong here: 1.12's lighting already carries an ambient term standing in for
indirect light, so adding a measured one double-counts it. Unscaled it lifted a
daylight exterior's median from 85.7 to 101.3 and washed the frame flat. Scaled
by `occ`, the lane puts back only the share of the ambient the occlusion just
took away, in the occluder's colour. An open field gets nothing, because
nothing was removed there; a corner gets the colour of the wall shading it.

  Redridge overlook noon   median 85.7 -> 86.7
  Goldshire inn noon       median 26.7 -> 29.0

Neutral in daylight, +8.6% in the shaded room — the same shape as the
auto-exposure lane's floor of 1.0, for the same reason.

The gather runs in LINEAR (`c*c` in, `sqrt` out): the scene is stored in gamma,
and 0.5 gamma is 0.22 of the light, not half of it.

## Cost

Frame time is unchanged (inn 2.22 -> 2.21 ms over 600 frames; overlook 2.25 ->
2.11). That is the frame being CPU-bound, not the lane being free. The GPU
meter is the honest instrument: whole-frame GPU 1.68 -> 1.81 ms, and the bucket
these passes land in 0.40 -> 0.54. So **+0.14 ms of GPU over High and nothing
on the main thread** — which is why the Options text says "a fraction of a
millisecond" rather than what I first wrote before reading the meter.

## Two bugs, both found in the intermediate rather than the frame

- **Screen +y is view -y.** `view_at` flips clip y, so a slice direction built
  as `vec3(omega, 0.0)` measures its taps against a mirrored normal. One slice
  of two was right and the other inverted, averaging to A FLAT FLOOR READING
  50% OCCLUDED. `WOW_AO_DEBUG=4` showed it in one run; the composited frame
  never would have.
- **`a / b` and `a * (1.0 / b)` are not the same number.** Factoring the blur's
  divisor into one reciprocal — a tidy-up, not a change — moved `water-lake` at
  tier **1** by up to 53/255 on 0.021% of its pixels, because the AO is
  multiplied into the colour the water lane's depth-march then refines by
  bisection, and one ulp picks a different step. Restored to two divides.

## Control

Tiers 0-2 byte-identical to the build before this, on three deterministic
scenarios at three tiers each, nine of nine. `fence-shadowline-night` had to be
dropped mid-control, and it sharpens the determinism table added last commit:
it screens as deterministic at the DEFAULT tier and is not at tier 1. Screen a
scenario at the setting you are about to measure it at, not at its default.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
2026-10-27 18:48:00 -06:00
gromandClaude Opus 5 11dda4f9cf Two cheap lanes: RCAS sharpening, and a field of view a player can move
Both were named in docs/GRAPHICS.md as nearly free and neither had been built.
They are unrelated to each other; they land together because each is small.

## Sharpening (`sharpening`, 0 Off / 1 Low / 2 Medium / 3 High)

RCAS, FSR 1.0's sharpening half taken on its own. It does not resample, so it
needs none of the upscaler's motion vectors, jitter or history — which is the
whole reason it can land while §7 cannot. One 5-tap cross; the lobe is not a
constant but the widest one that cannot push the result outside the ring's own
range, solved per channel against a peak of 1.0 and taken as the most
conservative of the three. Flat regions get nothing and nothing rings.

The seat is most of the design. `Core3d`, after `FfxGlowLabel` and before
`Node3d::Bloom`:

- After the glow, not before. `FfxGlowNode` builds the blur chain the glow and
  the underwater haze read, and a sharpened bloom source is how an unsharp mask
  turns a lit window into a ringed halo.
- The world view only. The world's last reader is the FFXGlow combine drawn
  inside the UI camera's main pass, which binds `main_texture_view()` at
  node-run time per frame, precisely so a `post_process_write` upstream lands.
  The interface is drawn afterwards into a different target — and sharpening
  text is the one thing this must never do.
- Every tap saturates, as the combine's own scene read does one pass later: the
  limiter is solved against a peak of 1.0, so a super-white tap would invert
  the bright side's sign. Nothing is lost, the blur chain already took its copy.
- No denoise term. The deband dither it would exist to suppress is added
  downstream, in the combine's exit.

Measured, 600 frames: Redridge overlook 2.95 -> 3.02 (Medium) -> 3.01 (High);
Goldshire inn 3.46 -> 3.51 -> 3.29. Inside the noise, the inn reading *faster*
at High being how you know. CPU flat. What it buys: 3.8% / 19.9% / 48.6% of the
frame moved by more than 2/255.

**The first run hit the trap this repo already has a note about.**
`max(4.0 * mx, 1e-5)` where `mx` is a `vec3` is `error: inconsistent type
passed as argument #2 to max`. naga refused the shader, the pass drew nothing,
and the capture came out looking perfectly normal. One `grep -ciE 'error:'` on
the capture log answered it before a single image was diffed.

## Field of view (`fieldOfView`, vertical degrees, 40..70, default 45)

Field of View on the Graphics page under Render Scale, live as you drag.

It is the BASE lens, not a fourth factor. `player::scoped_view` already
multiplied two things in and neither is a field of view: SuperWoW's `FoV` is
the reference's `[camera+0x40]` as a ratio, the spyglass is a zoom off normal.
Both answer "how much wider than normal"; nothing a player could move answered
what normal IS, and the answer was a `const` in `benilla-world`. So the term
order is `base × FoV × zoom`, and an addon writing `FoV` still means the same
fraction of the lens the player chose.

Vertical degrees, not horizontal, which keeps the reference's Hor+ framing: a
fixed vertical lens widens horizontally as the window does, so an ultrawide
player gains picture at the sides instead of losing it off the top.

The default is `CAM_FOVY` to the bit — `45f32.to_radians()` and `FRAC_PI_4` are
both `0x3f490fdb` — which the defaults-mirror test now asserts on `to_bits()`
rather than against a tolerance. An ulp of projection is a pixel in a capture
diff, and a row that reframes the world for a player who never opened Options
is not a row, it is a regression.

## Both lanes off: byte-identical

Against a `git stash`ed build of the same tree: `overlook-noon` and
`water-lake` identical, and `fieldOfView=45` written explicitly identical to
the row never touched (40 and 65 move 97.6% and 99.3% of the frame).

`inn-noon` is excluded WITH PROOF rather than quietly: two runs of the *same*
binary differ on 37.8% of their pixels by up to 199, because that scene's flame
emitters do not reproduce. Its own before/after delta is 0.00% of pixels at a
maximum of 1. A scenario that cannot reproduce itself cannot be a control, and
the number to check first is always the baseline against itself.

## Seats

Six Options edits each, per the recipe, including the silent one
(`OPTIONS_PAGE_ROWS`). Sharpening is a dropdown on Advanced Graphics with the
other screen effects and reuses the existing `qOff/qLow/qMedium/qHigh` labels;
Field of View is a `raw`-format slider on Graphics, whose label is literal
because that page is not localised. Preset ladder: `sharpening` takes
`ambientOcclusion`'s shape (`0 0 1 2 2`) because a look change starts at Medium
in this file's convention; `fieldOfView` is on no rung at all, because it
changes how the game feels to play and that is the player's call.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
2026-10-27 16:56:54 -06:00
gromandClaude Opus 5 280c2e886f Planar water: the mirror works, and ladders in at waterQuality 3 (Ultra)
The starburst was three defects, and none of them was the oblique
projection I blamed it on in the last commit. What found them was looking
at the mirror's own render target instead of reasoning about the
composited frame — the water reads it through a fresnel weight, a rippled
UV, a plane-match band and an SSR march that paints over it, so from the
outside a broken mirror and a broken water shader look identical.
WOW_MIRROR_DUMP=1 now writes that target beside the capture. (Bevy's own
save_to_disk cannot: try_into_dynamic knows four 8-bit formats and answers
UnsupportedFormat for Rgba16Float, logging and writing nothing.)

- The oblique row's scale. Replacing the near plane with the water plane
  costs the FAR plane: `z >= 0` stops meaning "nearer than CAM_FAR" and
  becomes `depth >= a*d`, a plane through the eye tilted by a. Lengyel's
  own scale puts that plane exactly through the frustum's far corner, and
  the sky dome is pinned just inside the far plane, so it straddled the
  boundary and was sliced along its own tessellation. Scale by the
  supremum of d/depth over the frustum instead, with 2% of margin.
- The water surface lay ON its own clip plane. This was the starburst and
  it was fatal rather than untidy: depth now means "distance above the
  plane", so a fragment at d=0 reads z/w=1, the nearest value reverse-Z
  has, and beats every other surface in the frame. The mesh is
  wave-displaced, so its triangles straddled the plane instead of lying in
  it, and each was clipped to a sliver that kept the winning depth.
  SURFACE_CLEARANCE lifts the plane a yard: the swell sums to 0.34 and the
  probed height answers for the surface under the eye, not this fragment.
- The mirror was stored upside down. A true reflection is what a camera on
  the far side sees with its up vector reflected too — an improper basis
  no Transform can hold. The honest right-handed camera sees the same
  surfaces through the same frustum and stores the image flipped, so the
  shader samples 1 - v; that costs no winding flip, so backface culling
  stays correct. It read as a lake reflecting the ridge the RIGHT way up.

Measured: Lake Everstill 2.87 -> 3.36 ms mean over 600 frames (+17%), the
Stormwind canal inside the noise, CPU +0.2 to +1.1 ms. It is the first
lane in docs/GRAPHICS.md that spends main thread, so it is a rung of its
own above High and the default stays 2.

Seated as a setting, per the standing rule that no graphics lane hides
behind an env var: waterQuality gains tier 3, the Ultra preset takes it,
and the Advanced Graphics dropdown gains a fourth entry in both locales.

With the lane off, both scenarios are byte-identical to the build before
it — which took two findings of its own:

- The system could not live in Update at all. `&mut Transform` on a camera
  is shared with everything that moves anything; declaring it put 59
  undeclared orders into Update and tripped the schedule ratchet, which is
  what kept this behind an env var. It belongs in PostUpdate with the
  skybox and the billboards, after propagation and before visibility,
  where it also reads the eye's settled pose instead of a frame-old one.
  (The comment blaming ResMut<Assets<Image>> for those 59 was wrong —
  dropping the asset handle changed the count by zero.)
- A system that runs and returns on its first line is still not free.
  Holding &mut Camera and &mut GlobalTransform for a frame is enough to
  change which undeclared orders the executor picks: the canal moved 0.75%
  of its pixels by up to 96/255 with the lane switched off. So the whole
  system sits behind a run condition rather than an early return — and the
  same effect killed a .before(UpdateDirectionalLightCascades) order that
  was correct in itself.

PostUpdate's ambiguity ceiling goes 466 -> 495 with the reason (the pairs
are structurally impossible: nothing else in the schedule names the mirror
camera); Update's ratchets down 4973 -> 4952.

Also restores scripts/gates.sh's `where` example, which had gone missing.
Its absence was silent — cargo's error went to /dev/null and `|| true`
made it read as "no install" — so the memo key and the player-tests gate
saw none even when WOW_DATA was set.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
2026-10-27 09:35:02 -06:00
gromandClaude Opus 5 88b6998453 Auto exposure: dark rooms read at their own level
The programme's item 1 was terrain horizon occlusion, ranked first on the
reasoning that it would make the frame cheaper on the thread that is
actually full. **I measured it before building it, and the premise does
not hold for this renderer**, so this is item 2 instead. The numbers are
in `docs/GRAPHICS.md`: cutting the view distance from 777 yd to 177 —
removing four fifths of the visible world — saves 0.44 ms of a 3.6 ms
frame, p95 is flat at 6.4 ms at every distance, no main-world system in a
steady vista exceeds 0.12 ms/run, and the model cull already takes 6,016
submeshes to 29 drawn. Horizon occlusion can only remove the fraction of
that which sits behind a hill. The reference needed it on a 2004 CPU with
no GPU-side culling; we do not.

What the same harness said about item 2 was the opposite. Before anything
was written: the Elwynn vista renders at a gamma median of 87.6/255 with
1.4% of its pixels under 32, the Goldshire inn at noon at 31.9 with 50.1%
under 32, and the inn's kitchen at **12.8 with 94.5% under 32**. That is
not a dim room, it is a black screen — and the reference has the same
problem, because it has no metering either.

A histogram-and-reduce over the HDR view target, smoothed in the log
domain, applied ahead of the shafts, bloom and glow chain so FFXGlow's
blur — which is built from the scene — sees the same exposed image
everything else does. Two departures from the textbook version, both
forced by what this renderer's pixels mean (the world is drawn in the
reference's gamma convention, which the combine clamps to 1.0):

- The gain has a **floor of 1.0**: it only ever brightens. Aiming at the
  exterior's own measured level is what lands it at 1.0 outdoors with no
  special case — the vista's median moves 87.6 -> 87.5, and 0.12% of its
  pixels with it. Daylight is the one look this client is measured
  against and nothing here was worth moving it.
- It has a **ceiling**, which is the tier. The kitchen meters to roughly
  24x; uncapped it comes out as daylight, which erases the room instead of
  making it readable. Subtle and Full are 2x and 4x: "a dark room you can
  see in" and "one you can read in", neither "a lit room".

Measured after: inn noon goes 50.1% -> 4.6% of pixels under 32, the
kitchen 94.5% -> 40.3%, and the vista does not move. **+0.08 ms of GPU**,
nothing on the main thread.

The calibration is the one thing to get right if this is ever re-derived,
and it is written down because I got it wrong first: the aim level is the
level of the scene THIS PASS SEES, not of the screenshot. The glow adds
`w·blur²` downstream, so the finished vista log-averages to 0.0931 linear
and the pass's own input to 0.060. Calibrating against the former aimed
the lane 1.5x high and brightened the daylight exterior it exists to leave
alone — which the vista capture caught immediately.

Two rows on Advanced Graphics, both locales, the blend greyed while there
is no gain to blend; on the Graphics Preset ladder from Medium, in the
shape the other post lanes take. Classic keeps it off, as the reference
has no metering at all.

Verified: ALL GATES GREEN (550 s); `benilla-app --lib` against the install
at the exact 269-failure baseline, set-identical by `comm`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
2026-10-26 18:05:43 -06:00
gromandClaude Opus 5 50fd1f158e The interior lighting rig gets its ten rows, and the audit closes
The last ten CVars `video::on_cvar` answers that a player could not reach.
With these the sweep is done: every graphics and video row the observer
takes now has a seat, bar the three that are not settings — `frillDensity`
(World Detail already drives it, and two dials on one knob is worse than
none), `nearclip` (the projection near plane: changing it is a bug, not a
preference) and `interiorDebug` (a visualiser).

Seven shape the room — the light budget, the ambient floor under every
indoor surface, the indoor daylight floor, the bounce gain, the reach past
each fixture's authored window, the share of painted-in lighting kept
where nothing reaches, and the room gate — and three are the indoor shadow
budgets.

Named for what they do to a room, not for the quantity they scale, because
the quantity is the renderer's word and the room is the player's: "Unlit
Room Floor", not "interior bake floor"; "Lights Stay in Their Room", not
"room gate". Each description says what the setting is FOR rather than
what it multiplies, and three of them carry the thing a player would
otherwise misread:

- `interiorAttenScale` at 0 is not darkness, it is the authored window
  switched OFF, so every fixture reaches the whole room. A left end that
  looks like "no light" and means "all light" needs saying.
- `interiorRoomGate` is the one setting whose job is diagnosis: a room the
  gate leaves on ambient alone looks exactly like a bug, and one click
  tells the two apart. That belongs in the options window and not only in
  `/console`.
- `interiorShadowEntityRate` is the only row in the indoor shadow block
  that actually costs frame time — neither count dial above it moved it,
  which is why it exists as its own row — so its description says to lower
  it first.

Two grooves deliberately offer less than their CVar's clamp, as
`RowRenderScale` already does (50-200% inside a 0.25-4 clamp): ambient and
indoor daylight run 0..0.1 of a 0..1 clamp, because the shipped value is
0.015 and past about 0.1 a room is not lit, it is fogged. `/console`
still reaches the rest. One offers less than its *registration* says:
`interiorShadowDynamic`'s help line reads 0..16, but the observer raises 0
to 1 and clamps to `MAX_TORCH_DYNAMIC` (8), so a groove to 16 would be
seven stops that all mean 8 and a zero that means 1.

The gating is two-level: every row needs the live-fixture lane, and the
three shadow budgets need the shadows on top of it. A budget row left live
while its shadows are off is a dial that does nothing, which is how
settings earn the reputation these ones had.

Russian for all ten rows and their descriptions is written to match the
register of the column already there; a native reader would still improve
it.

Verified: ALL GATES GREEN (346 s); `benilla-app --lib` against the install
at the exact 269-failure baseline, set-identical by `comm`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
2026-10-26 16:37:01 -06:00
gromandClaude Opus 5 2d1dbd5639 Seven more graphics settings get a seat: the filter pair, MSAA, the shadow rig
The sweep behind this: every CVar `video::on_cvar` answers, against every
CVar the Options window names. Twenty of fifty-five had no seat at all, so
the only way to them was `/console` — which is not a setting. Seven of
them here; the ten interior-lighting rows follow.

**Graphics page.** Trilinear Filtering under Anisotropic, because they are
one policy: aniso at 2 or more writes filter mode 5 and trilinear writes
mode 4 only while that bit is clear (`0x688d0d`), so the row is GREYED
while anisotropy is on — not hidden, and not rewritten. It keeps the value
a player set, and turning anisotropy off hands it straight back. The
gating reads the STAGED anisotropy where there is one, since both rows
defer: otherwise a player who moves one and has not pressed Apply sees the
row it is about to gate stay live for the rest of the visit.

Multisampling beside Vertical Sync and Resolution, the latched `gx*` rows
it commits with. 1.12 had this row and we did not. Backed by the index
verbs its own dropdown used (`0x48c580` / `0x48c640`) rather than by
`gxMultisample`, because picking a format writes the colour and depth rows
with it as one record — and it rebuilds its list on open, like Resolution,
since at OnLoad the engine has published no formats yet. Only the sample
count is named: the colour and depth halves of each triple describe our
swapchain and steer nothing, so offering them would offer a choice that
does not exist. Its description says plainly that multisampling does less
here than in a modern game, because it misses the alpha-tested cutouts
most of this game's foliage and fencing is made of.

**Advanced Graphics.** The shadow rig's four unreachable rows — Soft
Shadow Edges (the 9-tap gaussian against the card's own 2x2; Bevy's third
mode is deliberately not offered, since it only resolves under TAA this
camera does not run), Shadow Caster Range, and the two update-rate caps —
plus Torch Shadow Darkness beside the softness dial it shares a shadow
with.

The rate caps needed their own readout. `0` there is **every frame**, the
opposite of what it looks like, so a bare "0 per second" would read as the
shadows being switched off — which is what the two toggles above them are
for. They now say "Every frame", in both locales.

Gating is per-lane where the lane is what matters: each rate cap is dead
only while its own lane is, while the filter and the reach ride the
cascade and need just one sun lane left. Russian for all five rows and
their descriptions is written to match the register of the column already
there; it would still benefit from a native reader.

One thing worth recording for the next row: `OPTIONS_PAGE_ROWS` has
positional contracts in its tests, and the list's own comments say
"appended so the other rows keep their indices". It drives refresh, search
and Restore Defaults — never layout, which is the frames' own anchors — so
appending costs nothing and seats each row where it belongs.

Verified: ALL GATES GREEN (322 s); `benilla-app --lib` against the install
at the exact 269-failure baseline, set-identical by `comm`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
2026-10-26 16:22:20 -06:00
gromandClaude Opus 5 c315fb4f35 Anisotropic filtering and trilinear apply in the session
The Options row shipped last commit said the setting needed a relaunch.
It does not have to. The reference's "enabled upon restart" is about its
TEXTURES, not its CVars — both register with `flags = 1`, live — because
its sampler state was baked inside each `TextureCreate` (`0x449ae0`) and
it kept no list of live textures. Ours can be re-stamped in place.

Two things make it possible, and neither is new work:

- A governed sampler now wears `POLICY_SAMPLER_LABEL`, written by the one
  constructor `TexFilterSetting::descriptor`. That is the `applyGlobal`
  split the reference draws, made legible at runtime: terrain, WMO/M2 and
  the liquid sheets take the policy; weather sprites, the minimap, glue,
  Lua widgets, the sky and decals keep their own mode. All four governed
  lanes went through that constructor, so a lane cannot forget the label
  and silently opt itself out of every later change.
- Extracting a `RENDER_WORLD`-only `Image` takes its *pixels* and leaves
  the asset behind (`take_gpu_data` is an `Option::take` on `image.data`).
  So every resident texture's own descriptor is still readable in the main
  world, for nothing — there is no ledger to keep and none to go stale.

The policy itself stops being a `OnceLock` and becomes one atomic word
plus a revision, so it can be published again on every write and both
halves of the refresh can key off the same number in the same frame:
`refresh_policy_samplers` (render world, `ExtractSchedule`, before every
Prepare) swaps `GpuImage::sampler`, and `invalidate_materials::<M>` raises
`AssetEvent::Modified` so bind groups that captured the OLD sampler are
built again. `static_gx` carries two samplers of its own and rebuilds its
cached cell bind groups in place rather than dropping them — clearing the
cache would be one line, but a region with no GPU state does not draw, so
every building in view would blink out and trickle back under the bake
budget.

Measured, not asserted. `WOW_CVAR_LATE=<name>=<value>` is new here: it
writes CVars *after* the scene has settled, then re-settles the image
around them. A latched lever and a live one give the same boot picture, so
no boot-time A/B can tell them apart. On the Elwynn vista:

    anisotropic 16 at boot  vs  16 written late:
        0 of 1,440,000 pixels differ

and the control that proves the harness is not doing the work (boot 16,
written 16 again late, so no refresh runs but the re-settle is still paid)
is byte-identical to a plain boot. The cost of the change is 873 samplers,
75 material bind groups and 264 static_gx cell bind groups, once; nothing
is re-uploaded, since the pixels never move.

The first run of that A/B was NOT identical — 6.7% of the frame, a dapple
of stale tree shadow on a road that was otherwise correct. That is
`CutoutShadowCasterMaterial`, which deliberately reuses the forward pass's
own leaf handle and so holds a governed sampler, and which lives in
`benilla-app` rather than the engine. Hence the shape the invalidation
took: a public `invalidate_materials::<M>` each crate registers beside its
own `MaterialPlugin`, published through the world-API wall with the record
that says why (the engine naming every material type in the program is the
inversion that wall exists to stop). Forgetting one is silent in exactly
that way — a bind group built after the change is right and one already
cached is wrong — so the capture A/B is written into `docs/GRAPHICS.md` as
the check for this feature, not a unit test pretending to be one.

The Options row keeps its deferral to Apply, now for the real reason: the
commit is cheap once and silly sixteen times, which is what a drag across
the groove would ask for.

Verified: ALL GATES GREEN (520 s); `benilla-app --lib` against the install
at the exact 269-failure baseline, set-identical by `comm`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
2026-10-26 15:44:27 -06:00
gromandClaude Opus 5 1c786b7ecb Anisotropic Filtering takes its seat on the Graphics page
The CVar has been registered and read since the filter policy landed, and
last commit put it on the Graphics Preset ladder — but a player had no way
to reach it. The reference's own `OptionsFrameSliders` declares the row at
index 7; ours left that index a hole, so the only route to anisotropy was
`/console anisotropic 16`, and a player whose preset reads Custom (the
seeder never overwrites one) never met the setting at all.

Seated where the reference's own slider order puts it: after Brightness,
before Weather Intensity. Three things it does differently, each stated
beside it:

- The groove runs 1 to 16, the CVar's own `ANISO_RANGE`, not the stock
  table's 1 to 4. That ceiling was the reference *slider's*, not the
  CVar's — its callback parses 1-16 (`0x689110`) — and both the High and
  the Ultra rung ask for 16. So the row carries literal bounds and stays
  out of `BENILLA_GRAPHICS_SLIDER_ROWS`, exactly as `RowRenderScale` does
  for a CVar the stock table never had, which leaves `OptionsFrameSliders`
  verbatim for any addon that reads it.
- `1` reads out as "Off", not "1x": the reference writes filter mode 5
  only at 2 and above, so 1 is the feature off and not its weakest stop.
- The description is ours. 1.12's own OPTION_TOOLTIP_ANISOTROPIC describes
  a four-stop slider, and this one is sixteen.

Deferred to Apply, as Vertical Sync and Render Scale are: a sampler is
baked into each `Image` at load, so the write cannot reach a texture that
is already resident. The tooltip says so in the row rather than leaving a
player to wonder why the ground did not change — the same thing the
reference's UI means by "enabled upon restart".

Verified: `benilla-app --lib` at the exact 269-failure data-gated baseline,
set-identical by `comm`, with the new row's own test and the two census
counts (tooltip keys, raised plates) moved 116 -> 117 as the row asks.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
2026-10-26 14:39:25 -06:00
gromandClaude Opus 5 89ae4699bb Re-seat two picks the #1730 revert and the realm's interface displaced
Two follow-ups to the series, both consequences of the same root cause.

**#2150's OptionsFrame.xml hunk, re-ported.** It cherry-picked with no conflict
only because the botched #1730 resolution had already replaced this fork's
`OptionsFrame.xml` with upstream's, so it applied against upstream's file. The
revert then restored ours and took it with it. Ported by hand here: our rows
write `self.applyFunc` where upstream's write `this.applyFunc`, and inherit
`OptionsCheckboxRowTemplate`/`OptionsDropdownRowTemplate` where upstream's
inherit the `Benilla`-prefixed names. Both `TargetofTarget_Update` applyFuncs
are gone and the reference note comes along.

**#1972's blanket dialog test, scoped to our two dialogs.** The fix — this
page's two confirms declaring `timeout`, `whileDead`, `showAlert` — is right and
stands. Its test asserted a numeric `timeout` on *every* entry in
`StaticPopupDialogs`, which holds for stock 1.12.1 FrameXML but not here: this
fork loads the realm's own interface, whose `StaticPopup.lua` leaves `timeout`
off 73 of its dialogs. Those are the realm's to declare, and failing on them
says nothing about this fix.

benilla-app --lib is back to the 269-failure baseline exactly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
2026-10-25 23:43:52 -06:00
gromandClaude Opus 5 7a8765aa52 Revert "The Benilla scroll bar template scrolls its parent itself (#1730)"
This reverts e0895013. Two things were wrong with taking it.

**It was resolved by taking upstream's whole file.** `git checkout --theirs` on a
conflicted path takes that file's entire other side, not the conflicting hunk, so
the commit quietly cut 1454 lines out of this fork's own `OptionsFrame.xml`
(4586 → 3132) and replaced `ScriptLogFrame.xml` wholesale. That is what the 180
new test failures were: `BenillaKeyBindings_String` and `OptionsFrameSliders`
undefined, because the `<Script>` blocks defining them were gone.

**And the change itself is a regression here.** Its premise is that
`BenillaScrollBarTemplate`'s `OnValueChanged` is a faux-list handler every
instance has to override. In this fork it is not: our
`BenillaFauxScrollBar_OnValueChanged` (`ScrollTemplates.xml:33`) does the
faux-list `offset`/`updateFunc` step *and* `frame:SetVerticalScroll(value)` when
the parent is a ScrollFrame — a superset of upstream's
`BenillaScrollBar_OnValueChanged`. Swapping the template onto the plain handler
and deleting the faux one would take the faux-list path away from every bar built
from the template.

The earlier claim in e0895013's message — that
`BenillaFauxScrollBar_OnValueChanged` was "defined nowhere in the repo" — was
also wrong. It is defined at `ScrollTemplates.xml:33`. The grep behind that claim
ran against the conflicted working copy, where upstream's *non-conflicting*
deletion of the definition had already been applied, so only the call site showed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
2026-10-25 23:36:59 -06:00
andi79h-claude 73f4bf808d The target-of-target rows only write their globals, as 1.12's options do (#2150)
1.12's options declare Show Target of Target as a bare uvar
(UIOptionsFrame.lua:65) and the mode is read on save
(UIOptionsFrame.lua:365); neither calls TargetofTarget_Update. The frame
follows from TargetFrame_OnUpdate and TargetofTarget_OnUpdate on the next
frame (TargetFrame.lua:492-501). Our rows called it as their applyFunc,
with the row as `this`; the function refreshes `this`'s debuffs
(RefreshBuffs(this, 0, "targettarget"), TargetFrame.lua:538), so with a
target's target up the switch raised "BuffFrame.lua:281: attempt to index
local 'debuffBorder' (a nil value)" (seen on a Linux client).

Verified: the_target_of_target_switch_leaves_the_frame_to_its_own_updates
(with a target's target up: no error, the frame shows after one tick on
and hides after one tick off) fails with the applyFunc put back, with
exactly that error, and passes without it; the options and tot_frame
tests pass on a deDE install; cargo test -p benilla-app --lib 5145,
clippy -D warnings, fmt. Built on macOS only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit 4d6f9ecc8a0d99e6d3ecf7fa4a8da229ebad92c2)
2026-10-25 23:26:20 -06:00
Sam Sung 8f658cb516 Key-binding confirm dialogs no longer raise a Lua error each frame (#1972)
Unticking Character Specific Key Bindings, or pressing Defaults, raised `StaticPopup.lua:1713: attempt to compare number with nil` every frame the confirm stayed up.

1.12.1: `StaticPopup_Show` copies an entry's `timeout` into `dialog.timeleft`, and `StaticPopup_OnUpdate` compares it with 0 each frame; every stock entry declares one, the stock key-binding confirm included (`Blizzard_BindingUI.lua:10-24`, `timeout = 0`, `whileDead = 1`, `showAlert = 1`).

Our key-bindings page's two confirms declared none. Both now do, the character-set one with the stock entry's three fields. The checkbox test now runs a frame with the confirm up, and a new test fails on any dialog the loaded interface defines without a numeric timeout; both fail without the fix.

Fixes #1971

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit 881152bda6d48cc3fd1a47679a7465add212ae05)
2026-10-25 23:24:59 -06:00
Sam SungandClaude Opus 5 0c247396b7 The ESC menu's Support rung opens the help window (#1807)
The ESC menu's Support rung was greyed with nothing behind it. The 1.15 era menu that the layer's ladder follows opens the help window from Support. In 1.12 that window is the stock `HelpFrame`, opened by the "?" micro button's `ToggleHelpFrame()` (`MainMenuBarMicroButtons.xml:217`).

Support is now live: it plays the menu click, closes the menu as the Macros rung does, and toggles the help window. Edit Mode stays greyed.

Test: `support_closes_the_menu_and_toggles_the_help_window` clicks the rung and checks the toggle and the closed menu; the live-entries test now lists Support.

Fixes #1800

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit bb434a181f6a3815ec6fe3e5ace89c864fa1808c)

Absorbed into the octo fork. Ported by hand: upstream wires Support in GameMenuAdapters.xml, a Lua adapter layered over stock FrameXML that this fork does not carry — our menu declares its own rungs in GameMenuFrame.xml, where GameMenuButtonSupport was GameMenuButton_Pending (SetText + Disable). It now sets its text on load and, on click, plays igMainMenuOption, hides the menu as the Macros rung does and calls ToggleHelpFrame(). Our rungs carry no Benilla prefix, so upstream's test names are retargeted; our own narrowing_the_frame_keeps_the_button_column_centred test is kept. The 6 FloatingChatFrame failures in this module are pre-existing baseline (10 passed/6 failed before, 11/6 after).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
2026-10-25 23:18:06 -06:00
Sam SungandClaude Opus 5 e0895013ed The Benilla scroll bar template scrolls its parent itself (#1730)
`BenillaScrollBarTemplate`'s own OnValueChanged was a faux-list handler that both instances replaced with the real scroll, so a new bar that forgot the override wrote `offset`/`updateFunc` on its parent instead of scrolling. The reference's `UIPanelScrollBarTemplate` carries `this:GetParent():SetVerticalScroll(arg1)` itself (`UIPanelTemplates.xml:156-158`).

The template now calls `BenillaScrollBar_OnValueChanged(this, arg1)`; the two instance overrides, the dead faux handler and its header text are gone.

Test: a bare bar from the template scrolls its ScrollFrame parent with no `offset` or `updateFunc` write; it fails with the old template.

Fixes #359

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit 142ed718d3a360a5992c78b53540a356cacf6d1e)

# Conflicts:
#	crates/benilla-app/assets/ui/OptionsFrame.xml
#	crates/benilla-app/assets/ui/ScriptLogFrame.xml
#	crates/benilla-app/assets/ui/ScrollTemplates.xml
#	docs/DEVIATIONS.md

Absorbed into the octo fork. Upstream's side taken on all three XML files, and it fixes a live fault in this fork as well: our BenillaScrollBarTemplate called BenillaFauxScrollBar_OnValueChanged, a global defined nowhere in the repo, so every bar built from the template raised on value change — survived only because OptionsFrame and ScriptLogFrame each overrode the handler per instance. The template now carries the real handler and the two overrides are gone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
2026-10-25 23:10:12 -06:00
gromandClaude Opus 5 02e6fc4208 formats+app: a stale snapshot archive mounts below the realm's patches
grom is keeping `patch-A.mpq` — it is almost certainly textures — so the
shadowing it causes is handled in the chain instead of on his disk.

`mount_order` stays the reference's law, letter above digit and all, and
`demote_stale_snapshots` runs after it: an archive whose `FrameXML.toc`
is a **strict subset** of another's is an earlier edition of the realm's
manifest, so it mounts just below the patch set — above `patch.MPQ` and
the base archives, so anything only it carries still resolves. A toc
that differs by *removal* is a different manifest rather than an older
one and is left exactly where its name puts it. `WOW_CHAIN_DEMOTE=0`
keeps the reference's order for comparison.

On his install `patch-A.mpq` (Aug 7) now loses to `patch-5.mpq`, so the
realm's current data is what loads: its `FrameXML.toc`, its
`GlobalStrings.lua` and a `Spell.dbc` with **28019** rows against the
27992 his last session reported — 27 spells the update added that were
invisible.

Reading the realm's real files then broke ten tests, all of which had
pinned August's behaviour, and each is now pinned the realm's way:

- `AIOHandlers.lua` claimed the `OctoRadio` block as a no-op on the
  evidence that the radio file carried no AIO call. The update built the
  radio out into a file twice the size whose `RegisterNowPlayingFeed`
  registers that name at `VARIABLES_LOADED`, and `AIO.RegisterEvent`
  asserts on a duplicate, so our shim raised through seven tests. The
  row is gone, and the shim now claims at `PLAYER_LOGIN` through `pcall`
  so a realm that implements one of these takes the name and we step
  aside. `no_aio_block_we_shim_is_one_the_realm_now_handles` fails a row
  the realm has implemented — verified by putting `OctoRadio` back.
- `BATTLEFIELDS_CLOSED` no longer hides the battlefield list, by the
  realm's decision: the server stamps `SMSG_BATTLEFIELD_LIST`'s
  battlemaster guid with the player's own, so the client anchors the
  window to the spot it opened and closes it when you step away, on foot
  or on a boat (bug tracker #330 and its follow-up). Pinned that way,
  with the close button proving the list still closes.
- `MAX_QUESTLOG_QUESTS` is 25, not 1.12's 20. The test reads the realm's
  global now, so the next bump is data and not a failure.
- The realm's `OctoQuestLogPoller` redraws the log every 0.2 s, which
  legitimately costs a layout gate walk. `POLLS_ON_A_TIMER` stands it
  down by name before the quiet-frame bench measures, rather than
  raising the threshold and blunting the one instrument that catches an
  every-frame writer. A timed realm handler that is not listed fails.

269 data-gated failures, identical to the baseline at HEAD name for
name, and all nine gates green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
2026-10-23 17:44:21 -06:00
gromandClaude Opus 5 9ce0fe7b3f app: load the five FrameXML rows this client update added
benilla loads its own curated `benilla.toc`, not the chain's
`FrameXML.toc`, so a row the realm adds is silently absent: no "not
found", no warning, just a missing global. grom updated the client on
2026-10-23 and `/xpboost` did nothing. The chain itself was fine — every
archive mounted, no DBC field-count mismatch, and the two sessions'
warning sets differ by two lines *fewer* after the update. The command
was simply undefined.

Diffing the chain's toc against ours found five rows, all added here in
the chain's own order:

- `OctoXpBoost`: `/xpboost`, the bonus-experience opt-out. Slash commands
  never reach the server, so it sends the server's `.xpboost` as a SAY,
  which the server runs as a command and never broadcasts. This is the
  reported bug.
- `OctoTurtleBridge`: Turtle_General's addon-message dispatcher, for the
  realm's inspect replies and `RESPONSE_PLAYER_CHALLENGES`.
- `Turtle_GroupUI` + `OctoGroupUIFix`: the compact group/raid frames and
  three fixes to them. The fix stays immediately after, as the chain has
  it; the xml pulls its own .lua and templates.
- `OctoTurtleStore`: transmog outfits and LFT presets kept on the realm
  over AIO, FrameXML having no SavedVariables.

Verified with the real data: `b_opens_the_backpack_and_shift_b_opens_every_bag`
(the only test that reads a new row's OnLoads) stays green, and the
producer gate goes from red to green with it. Through the addon harness
against grom's own corpus: `SLASH_OCTOXPBOOST1` is `/xpboost` with a
function behind it, the bridge and all nine group frames exist, no
session errors — and the group frames are hidden with no OnUpdate armed
while solo, so they cost nothing until a group fills them.

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
2026-10-17 22:13:14 -06:00
gromandClaude Opus 5 81d96af35d Everwood: the HD/OctoWoW line of benilla, imported onto upstream
This checkout had no git history at all, so this one commit carries the whole
of the Everwood work as a single import on top of chairmanpow/benilla-octo's
`main` (706cd71). The two trees have genuinely diverged — each has files the
other does not — so this is a fork point, not a catch-up.

Today's work, which is the reason a baseline exists at all:

- Booth previews no longer cast world shadows (`NO_BOOTH_SHADOW`). Portrait
  characters were entering the sun's shadow cascades, so every skinned-rig
  shadow specialization compiled live mid-frame; the hitch detector logged
  "main thread blocked 1316 ms" beside a `compiled LIVE` warning. This was
  the Fashion/Tmog stutter.
- Per-pane dress-up: each `DressUpModel` pane assembles and stages its own
  tried-on look instead of every pane mirroring the live player.
- Item template cache is append-only JSONL written on the IO pool, replacing
  an 11.6 MB whole-map JSON rewrite on the main thread every 10 s.
- `WOW_SYSTEM_TIMING` rebuilt per-thread: it took 2 mutexes per system enter
  and 4 per exit, costing 15 ms a frame (590 fps -> 60) and corrupting the
  measurements it was taken to produce.
- The visibility-churn sweep moved to its own `WOW_FLASH_CHURN` switch, so a
  frame-cost reading no longer pays 2.7 ms a frame for it.

Local state (`benilla-config/`, `logs/`, `target/`) is excluded by the
existing .gitignore; the account credential it holds is not in this commit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
2026-10-15 15:01:14 -06:00
chairmanpow 706cd717a3 merge: absorb samwhosung/main on top of the upstream merge
Same fork policy: upstream structure wins, local-unique re-instated.

Cargo.toml unions both measured dev-profile philosophies (codegen-units=1 +
lto=off from samwhosung, debug=line-tables-only + split-debuginfo from the
fork's disk measurements).

glue_booth.rs takes samwhosung's typed GlueScene (token/of_race/from_model_path)
and re-adds the fork's TurtleWoW races (Goblin=9, BloodElf=10) as variants;
their glue fog stays on Human's row, exactly as the fork's scene_fog fallback did.

ui script takes samwhosung's mouseover/hyperlink rework (world_mouseover,
minimap_blip, hyperlink.rs) over the fork's inline hover_link/span_at/pointer
path — the rework covers the same OnHyperlinkEnter/Leave behaviour.

game_plugins.rs keeps the fork's re-measured ceilings (466 / 5,509); mail.rs
keeps the explicit 13-tuple with a float daysLeft; MAP.md takes the superset.
2026-10-12 17:24:31 +01:00
chairmanpow 26d714fd1d merge: absorb upstream/main into the Turtle fork
Fork policy (docs/METHOD.md): upstream structure wins; local-unique work
re-applied on top. This commit is the upstream half of the reconciliation.

Upstream is the same program at a rewritten history (884dff77 == 4e0e89cb
tree), so the merge base was replaced to keep a real three-way diff. The
large conflicts are upstream moving first: the UI manifest (benilla.toc ->
the binary LAYER_MANIFEST/layer.toc split), the CVar table (cvars.rs ->
cvars/{mod,table}.rs, +24 video rows), the editbox flush model, the
host-side GetTime, the Options* -> BenillaOptions* rename, expanded
ui_unit.rs, and a rebuilt volumetric_light.rs.

Local-unique pieces re-instated on the upstream structure:
- OptionsFrame.xml: the 1.12 OptionsFrameSliders table restored under the
  Benilla rename, with the TurtleWoW deviation note (patch-4 replaces
  OptionsFrame.lua).
- layer.toc: CharacterFrameAdapters.xml (the sixth TurtleWoW character tab).
- ui script: wipe/sizeof, BenillaGetMerchantItemStats, and the attach/mount
  teardown re-walk all survive.
- game_plugins.rs keeps UPDATE_ACTIONABLE_CEILING=5_509 (re-measure pending).

The LFT/Turtle_TransmogUI rows benilla.toc named off the player's install
now come from the chain's own FrameXML.toc via the new core loader.
2026-10-12 17:20:45 +01:00
Sam SungandClaude Opus 5.5 781bf36ab3 Key input follows the client's walks; stock login takes typing (#1027)
Key input follows the client's walks: key-down (`0x765f10`), char (`0x765df0`), key-up to the recorded consumer (`0x765fd0`) and repeat (`0x766070`).

- EditBoxes are in the walk from creation (`0x779ced`), so the stock login's boxes take typing; only a focused box consumes. A box declines PRINTSCREEN and unaltered arrows on an `ignoreArrows` box, so stack-split steps under a focused chat box. A binding's press records the world frame.
- Repeats follow the pressed-key list, so a held key resumes after alt-tab. Chords reach a box only through the walk. Keys the client can't name walk nowhere.
- The stock login reads the agreements before its first screen. Cinematics returns to login (no movie player; Deviation). The Realmlist box has its middle border.
- Alt+F4 closes the window on Windows and Linux (Deviation: the reference relies on the OS).

Fixes #179
Fixes #1003
Fixes #1004
Fixes #1005
Fixes #1019

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 18:07:34 +07:00
Sam SungandClaude Opus 5.5 7158c58575 Hidden frames defer their layout; GetWidth answers the set size (#973)
A hidden frame resolved and fired `OnSizeChanged` at once, a getter's resize fired at the drain, and `GetWidth`/`GetHeight` answered the rect over scale.

In 1.12.1 hiding sets the deferral flag `0x2` (`0x76ad50`); while set, an invalidate marks the frame pending and passes it to its dependents without a resolve (`0x768178`). Show resolves it first (`0x76ae58`), a getter resolves a pending one and fires `OnSizeChanged` inside the read (`0x7a1980`, `0x76b580`), and an anchor read pulls it for good (`0x7a2fc0`). `GetWidth` answers the set size when non-zero, else the rect span, unscaled (`0x7a1e93`, `0x7a1ee6`).

The deferral is modelled for frames and regions, getters fire inside the read, layout ids mint in handle order (deterministic), and the options window measures its scroll by its edges.

Tests bite on the parent; the verifier is green; stock quads match.

Fixes #948
Fixes #971

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 11:06:04 +07:00
Sam SungandClaude Opus 5.5 45b7914c9e The stock login screen logs in; realmd 0x0E is a success (#970)
Under `WOW_GLUE_STOCK=1` the stock login screen now logs in. Separately, realmd's 0x0E answer was refused by the native login too.

In 1.12.1 the glue manager runs the screen states (`0xb41da0`): `DefaultServerLogin` (`0x46afb0`) needs the agreements accepted and opens the status dialog; each tick re-texts it from the logon's state and result (`0x46b140`, tables `0x836b40`, `0x836b78`); the world connect re-texts it every frame (`0x46be8b`). The agreements seed from `readTOS`/`readEULA` (`0x46a400`). Realmd 0x0E is a success (`0x5b2cd4`); a proof's 0x0A changes nothing (`0x5bada2`).

The glue core verbs, the glue manager over the native login, the agreement CVars, `SimpleHTML file=`, case-insensitive loose files and a benilla Realmlist layer file are built; the protocol accepts 0x0E. A recorded `Deviation:` keeps the interface signature checks off.

Tests: fake-realmd refusals and 0x0E; stock-screen login reaching the native submit.

Part of #856

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 10:28:08 +07:00
Sam SungandClaude Opus 5.5 55729baff9 Message fades and scroll ranges run in the walk; OnUpdateModel gates (#913)
Hidden chat frames kept fading their lines, a scroll frame recomputed its range on every read and fired `OnScrollRangeChanged` on every `UpdateScrollChildRect`, hidden or not, and `OnUpdateModel` fired for panes with no size or off screen.

In the reference, each of these runs in the frame's own update slot, only while shown: message frames trim and fade there; a scroll frame recomputes a range its writers marked, stores it and notifies only on a real move. The model paint fires only for a resolved rect whose screen-clamped viewport has size.

Message frames and scroll frames are now walk kinds, the range is stored and marked, and the paint applies the rect and viewport gates. benilla's options scroll measures its body's own overhang.

Tests pin the hidden fades, the stored range and its notify, and the paint gates.

Fixes #182
Fixes #175
Fixes #892

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 00:25:14 +07:00
pkuzic f545081e9f MONKEY (planar water): planar replaces screen-space reflections - the Water Reflections option is gone
Owner: planar is cheap enough (~1 ms CPU, GPU about even) to replace SSR outright. High water
always reflects with the planar mirror; the waterReflections cvar, its preset rows, the options
row (EN/RU) and the debug knob are removed. The screen-space march stays only as the mirror's
fallback for water at another height than the mirror plane; WOW_WATER_REFLECT=0 remains a dev
A/B switch.
2026-10-09 19:06:59 +01:00
pkuzic 52bc8e2f94 Merge branch 'gfx/1009-water' into gfx/1009-upstream 2026-10-09 17:34:52 +01:00
pkuzic 8279d3b92d MONKEY (planar water): SSR stretch fixed; planar water reflections (Water Reflections: Screen Space / Planar)
SSR: the march accepted rays up to 2 + 0.1*dist yd behind the scene depth and returned the
bisected pixel unchecked, so rays passing BEHIND a thin dock deck converged on its front
silhouette - a vertical smear. After bisection a hit may now sit at most
0.25 + 1.5%*dist + the last interval behind; deeper = passing behind an occluder, march on.

Planar: liquid/mirror.rs (WaterMirrorPlugin) renders a half-res mirrored camera (oblique
near plane at the water) before the world camera into binding 107; no water, no sun
shadows, no post. The plane is voted by a 7x6 ray fan with hysteresis; water off that
plane fades back to SSR. Cost ~+1 ms CPU, GPU about even (SSR march skipped) -> its own
cvar waterReflections (0 Screen Space / 1 Planar, EN+RU option row), on for High/Ultra.
The High preset (= registered defaults) now carries High water (waterQuality 2); Low and
Medium keep Enhanced. Classic and Enhanced captures byte-identical.
2026-10-09 17:34:52 +01:00