89dc99f4d1e6eae3012fdf6cf714fb520830b50a
1701
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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
|
||
|
|
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 |
||
|
|
7a3ab5e7bf |
A tooltip raise cost 6.6 ms because every frame it minted re-derived the layout graph
`WOW_UI_VERBS=1` over the hover bench answered it in one line: the rebuilt frame's cost was **`frame:GetLeft`, 3 calls at 2188 us each — 6.56 of the 6.6 ms** — inside `UIParent:OnUpdate`. `GetLeft` forces a resolve, and each of those resolves was a whole-graph derivation of ~19,000 frames and ~44,000 regions, because `Model::frame_id` gave up naming the graph on every mint. OctoPawn mints two frames on every `GameTooltip` OnShow purely to own a one-shot `OnUpdate` — the 1.12 way to write `C_Timer.After` — so a raise dirtied the graph twice and the three `GetLeft`s each paid for it. A just-minted frame is now **appended to the cached graph as a leaf** (`LayoutScope::append_frame_leaf`). Sound because the id was minted this instant, so no anchor anywhere can name it: nothing cached reads the new node and no cached edge moves. What it reads is its own anchors' targets, which the append is handed — a mint is not always a creation, since `frame_id` mints on the first *fetch* too, and a frame built in Rust (a nameplate, a chat bubble) is first fetched when Lua asks for its wrapper with its anchors long written. `last` stays `NO_NODE` so the incremental seed sees the hash move; `commit_incremental` counts the slot, which keeps the next full pass's `matched != last_count` node-death test honest. Refused when the frame already carries regions: a region is a graph node without needing a minted id — the derivation mints region ids inside its own walk — so only a derivation can add those rows. **Measured, same bench, same interface: the rebuild costs -0.08 ms/frame under forty churning plates and +0.14 ms with none up, from 6.63 and 6.78.** The rebuilt windows now report no derivation sites at all. Two things about how this was found, both in the bench's doc now: - Its own earlier conclusion was that the cost was "the plate's line walk and the hooks hanging off it, flat, and not the graph it dirties", because the crowded and the empty shape cost the same. They cost the same because **both** were paying the full derivation. A fair experiment that was blind to its own answer, and one env var would have named it. - `WOW_LAYOUT_VERIFY` over the real interface is red on this change — and it is the documented pre-existing 48-region shortfall, reproduced identically with the whole change stashed: `frames 13544 -> 13544, regions 29481 -> 29529` either way. All 2069 benilla-ui tests pass, and they run that verifier forced on after every resolve. 189 workspace failures, unchanged, no regressions. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn |
||
|
|
3bbd99ebaf |
The slam macro, end to end through the path the game takes
He re-tested and it still never casts Slam, so I drove the whole thing rather
than the conditional: `UseAction` on a macro slot → `run_macro` fires
`EXECUTE_CHAT_LINE` per line (`0x4f14e0`) → the stock ChatFrame sends it through
its edit box (`ChatFrame.lua:1343`) → `ChatEdit_ParseText` finds
`SlashCmdList["CAST"]` → CleveRoids' hook → `GetParsedMsg` → `DoWithConditionals`
→ `Keywords.noslamclip` → `ValidateNoSlamClip` → `CastSpellByName`. On his own
interface, with his own 2.70 s weapon and Battle Stance, the line casts **Slam**
at the top of a swing and **Heroic Strike** 1.6 s in — which is the assertion the
old test never made, and the reason "the conditional answers true" meant nothing.
Three things came out of the drive.
**The load-time latch is worse than I wrote this morning, and it is what he is
seeing.** `GetSlamWindowPercent` reads SP_SwingTimer's `st_timerMax` cache, not
`UnitAttackSpeed`, so the `ADDON_LOADED` latch decides the window for the whole
first fight: the stand-in gives (2.0 - 1.5)/2.0 = **25%** against his weapon's
44%, while `st_timer` is still 0 so the swing reads **100% elapsed** — and
`[noslamclip]` is false with nothing said about it. Before the stand-in it was
false *and* printed "requires SP_SwingTimer" once; the fix traded a visible
failure for a silent one. Both states are pinned in the test now.
So the sheet is **seeded before the addon walk**, beside the roster record and
unit snapshot that are already seeded there for the same reason
(`ui_char::seed_combat_stats`). A `/reload` — and anyone mid-fight — now latches
the real weapon. A fresh login still has no self store (the create block has not
arrived, which is why the record comes off the roster), so the stand-in stands
there and heals on the first swing.
**And the stand-in now says so in the log.** One line per VM per token, through
the channel `/errors` lists: a combat-stats verb answering for "player" before
anything pushed that unit's sheet. One such read is expected at load; what the
line is for is the case where it never stops, which no test of the verb can see
because every one of them seats the sheet first. `UnitAttackSpeed` reaching Lua
with the store's own `UNIT_FIELD_BASEATTACKTIME` is now asserted through the real
`feed_char`, which is the test that was missing underneath all of this.
The dual-wield road is pinned too, because it is the one shape that can strand
the main-hand timer: `CheckDamageSource` returns "MAIN" on line one for a
two-hander and never runs its arithmetic, but dual-wielding it weighs the hit
against `UnitDamage`'s ranges scaled by the target's armour — and
`UnitArmor("target")` is 0 on any 1.12 client, ours included, because another
unit's resistances are not public. Every hit then reads UNKNOWN and falls to
`ClosestSwing == true or HeroicStrikeQueued()`, whose first half compares against
a *function* and whose second is true whenever this very macro has queued a
Heroic Strike — and `ResetTimer(true)` refills the **off-hand** timer while
`st_timer` runs to 0, where the elapsed read is 100 for good. That loop is the
addon's own and reproduces on any client; what we must not do is enter it when
the player is two-handed, which is `has_offhand` alone, and his combat log says
two-handed: 2.70, 2.71, 2.72, 2.68 between consecutive hits, with no 0.2 s
off-hand pairing anywhere.
189 failures, unchanged, no regressions.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
|
||
|
|
d89192d962 |
The group-loot popups were never under test: a stand-in had their name
All five group-loot reds were one cause, and it was in the harness rather than
the client. `test_ui`'s `UIParent_ManageFramePositions` wrapper seated a bare
stand-in for every non-`isVar` row of `UIPARENT_MANAGED_FRAME_POSITIONS`, and
`GroupLootFrame1` is a row (`UIParent.lua:1909`) declared in `LootFrame.xml`,
which loads later. A frame's publish to `_G` is non-overwriting (`0x701bd0`), so
the stand-in kept the name and the real popups — built, parented, scripted —
were unreachable: no `<OnShow>`, so no name, no icon, no bind-on-pickup
decoration, and no `OnLoad`, so `CANCEL_LOOT_ROLL` reached no registered frame.
Every assertion was reading an empty frame that happened to answer `IsVisible`.
This is the third time that seat has cost tests — `ChatFrame1`/`ChatFrame2` 137,
the pet bar 9 — so the chat windows' exemption is now the rule: a managed row's
own key is never seated. The pass reads every row through
`frame = _G[index]; if ( frame )` (`UIParent.lua:1962`), so an absent one is
skipped and costs nothing; what *is* reached unguarded is the custom-positioning
tail, and every name that tail touches is already in the explicit list above,
where it can be read. Anchor targets still seat — those are a raise at l.1668.
`FramerateLabel` and `TutorialFrameParent` were the same shape waiting to
happen. No regressions anywhere in the workspace from dropping them.
With the real popups under test, three assertions turned out to be written
against stock 1.12 rather than the file on the chain. This realm's
`GroupLootFrame_OnShow` opens:
local texture, name, count, quality, bindOnPickUp = GetLootRollItemInfo(this.rollID);
if ( not name ) then this:Hide(); return; end
where stock painted straight through a nil name. So an unresolved roll shows
nothing at all and frees the slot it claimed, rather than opening blank — and
the quality-miss-tail protection (`0x4c31a3`'s `nil, nil, 1.0, 1.0, nil`) is
never reached through that paint, so it is now asserted at the verb, where the
stock file and any addon still index `ITEM_QUALITY_COLORS` with it unguarded.
`ui_loot_roll`'s own doc says what that costs it: its `TEMPLATE_HOLD_MAX_MS`
fallback opens a frame unresolved "because a frame that never opens cannot even
be passed on", and this file closes it again on the same frame. Kept and written
down rather than worked around — the hold is what actually saves the roll, and
the alternative is never announcing it.
189 failures from 195.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
|
||
|
|
40cbec55cd |
The hardcoded-sentence gate failed on its own corpus read, not on a violation
`reference_strings` demanded a sentence from each of the four string sources, because the two `Localize()` files wrap their assignments in a function and that shape has to be proven to parse. This realm's `GlueXML\GlueLocalization.lua` has its whole `Localize()` body commented out and tweaks frames instead, so the assertion fired on a chain where nothing is wrong — and took the policy half of the test, the part that actually reads every Rust source for a sentence the reference ships, down with it. Asserted over the pair now: a base table can never be empty, and at least one `Localize()` file must yield, which `FrameXML\Localization.lua`'s enGB overrides do. The policy half runs again and passes, so nothing is hardcoded. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn |
||
|
|
76e022ba78 |
Two real macro-window bugs, and three expectations the realm had moved on from
The macro window's four reds were four separate things, two of them bugs a player meets. **Every macro made through the icon chooser had a blank icon.** This realm rewrote `Blizzard_MacroUI` to keep bare upper-cased icon names in its own `MACRO_ICON_FILENAMES` — `UpdateMacroIconFilenames` strips `INTERFACE\ICONS\` off `GetMacroIconInfo` — and `MacroPopupOkayButton_OnClick` hands that name straight to `CreateMacro`. `icon_path` took any string as a finished path, so `GetMacroInfo` answered `ABILITY_AMBUSH` and `MacroFrame_Update`'s `macroIcon:SetTexture(texture)` resolved nothing. The engine composes the prefix itself: `Interface\Icons\` is at `0x44ca64`, inside `UIMacros.cpp`'s own string block between the `DeleteMacro`/`GetMacroInfo`/`GetNumMacros`/`CreateMacro` registration names and `MACRONODE`, which is what says the macro code owns it. A number is still a chooser index, and a string that already carries a separator is still a path. **Selecting a macro with no icon raised and took the whole details pane down.** `Blizzard_MacroUI.lua:84-85` feeds `GetMacroInfo`'s texture to `SetTexture` and to `strupper` with no guard, so a nil answered there is `bad argument #1 to 'strupper' (string expected, got nil)` inside `MacroFrame_Update` — no editor, no body box, no character counter. The reference cannot be answering nil, or its own UI would raise the same way: a macro with no icon of its own now reads as the client's own `INV_Misc_QuestionMark` (`0x447fe4`, same UI block), which is also the name the realm's chooser puts in row 1. The other three were stale readings of a file the realm has rewritten, each checked against the chain copy rather than guessed: - the character counter is `MacroFrameCharLimitText:SetText(this:GetNumLetters() .. "/" .. this:GetMaxLetters())` in the realm's own XML — "21/510", with no `MACROFRAME_CHAR_LIMIT` and no "Characters Used" tail; - the chooser shows `GetNumMacroIcons() + 1` rows, because `UpdateMacroIconFilenames` inserts `INV_MISC_QUESTIONMARK` at index 1 ahead of anything the engine offers, so every list index is one past the engine's; - both tabs call `PanelTemplates_TabResize(0)` with no cap and read `text="GENERAL"`/`text="CHARACTER"`, where stock passed -15, capped tab 2 at 150 and formatted the player's name into it. That test was *about* the name, so it now checks the row is independent of it instead. 195 failures from 203, no regressions (`comm -13` over the sorted names). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn |
||
|
|
48c27d45bf |
The three multibar reds were one duplicate load, not a loader defect
`_G["MultiBarBottomLeftButton1NormalTexture"]` really was a different object from that button's `GetNormalTexture()`, with both answering that same `GetName()`, so every tint written through the global — the realm's own `ActionButton_ShowGrid`, and pfUI constantly — landed on a region nothing drew. I wrote that up as a state-texture publish naming the wrong region at two levels of template inheritance and left three tests red on it. It was neither the publish nor the depth. **These tests loaded `MultiActionBars.xml` twice**: once through `load_action_bar`, where toc order puts it, and again in each test's own list, left behind when the first was added — 15 tests. Two loads build two sets of MultiBar buttons, and the two `_G` publishes disagree about a collision: a frame's is non-overwriting (`0x701bd0`), so `MultiBarBottomLeftButton1` stayed the *first* set's button, while the loader's state-texture publish is a plain `globals().set`, so the region name became the *second* set's texture. Every write through the global reached the hidden second set. What rules the loader out is now its own test: `expand`'s merge concatenates children, and only the base of the real chain declares a `<NormalTexture>` — `ActionBarButtonTemplate` and `MultiBar1ButtonTemplate` declare only `<Scripts>`, read off the chain — so there is exactly one declaration at any depth, and a synthetic three-level chain answers `named == live` at every level, top-level or child of a named parent. The other two reds in that file were a misread of the reference, in the same place twice: `ShapeshiftBar_Update`'s last line is `UIParent_ManageFramePositions()`, and the pass hides all three shelf strips under `if ( MultiBarBottomLeft:IsShown() )`. So a form learned while the bottom-left bar is up shows the middle strip and takes it down again inside one call — it is never seen across the row, where both tests asserted it would be "until the next pass". 17/17 in that module now. The publish asymmetry is left alone and written down where it was found: no single load can reach it, the reference's region-name path is not disassembled, and matching frames on a guess is not a fix. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn |
||
|
|
27c75776c6 |
The plates step needs the install, because the WorldFrame does
`test-no-install` caught it: a plate is parented to the `WorldFrame`, which comes from FrameXML off the patch chain, so with `WOW_DATA=` there is nothing to parent to and nothing for the fixture's own scan to walk. Skipped without the install like its render and use-probe siblings — which is also the honest statement about that VM, since a plate addon can do nothing in it either. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn |
||
|
|
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
|
||
|
|
1b1a90cf13 |
A swing speed of 0 latched for the session, and [noslamclip] never fired
grom's own probe is the record of it:
st=0 max=0 pct=nil win=0 idx=1
Four readings of one cause. `UnitAttackSpeed("player")` answered `0` between the
VM's birth and the first `feed_char` push, and that window always contains the
addon walk — `load_ingame_ui_on_world_entry` mints the VM, seeds the player's
roster record and runs every addon's file scope and `ADDON_LOADED` before a
Bevy feed system gets a turn. SP_SwingTimer latches `st_timerMax` there behind
`if not st_timerMax`, and `0` is truthy in Lua, so the guard never re-fires:
the first write to that global in a session is always the pre-push answer.
With `0` latched, `CleveRoids.GetSwingPercentElapsed()` answers nil (its own
`attackSpeed > 0` gate), `GetSlamWindowPercent()` answers 0, and
`ValidateNoSlamClip()` takes its "requires SP_SwingTimer" branch and returns
false. So `[noslamclip]` is false and `[slamclip]` is true for the rest of the
session, and the macro picks Heroic Strike every time with no indication —
which is exactly what he reported. `idx=1` in the same probe says the
`stance:1/3` half was never the problem.
I had called this latch faithful, on the grounds that `reference/1.12-shapes.tsv`
has `UnitAttackSpeed` returning exactly 2 values and never none. That is about
the arity and says nothing about a `0`, which is a swing speed no 1.12 client
can answer: the player's UNIT_FIELD_BASEATTACKTIME is in the create block the
real client has before `UI_Init`, and a unit with no weapon still carries the
engine's 2000. `ui_char::base_swing` already wrote the rule down — "0 or absent
read as not yet streamed: the vanilla 2000, never zero, as the reference divides
damage by it" — and `UnitCombatStats::default()`, which *is* the pre-push
answer, broke it. Now both read `VANILLA_SWING_MS` from one place.
It was never only this macro: BetterCharacterStats' DPS rows divide by the same
number and read `inf` in that window.
The bench test that documented the latch as harmless now pins it as the defect,
and asserts what the stand-in buys: a drained swing timer measures 100% elapsed
rather than nil, `[noslamclip]` is false out of combat by the window math rather
than by the addon's own bail, and nothing tells him in chat to install the addon
he already has.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
|
||
|
|
9d5a974649 |
Say out loud that GetAuctionSellItemInfo answers more than 1.12
The shape gate caught the extra two returns, which is exactly its job. They are not a gap to close, so they do not belong on NOT_YET_ASSERTED, which is a list of things that are wrong and may only shrink. BEYOND_1_12_ARITY is the other category: a binding that answers more because the realm's own FrameXML reads more, named with the reference's arity, ours, and the reason. The gate asserts both numbers, so an entry cannot drift — a stale reference arity fails, and so does a different count than the one the reason covers. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn |
||
|
|
21ebe65d83 |
Three more kits loaded their files out of toc order, and one real defect found
Each of these was one cause behind a whole cluster, and none of them was our client being wrong — except the last, which is. The character sheet, six tests: the realm's PaperDollFrame_OnLoad builds a titles dropdown (UIDropDownMenu_Initialize at PaperDollFrame.lua:34, nothing stock 1.12 has), so without UIDropDownMenu.xml the OnLoad raises and the whole file fails to load. The trade-skill window, six tests: its two filter checkboxes index _G[this:GetName() .. "Text"] straight out of their OnLoad, and that FontString comes from OptionsCheckButtonTemplate, which the kit never loaded. The extra action bars, two of five: MultiActionBars.xml is toc row 48 and BonusActionBarFrame.xml is row 84, and the kit had them the other way round. The stance file's ShapeshiftBar_OnLoad runs the managed-positions pass, which seats bare stand-ins for MultiBarLeft/Right/BottomLeft; a publish to _G being non-overwriting, the real bars stayed shadowed by nameless empties — the bar frames answered, their buttons did not. Third kit in two sessions to trip on this exact thing. The three that remain are a genuine client defect, left red on purpose and written up where it fails. A MultiBar button's grid ring ignores its half-alpha tint because `_G["<name>NormalTexture"]` is a **different object** from `button:GetNormalTexture()` — while the live region's own GetName() is that very name. The fork is the depth of template inheritance: one level (ActionButton1) is fine, two (MultiBar1ButtonTemplate -> ActionBarButtonTemplate -> ActionButtonTemplate) is not, and neither outer template declares a <NormalTexture> of its own, so the duplicate comes from the loader's expansion. That reaches every _G[name .. "NormalTexture"] write in FrameXML and in addons, pfUI included, so it wants its own change in loader::widgets with its own tests. Workspace failures 257 -> 227, clippy clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn |
||
|
|
998ab3f2f0 |
Two one-cause clusters: the pet bar never loaded, the auction window never built
**The pet bar, nine tests.** No script errors, and the shelf simply never drew. PetActionBarFrame had no scripts attached at all: the kit seats a bare stand-in under that name from the managed-positions pass — which it must, since the pass does PetActionBarFrame:IsShown() (UIParent.lua:2065) — and the real frame could never take the name back, a publish to _G being non-overwriting (0x701bd0). So PetActionBar_OnLoad never ran, showgrid stayed nil instead of 0, and ShowPetActionBar's `showgrid == 0` gate refused forever. The kit was loading BonusActionBarFrame.xml, whose ShapeshiftBar_OnLoad calls that pass, *before* PetActionBarFrame.xml. The realm's FrameXML.toc has them at rows 83 and 84, the other way round. Loading them in toc order fixes eight; the ninth asserted stock 1.12's unguarded `Attack ()`, and the realm now wraps that append in `if ( key ~= "" )`, so the client was right. Same shape and the same seat list that cost 137 tests through ChatFrame1. **The auction window, fourteen tests.** The whole Blizzard_AuctionUI load raised: the realm's rewritten CalculateAuctionDeposit is `floor(price * (duration/120) * (1 + (maxStack - stackSize) * 0.05) * 0.025)`, and GetAuctionSellItemInfo answered the reference's six values — 1.12 returns exactly six — so maxStack was nil and the arithmetic raised out of AuctionFrameAuctions_OnLoad. The window never built. It answers eight now: maxStack from the template's stack size, and the link. Then the realm's own CalculateAuctionDeposit replaces the engine binding, as it is meant to, which is why that test's numbers move — its fixture also needed Linen Cloth's real stack of 20, since a missing one reads 1 and makes the factor shrink below 1 for a two-item stack. Also recorded, because it changes what every earlier count in this session meant: `cargo test --workspace` stops at the first failing binary, so the "96 failures" I had been tracking was benilla-app's lib target alone. With --no-fail-fast the real total was 257; these two clusters take it to 243. benilla-ui is fully green (2061), and 80 of the rest are the known benilla-formats data goldens. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn |
||
|
|
9d0b4d068a |
The stock chat bubble drew under pfUI's, because its art had no handle
His screenshot shows two plates: a rounded grey one with pfUI's square box laid across the middle of it. pfUI's bubble module blanks every Texture it finds in the bubble's regions and lays its own backdrop over the result (modules/bubbles.lua:30-48). Our bubble held its two art paths only in the Backdrop struct, so the loop found the tail and nothing else. The note in this file reasoned that pfUI's own box covers the plate anyway — it does not: pfUI anchors that box 10 px wider and 10 px *shorter* than the bubble and fills it at 75% alpha, so the stock plate goes on showing above and below, through a translucent square. That is exactly the photograph. The background and the border are now also Texture regions, listed first as the reference lists a frame's engine regions first. They are anchorless and draw nothing themselves; the backdrop still paints. drive() reads them back each frame, and a region that has been blanked or hidden clears that half of the backdrop — never restored, since a skin blanks a pool slot once and keeps managing that frame for the session. This is the reference's own shape rather than a deviation from it: a 1.12 backdrop's pieces are CSimpleTexture objects the frame owns, which GetRegions (0x773f60) walks. Keeping the geometry in one struct is our shortcut; these put the handles back. The transcribed-pfUI test now expects three textures instead of one, and goes on to assert the thing that actually matters — after the blank, the extract carries none of the bubble's own art. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn |
||
|
|
96e95a51ec |
Price the rest of the tooltip path, and name what a raise costs
He said the tooltip lag is not only signs, so the two hover benches now tally layout derives and the live frame count per window, which is what turned the sign case from a hypothesis into a number. What they say. A held hover is clean: 1.7 ms/frame under forty plates, 0.76 in a city, and no per-frame derive. A rebuild costs 6.1 ms and gives up naming the graph exactly once, always at frame_id — the mint of a frame the model has not seen, which is the one write that genuinely cannot be named, since a new node has to join the cached graph. The frame is not ours. OctoPawn hooks GameTooltip's OnShow and creates an anonymous frame on every show, purely to own a one-shot OnUpdate delay — twice over, from ScoringCore.lua:540 and Pdmo.lua:101, which is the ordinary 1.12 way to write C_Timer.After. So every tooltip raise on his interface mints frames and re-derives, on the reference client too. Live frames rise by far less than the number created (137 against 480), so Lua reclaims most of them: a cost per raise, not an unbounded leak. Not acted on, deliberately. The lever would be letting a brand-new leaf join the cached graph without a full derive, which is a layout-engine change and wants its own measurement, not a change tacked onto a bug session. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn |
||
|
|
fff48cd75c |
The sell-junk diagnostic sat behind the gate that fails
He hit sell junk again and the log said nothing — not because the sweep had nothing to report, but because the sweep never ran. pfUI's Sell Grey Items button is `if GetNumJunkItems() <= 0 then return end` and only then SellAllJunkItems (modules/autovendor.lua). That is the one path he clicks, so a zero count ends the click there. Last session's instrument lived inside SellAllJunkItems, downstream of the thing that was answering zero, and it could not be reached. Both now share one junk_survey of the carried bags, so the count and the sweep can never disagree about what they are looking at, and each says its half: GetNumJunkItems, when it answers zero while occupied slots have no resolved quality, says so and names them — a truthful zero stays silent; SellAllJunkItems says what it queued as well as what it could not judge; and ui_merchant's drain now warns, rather than debugs, when a queued slot resolves to no item guid. That was the last silent place the click could die, and debug does not reach his log. So whichever of the three branches is at fault, his next click answers it. Also checked and cleared on the way: the drain's slot.saturating_sub(1) is right — the feed keys container slots 1-based. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn |
||
|
|
7ebae975d9 |
A recipe never names the spell it teaches, so "Already known" never fired
He is still told he can learn recipes he already knows. The check was there; it was written against a shape no recipe in the game has. Read off his own item cache, the realm's Plans: Copper Chain Vest (3609) carries exactly one spell: 3349, at trigger 0. The rule looked for trigger 6 carrying the taught spell. Neither half matches — and 3349 is not the craft spell either, it is a learn *wrapper* whose single SPELL_EFFECT_LEARN_SPELL teaches 3321. Nothing in the item record names 3321 at all, so no check that reads only the item can ever answer this question. The app now makes the hop while it has the catalog in hand, through SpellCatalog::learned_spell, and hands the result over as ItemTemplateView::teaches. The tooltip reds ITEM_SPELL_KNOWN when any of those is in the known-spell set. The trigger-6 arm stays for an item that really does carry its taught spell. The old test passed throughout, which is the part worth keeping in mind: its fixture was invented — trigger 6, spell 3330 — and 3330 is Silvered Bronze Shoulders, nothing to do with this item. It now uses the realm's own record, and a second test pins the 3349 -> 3321 hop against the shipped Spell.dbc so the fixture can never drift from the data again. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn |
||
|
|
0da710810e |
An EditBox's engine quads answered nil, and ate the trade window's money row
His trade window has no money amounts: one empty box where the gold/silver/ copper row belongs. The cause is four regions that answer the wrong thing. pfUI skins that row by walking the gold EditBox's regions for the first Texture whose texture names "MoneyIcons", showing it, and re-anchoring it to the right of the box — then the silver box hangs off that icon and the copper box off the silver one (api/ui-widgets.lua:1225, api/api.lua:1693). Its scan reads a nil GetTexture() as "no name to match against, take this one". An EditBox's first four regions are the engine's own: the three selection highlights and the caret, which GetRegions lists ahead of the authored <Layers> ones. They paint host-side here, so they carried no texture and no fill and answered nil — and pfUI took the caret for the gold coin. Silver and copper then anchored off a zero-size region and the whole row collapsed. The reference builds those four as colour-set textures, not empty ones, so GetTexture answers them "Solid Texture" — the string is in WoW.exe, and pfUI's scan does filter that out. They answer it here now. Nothing renders differently: the host still draws the highlight and caret from sel_start / sel_end / caret_shown, and the fill stays unset. The new test runs pfUI's scan itself rather than asserting on our side of it, because what matters is which region that loop picks. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn |
||
|
|
e17d7dff04 |
The slam-clip macro chain is sound, and now has a test that says so
His rotation macro never picks Slam. Five of our verbs and feeds stand between `[noslamclip]` and an answer, and any one of them would read in play exactly as he describes, so this drives all five on his own 92-addon interface rather than reasoning about them: UnitAttackSpeed's (number, nil) shape, which is what isDualWield() asks; CHAT_MSG_COMBAT_SELF_HITS reaching a registered frame with arg1 as the composed sentence, which is the only thing SP_SwingTimer resets on; the GetSpellName spellbook walk; GameTooltip:SetSpell on a nil-parent scanning tooltip built from GameTooltipTemplate, read back through getglobal; and the <OnUpdate> delta arriving as seconds. All five answer correctly. Measured, the conditional is true at the top of a swing with a 44.4% window off a scanned 1.5 s cast time — so the cause is not in this chain, and the test is here to keep that true. The one asymmetry it does pin down is faithful: before any swing lands, SP_SwingTimer's ADDON_LOADED arm latches st_timerMax to 0, because UnitAttackSpeed answers 0 while the stats feed has not pushed and 0 is truthy in Lua, so its own `if not st_timerMax` guard never fires again. reference/1.12-shapes.tsv has UnitAttackSpeed returning exactly two values and never none, so answering 0 is right; the first swing heals it, which the second half of the test shows. The scanning-tooltip leg is worth naming on its own: without it the addon falls back to a 2.5 s constant, and against a 2.70 s swing that is a 7% window instead of 44% — which would read as "Slam never fires" too. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn |
||
|
|
0c698b8797 |
Hovering a sign re-derived the whole layout graph, every frame
grom reports a frame drop on hovering signs, and asked for that one first. A signpost is the only GameObject whose plate follows the cursor — GO_TYPE_GENERIC with floating_tooltip, the `cursor_seated` leg of drive_mouseover_tooltip — so every frame the hand on the mouse moves, GameTooltip is re-anchored. That re-anchor called the whole-graph Model::touch_layout for a write that moves two offsets and nothing else. Measured on his own interface, not guessed at: a new bench window prices a held plate against a drifting one against no plate at all. A drifting pointer cost +8.6 ms a frame over the baseline; a held one cost +0.0, so the plate is not the cost, the re-anchor is. It is now +0.2 ms. tooltip_unit::seat_on_cursor is the shared seat for both arms (the raise in world_tooltip_gameobject and the follow in world_tooltip_move). An offset-only move names the frame — touch_layout_frame, exactly as SetPoint's own write_anchor does for a dragged window — and only a roster or target change gives up naming. The bench asserts the ratio rather than the absolute, so a loaded machine scales every window together, and reads GetLeft at the end: a "fix" that stopped the plate moving would price identically, and this test would otherwise applaud it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn |
||
|
|
17ee517602 |
An unpainted loot-roll name is the reference's own placeholder, not nothing
`nothing_repaints_a_frame_that_opened_before_its_snapshot` asserted that a frame
which never painted has no name text. It has one: `LootFrame.xml` authors
`text="Item Name"` on `$parentName` as a design-time placeholder, and every copy
of that file carries it — the realm's `patch-4`, `patch.MPQ` and stock
`interface.MPQ` alike. So "stays blank" means **unchanged**, which is what the
test is actually about, and it now reads the untouched label once and compares
against that rather than against nil. The realm owns its own placeholder.
Leaves the five other `group_loot_tests` failures, and they are a different
thing worth stating: the realm has **rewritten `GroupLootFrame_OnShow`**. It now
opens with
local texture, name, ... = GetLootRollItemInfo(this.rollID)
if ( not name ) then this:Hide() return end
where the stock body had no such guard and called `SetText(name)` with a nil.
So on this realm an in-flight roll does not open at all until its name is known,
and `in_flight_roll_opens_without_a_name_or_icon` encodes the older behaviour in
its name as well as its assertions. That one needs a probe of what
`GetLootRollItemInfo` answers in that state before anything is rewritten — the
visibility assertion above it passes, which does not fit either reading yet.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
|
||
|
|
cb8eb7f01d |
The chat dock indexes the video window unguarded, so the kit owes it
`not OptionsFrame:IsShown()` at `FloatingChatFrame.lua:862`, inside the hover pass `UIParent`'s OnUpdate drives — an unguarded index of a global the kit only seated from `IsOptionFrameOpen` and `ToggleGameMenu`, neither of which that path calls. Every kit that drove a chat hover raised on it. Given as a prerequisite rather than a stand-in, because the realm's toc loads the stock video window 29 rows above the chat one (row 29 against 58): that is the toc's own order, and it hands over the real frame instead of something that would shadow it the way the `ChatFrame1` stand-in did. `chat_resize_tests` 7 → 0, `chat_options_tests` 7 → 0, `chat_tests` 6 → 1. 116 → 97, nothing newly failing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn |
||
|
|
1aa3418fdc |
Read the test that reports errors first: 20 more, from four causes
The guild and friends clusters — 26 tests between them — were one nil function.
`guild_tests::driving_the_guild_windows_raises_no_script_errors` exists to name
its errors, and all four of them were the same line: `attempt to call global
'GetDifficultyColor' (a nil value)` at `FriendsFrame.lua:523`, reached from four
entry points. `GetDifficultyColor` is FrameXML's, not a client global
(`reference/1.12-globals.tsv` types it so), and `QuestLogFrame.lua` defines it —
ten toc rows above `FriendsFrame.xml`. The raise took the rest of the roster
update with it, including the `GuildPlayerStatusFrame:Show()` that ends that
branch, which is how it read as "the guild tab opens and the player pane is not
visible": an assertion about visibility with no hint of a nil function behind it.
One prerequisite row took guild 17 → 1 and friends 9 → 1.
The reputation, honor and skills pages (20 more) shared a fixture gap: their
player `UnitState` had a level, a race and a class and **no name**. The realm's
`CharacterFrame_OnShow` formats `gsub(UnitPVPName("player"), "\n.+", "")`, and
`UnitPVPName` answers nil for a nameless unit, so every one of those pages raised
`bad argument #1 to 'gsub'` on the way up and never finished showing.
The inspect page's arena tab raised three more: its team buttons inherit
`ArenaTeamTemplate`, which is a FrameXML row of the realm's own
(`Turtle_ArenaUI\Turtle_ArenaUITemplates.xml`) and not in the addon's templates
file, so the inherits was skipped, `$parentText` was never built, and the OnLoad's
`_G[this:GetName().."Text"]:SetText("(2v2)")` had nothing to index. That fixture
already said "Before the inspect addon, as `inherits=` resolves at load" — it was
simply missing this one.
And five expected a rank title without its trailing space. The realm's
`GlobalStrings.lua` gives every rank one — `PVP_RANK_8_1 = "Master Sergeant "`,
where stock 1.12's copy has none — because its own callers concatenate a name
after the title. The titles are now read off `PVP_RANK_<rank>_<team>` through one
helper, so the realm owns its own strings; that also turned up a wrong index
hiding behind the stale spelling (Grand Marshal is rank 18, not 14).
136 → 116, nothing newly failing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
|
||
|
|
bae75b9d3f |
A WMO material with no extension lost its texture outright
`wmo.rs` built its submesh texture URLs by hand — backslashes folded,
lowercased, `mpq://` prefixed — which is exactly what `texture_url` does, minus
the one thing that matters: `texture_url` appends `.blp` to a name that has no
extension, as the reference's `CImageManager::CreateTexture` does. The M2 loader
has gone through it all along.
The realm's WMOs ship bare material names too, so that submesh drew untextured
and Bevy could not even pick a loader for it. From grom's 2026-10-28 log, the
only two lines in the whole session above WARN:
WARN Multiple AssetLoaders found for Asset … Extension: None
ERROR Path not found: textures/wetlands_grimbatol/mm_irnfg_trim_02flip
Repeat/repeat is passed, which is what the hand-rolled path was: `texture_url`
returns the plain `mpq://…` for that pair and marks the stem only for a clamped
mode, and a WMO submesh carries no wrap flags here to ask about. That one name is
now a case in `texture_url`'s own test.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
|
||
|
|
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 |
||
|
|
3b8050a91d |
Party members had no buffs because nothing ever wrote their auras
grom reported it from play: the party frames show no buffs. pfUI's group header
reads `UnitBuff("party1", i)` and the stock `PartyMemberFrame_UpdateAuras` reads
the same verb, and nothing was wrong with the verb — `ui_aura`'s feed pushed six
tokens (player, target, pet, targettarget and ClassicAPI's focus pair) and not
one party token, so `nth_aura` found no list at all for `party1` and every buff
row stopped on the first nil.
The four tokens now get their own lists off `GroupState::party_slots`, through
the same `other_list` builder and the same `buffs_visible_on` gate the target,
pet and focus tokens use — so a party member shows exactly what a targeted party
member shows, and an empty slot clears its token. Each gets its own `UNIT_AURA`
fire keyed on guid and projection, because a party member's auras change on their
own clock and a `"target"` fire tells a party frame nothing.
Not the raid tokens: `raid1`..`raid40` is ten times this work every frame, each
list walking a descriptor and re-running the visibility gate, and that cost has
not been measured. The party's four are bounded and are what was reported.
The guid index and the roster share one system parameter because bevy's
`IntoSystem` tops out at sixteen.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
|
||
|
|
b39d901257 |
Twelve more, all of them numbers the realm moved out from under
Both clusters left after the chat-window shadow were stale numbers written into tests rather than anything of ours, and both are stale because the realm's FrameXML moved — which is the thing to suspect first in this crate. Seven in `battlefield_minimap_tests`: the manifest was expected to warn exactly once, about `gxRefresh`. This client does not register that CVar because no target offers the exclusive mode-set a refresh rate needs, and the only reader was stock `OptionsFrame.lua`'s `OptionsFrameRefreshDropDown_OnLoad` — which the realm replaced with a rewrite that has no refresh-rate dropdown at all: three mentions of the CVar in `patch.MPQ`'s stock copy, none in `patch-4.mpq`'s. The list is now empty rather than gone, so a realm that restores the stock window brings the row back and that is where it shows. Five in `questlog_tests`, from three separate numbers in the one file: `"Quests: 8/20"` — the realm sets `MAX_QUESTLOG_QUESTS = 25`, and this file had already learned that once: another test reads the cap off the global with a comment saying why. This call site had been missed, so it is read the same way. A wheel notch was asserted to scroll the list, with eight fixture quests described as "two more than the list shows". The realm shows `QUESTS_DISPLAYED = 24` where 1.12 shows six, so eight fit with room to spare and the notch had nothing to move — the `assert_ne!` was passing only while the row count read as six, which is to say it stopped testing anything a while ago. The log is re-seated past the window for that leg, sized off the live global, and put back to the eight the rest of the test reads. And `frame_rect(quads, 300.0, 261.0)` / `(384.0, 512.0)` hunted the quad list for a bare frame of a given size, which is a number the realm can change and did. `named_rect` asks the frame where it is instead. Nothing any of those tests asserts was ever about the size: they clip reward plates and text against the pane and compare the window's own edges. 269 → 160, still nothing newly failing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn |
||
|
|
eeebb5abf0 |
A stand-in shadowed the real chat window, and took 96 tests with it
`benilla_seat` — the test kit's stop-gap for frames a one-window kit does not
load — seats one for every row of `UIPARENT_MANAGED_FRAME_POSITIONS`, and
`ChatFrame1` and `ChatFrame2` are rows of it. The pass that triggers the seeding
runs **at load**, from `ShapeshiftBar_OnLoad` (`BonusActionBarFrame.lua:109` →
`:157`), twelve toc rows before `FloatingChatFrame.xml` builds the real windows.
A frame's publish to `_G` is non-overwriting (`0x701bd0`), faithfully, so the
stand-in stood and the real window — correctly built, correctly parented — was
unreachable by name for the rest of the VM's life.
`FloatingChatFrame.lua` then read the stand-in back three times: `FCF_SetWindowName`
concatenating its nil `GetName` (l.693), `FCF_SaveDock` handing
`SetChatWindowDocked` window 0 (l.1284), and `FCF_UpdateButtonSide` doing
arithmetic on its unresolvable `GetRight` (l.1019). Every kit that seats any part
of the chat UI died on the first of those, which is **96 of this crate's tests** —
27 bag, 26 guild, 18 raid, 18 friends and the rest — all reported as one
`assert!(report.errors.is_empty())`.
The two rows are now excluded from the seeding, and the exclusion is the point
rather than a special case: the pass reads a managed row *only* through
`frame = _G[index]; if ( frame )` (`UIParent.lua:1962`) and compares at l.2024
with `frame == ChatFrame1`, which a nil global answers without raising — so these
two never needed a stand-in at all. The rest of the table still seats, because the
custom-positioning tail does read some rows unguarded, and one of those
(`MultiBarBottomRight`, l.2070) is not in the helper's hardcoded list.
Two smaller things on the way, each right on its own and neither enough alone.
Stand-ins are created **named and with the id their name implies** — a bare
`CreateFrame("Frame")` answers `GetName()` nil and `GetID()` 0, the publish is
`setglobal` either way, so the naming costs nothing and it is what a stand-in is
for. And `floatingchatframe.xml` gains `ChatFrame.xml` as a prerequisite, which
the realm's toc puts immediately above it.
Four tests in `loader/tests.rs` are what ruled the loader out, each killing a
hypothesis about template `parent=` inheritance: it reaches the frame that
inherits it one level down, two levels down, with the chain's base in an earlier
document, and for a `<ScrollingMessageFrame>`, which is the kind `ChatFrame1` is.
None of them was the bug — the frame was always built with its parent, and only
its name never reached `_G` — but they pin behaviour the realm's chat windows
depend on and they are how the next one of these gets cheaper.
269 → 173, with no test newly failing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
|
||
|
|
f4960efc53 |
The realm update lands clean, and three of the "269 known red" were real
His 2026-10-28 20:18 update rewrote `Data/patch-4.mpq` (1.37 GB) and
`patch-5.mpq`
(230 MB). Verified rather than assumed, and nothing of ours broke: the realm's
`FrameXML.toc` went 146 → 151 rows and patch-5 holds the richest copy, so
`chain::demote_stale_snapshots` fires and it wins the manifest; the five new
rows
are realm modules (`OctoChallengesSession`, `OctoHardcoreBGMenu`, `OctoReplay`,
`OctoReplayBar`, `OctoReport`) that `unnamed_chain_rows` picks up by design and
that load with **0 load errors and 0 session errors**, every global they name
resolving; DBC field counts are unchanged everywhere, so no parser is at risk,
while record counts moved (Spell 27992 → 28026, CreatureDisplayInfo 13423 →
13451), which is the proof the new data is live; a `WOW_CAPTURE=vista` at
Ironforge prints **0 ERROR lines**; and both golden baselines held exactly, 269
with an identical failing name set and 80, `goldshire_cell` still at 1874.
Two examples answer "did it land", because "the file is in the archive" never
does. The reference mounts `patch-?.MPQ` by case-folded name, so every
`patch-A..Z` outranks `patch-4`/`patch-5`, and the toc-subset demotion cannot
see
an archive that carries no toc at all. `chain_report` prints the `FrameXML.toc`
copies in priority order and, with `--toc`, the winner of every row of the
realm's manifest with a tally — 68 to patch-4, 43 to patch-5, 32 to `patch.MPQ`,
7 to `interface.MPQ`, 1 to patch-3, and **none to a stale letter patch**.
`chain_shadow` answers from the archive's side: patch-4 carries 20326 paths of
which 10220 are shadowed, patch-5's 1494 are not shadowed at all.
Then, grouping the red bucket by panic *site* rather than trusting its label:
252 of the 269 are shipped-UI tests and **136 share one site**. Three of the
rest
were real, and are fixed here.
Six `OptionsFrame_*` enable/disable helpers were missing outright. `benilla.toc`
is the only thing that loads two of the reference's settings windows — the
realm's own toc lists neither `SoundOptionsFrame.xml` nor `UIOptionsFrame.xml` —
stock 1.12 keeps those six beside them in its own `OptionsFrame.lua`, and the
realm replaced that file with Turtle's rewrite, which drops them. Checked
against
all 151 interface rows, Lua and XML: nothing defines one. So those windows held
controls that could not grey out, with the first raise waiting on the click that
reached one. `assets/ui/OptionsFrameAdapters.lua` carries the reference's own
bodies, read out of `patch.MPQ` rather than from memory.
`CHARACTERFRAME_SUBFRAMES` was asserted to hold exactly five. The realm's
`CharacterFrame.lua` carries a sixth, `ArenaFrame` — which this client already
stubs (`CharacterFrameAdapters.xml`), so the count was the only thing wrong. It
now checks the reference's five by membership and order, which is what the test
is for; a realm may append.
And the kit's stand-ins were anonymous. `CreateFrame("Frame")` with no name
answers `GetName()` nil and `GetID()` 0, the publish is `setglobal` either way,
so naming them costs nothing and the absence cost two of the 136's three errors:
`FCF_SetWindowName` concatenating a nil name and `SetChatWindowDocked` refusing
window 0. `ChatFrame1`/`ChatFrame2` are no longer seated at all — every row of
`UIPARENT_MANAGED_FRAME_POSITIONS` is read through `frame = _G[index]; if
( frame )`, so a missing one is skipped, and seating them only shadowed the real
windows for good. The pass runs at load, from `ShapeshiftBar_OnLoad`, twelve toc
rows before the chat files, which is why the stand-ins were always first.
That leaves 136 on one cause, and the two tests added to `loader/tests.rs` are
what ruled out the first hypothesis: a template's `parent="UIParent"` does reach
the frame that inherits it, one level and two, which is how `ChatFrame1` is
built. The next suspects are written down in the gates memory.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
|
||
|
|
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 |
||
|
|
31b7d4f228 |
A unit that walks is still the same unit, so its plate stays up
Mousing over a patrolling NPC cost grom about sixty frames a second. The hover driver rebuilds a unit plate when its subject changes, and `lines_view` — the key that decides that — carried where the unit stands, twice: `UnitState:: position`, which is the interpolated `Transform`, and `UnitState::fields`, whose raw `posX`/`posY` columns are the same thing again. `sample_splines` rewrites that transform every frame a creature is on a move path, so the key changed every frame and the plate was torn down and built again at the frame rate. A rebuild is a deliberate hide/show pair plus `clear_content` and a full line walk — `tooltip_unit.rs` says so in as many words, "a hide/show pair per new subject, not per frame" — so every tooltip hook in the interface ran again: pfUI's skin, pfQuest's annotation, both `OnShow` GUID readers. The bench added here prices it on the player's own 94-addon interface at 6.63 ms/frame under forty churning nameplates and 6.78 ms/frame with none up, against whole-frame baselines of 1.84 ms and 0.85 ms. The guess was that the two shapes would differ, because a pull re-derives the layout graph anyway; they do not, so the cost is the plate's own line walk and the hooks on it, flat. The live asset probes are installed and cost 0.001 ms/frame, and the plate re-measures no text, so neither the patch chain nor the font engine is in it — the rest of grom's 90-to-31 fps is app-side, outside this harness. His video says the same thing from the other end: the *same* Ironforge Guard read 89.7 fps standing at a waypoint and 31.3 walking, Katrina Shimmerstar and Hemming Silverbeard never moved and never cost anything, and Myra Tyrngaarde walked across frame with the cursor tracking her, 50.7 to 39.4 to 35.7. A mob you are fighting stands in melee, which is why those never did it either. `lines_view` now also drops the whereabouts, the raw columns and the four power slots, leaving only what can draw a line. A late name or creature answer still rebuilds the plate, which is the whole reason the key is a snapshot and not the GUID; both halves have a test. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
11dda4f9cf |
Two cheap lanes: RCAS sharpening, and a field of view a player can move
Both were named in docs/GRAPHICS.md as nearly free and neither had been built. They are unrelated to each other; they land together because each is small. ## Sharpening (`sharpening`, 0 Off / 1 Low / 2 Medium / 3 High) RCAS, FSR 1.0's sharpening half taken on its own. It does not resample, so it needs none of the upscaler's motion vectors, jitter or history — which is the whole reason it can land while §7 cannot. One 5-tap cross; the lobe is not a constant but the widest one that cannot push the result outside the ring's own range, solved per channel against a peak of 1.0 and taken as the most conservative of the three. Flat regions get nothing and nothing rings. The seat is most of the design. `Core3d`, after `FfxGlowLabel` and before `Node3d::Bloom`: - After the glow, not before. `FfxGlowNode` builds the blur chain the glow and the underwater haze read, and a sharpened bloom source is how an unsharp mask turns a lit window into a ringed halo. - The world view only. The world's last reader is the FFXGlow combine drawn inside the UI camera's main pass, which binds `main_texture_view()` at node-run time per frame, precisely so a `post_process_write` upstream lands. The interface is drawn afterwards into a different target — and sharpening text is the one thing this must never do. - Every tap saturates, as the combine's own scene read does one pass later: the limiter is solved against a peak of 1.0, so a super-white tap would invert the bright side's sign. Nothing is lost, the blur chain already took its copy. - No denoise term. The deband dither it would exist to suppress is added downstream, in the combine's exit. Measured, 600 frames: Redridge overlook 2.95 -> 3.02 (Medium) -> 3.01 (High); Goldshire inn 3.46 -> 3.51 -> 3.29. Inside the noise, the inn reading *faster* at High being how you know. CPU flat. What it buys: 3.8% / 19.9% / 48.6% of the frame moved by more than 2/255. **The first run hit the trap this repo already has a note about.** `max(4.0 * mx, 1e-5)` where `mx` is a `vec3` is `error: inconsistent type passed as argument #2 to max`. naga refused the shader, the pass drew nothing, and the capture came out looking perfectly normal. One `grep -ciE 'error:'` on the capture log answered it before a single image was diffed. ## Field of view (`fieldOfView`, vertical degrees, 40..70, default 45) Field of View on the Graphics page under Render Scale, live as you drag. It is the BASE lens, not a fourth factor. `player::scoped_view` already multiplied two things in and neither is a field of view: SuperWoW's `FoV` is the reference's `[camera+0x40]` as a ratio, the spyglass is a zoom off normal. Both answer "how much wider than normal"; nothing a player could move answered what normal IS, and the answer was a `const` in `benilla-world`. So the term order is `base × FoV × zoom`, and an addon writing `FoV` still means the same fraction of the lens the player chose. Vertical degrees, not horizontal, which keeps the reference's Hor+ framing: a fixed vertical lens widens horizontally as the window does, so an ultrawide player gains picture at the sides instead of losing it off the top. The default is `CAM_FOVY` to the bit — `45f32.to_radians()` and `FRAC_PI_4` are both `0x3f490fdb` — which the defaults-mirror test now asserts on `to_bits()` rather than against a tolerance. An ulp of projection is a pixel in a capture diff, and a row that reframes the world for a player who never opened Options is not a row, it is a regression. ## Both lanes off: byte-identical Against a `git stash`ed build of the same tree: `overlook-noon` and `water-lake` identical, and `fieldOfView=45` written explicitly identical to the row never touched (40 and 65 move 97.6% and 99.3% of the frame). `inn-noon` is excluded WITH PROOF rather than quietly: two runs of the *same* binary differ on 37.8% of their pixels by up to 199, because that scene's flame emitters do not reproduce. Its own before/after delta is 0.00% of pixels at a maximum of 1. A scenario that cannot reproduce itself cannot be a control, and the number to check first is always the baseline against itself. ## Seats Six Options edits each, per the recipe, including the silent one (`OPTIONS_PAGE_ROWS`). Sharpening is a dropdown on Advanced Graphics with the other screen effects and reuses the existing `qOff/qLow/qMedium/qHigh` labels; Field of View is a `raw`-format slider on Graphics, whose label is literal because that page is not localised. Preset ladder: `sharpening` takes `ambientOcclusion`'s shape (`0 0 1 2 2`) because a look change starts at Medium in this file's convention; `fieldOfView` is on no rung at all, because it changes how the game feels to play and that is the player's call. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn |
||
|
|
280c2e886f |
Planar water: the mirror works, and ladders in at waterQuality 3 (Ultra)
The starburst was three defects, and none of them was the oblique projection I blamed it on in the last commit. What found them was looking at the mirror's own render target instead of reasoning about the composited frame — the water reads it through a fresnel weight, a rippled UV, a plane-match band and an SSR march that paints over it, so from the outside a broken mirror and a broken water shader look identical. WOW_MIRROR_DUMP=1 now writes that target beside the capture. (Bevy's own save_to_disk cannot: try_into_dynamic knows four 8-bit formats and answers UnsupportedFormat for Rgba16Float, logging and writing nothing.) - The oblique row's scale. Replacing the near plane with the water plane costs the FAR plane: `z >= 0` stops meaning "nearer than CAM_FAR" and becomes `depth >= a*d`, a plane through the eye tilted by a. Lengyel's own scale puts that plane exactly through the frustum's far corner, and the sky dome is pinned just inside the far plane, so it straddled the boundary and was sliced along its own tessellation. Scale by the supremum of d/depth over the frustum instead, with 2% of margin. - The water surface lay ON its own clip plane. This was the starburst and it was fatal rather than untidy: depth now means "distance above the plane", so a fragment at d=0 reads z/w=1, the nearest value reverse-Z has, and beats every other surface in the frame. The mesh is wave-displaced, so its triangles straddled the plane instead of lying in it, and each was clipped to a sliver that kept the winning depth. SURFACE_CLEARANCE lifts the plane a yard: the swell sums to 0.34 and the probed height answers for the surface under the eye, not this fragment. - The mirror was stored upside down. A true reflection is what a camera on the far side sees with its up vector reflected too — an improper basis no Transform can hold. The honest right-handed camera sees the same surfaces through the same frustum and stores the image flipped, so the shader samples 1 - v; that costs no winding flip, so backface culling stays correct. It read as a lake reflecting the ridge the RIGHT way up. Measured: Lake Everstill 2.87 -> 3.36 ms mean over 600 frames (+17%), the Stormwind canal inside the noise, CPU +0.2 to +1.1 ms. It is the first lane in docs/GRAPHICS.md that spends main thread, so it is a rung of its own above High and the default stays 2. Seated as a setting, per the standing rule that no graphics lane hides behind an env var: waterQuality gains tier 3, the Ultra preset takes it, and the Advanced Graphics dropdown gains a fourth entry in both locales. With the lane off, both scenarios are byte-identical to the build before it — which took two findings of its own: - The system could not live in Update at all. `&mut Transform` on a camera is shared with everything that moves anything; declaring it put 59 undeclared orders into Update and tripped the schedule ratchet, which is what kept this behind an env var. It belongs in PostUpdate with the skybox and the billboards, after propagation and before visibility, where it also reads the eye's settled pose instead of a frame-old one. (The comment blaming ResMut<Assets<Image>> for those 59 was wrong — dropping the asset handle changed the count by zero.) - A system that runs and returns on its first line is still not free. Holding &mut Camera and &mut GlobalTransform for a frame is enough to change which undeclared orders the executor picks: the canal moved 0.75% of its pixels by up to 96/255 with the lane switched off. So the whole system sits behind a run condition rather than an early return — and the same effect killed a .before(UpdateDirectionalLightCascades) order that was correct in itself. PostUpdate's ambiguity ceiling goes 466 -> 495 with the reason (the pairs are structurally impossible: nothing else in the schedule names the mirror camera); Update's ratchets down 4973 -> 4952. Also restores scripts/gates.sh's `where` example, which had gone missing. Its absence was silent — cargo's error went to /dev/null and `|| true` made it read as "no install" — so the memo key and the player-tests gate saw none even when WOW_DATA was set. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn |
||
|
|
355bac7b0e |
Planar water: the mirror pass, built and inert behind a dev lever
A second camera on the far side of the water plane, rendering into a half-viewport target that `enhanced_water.wgsl` reads BENEATH the screen-space march — so SSR still wins wherever it finds something, and the mirror only fills the hole SSR cannot reach: the ray leaving the screen, and the top-edge fade, which is exactly where a shallow-angle lake wants its far bank. **It does not work yet, and it is not offered to anyone.** The mirrored eye sits as far below the surface as the real eye sits above it — 26 yd down on Lake Everstill — which is inside the lake bed, so the pass must clip at the water plane or it renders sand. The clip is implemented (Lengyel, adapted to this client's reverse-Z, around a finite far plane because the oblique row makes depth affine and an infinite far collapses every distant fragment onto one value) and it is WRONG: the mirror comes back a radial smear. So this is `WOW_WATER_MIRROR=1`, a dev lever — no CVar, no row in Options. It graduates to both, like every other lane, once a capture shows a reflection instead of a starburst. What IS established, and is why this lands rather than waiting: - **With the lane off the water is byte-identical** to before the pass existed, verified against a `git stash`ed build of the same tree. - With it on it reaches the water and moves 63% of a lake frame — the plumbing, the binding, the uniform and the composite all carry. - Off, the plugin **registers nothing at all**, the posture `static_gx::enabled` takes. That is not tidiness: `drive_mirror` declares `ResMut<Assets<Image>>`, and merely registering it dormant added 59 undeclared orders to `Update` and tripped the schedule ratchet. Three things this cost, all of them now written into `docs/GRAPHICS.md` and all of them my own: 1. **The pipeline failed to compile for most of a session while every A/B read a clean zero.** The field is `clip_position`; `WaterFragment` has no `position`. Naga refused the shader, the water fell back to another path, and I measured the fallback over and over — including twice mistaking the lake bed seen through clear water for a working mirror. The client logs pipeline errors rather than panicking, by design, so the answer was in the log the whole time. **Grep the capture log for `error:` before believing any shader A/B.** 2. **A disabled lane is not automatically free.** Starting SSR's march from the fogged `reflection` instead of `sky` looked equivalent and moved 10.1% of a lake frame with the mirror switched off. The control that catches that is diffing lane-off against a stashed build — not against an older capture, which is what I reached for first. 3. **A flag colour has to be one the scene cannot produce.** My first three "every gate passes" measurements were counting the orange lake bed as my red flag, which sent the hunt in the wrong direction for an hour. The pass's own cost is deliberately unmeasured: it draws the scene a second time, so it is the first item on the programme that spends main thread, and pricing a broken render against item 1's view-distance table would mean nothing. That happens when the projection does. Verified: ALL GATES GREEN (591 s); `benilla-app --lib` against the install at the exact 269-failure baseline, set-identical by `comm`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn |
||
|
|
f67f9ca5dd |
Fix: the bright pixels along interior seams are a centroid bug under MSAA
grom's screenshots: a one-pixel-wide dotted line of saturated colour
running down a wall seam in Ironforge, still when he is still. The colours
are the tell — pure white, pure blue, orange, red against a (26,32,45)
background — so this was never a lighting level or a crack letting the
outside in. It is the Great Forge's own lighting read off a cliff.
It is MSAA, which he has at 8x through the Multisampling row I seated last
week. A partially covered pixel is shaded ONCE, at the pixel centre, and
at a geometry edge that centre can lie OUTSIDE the triangle — so a
centre-interpolated varying is EXTRAPOLATED past its vertices. For
`point_lit`, which is a SUM OF POINT LIGHTS accumulated per vertex, that
does not nudge a value, it runs the sum away: the fragment comes back the
saturated colour of whatever light dominates that vertex. Centroid moves
the sample point back inside the covered area, so every varying is
interpolated and never extrapolated.
Reproduced, bisected and fixed by measurement on `daylight-if-forge`,
counting pixels that differ from the 1x render by more than 60:
1x baseline 0
8x, before 6 (255,255,255) (36,34,255)
(255,52,40) (190,26,33) ...
8x, post chain off 6 not the post chain
8x, static_gx off 0 the lane is the source
8x, centroid on uv alone 6 not the texture lane
8x, centroid on point_lit and color 0
Verified across four interiors: Great Forge, Ironforge forge, Stormwind
cathedral, the Goldshire inn. Every MSAA difference that survives is
ordinary edge blending — each value lies inside the range of its own
neighbours — and **the 1x render is byte-identical to before this change**,
which is what centroid should be when every pixel is fully covered.
Two things I got wrong on the way, both worth recording:
- I reported MSAA as inert. It is not. `gxMultisample` is a LATCHED row, so
`WOW_CVAR=gxMultisample=8` only STAGES it and the registry still reads 1
— my A/B was two 1x runs. Through `config.toml`, the path a player
actually uses, it reaches the camera and 15 pipelines compile at
`samples=8`. A latched row cannot be A/B'd with `WOW_CVAR`; it needs
`WOW_CAPTURE_CVARS`.
- On that false reading I added a `publish_msaa_setting` system to force
the value in at `CvarLoad`. It was a no-op against a bug that was not
there, and it is reverted.
`uv` keeps its centroid even though it was not the culprit: it is the same
class of error on the same lane, where an extrapolated UV into the shared
pool lands on unrelated texels, and it costs nothing. `world_position` and
`world_normal` are left alone deliberately — four interiors measure clean
without them, and a change with no measurement behind it is how the last
two rounds went wrong.
Verified: ALL GATES GREEN (315 s); `benilla-app --lib` against the install
at the exact 269-failure baseline, set-identical by `comm`.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
|
||
|
|
77fce9210e |
The water already reflected the scenery; it was switched off
`docs/GRAPHICS.md` said we had refraction and foam but no mirror. That was
wrong. `waterQuality` 2 has carried a depth-marched screen-space
reflection of the scenery all along (`enhanced_water.wgsl`, "HIGH"), with
bisection refinement against the real scene depth rather than the fixed
probe its WarcraftXL source took. What was missing was the switch: the
registered default was Enhanced (sky only), and of the five lighting rungs
only Ultra reached High.
The line holding it back said exactly what it was waiting for —
"Mirror reflections stay opt-in until their cost is measured." So I
measured it, `WOW_FPS_PROBE=300`:
Classic Enhanced High
Lake Everstill 1.91 ms 1.84 ms 1.90 ms
open ocean 1.89 ms 1.88 ms 1.89 ms
Every tier is inside the run-to-run noise — Enhanced lands FASTER than
Classic on the lake, which is how you know you are reading noise rather
than a cost. What it buys is not noise: the far bank and the mountains
appear in the water, 18.5% of the lake frame and 35.1% of the ocean frame.
So High becomes the default and the High rung. That is a weld across four
places the tests hold together, and each one bit in turn and was fixed as
it asked: `REGISTERED`, `VideoConfig`'s literal, `WaterQuality::default`,
and the rung itself — the High rung IS the registered defaults, which
`the_high_preset_is_the_registered_defaults` states and enforces.
Low and Medium keep Enhanced deliberately, and that needed saying in the
one test that assumed every rung between Off and Ultra sits AT its
default: free on the machine it was measured on is an argument for the
default, not for the rung whose whole job is hardware that cannot afford
what this one can. `water_and_lava_observers_clamp_and_water_high_is_opt_in`
asserted the old decision in its name and its body, so it is renamed and
now pins the pairing the other way: High reflects, Medium does not.
The honest remaining gap is written into the doc rather than claimed as
done: SSR gives up when the ray leaves the screen and fades toward the top
edge, which is where a shallow-angle lake wants its far bank. A planar
pass would fill that — and it is the first item on the list that draws the
scene twice, so it costs main thread and gets priced against item 1's
view-distance table before anyone builds it.
Verified: ALL GATES GREEN (492 s); `benilla-app --lib` against the install
at the exact 269-failure baseline, set-identical by `comm`.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
|
||
|
|
113572b8e2 |
Fix: auto exposure was manufacturing bloom on ordinary bright surfaces
grom's play test, in Ironforge: "some edges, and seems to be only in
interiors, are shining really bright, like flashing colors". His config
has `autoExposure = "1"`, so the lane I landed last commit was live, and
"interiors only" is its exact signature — the gain is 1.0 outdoors by
construction and only ever rises indoors.
The apply pass was a plain multiply, and a plain multiply pushes ordinary
bright surfaces past the white point. Everything downstream reads a value
above 1.0 as EMISSIVE: `post/bloom.wgsl`'s extract is literally
`max(c - 1, 0)`, and FFXGlow squares its blur. So a lamp surround, gold
trim or a window frame sitting just under white crossed the line and grew
a bloom halo it had no business having — and because the gain drifts with
what is on screen, the set of pixels across that line kept changing and
the haloes FLASHED. It also scaled the HDR headroom itself, doubling the
bloom of everything that genuinely was emissive.
Measured, pixels at or above 250/255 — which is the bloom's fuel:
off Subtle Full
vista 0.338% 0.338% 0.338% (gain is 1.0: untouched)
inn, noon 0.024% 1.930% 9.779% ( 80x / 407x)
inn kitchen 0.001% 0.312% 1.549% (312x / 1549x)
Interiors only, exactly as reported.
The fix is a tone curve instead of a multiply: extended Reinhard with its
white point AT the gain. Three properties, each load-bearing — it is the
exact identity at gain 1 (`e(1+e)/(1+e) = e`), so a daylight exterior is
untouched; it maps the white point to white at every gain, so it can NEVER
produce a value above 1.0 and cannot manufacture an emissive pixel; and it
is monotonic, so highlights roll off toward white instead of flattening
onto it, which a hard clamp would have done. The HDR headroom now passes
through unscaled: it belongs to whatever was actually emissive, and how
bright the room is has no bearing on it.
After, same measurement at his tier: the inn goes 1.930% -> 0.388% and the
kitchen 0.312% -> 0.061%, while the brightening is kept whole (kitchen
median 35.8 -> 35.4, pixels under 32 40.3% -> 41.0%). What remains is
genuinely bright pixels rather than manufactured ones — a surface that was
never emissive now cannot bloom at all, because the picture path cannot
reach 1.0 from below.
`the_tone_curve_cannot_manufacture_an_emissive_pixel` pins all three
properties over the tier range, and a second test pins the Rust mirror to
the shader text so they cannot drift.
The exterior is a no-op to within 0.12% of pixels at mean |d| 0.16 — a few
dozen shadowed-foliage pixels where the gain lands a hair over 1.0. Below
anything visible, and stated rather than rounded away.
Verified: ALL GATES GREEN (290 s); `benilla-app --lib` against the install
at the exact 269-failure baseline, set-identical by `comm`.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
|
||
|
|
88b6998453 |
Auto exposure: dark rooms read at their own level
The programme's item 1 was terrain horizon occlusion, ranked first on the reasoning that it would make the frame cheaper on the thread that is actually full. **I measured it before building it, and the premise does not hold for this renderer**, so this is item 2 instead. The numbers are in `docs/GRAPHICS.md`: cutting the view distance from 777 yd to 177 — removing four fifths of the visible world — saves 0.44 ms of a 3.6 ms frame, p95 is flat at 6.4 ms at every distance, no main-world system in a steady vista exceeds 0.12 ms/run, and the model cull already takes 6,016 submeshes to 29 drawn. Horizon occlusion can only remove the fraction of that which sits behind a hill. The reference needed it on a 2004 CPU with no GPU-side culling; we do not. What the same harness said about item 2 was the opposite. Before anything was written: the Elwynn vista renders at a gamma median of 87.6/255 with 1.4% of its pixels under 32, the Goldshire inn at noon at 31.9 with 50.1% under 32, and the inn's kitchen at **12.8 with 94.5% under 32**. That is not a dim room, it is a black screen — and the reference has the same problem, because it has no metering either. A histogram-and-reduce over the HDR view target, smoothed in the log domain, applied ahead of the shafts, bloom and glow chain so FFXGlow's blur — which is built from the scene — sees the same exposed image everything else does. Two departures from the textbook version, both forced by what this renderer's pixels mean (the world is drawn in the reference's gamma convention, which the combine clamps to 1.0): - The gain has a **floor of 1.0**: it only ever brightens. Aiming at the exterior's own measured level is what lands it at 1.0 outdoors with no special case — the vista's median moves 87.6 -> 87.5, and 0.12% of its pixels with it. Daylight is the one look this client is measured against and nothing here was worth moving it. - It has a **ceiling**, which is the tier. The kitchen meters to roughly 24x; uncapped it comes out as daylight, which erases the room instead of making it readable. Subtle and Full are 2x and 4x: "a dark room you can see in" and "one you can read in", neither "a lit room". Measured after: inn noon goes 50.1% -> 4.6% of pixels under 32, the kitchen 94.5% -> 40.3%, and the vista does not move. **+0.08 ms of GPU**, nothing on the main thread. The calibration is the one thing to get right if this is ever re-derived, and it is written down because I got it wrong first: the aim level is the level of the scene THIS PASS SEES, not of the screenshot. The glow adds `w·blur²` downstream, so the finished vista log-averages to 0.0931 linear and the pass's own input to 0.060. Calibrating against the former aimed the lane 1.5x high and brightened the daylight exterior it exists to leave alone — which the vista capture caught immediately. Two rows on Advanced Graphics, both locales, the blend greyed while there is no gain to blend; on the Graphics Preset ladder from Medium, in the shape the other post lanes take. Classic keeps it off, as the reference has no metering at all. Verified: ALL GATES GREEN (550 s); `benilla-app --lib` against the install at the exact 269-failure baseline, set-identical by `comm`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn |
||
|
|
50fd1f158e |
The interior lighting rig gets its ten rows, and the audit closes
The last ten CVars `video::on_cvar` answers that a player could not reach. With these the sweep is done: every graphics and video row the observer takes now has a seat, bar the three that are not settings — `frillDensity` (World Detail already drives it, and two dials on one knob is worse than none), `nearclip` (the projection near plane: changing it is a bug, not a preference) and `interiorDebug` (a visualiser). Seven shape the room — the light budget, the ambient floor under every indoor surface, the indoor daylight floor, the bounce gain, the reach past each fixture's authored window, the share of painted-in lighting kept where nothing reaches, and the room gate — and three are the indoor shadow budgets. Named for what they do to a room, not for the quantity they scale, because the quantity is the renderer's word and the room is the player's: "Unlit Room Floor", not "interior bake floor"; "Lights Stay in Their Room", not "room gate". Each description says what the setting is FOR rather than what it multiplies, and three of them carry the thing a player would otherwise misread: - `interiorAttenScale` at 0 is not darkness, it is the authored window switched OFF, so every fixture reaches the whole room. A left end that looks like "no light" and means "all light" needs saying. - `interiorRoomGate` is the one setting whose job is diagnosis: a room the gate leaves on ambient alone looks exactly like a bug, and one click tells the two apart. That belongs in the options window and not only in `/console`. - `interiorShadowEntityRate` is the only row in the indoor shadow block that actually costs frame time — neither count dial above it moved it, which is why it exists as its own row — so its description says to lower it first. Two grooves deliberately offer less than their CVar's clamp, as `RowRenderScale` already does (50-200% inside a 0.25-4 clamp): ambient and indoor daylight run 0..0.1 of a 0..1 clamp, because the shipped value is 0.015 and past about 0.1 a room is not lit, it is fogged. `/console` still reaches the rest. One offers less than its *registration* says: `interiorShadowDynamic`'s help line reads 0..16, but the observer raises 0 to 1 and clamps to `MAX_TORCH_DYNAMIC` (8), so a groove to 16 would be seven stops that all mean 8 and a zero that means 1. The gating is two-level: every row needs the live-fixture lane, and the three shadow budgets need the shadows on top of it. A budget row left live while its shadows are off is a dial that does nothing, which is how settings earn the reputation these ones had. Russian for all ten rows and their descriptions is written to match the register of the column already there; a native reader would still improve it. Verified: ALL GATES GREEN (346 s); `benilla-app --lib` against the install at the exact 269-failure baseline, set-identical by `comm`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn |
||
|
|
2d1dbd5639 |
Seven more graphics settings get a seat: the filter pair, MSAA, the shadow rig
The sweep behind this: every CVar `video::on_cvar` answers, against every CVar the Options window names. Twenty of fifty-five had no seat at all, so the only way to them was `/console` — which is not a setting. Seven of them here; the ten interior-lighting rows follow. **Graphics page.** Trilinear Filtering under Anisotropic, because they are one policy: aniso at 2 or more writes filter mode 5 and trilinear writes mode 4 only while that bit is clear (`0x688d0d`), so the row is GREYED while anisotropy is on — not hidden, and not rewritten. It keeps the value a player set, and turning anisotropy off hands it straight back. The gating reads the STAGED anisotropy where there is one, since both rows defer: otherwise a player who moves one and has not pressed Apply sees the row it is about to gate stay live for the rest of the visit. Multisampling beside Vertical Sync and Resolution, the latched `gx*` rows it commits with. 1.12 had this row and we did not. Backed by the index verbs its own dropdown used (`0x48c580` / `0x48c640`) rather than by `gxMultisample`, because picking a format writes the colour and depth rows with it as one record — and it rebuilds its list on open, like Resolution, since at OnLoad the engine has published no formats yet. Only the sample count is named: the colour and depth halves of each triple describe our swapchain and steer nothing, so offering them would offer a choice that does not exist. Its description says plainly that multisampling does less here than in a modern game, because it misses the alpha-tested cutouts most of this game's foliage and fencing is made of. **Advanced Graphics.** The shadow rig's four unreachable rows — Soft Shadow Edges (the 9-tap gaussian against the card's own 2x2; Bevy's third mode is deliberately not offered, since it only resolves under TAA this camera does not run), Shadow Caster Range, and the two update-rate caps — plus Torch Shadow Darkness beside the softness dial it shares a shadow with. The rate caps needed their own readout. `0` there is **every frame**, the opposite of what it looks like, so a bare "0 per second" would read as the shadows being switched off — which is what the two toggles above them are for. They now say "Every frame", in both locales. Gating is per-lane where the lane is what matters: each rate cap is dead only while its own lane is, while the filter and the reach ride the cascade and need just one sun lane left. Russian for all five rows and their descriptions is written to match the register of the column already there; it would still benefit from a native reader. One thing worth recording for the next row: `OPTIONS_PAGE_ROWS` has positional contracts in its tests, and the list's own comments say "appended so the other rows keep their indices". It drives refresh, search and Restore Defaults — never layout, which is the frames' own anchors — so appending costs nothing and seats each row where it belongs. Verified: ALL GATES GREEN (322 s); `benilla-app --lib` against the install at the exact 269-failure baseline, set-identical by `comm`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn |
||
|
|
c315fb4f35 |
Anisotropic filtering and trilinear apply in the session
The Options row shipped last commit said the setting needed a relaunch.
It does not have to. The reference's "enabled upon restart" is about its
TEXTURES, not its CVars — both register with `flags = 1`, live — because
its sampler state was baked inside each `TextureCreate` (`0x449ae0`) and
it kept no list of live textures. Ours can be re-stamped in place.
Two things make it possible, and neither is new work:
- A governed sampler now wears `POLICY_SAMPLER_LABEL`, written by the one
constructor `TexFilterSetting::descriptor`. That is the `applyGlobal`
split the reference draws, made legible at runtime: terrain, WMO/M2 and
the liquid sheets take the policy; weather sprites, the minimap, glue,
Lua widgets, the sky and decals keep their own mode. All four governed
lanes went through that constructor, so a lane cannot forget the label
and silently opt itself out of every later change.
- Extracting a `RENDER_WORLD`-only `Image` takes its *pixels* and leaves
the asset behind (`take_gpu_data` is an `Option::take` on `image.data`).
So every resident texture's own descriptor is still readable in the main
world, for nothing — there is no ledger to keep and none to go stale.
The policy itself stops being a `OnceLock` and becomes one atomic word
plus a revision, so it can be published again on every write and both
halves of the refresh can key off the same number in the same frame:
`refresh_policy_samplers` (render world, `ExtractSchedule`, before every
Prepare) swaps `GpuImage::sampler`, and `invalidate_materials::<M>` raises
`AssetEvent::Modified` so bind groups that captured the OLD sampler are
built again. `static_gx` carries two samplers of its own and rebuilds its
cached cell bind groups in place rather than dropping them — clearing the
cache would be one line, but a region with no GPU state does not draw, so
every building in view would blink out and trickle back under the bake
budget.
Measured, not asserted. `WOW_CVAR_LATE=<name>=<value>` is new here: it
writes CVars *after* the scene has settled, then re-settles the image
around them. A latched lever and a live one give the same boot picture, so
no boot-time A/B can tell them apart. On the Elwynn vista:
anisotropic 16 at boot vs 16 written late:
0 of 1,440,000 pixels differ
and the control that proves the harness is not doing the work (boot 16,
written 16 again late, so no refresh runs but the re-settle is still paid)
is byte-identical to a plain boot. The cost of the change is 873 samplers,
75 material bind groups and 264 static_gx cell bind groups, once; nothing
is re-uploaded, since the pixels never move.
The first run of that A/B was NOT identical — 6.7% of the frame, a dapple
of stale tree shadow on a road that was otherwise correct. That is
`CutoutShadowCasterMaterial`, which deliberately reuses the forward pass's
own leaf handle and so holds a governed sampler, and which lives in
`benilla-app` rather than the engine. Hence the shape the invalidation
took: a public `invalidate_materials::<M>` each crate registers beside its
own `MaterialPlugin`, published through the world-API wall with the record
that says why (the engine naming every material type in the program is the
inversion that wall exists to stop). Forgetting one is silent in exactly
that way — a bind group built after the change is right and one already
cached is wrong — so the capture A/B is written into `docs/GRAPHICS.md` as
the check for this feature, not a unit test pretending to be one.
The Options row keeps its deferral to Apply, now for the real reason: the
commit is cheap once and silly sixteen times, which is what a drag across
the groove would ask for.
Verified: ALL GATES GREEN (520 s); `benilla-app --lib` against the install
at the exact 269-failure baseline, set-identical by `comm`.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
|
||
|
|
1c786b7ecb |
Anisotropic Filtering takes its seat on the Graphics page
The CVar has been registered and read since the filter policy landed, and last commit put it on the Graphics Preset ladder — but a player had no way to reach it. The reference's own `OptionsFrameSliders` declares the row at index 7; ours left that index a hole, so the only route to anisotropy was `/console anisotropic 16`, and a player whose preset reads Custom (the seeder never overwrites one) never met the setting at all. Seated where the reference's own slider order puts it: after Brightness, before Weather Intensity. Three things it does differently, each stated beside it: - The groove runs 1 to 16, the CVar's own `ANISO_RANGE`, not the stock table's 1 to 4. That ceiling was the reference *slider's*, not the CVar's — its callback parses 1-16 (`0x689110`) — and both the High and the Ultra rung ask for 16. So the row carries literal bounds and stays out of `BENILLA_GRAPHICS_SLIDER_ROWS`, exactly as `RowRenderScale` does for a CVar the stock table never had, which leaves `OptionsFrameSliders` verbatim for any addon that reads it. - `1` reads out as "Off", not "1x": the reference writes filter mode 5 only at 2 and above, so 1 is the feature off and not its weakest stop. - The description is ours. 1.12's own OPTION_TOOLTIP_ANISOTROPIC describes a four-stop slider, and this one is sixteen. Deferred to Apply, as Vertical Sync and Render Scale are: a sampler is baked into each `Image` at load, so the write cannot reach a texture that is already resident. The tooltip says so in the row rather than leaving a player to wonder why the ground did not change — the same thing the reference's UI means by "enabled upon restart". Verified: `benilla-app --lib` at the exact 269-failure data-gated baseline, set-identical by `comm`, with the new row's own test and the two census counts (tooltip keys, raised plates) moved 116 -> 117 as the row asks. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn |
||
|
|
b6f3bb001c |
Anisotropic filtering joins the Graphics Preset ladder from High
The reference ships `anisotropic` at 1, which is *off*, so ground texture at a
grazing angle smears to mush a few yards out — which is most of the screen in
an outdoor scene. The row was registered and wired (`benilla_assets::
TexFilterSetting`, the reference's own `[1, 16]` clamp at `0x689110`) but sat
outside the preset ladder, so no rung ever spent it and even Ultra left it off.
Measured on an Elwynn vista (`WOW_CAPTURE=vista`, the offline harness
`docs/GRAPHICS.md` now documents), one knob changed, 1600x900:
* **34.1% of the frame moves** (490,600 of 1,440,000 pixels by more than 2),
mean |d| 4.21, max 212. By band it peaks at **66.5%** across y 420-479 and
runs 48-66% from y 300 to y 780 — exactly where the ground recedes to the
horizon, which is what anisotropy is for. For scale, the interior
prop-probe table's whole stake is 7.6% of pixels by at most 14.
* **+1.0 ms of GPU and nothing on the main thread** (gpu 0.8 -> 1.8 ms, main
unchanged at 4.2-8.4 ms). The filter policy confirms it lands: "mode 4
(trilinear, aniso off)" -> "mode 5 (trilinear + aniso) anisotropic=16".
That ms is one this client is not spending. It is main-thread bound with the
GPU about two thirds idle — flat ~3 ms in play at every framerate, 0.8 ms in
this capture — so a sampler state is the cheapest picture on the table.
Ladder: Classic and Low keep the reference's 1, Medium takes 4, High and Ultra
16. Classic is the reference client, so it is judged against the registered
default the way `farclip` already is, not against zero — `anisotropic` is not a
0/1 lane, its "off" *is* 1. The High column differs from the reference, so it
is declared in `SEEDED_DEVIATIONS` with its reason, and
`graphics_rows_pass_their_observers_unclamped` now drives all five rungs
through the real setter and reads the level back off the sampler policy.
The sampler is baked into each texture at load, so a rung change lands on the
next launch, as the reference's own options window says ("enabled upon
restart"). A player already on a Custom preset is not seeded and keeps their
saved value; `/console anisotropic 16` and a restart is the whole fix there.
`docs/GRAPHICS.md` is new: where the frame's budget actually is, the offline
capture-and-diff harness every claim here uses, what is already shipped and
unspent, and the ordered list of what the picture is missing — costed on this
engine, with upscaling placed where the measurement puts it rather than where
it is usually asked for.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
|
||
|
|
ba909276a0 |
docs: record the bourbon fork, the engine wall, and what each tree holds
`docs/BOURBON_SYNC.md`, beside the existing `UPSTREAM_SYNC.md`: what
tripleslash/benilla-bourbon is, measured rather than read off its README, and
the three things that decide what can be taken from it.
* It is published as **one squash commit** — one branch, no tags, 1,662 files
and 1,139,563 insertions in `b91c12e5` on samwhosung's `359eff416`. There is
no per-change history, so a pick is the whole fork or nothing.
* Its renderer is a **Bevy 0.18 -> 0.19 engine move**, not a file copy. Bevy
0.19 made the render graph systems: bourbon has 0 files implementing a
`Node` and this tree has 14, ten of them our own graphics. samwhosung is on
0.18.1/wgpu 27 at both that base and his head, so the move is bourbon's own
and lives inside the squash.
* Its Turtle WoW 1.18.1 support is **web-only** in its own tree, and this
chain is Turtle-derived rather than Turtle: no `patch-T`, instead
`patch-1`..`patch-5` and `patch-A`..`patch-Q`. The content and protocol
fixes inside that programme are the portable part; the layer switch cannot
fire here.
Also the file-set split (339 files only ours, 1,374 only theirs), what each side
holds that the other has no answer for — bourbon has no SuperWoW, nampower,
ClassicAPI, UnitXP_SP3, VanillaUtils or VanillaHelpers surface at all — the four
fixes this sync landed with the numbers measured over this install's chain, the
thirteen things refused with a reason each, and the three shapes a renderer take
could have, with their costs.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
|
||
|
|
b8e8fd4382 |
A creature-skin record fills its slot only where it holds a path
`Monster1/2/3` exist so `CreatureDisplayInfo`'s skin variation can be dropped in
at spawn, and the record's own filename is the fallback where the display has no
variation for that index. The fallback was unconditional, and the slot does not
always hold a name: the stock models leave whatever the exporter left there.
That matters because a filled `texture` wins over the slot downstream
(`benilla_app::entities::display::skin_for`), so junk in it would mask the
display's real variation for good.
Measured over this chain, separating the two populations by whether the name
reads as a path at all:
* **26 records in 17 models bake a real `.blp` path** and are the winning copy
— the Crab, Scorpion, Crocodile, Thunder Lizard and RavenLord mounts, the
WC2 Death Knight (body, mount and a cape out of `ITEM\ObjectComponents`),
the Wizard Frog's hat, the Duck wearing a turkey skin, the Goblin rocket
car, the Worgen Demon Mage reaching for the Felhound's skin, Incubus,
Crystal Satyr, the alternate and Northrend bear mounts. These have nothing
else to draw with.
* **55 more carry unprintable leftovers** — Banshee, every DragonSpawn,
DruidCat, FrostSabre, Lobstrok, ObsidianDestroyer and the rest — all of them
in `model.MPQ` and `patch.MPQ` copies that this chain's letter patches
shadow, so none of them is a copy a read reaches today.
So `monster_skin_name` keeps only what reads as a texture path: printable ASCII,
no control bytes, longer than the extension, ending `.blp`. Every one of the 26
passes it and not one of the leftovers does.
benilla-bourbon (tripleslash/benilla-bourbon, "junk creature-skin records")
drops the fallback outright, on Turtle WoW's `Stag.m2` carrying one junk byte
there. Applied here that removes the skin from all 17 models above — the new
test fails exactly that way, "17 of 17 models with a baked skin path did not get
it" — so this takes the diagnosis and not the patch. The leftovers it names are
real; dropping the whole fallback is what this chain cannot afford.
Tests: a synthetic pair beside the guard for both kinds of name, and a corpus
test gated on `$WOW_DATA` that measures both populations over the winning copies
and asserts a path reaches `texture` while a leftover never does.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
|