Commit Graph
171 Commits
Author SHA1 Message Date
gromandClaude Opus 5 167d4721e7 Write down the 2026-11-05 session: five defects, and the map cost that measured clean
One section per report, each with the measurement that settled it and the mechanism
underneath — the saved-variables nil poison and the harness gap beside it, the pfUI skin
that was applied as far as its own line 63, the arrow left outside the canvas, the stratum
a reparent took away, the fade, the two verbs our own port already called, and the sound
cut that was a frame late.

The last section is the one with nothing in it: grom reported the map as laggy and it
measures 2.90 ms a frame panning with no addons, 5.43 at 2560x1440 with his, 0 glyph-sheet
resets, and his own log's eleven hitches are all at world entry. The numbers are written
down so the next report can be read against them, and so is what would still explain it.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Also here, from the same play test:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
2026-11-03 19:34:52 -07:00
gromandClaude Opus 5 c3a0315985 map: the three capitals with no traced outline drew nothing; feather them
OpenMapClassic declines to stitch a city with no MapCityMask and names
the three it means -- Stormwind, Ironforge, Darnassus -- so zooming into
the Alliance capitals showed the zone's painting of them and never their
own map. The stated worry is a hard box.

PlaceBase takes the outline and the edge feather in the same argument,
and the city branch passes MapCityMask\<id> as the feather, so a city
with no outline can go through the slot its outline would have used. A
feather is the answer to a hard box. Captured at zoom 30: Stormwind's
districts, harbour and Valley of Heroes, blended into Elwynn.

The trace was attempted first, and this is the record so nobody re-runs
it: unlike the zones' land masks there are three shipped city masks to
fit against, so it is a fitting problem. Local-contrast detail,
threshold, close at quarter scale, largest blob, holes filled, dilate,
blur, swept over the three parameters -- best IoU 0.58, and it swallows
the sheet's own torn frame, because a city sheet is busy out to its
border where Orgrimmar's is blank parchment. A 0.58 hull shipped as a
trace would be a guess in an artist's clothes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
2026-11-03 19:09:22 -07:00
gromandClaude Opus 5 758665d4d8 map: a 1.12 zone sheet is opaque, so the feather belongs on
What was left after the stitch's geometry was hard rectangles at the
zone zooms. Bisected with OpenMapClassic's own /omc layer switches:
baked and gaps draw nothing on this build, zoneart draws them all.

A zone's sheet is masked to its land only for ids in ZoneMaskIDs, the
contiguous Classic range 1411-1452. On this realm that leaves
thirty-four without one -- every city, the dungeons, and every zone the
realm added of its own. Cities already decline to draw unmasked; zones
do not, and Gilneas puts its whole sheet, painted sea and torn parchment
border, over Silverpine.

PlaceBase takes a feather for exactly this and the zone walk passed nil
behind `(not era) and FEATHER`. Sound on Era, where a C_Map zone texture
carries a transparent surround; not true here, where the realm's sheets
are alpha 255 across every texel. The port passes it. PlaceBase prefers
the land mask where there is one, so the forty masked zones are
untouched -- 0.57% of pixels moved in a Dun Morogh view.

scripts/make-map-feather.py generates the three masks as 256x256
uncompressed TGA. A feather is a smoothstep ramp, which upstream's own
NOTICE calls generated rather than cut from art. A per-zone LAND mask
was deliberately not attempted: on this art sea and land do not separate
by colour (a sea patch reads R-B +28, a land patch +34) and the realm's
zone bitmap is 13x13 cells for its largest zone. Either would be a guess
dressed as art.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
2026-11-03 18:17:14 -07:00
gromandClaude Opus 5 7cb45769c5 docs: the OpenMap zoom ladder end to end, and carrying the tile mode to an existing save
A default only reaches a fresh install, and grom's OpenMapClassic.lua
pins minimapTileMode = "fdid" -- the one value that cannot work on a
client with no FileDataIDs. OpenMapClassic has a migration hook for
exactly this, so the port edit adds a one-shot flip beside its own six,
flagged so an explicit /omc tilemode fdid afterwards is respected.

Measured through the real wheel handler: thirty clicks out reaches
0.0330, the ladder's floor and the whole world; forty back in reaches
2.3734, its ceiling; 27 steps between. Captured at zoom 1, 3, 9 and 40
with his own settings and no setup chunk.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
2026-11-03 17:07:12 -07:00
gromandClaude Opus 5 2281179874 capture: a setup slot, for the moment before the scene ages
$WOW_CAPTURE_SETUP runs a chunk at the first aging frame -- early enough
to change a setting or create a frame and still have the scene settle
around it. $WOW_CAPTURE_EVAL keeps the shutter, where a read and the
pixels it explains come from the same frame; anything created there is a
frame too late to be in the image.

The deep-zoom map shot needed it. OpenMapClassic resolves minimap tiles
by FileDataID by default and this client has none, so its own status
reports "loaded YES=0 NO=8"; it already carries a path mode for exactly
that, asking for textures\Minimap\<folder>\map<col>_<row>, of which this
install ships 10,884. Switching to it raised, because
S.ResetMinimapTiles reads lodPieces/lodPool sixteen lines before they are
declared and so bound them to nil globals -- an upstream bug, and the
only two callers are the two switches that reach the mode.

Port edits mirrored to Addon_Ports/openmap_port/: the declaration moved
above its use, and the tile mode defaulted to the one that can work here.
Captured at zoom 40: the minimap's terrain draws through the map.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
2026-11-03 17:03:21 -07:00
gromandClaude Opus 5 62c019e744 blp: a short colour map is a sliver tile, not a corrupt file
Seven of the client's own Interface\WorldMap textures never decoded. They
are the last column or row of an exploration overlay -- a few pixels
wide, a handful of colours -- so they store a SHORT BGRA palette and end:
RuinsOfThaurissan4.blp is 404 bytes in all, LakeEverstill6.blp is 276.
The parser sliced the nominal 256 entries and refused anything shorter.

A refused decode is a quad that draws nothing, and until this week a
texture that drew nothing also said nothing, so the symptom was a hole in
a zone's revealed art -- indistinguishable from not having explored it.
It surfaced only once the stitched world's geometry was right and the
render reached those tiles.

The palette is now as much of it as the file has, and an index past the
end is transparent black, which is what an unused index means.
every_world_map_texture_in_the_chain_decodes walks the folder: 3553
textures, 7 failures before, 0 after, and it fails on the old decoder.

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
2026-11-03 16:42:09 -07:00
gromandClaude Opus 5 388db114fc docs: what OpenMapClassic is for, and why the canvas is grey between zoom steps
Two recordings made the target concrete: one continuous stitched world,
wheel from Azeroth through continent, zone, city, dungeon floor, no mode
change and no seams. Ours navigates correctly -- breadcrumb coordinates,
lore panel, Find me, zone crossing -- and draws the continent step, and
is grey everywhere between.

Grey is "the tile files were never asked for": PlaceTiles stores
tex.omFile instead of setting it, and ShowZone only queues it when
inView[mapID] == true, which S.Cull writes from a view box built out of
GetCurrentScrollX/Y, GetCanvasScale, the container's size and
GetMapArtLayers(947). Four of our verbs and one constant art size decide
whether a zone's art is ever loaded.

Also records the third BetterCharacterPanel port edit: the stats column
pinned to the panel's own backdrop, so the overflow is reached by
scrolling inside the window instead of hanging over its edge -- window
359, content 699, needs 340, bar reaches 340.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
2026-11-03 15:32: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 07dd5ba7e1 capture: a shot taken of a player's config must not write it back
`$WOW_CAPTURE_SAVED` points the saved-variable READS at a real player's
configuration so a capture reproduces their interface. The writes went to
the same place, so taking the shot overwrote the configuration it was
taken to study -- with a fixture character's state, since a capture logs
in as nobody. Three of grom's files came back changed from one run, his
pfUI chat scrollback among them.

The shutdown tail returns before all four writes under $WOW_CAPTURE.
Verified by md5 across a run: zero files touched, zero "wrote" lines.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
2026-11-03 14:34:31 -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 c9756de381 docs: the anchor pfUI's save dropped, the seed that was only a comment, and the fade
Four sections, three of them for changes that cannot be committed because `benilla-config/` is
gitignored (it carries the credentials). Those live on disk and in the mirrors:

  Addon_Ports/pfui_port/pfUI/api/api.lua           AnchorOffsets + SaveMovable/LoadMovable
  Addon_Ports/pfui_port/pfUI/modules/autofade.lua  new module
  Addon_Ports/pfui_port/pfUI/modules/gui.lua       the "Auto Fade" page
  Addon_Ports/pfui_port/pfUI/init/modules.xml      the include
  Addon_Ports/openmap_port/OpenMapClassic/Map/Window.lua   SetClampedToScreen either way

- The map opened with nothing on screen because pfUI's `SaveMovable` kept three of `GetPoint`'s
  five values, dropping `relativeTo` and `relativePoint`, and `LoadMovable` replayed the offsets
  against the frame's own point. OpenMapClassic anchors the map TOPLEFT to UIParent's BOTTOMLEFT,
  so the save moved it a full screen height — measured at 1800 units on a 1440 screen at scale
  0.8. It was latent while OpenMap's own hook kept putting the window back, and surfaced the
  moment that stood down. Fixed by measuring the offsets for the point being named instead of
  copying a pair in another convention, which also fixes an anchor to a third frame; plus an
  off-screen check on restore, so a save already written heals once instead of every login.

- The player's first buff of each session (the commit before this one).

- pfUI: a config section added to `LoadConfig` does not survive a character that already has a
  config, because `pfUI_config` is SavedVariablesPerCharacter and the saved table replaces the one
  LoadConfig filled. `C` is not a stable table either — `GetEnvironment()` rebinds it per module.

- The fade grom actually wanted out of Cinematic, which he has now removed: pfUI's own
  `EnableAutohide`, which only the action bars and four info panels could reach, pointed at the
  minimap, the buff rows, the unit frames and Location Plus, every switch defaulting off and each
  with a row in pfUI's GUI. Buffs and debuffs are separate switches on purpose, and the one real
  cost is stated rather than hidden: with the unit frame switch on, the fade and pfUI's
  out-of-range dimming both write one alpha.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
2026-11-03 08:22:36 -07:00
gromandClaude Opus 5 92237dfb54 docs: the first-open pickers, the pfUI window handover, and why not to bake the map in
Records both fixes with the measurements behind them, the rejected first
attempt and what made its test worthless, and the answer to "should this just
be built into the client": no — the addon's art already draws correctly here,
it is ~26k lines and 67 MB of assets, and Questie and TomTom reach it through
`OpenMapAPI`, which exists only because it is an addon.

The pfUI handover itself is a change to the port's `Map/Window.lua` and
`Map/Compat.lua`, which cannot be committed here: `benilla-config/` is
gitignored because it carries the login credentials, so the addon tree lives
only on disk and in the `Addon_Ports/openmap_port` mirror, verified identical.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
2026-11-02 21:57:23 -07:00
gromandClaude Opus 5 d1382f8e01 docs: the nameplate row count is twenty-one, not seventeen
Ten friendly names and five hostile ones are the per-kind switches, plus
`nameplateMaxDistance` — sixteen live — and five more registered and inert.
The previous commit's message carries the same wrong total; this is the
correction, since the doc is the thing anyone reads later.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
2026-11-02 21:17:33 -07:00
gromandClaude Opus 5 ae929e8a81 map: the art trace reads the tiles the repaint put up, not the ones it replaced
The line fired in the frame the selection changed, and the repaint that fills
the tiles is Lua's own off a queued `WORLD_MAP_UPDATE` — so it read the map
that had just left. That is how "the map shows Dun Morogh and the tiles are
Azeroth's" got into a log with nothing wrong behind it. It now waits three
frames, restarting the wait if the selection moves again.

docs/ADDON_COMPAT.md records this round: the cursor position no verb ever
wrote, the per-kind nameplate CVars a nil answer greys out, the capture lever
above with its two limits measured rather than assumed, and one open item — the
map's Continent and Zone pickers, blank on the first open with the addons
loaded and fine without them, with the candidate engine fix rejected because
the test for it passed either way.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
2026-11-02 21:15:38 -07:00
gromandClaude Opus 5 5308b5a200 docs: a hosted page draws under its host, and art that does not resolve is invisible
Two defects from one play report, both in the ports, both measured rather
than guessed:

`SetParent` re-levels the frame it is called on and nothing under it
(0x76ab65 pushes propagate = 0, and a script SetFrameLevel does the same),
so Cinematic's pages -- built parentless, widgets at levels 1..20 -- landed
25 levels under the window its toplevel raise had lifted to the top of
DIALOG. The page drew under the window's own backdrop and the backdrop took
every click. The screenshot named it before any code was read: the page's
white read (103,103,103) and its gold (103,84,0), the same 0.404 factor as a
nameplate behind the window, where a fade on top would have composited to
(110,116,121). Multiply, not blend, means underneath.

And the map's resize grips are shown, mouse-enabled at level 9002, and take
the cursor -- but wear a 2.0 texture this chain has not got, and a Lua caller
cannot find that out: SetTexture returns nothing and GetTexture echoes the
path it was handed either way. Twenty pixels of nothing in the map's corners.
The client's own `texture miss:` warning is the only instrument that names
one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
2026-11-02 18:42:54 -07:00
gromandClaude Opus 5 eef0d06852 docs: the map, answered by its own trace
What `WOW_MAP_TRACE` printed, what it meant, and the two smaller faults that came out with it —
including the harness note that `--tick` does not call `resolve`, so `OnSizeChanged` never fires
under it and its silence there is not evidence of an engine gap.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
2026-11-02 16:36:00 -07:00
gromandClaude Opus 5 4ea1d97faf docs: the realm's LibStub decides by load order, not version
`Interface\FrameXML\AIO\Dep_LibWindow-1.1\LibStub.lua` loads before every addon and its
`NewLibrary` has the minor-version parse replaced by a literal `minor = 2`. So every library
registers as 2, every later copy is refused, and the FIRST copy of a library to load wins whatever
its version says. Measured: all 22 registered libraries carry minor 2.

What it did to grom: three AceGUI-3.0 copies, a 1.12 backport's core wins by load order, but widget
files never go through LibStub (RegisterWidgetType compares widget versions itself), so the install
runs the retail tree's newer widgets against the older core. `AceGUI.tooltip` is created by the
retail core and neither backport, so TreeGroup's button tooltips raised on every category row the
mouse touched. The `OptionsListButtonTemplate.toggle` gap from the previous round is the same shape
one layer out.

Also records WOW_MAP_TRACE and why it exists.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
2026-11-02 15:55:02 -07:00
gromandClaude Opus 5 08689e7d14 docs: the plate token, Cinematic's own settings window, and two more fade rows
Second round of grom's 2026-11-02 session. The nameplate half is engine work and is written up
beside the rest of the compat record; the settings window and the two support rows are in the
ported addons, which live outside this tree, so this is where they are recorded.

The general note worth keeping out of the Cinematic-specific part: an addon that registers its
options with a host and ships no fallback is one stomped global away from having no settings UI
at all, and no engine can stop an addon reassigning a global. Shipping `InterfaceOptions_*` is
still worth doing for the addons that have no window of their own — it cannot be the whole answer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
2026-11-02 14:51:44 -07:00
gromandClaude Opus 5 607ce45788 docs: six things an Era addon assumes a 1.12 template or verb carries
grom's 2026-11-02 session, written down where the deviations live: the AceGUI TreeGroup toggle that
1.12's OptionsListButtonTemplate has no child for (and why the newest of three installed AceGUI
copies is the one every addon's config window gets), the two templates that reach their children
through the frame's own name so the frame must be named, GetDebugName, the override-binding family,
pfUI's two panel bars, and the rule that a minimap-button drawer only collects the minimap's own
children.

Five of the six are fixes in the two ported addons, which live outside this tree; this section is
where they are recorded.

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
2026-11-02 11:10:57 -07:00
gromandClaude Opus 5 18c8cdd762 docs: the MOD blend, SetEnabled, and the label that rode into the canvas
One new section and two amended rows, the three faults behind what grom recorded
on 2026-11-02: the fullscreen tint that drew white, the chat traceback from
picking a profile, and the hovered-zone label scaling with the map's zoom. Each
carries what was measured rather than what was argued, and the general shape —
answering half of an era's call shapes is what makes an addon's `pcall` feature
test lie.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
2026-11-01 13:11:00 -07:00
gromandClaude Opus 5 9df4b6abd8 Read the client's key enum out of WoW.exe — the fact KEY_DOWN was blocked on
`KEY_DOWN`/`KEY_UP` were the last two nampower events left unfired, and the blocker was never
effort: it was the client's numeric `KEY_*` enum. The DLL's own strings publish four values
(`KEY_NONE=-1`, `KEY_SHIFT=0`, `KEY_A=65`, `KEY_ESCAPE=512`, `KEY_F1=768`) — enough to see three
banks and not enough to name a key past the first in any of them, and a guessed code is worse than
no event, since an addon switching on `arg1` would silently never match.

It is in the binary, in the name parser `SetBinding` resolves a key string through (`0x4886f0`),
and the arithmetic is in the instructions rather than inferred:

- **ASCII, bank 0** — a printable key is its own byte (`movsbl (%esi)`), which is why `KEY_A` is 65.
- **Numpad, `0x100`** — the digit character plus `0xd0` (`add $0xd0,%edi` at `0x48875b`), so
  `NUMPAD0 = 0x100`..`NUMPAD9 = 0x109`, then `+ - * / .` at `0x10a`..`0x10e`, each reached by its
  own character compare.
- **Specials, `0x200`** — contiguous from `ESCAPE = 0x200` through `PAGEDOWN = 0x20d`, then a hole,
  `NUMLOCK = 0x20f`, two more holes, `PRINTSCREEN = 0x212`. Every one is a `mov $imm,%edi` in the
  chain at `0x4888a0`..`0x488a20`; the holes are codes this parser assigns to no name.
- **Function keys, `0x300`** — `0x2ff + n` (`add $0x2ff,%edi`), so `F1 = 768` as published, and
  `NUMPADEQUALS` sits past them at `0x30c`, from the parser's own `=` arm.

Transcribed into `bindings::chord::key_code`, which composes with the existing `key_token`, with a
test per bank and the three published values as its anchors. `CAPSLOCK`, `SPACE` and the
mouse-wheel names are in the client's name table at `0x846c20` but take no code from this parser,
so they answer `None` rather than a number nobody can check — and a second test walks every token
`key_token` can produce and fails on any that has neither a code nor a written-down reason.

No caller yet, and that is the honest state: what is left for the events is a **producer** on the
keyboard path, which is nampower's hook and the same hook the press-interception queue uses, so it
belongs with that decision rather than ahead of it. `docs/ADDON_COMPAT.md` now says so instead of
naming the enum as the blocker.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
2026-10-31 18:10:36 -06:00
gromandClaude Opus 5 f8e93c4484 MasterSoundEffects 0→1 is how a 1.12 addon stops a sound — make it stop sounds
grom: SoftSpoken "played a voice line over and over", and its **Silence NPC Voices**
option did nothing although it was switched on. Both are one deviation, and it is ours.

There is no Dialog channel to mute on 1.12, and SoftSpoken's own shim says so:

    function SoundUtils:MuteChannel(channel, muted, fadeOut)
        -- Intentionally unsupported on this client.
    end
    function SoundUtils:StopSound(clip, fadeMs)
        SetCVar("MasterSoundEffects", 0)
        SetCVar("MasterSoundEffects", 1)
    end

That toggle is its whole repertoire, and it is not a hack the addon invented — the
reference makes it work on the **enabling** edge:

    0x457500   the CVar callback: cl = !!value, tail-calls…
    0x7a6570   …the flag store, edge-triggered (7a657f: je on an unchanged flag)
    0x7a67a0   walks the live instance list; per node:
                 flags & 0x2        → skipped (the cinematic bypass)
                 flags & 0x4000008  → muted, not stopped (0x7a65a0)
                 otherwise, and ONLY when enabling (7a67eb: test cl,cl / je):
                   flags & 0x4      → 0x7a5950   stop
                   else             → 0x7a4200   stop, then unlink (0x7604f0)

`0x7a5950` is unmistakable: it takes the node's stream handle (+0x34) and channel id
(+0x30) and calls the FMOD stop thunks. The *disabling* edge stops nothing at all —
sounds run on, gated silent at the pump, which is exactly what `category_amp` already
did. So we had half the CVar, and it was the half that does not matter. The comment on
`SoundConfig::enabled` claimed the two "sound the same"; they do for a player moving a
slider, and do not for an addon.

Both symptoms follow. `MuteGameDialogue` is gated on `AutoToggleDialog`, whose default is
`(Version.IsLegacyVanilla or …)` — true here, which is why his saved profile has no
`Audio` block and the option was on anyway — and on this client its body is nothing but
the toggle. The gate passed, the toggle ran, the NPC greeting played straight over the
line being read. The repeat is the same toggle spent on SoftSpoken's own clip:
`PlaySound` cuts *before* it plays ("On Vanilla, interrupt NPC audio BEFORE playback"),
and `StopKeeping` is documented as "silence the head but keep its place: it replays from
the start when Advance next reaches it, since the client has no seek". With the cut inert
the head never stopped, so the replay layered a second copy on a first still going.

`kit::cut_effects_on_reenable` now stops every `SoundCategory::Sfx` channel when the edge
is spent. Music and ambience are spared (the reference's mute-not-stop class) and
cinematic audio never enters `SoundOutput::channels` at all (the `flags & 0x2` bypass).

Two details are load-bearing, both pinned:

- **The edge is armed by the write, never sampled from the value.** The addon writes 0
  and 1 inside one Lua call, so a system reading `SoundConfig::enabled` once a frame sees
  `true` on both sides and no edge at all. `EffectCutArmed` is set in the CVar observer,
  the one place that sees every write. It is its own resource rather than a `SoundConfig`
  field because spending it needs `ResMut`, which as a field measured **43** new
  ambiguous pairs against every `Res<SoundConfig>` reader in the frame.
- **The cut is spent between the Lua pass and the sound drain.** The reference stops
  synchronously inside `SetCVar`, which is before the `PlaySoundFile` the addon calls
  next. Applied after the drain it would stop the new clip instead of the old one — the
  symptom inverted, not fixed.

The ceiling rises by 1, for the pair against the collider finisher: the same exclusive
`&mut World` shape `log_new_corpses` and `textinput::open_early` already record.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
2026-10-30 14:48:13 -06:00
gromandClaude Opus 5 08d574b09c GetCastTime applies spell-mod op 0xa itself — so a talented Slam is 2.0 s, not 2.5
grom's probe closed the slam hunt in one line:

    mx=2.7  st=1.0255  u=2.7  c=2.5  w=7.4074

Both of my remaining candidates were wrong. The swing speed arrived correctly
(2.7, not the 2000 ms stand-in the last commit instrumented) and the swing timer
was running, so neither the feed nor the combat events were at fault. That leaves
one term: `c`, the cast time, and `w = (2.70 - 2.50) / 2.70` falls straight out
of it.

`SuperCleveRoidMacros` does not ask a verb for that number. `GetSlamCastTime()`
walks the spellbook for Slam, builds a nil-parent `GameTooltipTemplate` scanning
tooltip, calls `SetSpell`, and reads the `"%d+%.?%d* sec cast"` line — so the
whole conditional rests on one line of our spellbook tooltip.

`Spell_C::GetCastTime` (`0x6e3340`) applies `SPELLMOD_CASTING_TIME` **inside
itself**, after the row's minimum clamp:

    6e33b0:  cmp    %esi,%eax         ; row minimum (2500 for Slam)
    6e33b4:  mov    %esi,-0x4(%ebp)   ; ...clamped to it
    6e33b7:  push   $0xa              ; op 10 = SPELLMOD_CASTING_TIME
    6e33be:  call   0x6e6af0          ; value = (value + flat) * pct / 100

and the tooltip's cast line is a call to it, not a DBC read (`52eb48`/`52eb4b`/
`52eb4d: call 0x6e3340`). We read `SpellCastTimes.dbc` directly, so a
talent-shortened cast showed its base length — a deviation `ui_action/mod.rs` and
`cast_times.rs` both *documented* without anyone pricing it.

The realm's own data prices it. Slam (1464/8820/11604/11605) is `CastingTimeIndex`
20, row `{base 2500, perLevel 0, minimum 2500}`. Improved Slam is two ranks —
12862 and 12330, aura 107 / misc 10 / `basePoints` -251 and -501 with `dieSides`
1, a flat -250 or -500 ms on op 10 against `SpellFamilyFlags` bit 21, family 4
(warrior, `ChrClasses.dbc` field 15; the five-rank version is `zzOLD`). So a 2/2
warrior's Slam is 2.0 s, and on his 2.70 s main hand:

    2.5 s (ours)        0.20 s window      7.4% of the swing
    2.0 s (his client)  0.70 s window     25.9% of the swing

It is a size, not a sign. 0.20 s is narrower than human reaction time, so a
player pressing by hand overshoots it every press; 0.70 s he lands in every
swing. "477 Heroic Strikes, zero Slams" and "works just fine on the normal
client" are the same observation.

Note the order: the minimum clamp is 2500 and it runs *before* the modifier.
Applying op 0xa first would pin Slam at 2.5 s for ever and look identical to the
bug it was meant to fix.

`Spells::cast_time_ms` is now `GetCastTime` — row resolve, minimum clamp, op 0xa
— with `cast_time_signed_ms` for the reference's `allowNegative` form the
tooltip's "Instant cast" ladder needs (`0x52ebce`), and `cast_time_row_ms` kept
for the one question that wants the row alone: whether a spell has a cast time
*authored* at all, which is `opens_cast_bar`'s gate. The tooltip's cast line and
the four live readers pass the modifier tables through — including both
`PendingCast` arms, so nampower's local lockout on a talented cast was 500 ms too
long as well. The tooltip feed re-pushes on a level change now that the cast line
is level-scaled.

Still deviating, written where it lives: the final `UNIT_MOD_CAST_SPEED`
multiplier (`6e342c`, taken only when `Attributes & 0x30` is clear) is not
applied, because no site streams unit field 139. It is 1.0 without a casting-speed
aura, so it moves nothing a physical ability does.

Tests: `a_talent_shortened_cast_line_shows_its_talented_length` grades Slam on the
real DBCs untalented, at 2/2, and against a foreign class family (the gate that
stops a mage's table shortening a warrior's Slam). The harness test now drives
grom's own addon chain through both widths — `WINDOW_PCT` 7.4074074074074 and
`WINDOW_PCT_TALENTED` 25.925925925926 — with the addon's own
`RefreshSlamCastTime` between them, because its scan is cached for 2 s and a test
that re-pushed without it would measure the stale value and pass.

Both earlier answers are written down in `WINDOW_PCT`'s doc and in
docs/ADDON_COMPAT.md: I called this "not ours" twice, from two green tests, and
each was green because it asserted a number I had supplied.

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

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

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

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

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

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

----

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
2026-10-30 09:02:27 -06:00
gromandClaude Opus 5 f3fec804eb Two addons another player asked for, and the Linux-only gap that killed one
AlternativeClassicActionBars and UnitPlates, both now in `other-addons/`
(untracked — third-party source, not ours to vendor). Measured with the survey
and the read-back probe on a root holding only those two, so neither borrows the
other's globals. Both already loaded clean; three things were missing, and the
first was costing an entire addon's keybinding surface.

**A binding table spelled `bindings.xml`.** `ui_script::addons` asks for
`Bindings.xml` by the reference's own fixed name — `0x51f400` reads the name,
not a `.toc` entry — and the open was case-sensitive, so on Linux ACAB's 69
`<Binding>` rows never registered. Silently: an absent binding table is the
normal case, so nothing raised and nothing warned. Hover-bind, edit-mode escape
and every bar page key were dead. The texture and font side of the same file
namespace has always resolved case-insensitively (`loose_addon_file`, which
walks the path a component at a time and enumerates a directory when the spelling
does not match); the Lua and XML side now shares one resolver with it, and both
crates' private `read_under` copies delegate to it. Measured after: 69 bindings
listed by `GetBinding`, `SetBinding("CTRL-9", "ACABBIND1")` round-trips, and the
addon's drawn-frame count went 42 -> 175, because its login sequence had been
stopping short of its own bars.

**`PixelUtil`.** Called unguarded on ACAB's bar-geometry path at four sites,
three of them the same question — one screen pixel in UI units, which is what
every pixel-perfect border in that addon is drawn from. Not in ClassicAPI's
published list, but ACAB disables itself without ClassicAPI, so on this server's
kit it is present by implication. A Lua chunk beside `math_ext`, whose `Round` it
uses. One deviation, written down where it lives: upstream reads
`region:GetEffectiveScale()`, which a 1.12 texture does not answer, so the scale
read falls back to the parent and then `UIParent` — the same number in every case
that matters.

**`UnitXP("nop", "nop")` answering the boolean.** The presence probe.
SuperCleveRoidMacros keeps only `pcall`'s `ok`, but ACAB's `HasUnitXPSP3` wants
`ok and result == true`, and the fall-through handed it the stock XP getter's
`0` — truthy, so it passed the loose reading and failed the strict one. ACAB
reported UnitXP_SP3 absent on a client that answers the whole family.

Left alone with the reason in `docs/ADDON_COMPAT.md`: `IsStackableAction`
(post-1.12, guarded, and stock 1.12's own ActionButton.lua shows a count off
`IsConsumableAction` alone), `CursorHasMacro` (post-1.12 and redundant — ACAB
tests `GetCursorInfo() == "macro"` first), and five third-party tables that are
missing because those addons are not installed.

The harness grew a `--plates [n]` step, because a nameplate addon is the one kind
it could not see working: the subject is a `WorldFrame` child `vplates.rs` mints
per frame, so with none seated the addon's scan finds nothing, does nothing, and
reports a clean load over an empty screen. It seats n live plates and counts how
many an addon hung a frame on, which doubles as the contract check on the plate's
own shape — a plate that stopped being a `Button`, or stopped putting its border
texture first, would read as an addon adopting nothing. UnitPlates adopts every
plate and reads its name, health and level off ours.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
2026-10-29 14:15:51 -06:00
gromandClaude Opus 5 b5259bd608 The missing item values were eight absent id getters, not the tooltips
grom reported no item values on quest-reward tooltips. The value line is pfUI's,
and `modules/sellvalue.lua` says plainly how it works: it hooks every `Set*Item`
tooltip verb and takes the row's **id** — never its link — then asks
`C_Item.GetItemSellPriceByID`. An absent getter answers nil and
`AddVendorPrices` returns on its first line.

So it was never about quests. This client answered exactly two members of that
family, `C_Container.GetContainerItemID` and `GetInventoryItemID`, which is why
his bag and equipped items had a price line and his quest rewards, loot rows,
loot rolls, merchant rows, mail attachments and trade-skill rows did not. The
other eight are here: `GetQuestItemID`, `GetQuestLogItemID`,
`GetLootSlotItemID`, `GetLootRollItemID`, `GetMerchantItemID`,
`GetInboxItemID`, `GetTradeSkillItemID`, `GetTradeSkillReagentItemID`. Each sits
beside that row's own `*Link` getter, in that row's own module, reading state
already held — no new feed, no new state.

The id is also the **earlier** fact, which is the part worth keeping in mind. An
item id rides the packet; a link is built only once the item's template has
landed to give it a name and quality, and `ui_quest::resolve_item` says so in as
many words. A quest reward is close to the worst case for that — it is usually
an
item the player has never seen, so its template is not in the 14,000-row cache
and the first hover beats the answer home. These getters answer there, where the
link beside them is still nil; the test asserts exactly that contrast.

Also, from the same thread of play reports: the sell-junk sweep now says what it
could not judge. `SellAllJunkItems` and `GetNumJunkItems` both gate on a *known*
poor quality, and a carried slot's quality is `None` until its template arrives,
so a grey sitting in such a slot is neither counted nor sold — which is what
"not all grey items sell" looks like from outside. Whether that is in fact the
cause is not something this end can tell, so the sweep records the slots it
skipped for that reason, with their item ids, once per click. If the next log
names some, the fix is to wait for those templates; if it names none, the cause
is downstream in the drain or the server's refusal, and the line says that too.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
2026-10-28 22:13:28 -06:00
gromandClaude Opus 5 c8c4e7f855 Four defects out of his log, and the 65 warnings that are not ours
His 19-minute session put 8 errors on screen from two causes, and 73 WARN lines
of which 8 are ours. The whole triage is written down in the play-test memory,
because most of the log is the addons' own and nothing should re-chase it: five
files their `.toc`s name and do not ship, a QuickHeal template that is never
defined, an ATSW global string that only exists in its deDE branch, inspect
frames from a load-on-demand addon with no `## Dependencies` line, `<Binding>`
elements the frame loader is *supposed* to log, and a world-map lag line that
reported its own recovery 5.2 s later. Four of the eight are fixed here.

`GameTooltip:SetSpellByID` was nil, and it is not a keypress: the traceback is
pfUI's `ButtonEnter` into `SuperCleveRoidMacros/Core.lua:4217`, so it raised on
every action-bar hover of a macro that resolves to a spell — 7 times, each
leaving the button with no tooltip at all. `compat/classic_api/tooltip.rs` had
declined the whole `Set*ByID` family pending "an argument-shape check against
the stock verb each aliases". Done, and the check is why this is four lines:
there is no aliasing to do, because `tooltip_spell::set_spell_by_id` is the body
all four of 1.12's spell verbs already funnel through, and it takes a bare id —
the book slot or list row is what *they* have to convert first. `show_rank` is
the action's rather than the spellbook's, because the book draws "Rank N"
itself as the button's `subSpellName` and an id-named caller has no such label.
The caller's own comment says it reaches this only for a spell in neither
spellbook, so the cold-view path is the common one here; that needs no special
case, since the app's feed builds a view for any id in the catalog and an action
button re-asks its tooltip every `TOOLTIP_UPDATE_TIME` while hovered.

`resolve_spell_fx` despawned an entity the world no longer had. `FxInstance`'s
root is a *stored handle*, and `attach_spell_fx` is the system that notices one
lost with the unit's whole visual and re-arms or drops the instance — so between
that loss and that pass, the same-slot replace, the spell-id reap and the
self-termination clock all hold handles to entities that are gone. `try_despawn`
at those three, as `lifecycle.rs` already does; `tend_world_plants` keeps its
plain `despawn` because its entity comes from a query, not from a record.

The first paste of every Wayland session could never have worked.
`smithay-clipboard` sets `latest_seat` only on a `wl_keyboard` Key/Modifiers
event or a pointer press (`state.rs:522`, `:386`) — `Enter` sets `has_focus` and
nothing else — and refuses to load a selection without it. `HostClipboard`
opened its backend on first *use*, so the backend that read the clipboard was
created by the very Ctrl+V asking for it and its keyboard object was younger
than that chord's own key events. It is now opened as soon as the window can
give the `wl_display`. Costs +2 on the `Update` ambiguity ceiling, both pairs
against the schedule's two exclusive `&mut World` systems, which pair with
everything they have no declared order against.

And the one `changed_windows` ERROR a login prints had no remedy behind it: no
client can warp a free pointer on Wayland, so the loading screen's cursor
hand-back now asks only where the answer can be yes — off Wayland, or with the
pointer locked, which is winit's own rule.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
2026-10-28 20:26:05 -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 4c3c128594 Screen the capture scenarios for determinism, and correct what rested on one
Routine work on the sharpening lane needed a byte-identity control, so I
checked the baseline against itself first. Three of seven scenarios do not
reproduce run to run with ONE binary:

  overlook-noon, water-lake, water-ocean, fence-shadowline-night   identical
  stormwind-canal-near   bimodal: 7 of 15 pairs identical, rest 0.01% / max 96
  inn-noon, volfog-inn   usually the bottom bit; one pair 37.8% / max 199

`stormwind-canal-near` is the one that matters, because the water-mirror
write-up used it as a byte-identity control and it cannot be one. The identity
measured there was a coin flip that landed well.

The finding it supported still stands, but on its EXTENT and not on the
amplitude it was also quoted at. "0.75% of pixels by up to 96/255" reads as one
observation; it is two, and only the first is evidence. 96 is what the canal's
own flicker reaches with nothing changed at all — the amplitude says nothing.
0.75% against the scene's own 0.01% is 75x, which is the part that was real,
and the run condition is still the right fix.

docs/GRAPHICS.md's harness section now carries the table, so the next person
reaching for a control picks from the first row.

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