Commit Graph
681 Commits
Author SHA1 Message Date
gromandClaude Opus 5 164eb7e624 ui: the ClassicAPI-era templates addons inherit by name
`CreateFrame(): Couldn't find inherited node "DialogBorderOpaqueTemplate"` is where
OpenMapClassic stopped — on its first file, inside Ace3, before a line of its own code ran.
It is not that addon's problem: `BackdropTemplate` is named **unguarded** by Ace3 itself
(`AceGUIContainer-Frame.lua:179` and `:206`, `AceGUIContainer-DropDownGroup.lua:128`), so
every Ace3 addon on this install hits it.

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

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

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

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

Full chain green.

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
2026-10-31 15:25:36 -06:00
gromandClaude Opus 5 c5544a7ea3 A double load, a stand-in that won a race, and the unified ActionButtonUp
Three of these are the harness telling the truth about load order, which is worth more than the
tests they unblocked:

- **The load ledger now guards a file a kit names twice**, not just a prerequisite. A second load
  builds a second frame under the same name, the global keeps one of them, and the reference's own
  `parent == MultiBarRight` (`ActionButton.lua:363`) then reads false for a button whose parent
  really is the bar. The toc names each file once; a kit should too.
- **`MultiActionBars.xml` belongs before `BonusActionBarFrame.xml`** (toc rows 48 and 84), and the
  order is load-bearing: `ShapeshiftBar_OnLoad` runs `UIParent_ManageFramePositions` at load, whose
  stand-in pass seats a hidden parentless frame for every managed name still absent —
  `MultiBarRight` among them — and a frame's publish to `_G` never overwrites (`0x701bd0`). The
  stand-in kept the global for good and the anchor fork read the wrong side.
- **`paperdollframe.xml` must not name `UIDropDownMenu.lua`** beside its `.xml`. The XML brings its
  own `<Script file=>`; naming the chunk again **re-ran** it, and the file declares
  `UIDROPDOWNMENU_DEFAULT_TEXT_HEIGHT = nil` at scope while `DropDownList1`'s OnLoad is what fills
  it from Button1's font. The second run put the nil back, and then every dropdown row went to
  `SetFont(STANDARD_TEXT_FONT, nil)` — a usage refusal that took out a whole file's OnLoads, seven
  at a time. The ledger could not catch it: a `<Script file=>` chunk never passes through
  `load_entry`. `uidropdownmenu.xml` also gained `Fonts.xml` as a prerequisite for the same height.
- **`ActionButtonUp` is one body now** (`ActionButton.lua:27-46`): it picks the bonus or the normal
  button and passes the caller's own `onSelf` through. Stock's separate BONUS branch handed
  `UseAction` a literal 0, so ALT-1 in a stance drove the bonus page without self-casting. Gone.
- The bonus bar is driven by an offset plus `UPDATE_BONUS_ACTIONBAR`, not a bare `Show()`: the
  event is the only thing that runs `ActionButton_Update` on that row, and a button registers
  `ACTIONBAR_UPDATE_COOLDOWN` only inside its `HasAction` arm — while the event's own handler hides
  the bar again when the offset is 0, so both halves are needed.
- And `tooltip_compare`'s player needed the name `CharacterFrame_OnShow` gsubs unguarded.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
2026-10-31 14:53:07 -06:00
gromandClaude Opus 5 9dde1a80e5 The keyring grows in rows, the rank is art, and two fixtures I left behind
- **Keyring**: `GetKeyRingSize` has no level term at all — it scans for the highest filled slot
  and rounds up to a multiple of four (`ContainerFrame.lua:817-844`), expanding only when the key
  count has caught up with that slot. So one key is one row of four, four keys in four slots grow
  to eight rather than refusing a fifth, and full is all 32. The realm also **fixed** 1.12's silent
  refusal: it renamed the message to `NO_EMPTY_KEYRING_SLOTS_ERROR` and defined it
  ("Your keyring is full."), where stock printed a name that did not exist.
- **Bag tooltip**: the item-button `OnUpdate` accumulates toward `TOOLTIP_UPDATE_TIME` instead of
  counting down from it (`:694-704`), and reads the threshold before adding — so a landing template
  repaints an open plate two frames after it crosses a fifth of a second, not on the next frame.
- **Raid grid**: the rank is a `$parentRankIcon` Texture now (`Blizzard_RaidUI.xml:49`), set to
  `RAID_LEADER_ICON`/`RAID_ASSISTANT_ICON` (`:100`, `:155-161`). The `$parentRank` FontString that
  held "(L)"/"(A)" is still declared and never written.
- **Ready check**: the realm moved it into its own group UI (`Turtle_GroupUI.lua:259`) and
  `RaidFrameReadyCheckButton`'s OnClick calls `GroupFrame_InitiateReadyCheck` by name, so
  `load_social_ui` seats that file; without it the button clicks into a nil.

And two fixtures that were mine to fix and I missed: `quest_tests`' detail and reward panels still
carried `item_id: 0`, which `3f2982445` taught the counters to take-while out of existence. Same
correction as the `benilla-ui` fixture in that commit, with the reason written where it is read.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
2026-10-31 14:15:58 -06:00
gromandClaude Opus 5 34097b6164 The panel registry, the map's own row, and a test that raced
`UIParent.lua`'s registry moved under three tests at once:

- `ItemTextFrame` is `pushable = 5`, so the reader shares the screen instead of replacing what is
  there; `QuestLogFrame` is `area = "doublewide"` with `width = 690`, not a left-slot window, so a
  `whileDead` open seats at the doublewide slot and leaves the left one empty; and
  `UIChildWindows` has five rows, the realm having added `ReputationDetailFrame`.
- `SetUIPanel` runs `CloseChildWindows()` for **any** slot it seats (`UIParent.lua:792-796`), not
  just the centre one, so a window merely pushed across also puts the open letter away. The test
  that pinned the stock trigger now watches both halves go.
- `user_placed_layouts()` is not empty after a manifest load, and should not be: the realm's own
  movable windows stamp themselves in their OnLoad — `PlayerFrame.xml:488-489` is
  `SetMovable(1) SetUserPlaced(true)`, the same feature as the `MOVE`/`MOVE_RESET` rows in their
  right-click menus — and `TargetFrame`, the Turtle shop, transmog and guild bank do likewise. The
  map test now asserts about its own row rather than about the file being empty.
- The blip tooltip calls `UnitName("player")` (`WorldMapFrame.lua:660`), which reads the player
  record; `load_default_ui` seats a placeholder one so the realm's files have a race and class at
  load, so a test that wants a named player names the record, after the manifest.

And one real race, not a realm change: `a_binding_table_resolves_whatever_case_the_package_spelled_it_in`
moves `BENILLA_HOME` and `WOW_CAPTURE` without taking `ENV_LOCK`. It was the only env-moving test
in that module missing it, and the collision shows up as a *neighbour* reading the wrong roster —
which is how it surfaced here, two addons tests failing in the full run and passing alone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
2026-10-31 13:58:14 -06:00
gromandClaude Opus 5 b9a58c9806 Seven more: the realm rewired what these windows actually do
- **Buff frame**: 32 helpful slots and 16 harmful (`BuffFrame.xml`), not 16 of each, so the first
  debuff is `BuffButton32`. `BuffButton16` is a helpful slot here and stays down with one buff up.
- **Pet frame**: its `PetFrameDebuffN` row really is debuffs now — `RefreshBuffs(this, 0, "pet")`
  (`PetFrame.lua:38`, `:57`) picks `UnitDebuff`, where stock passed `1` and showed the pet's buffs
  under those names. The fixture seeds harmful auras.
- **Pet paper doll**: the diet icon is the realm's own `PetStable-DietIcon` at 32x32, and it was
  moved out of the `<PlayerModel>` into a `frameLevel="5"` frame of its own
  (`PetPaperDollFrame.xml:245-258`), so it draws over the scene by level rather than by the
  model's ARTWORK callback draining last — the ordering this test used to pin.
- **Merchant**: both mouse buttons share one shift arm now (`MerchantFrame.lua:304-338` and
  `:345-375` are the same body), and it opens the stack splitter for a bundle row too, stepping by
  the bundle size. The fork inside is only where `maxStack` comes from: `GetItemInfo` off the link
  for a bundle (so the row needs its template cached and a link to parse the id out of),
  `GetMerchantItemMaxStack` for a single.
- **Trainer**: after a purchase the realm re-selects `ClassTrainerFrame.selectedService`
  (`Blizzard_TrainerUI.lua:75-91`) instead of leaving the pane blank, so the pane and the
  highlight follow the row that took the learned one's seat, and the engine's selection index
  comes back off the hidden tail.
- **Game menu**: a `"full"` frame is exempt from both open gates (`UIParent.lua:927`, `:951` each
  spell out `frameArea ~= "full"`) and its arm runs `CloseAllWindows()` before taking the slot
  (`:962-966`), so asking for the map over the game menu closes the menu and opens the map.
- A third prereq row, and a latent raise named: `WorldMapFrame.xml` needs `Overrides.lua`'s
  `CONTINENTS_LENGTH = 2` (toc row 13 against 85), because the continent and zone dropdown text
  helpers compare against it unguarded (`WorldMapFrame.lua:440`, `:457`).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
2026-10-31 13:38:49 -06:00
gromandClaude Opus 5 04a3f68009 Nine reds where the realm changed the data under a transcribed assertion
None of these were defects; each is a value, a row index or a semantic the realm moved and the
test still asserted stock 1.12's:

- `RAID_CLASS_COLORS.SHAMAN` is the later blue `|cff0070de` (`Fonts.xml:50`), not a copy of
  PALADIN's pink — 1.12 reused the row and the realm did not.
- `ChatTypeInfo` has 106 keys, the extra being the realm's `HARDCORE`, and ten of them are sticky
  rather than five: it marks `CHANNEL`, `OFFICER`, `RAID_WARNING` and `WHISPER` too. Sticky is what
  the edit box keeps between sends, so that list is typing behaviour a player feels.
- `ChatMenu`'s Macro row is eleventh: `HARDCORE_MESSAGE` went in fourth (`ChatFrame.lua:2371`).
- A friend row is coloured three ways (`FriendsFrame.lua:186-197`) — name and class from
  `RAID_CLASS_COLORS[TW_CLASS_TOKEN[class]]`, level from `GetDifficultyColor`. `TW_CLASS_TOKEN`
  lives in the realm's `Globals.lua`, so that row joins `SOCIAL_UI`; without it the window draws
  uncoloured, which is a different window than the client paints.
- A guild row goes offline by **alpha 0.55** on all four columns (`FriendsFrame.lua:526`), keeping
  its class and difficulty inks, where 1.12 wrote a flat grey.
- The minimap clock plate is four lines — `Clock`, then a Zone/Server/Local time triple
  (`GameTime.lua:50-56`) — not one bare time.
- `ERR_TAXINOPATHS` uses a straight ASCII apostrophe; the test looked for U+2019 and so never
  matched a line that was being drawn correctly all along.
- `DropDownList1/2`'s OnHide **wipes** `OPEN_DROPDOWNMENUS[n]` instead of dropping it
  (`UIDropDownMenu.xml:17`, `:28`), so the row is emptied in place and an addon's reference to it
  stays live. Asserted both halves.
- `CONFIRM_PET_UNLEARN` gained an `OnUpdate` that hides it unless `CheckPetUntrainerDist()` answers
  (`StaticPopup.lua:1290-1294`), so the dialog needs that verb seated before the first tick.
- The spellbook shows its Spellbook tab with no pet at all (the realm sets tab 1 up on both arms of
  `hasPetSpells`), and it **fixed** both pet-paging quirks this test had pinned as "DO NOT FIX":
  `SpellBook_GetSpellID`'s pet arm carries the page term and Next writes the pet book's own page.

Also corrected a test of mine from the last batch: `IsEnabled()` answers the number 0, which is
truthy in Lua, so it is compared and not tested — as the stock UI itself does.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
2026-10-31 13:13:33 -06:00
gromandClaude Opus 5 d49f8daad8 Six more menu reds: the realm's UnitPopup tables are not 1.12's
Every one asserted stock 1.12's row set. The realm's `UnitPopup.lua:75-80` adds `REPORT` and
`IGNORE` to PLAYER/PARTY/FRIEND, its own `XP` row to SELF, and `MOVE`/`MOVE_RESET` (its movable
unit frames) to SELF, PARTY and PLAYER — and it **dropped the `RESET_INSTANCES` gate**: nothing in
its 51 interface Lua rows so much as names `CanShowResetInstances` (scanned, file by file), so that
row is always there, solo included. Stock left Cancel alone in a solo SELF menu, which is why the
menu used not to open at all.

The row lists are now asserted whole and in order rather than by count and spot index, which is
what made these read as "7 != 5" instead of naming the two rows that arrived. The raid-marks row
is found by label, since its seat has moved twice now.

Also `status_bar_text_paints_the_player_numerals_but_not_the_targets`, which blamed the
`TextStatusBar` template for a leave that never hides the plate. The realm overrides that bar's own
`<OnEnter>`/`<OnLeave>` in `TargetFrame.xml:280-298`: the enter shows its two HP strings and then
borrows the parent's plate by hand, because a child that takes the mouse means `TargetFrame` never
gets an enter of its own — and the leave hides only those two strings. The plate standing after the
cursor leaves is the realm's asymmetry, not ours. Measured by wrapping the handler and watching
`GameTooltip:IsShown()` on both sides of it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
2026-10-31 12:46:03 -06:00
gromandClaude Opus 5 3a89f825bf The realm ships its own ArenaFrame, so our stand-in only shadowed it
`CharacterFrameAdapters.xml` existed on one premise: "There is no `ArenaFrame.lua` or `.xml`
anywhere in the player's chain". That is no longer true — the realm's FrameXML carries
`Turtle_ArenaUI\Turtle_ArenaFrame.xml` (toc rows 106-110, which `benilla.toc` already loads at
270-274), and it defines `ArenaFrame` with `enableMouse="true"` and `id="5"`, sharing the PvP tab
rather than inventing a sixth. Our empty stub loaded 100 rows earlier and was overwritten, and
wherever it loaded last it replaced the realm's real arena window with a frame that has no
content. `frame_flag_gate` had both halves of that pinned as divergences (mouse false, id 6).

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

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

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

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

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

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

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

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

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

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

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

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

Three things came out of the drive.

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

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

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

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

189 failures, unchanged, no regressions.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

It publishes two things:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

## Both lanes off: byte-identical

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

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

## Seats

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Fixes #1971

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

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

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

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

Fixes #1800

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
2026-10-25 23:18:06 -06:00
gromandClaude Opus 5 44aac335fd classicapi: the last two with data behind them — item sets and area triggers
Both over DBCs 1.12 reads C-side and publishes to Lua nowhere, so both
are facts no addon on this realm could reach.

`C_Item.GetItemSetInfo`: the tooltip prints a set block and `GetItemInfo`
reports no set at all, so nothing can tell which pieces complete one. The
table's shape is its caller's own documentation (`SuperCleveRoidMacros/
ClassicAPI.lua:171-174`) — including the bonus pair being
`{ spellID, threshold }`, which is the REVERSE of the DBC's column order,
so the swap happens once in the host and not in every reader.

`C_Map.GetAreaTriggerInfo`: pfQuest's own comment says it reads trigger
geometry from this "instead of a shipped coordinate table", and the three
fields it takes — areaID, mapX, mapY — go straight into `pfMap:AddNode`,
whose coordinates are 0-100. So the percentages are the consumer's
measured shape rather than a guess. The zone resolution is the host's,
through the same `zone_uv` law the map window's own hover uses
(`WorldMapUiData::zone_at`), so a trigger lands in the zone the map would
name for that point — quirk included: a point exactly on a rect's east or
south edge reads as outside, which is that law's own `(0, 0)`. The three
map fields are absent TOGETHER when the centre resolves to no zone (an
instance interior), which is exactly the case pfQuest guards for.

Both ride host probes rather than pushed tables: the resolution needs the
`WorldMapArea` rects, which do not cross the seam, and neither catalog is
something to copy per VM for a verb nothing calls at boot. Two catalogs
gained an `iter()` for it, each said to be for this.

That leaves four of the original 56. Two have no caller in the corpus;
`C_FriendList.SetFriendNotes` has no note field on the 1.12 wire at all,
so a note typed and lost at logout would be worse than a button that does
nothing; and `C_BattleNet.SendGameData` stays deliberately absent, because
every caller guards it and registering it would claim a BattleNet this
client does not have.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
2026-10-25 18:33:44 -06:00
gromandClaude Opus 5 546e60765d classicapi: fourteen members over four namespaces — the measured tail down to six
Of the 153 `C_<Namespace>.<Member>` names grom's addons reference, the
fresh VM was missing 56; this takes it to 6. Fourteen members here, each
over data or a stock verb that already existed:

`C_Spell` gains the four melee-timing predicates and `IsSpellInRange`.
1.12 answers all of those only about an ACTION BAR SLOT — `IsAttackAction`
and `IsAutoRepeatAction` — so a spell the player knows but has not placed
on a bar cannot be asked at all, which is the gap a condition engine
keeps running into. The three attribute answers ride named booleans on the
spell lookup rather than a raw attribute word, so the bits and their
reference citations stay in `benilla-formats` where they can be checked;
`do_not_reset_combat_timers` is named there for the first time, beside the
`ranged_speed_cooldown` that already tested the same bit. The melee
auto-attack is an ID (6603) rather than an attribute, because no bit marks
it — the ranged half has the auto-repeat flag and the melee half is that
spell.

`C_SpellBook.GetSkillLineName` is the most-read missing member of the
whole family (nine call sites) and is one delegation to `GetSpellTabInfo`.
`IsAutoAttackSpellBookItem` resolves by id against the same 6603, so the
two cannot disagree about what auto-attack is.

`C_Item.GetCurrentItemLevel` answers a number 1.12 prints in a tooltip
and returns from nothing, so no addon can compare two pieces of gear.
`RequestLoadItemDataByID` is the modern async load over the ask-once queue
`ItemMixin:ContinueOnItemLoad` already drives. `GetWeaponEnchantInfo` is a
pass-through.

`C_QuestLog`'s abandon pair delegates to the stock two-step, so the mark,
the still-in-the-log search and the send keep one owner — a namespaced
abandon cannot drop a quest the stock path would have refused.

The six left are each written down with what they need. Two are a host
probe over a DBC `benilla-formats` already parses (`AreaTrigger` for
pfQuest's trigger geometry, `ItemSet`), two have no caller in the corpus,
`SetFriendNotes` has no note field on the 1.12 wire at all — a note typed
and lost at logout is worse than a button that does nothing — and
`C_BattleNet.SendGameData` stays deliberately absent, because every caller
guards it and registering it would claim a BattleNet we do not have.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
2026-10-25 18:00:13 -06:00
gromandClaude Opus 5 38a6b6f149 unitxp: inSight and behind — four macro conditionals that silently never worked
Not a gap: a defect. Defining `UnitXP` at all is what makes
SuperCleveRoidMacros believe the whole family is present —
`hasUnitXP = pcall(UnitXP, "nop", "nop")` (`Init.lua:274`), which
succeeds here because the dispatcher's fall-through does not raise — so
four macro conditionals have been running against commands this client
did not implement: `[insight]`, `[noinsight]`, `[behind]`, `[nobehind]`,
each with a count mode too.

The fall-through answers the stock `UnitXP(unit)` getter's 0, and every
one of those conditionals compares against `true` LITERALLY (`== true`,
`~= true`). So `[insight]` and `[behind]` could never fire while
`[noinsight]` and `[nobehind]` always did, with nothing to notice it by.
That is why both commands answer a boolean rather than 1.12's `1`/nil
flag shape, and why a pair they cannot measure answers nil rather than a
fabricated `false`: the two directions are read separately, so "cannot
say" has to be distinguishable from "no".

`inSight`'s blocker is gone, and it was gone before this: the row said
"needs a real raycast with no current binding-layer access", and the
nameplate-occlusion work built exactly that raycast. This is the same
body-layer ray from the player's chest instead of the camera's eye, fed
into the per-token `UnitReach` record `distanceBetween` already reads —
only the app can trace the world, which is the whole reason the command
sat blocked in the binding.

`behind` this file called *unconfirmed* ("treat as unconfirmed until seen
elsewhere"). It is now seen elsewhere: ten call sites, with the argument
order documented by its own readers, so `behind(A, B)` asks whether A
stands in B's rear arc. The arc is the rear HEMISPHERE, which is what
this realm's server means by behind — vmangos tests `!HasInArc(M_PI,
attacker)`, the same rule that accepts or refuses Backstab — taken flat,
so a ledge above a mob's back counts and directly overhead does not.

Two things my own tests caught, both worth keeping:

- A two-argument `inSight` answered `true`. These are recognised
  commands, so they now match on the NAME alone and say nil when the
  arguments make no pair; the arity fall-through is right for an
  unrecognised command and wrong here, because neither name is a unit
  token so the stock getter can only answer a meaningless 0 — which is
  truthy in Lua, i.e. a confident "yes, in sight".
- A `#[cfg(test)]` module placed mid-file hid every `fire_event` after it
  from `event_shape_gate`, which truncates each file at the first one:
  six unit-field bridge events read as unproduced. The test moved to the
  file's own tests module, where the one-test-module-per-file convention
  that gate assumes puts it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
2026-10-25 17:03:30 -06:00
gromandClaude Opus 5 434d514879 superwow: the chat bubbles are widgets — CREATE_CHATBUBBLE and pfUI's skin
The last real SuperWoW feature, and one piece of work closed three
things: `CREATE_CHATBUBBLE` (which could not honestly be fired, because
its payload can only be the bubble), `C_ChatBubbles.GetAllChatBubbles`
(which answered an empty list, truthfully, for the same reason) and
pfUI's whole bubble skin, which walks that list.

`benilla-ui`'s `script/chat_bubble.rs` is a pool of real frames —
anonymous WorldFrame children with a backdrop (`0x4b0a35` IS
`SetBackdrop`), a tail texture and a FontString, which is what the
reference's own `CGChatBubbleFrame` is and how addons of the era found
bubbles before SuperWoW published the event. The app keeps everything a
bubble's existence depends on — the spawn gate, the lifetime, the fade,
the range re-test, the stacking sort and the text measurement that sizes
the frame — and hands it over as `BubbleState`: the same split
`vplates.rs` already makes for the plates, so the engine decides what is
on screen, the widget layer decides what Lua sees, and neither can
disagree about a bubble because there is one feed.

Three things improved by the move rather than being preserved by it:

- The bubbles sit at frame levels 2, 3, 4… over the V-plates at level 1,
  which is the reference's own order (`0x4b12d5`-`0x4b1312`). The quad
  band had them UNDERNEATH — a deviation `overlay_z` carried in a
  comment. That band is gone and the lane is down to one tenant.
- The art is loaded, tiled and bleed-inset by the UI pass like any other
  frame's, so this file drops its own texture resource and its own
  half-texel inset helper.
- The system moved BEFORE the paint. It sat in `UiQuadAppend`, the window
  *after* the extract: right for an appender, a frame late for a feed.

The test that matters transcribes pfUI's own `ScanBubbles` loop — it
finds the FontString with the line and its chat-type colour, blanks the
one Texture region and parents its own frame to the bubble — and then
checks the EXTRACT for drawn quads: both backdrop halves, the tail, and
the text at the em the app measured the box with. A test that only
checked the widgets would have passed with nothing on screen.

Two architectural gates caught this and both were answered rather than
silenced. The one-shot gate: `drive_bubbles` now holds the VM and drains
a queue, so it is EXEMPT with the argument — the queue's product is
app-side state, the widget sync is idempotent and binds on the first
frame there is a WorldFrame, and gating the system instead would pile
lines up and spawn them all at once with fresh lifetimes. The VM wall:
its ceiling rises by one with the reason, which is that this file is
`vplates.rs`'s exact twin.

`CREATE_CHATBUBBLE` fires per APPEARANCE, not per pool slot ever built —
the opposite of `NAME_PLATE_CREATED`'s rule, because a recycled frame has
been re-anchored, re-sized and re-texted since it was last seen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
2026-10-25 16:11:54 -06:00
gromandClaude Opus 5 0e02401325 superwow: CastSpellByName at a unit, FoV, NameplateMotion, the raw log
The DLL publishes its own **usage strings**, in one block beside the
names it registers, and reading them turned up four things this file had
wrong — three of them bugs rather than gaps.

`CastSpellByName(name [, "unit"])`. A unit ID, which is what every real
call site passes and what `Model::unit` already resolves. It used to fall
through to the ordinary queue, which casts at the player's OWN selection
— a silent wrong target at six sites in grom's corpus: pfUI's /castfocus
(a GUID) and /castmouseover (a token), Decursive's cure-at-guid, BigWigs'
spell requests and two encounter scripts. Each asked for a unit and got
whatever was targeted. The stock `onSelf` meaning keeps its behaviour:
only a string that resolves to a live unit takes the new path.

`CombatLogAdd`'s second argument names a FILE, not the event. There is a
third log literal in the same pool — `Logs\WoWRawCombatLog.txt` — and it
is the GUID-form log, the same lines RAW_COMBATLOG carries as arg2.
`ui_chat/logging.rs` keeps it beside the ordinary one now, opened by the
same `LoggingCombat`, which also makes the arg2 composition worth paying
for when nothing is registered but the log is on (`wants_raw_form`). My
first reading, "also fire the event", was an inference from the name
alone, and the second literal settles it.

`FoV`'s hold-back is resolved, and the answer was already in the tree.
The row was held because 1.57 looked irreconcilable with CAM_FOVY's
0.785. It is the same number: 1.57 is π/2, the reference's own
`[camera+0x40]` default of 90° in radians, and that field is not a
vertical FOV at all — `scoped_view.rs` already divides by it as a
DENOMINATOR, because what it expresses is a ratio. So upstream's own
default is this client's existing lens by construction, which is what the
test asserts; the DLL even registers the literal `1.57079`. Live, where
upstream's panel says "Requires UI Reload".

`NameplateMotion` asked for an investigation that was also already
answered: benilla spreads plates with the reference's own SmartScreenRect
solver. 0 (Overlap) bypasses it, 1 (Default spread) is it; 2 and 3 are
the mod's own algorithms and fall back to 1, a documented silent-equal
rather than a refused row.

Also: `GetTradeSkillItemLink(index[, spellLink])`, the only way to link a
recipe that makes no item — an enchant, whose product_item is 0 and which
answers nothing at all on the stock surface. And four stored-only rows
(SelectionCircleStyle, UncapSounds, HealingText, InterfaceVolume), each
with its own reason, so SuperAPI's options panel round-trips.

Three ghosts retired: outline/shadow text, force-show-auras and XP text
are in no part of the DLL's string pool, nor is NameplateVerticalSpacing.
They came from the early wiki reading, not from the binary.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
2026-10-25 13:53:40 -06:00
gromandClaude Opus 5 323f3ae03c superwow: the map pair, GetQuestLinkForID, GetWeaponEnchantID
Four more off the DLL's manifest, and two measured corrections.

GetMapPositionWorldLoc is the INVERSE of GetPlayerMapPosition, which is
what the test asserts rather than three numbers: `map_proj::zone_world`
is the reference's `0x4a7100`, whose own note is that used one axis per
call it inverts the `zone_uv` that verb answers from. The law is
transcribed into benilla-ui (which cannot depend on benilla-world) and
the round trip is welded, which is the thing that actually keeps the two
in step. GetMapBoundaries shares one new `worldmap::loc_rect_at` with the
hover grid's own windowing, so no two readers of a (continent, zone)
selection can disagree about which rect it means.

GetWeaponEnchantID is the one thing GetWeaponEnchantInfo cannot say: the
reference reads that very dword as its gate and then reports only that
there WAS an enchant, so nothing on the stock surface can tell Windfury
from Rockbiter. The id was already in hand and discarded as a `?` gate,
so this was one field on the snapshot, plus a test that the two functions
cannot disagree about which hands are enchanted.

GetQuestLinkForID answers for a quest in the log and nil otherwise —
which is also what upstream answers on the first call for one it fetches.
The gap is one feed, not new protocol: the ask-once CMSG_QUEST_QUERY
cache already exists app-side.

That row also corrected the LINK FORMAT for both quest getters. The DLL's
own formatter is `|cffffff00|Hquest:%d:%d|h[%s]|h|r`; ours was built
without the colour wrapper on the reasoning that it matched the stock
quest-log shift-click — but 1.12 has no quest links at all, so there was
no stock shape to match and the measured string is the only authority.

The four sound-driver getters are deliberately NOT built, with the reason
recorded: every one of their return shapes is unknowable from here, none
is called in the corpus, and this client has one backend and no
enumeration to report — the same bargain KEY_DOWN/KEY_UP lost.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
2026-10-25 13:13:38 -06:00
gromandClaude Opus 5 3b6e91be35 superwow: RAW_COMBATLOG and CombatLogAdd, the only two names his addons call
Of SuperWoW's whole surface these were the two names grom's corpus calls
that this client lacked (`scripts/dll-surface.py --wanted`), and the first
is load-bearing out of proportion to its size: `SuperCleveRoidMacros`
funnels FIVE subsystems through one `RAW_COMBATLOG` frame — immunity and
resist learning, hidden-CC detection, debuff-duration learning, and the
reactive-proc window that lights Overpower and Revenge. Its own comment
says why the last of those can come from nowhere else: "SPELL_GO only
provides hit/miss COUNTS, not miss TYPES".

`route` is the fire site: the one funnel every combat line already passes
on its way to a window and to WoWCombatLog.txt. `arg1` is the printed
sentence; `arg2` is the same sentence with each unit name spelled as its
GUID, composed a second time through the same `compose_line` so the two
can only differ in the name slots. Fired before the `CHAT_MSG_*`, which is
what "raw" means upstream. Both the fire and the `arg2` composition are
gated on `has_listener` — a raid's combat log is tens of lines a second.

`CombatLogAdd` is also BigWigs' SuperWoW detection (`if CombatLogAdd
then`), so defining it turns that module's path on.

Three corrections came out of reading the DLL's own usage strings, which
it publishes beside the names — the arity and argument meaning of every
remaining SuperWoW function is now measured rather than inferred, and
`docs/ADDON_COMPAT.md` carries the manifest:

- `CombatLogAdd("text" [, addToRawLog])` has a SECOND argument. Nothing
  in the corpus passes it; it would have been missed.
- `SUPERWOW_COMBAT_LOG_EVENT` does not exist. The DLL registers exactly
  three events, and that name is in no part of its string pool.
- `IsIndoors` is capital-I beside `IsSwimming`/`IsMounted`, not the
  lowercase `isIndoors` two files guessed at — all three are already live
  off one feed, so that clause of the "blocked" row was already answered.
- The detection pair is `SUPERWOW_STRING="SuperWoW 2.2 by Balake"` and
  `SUPERWOW_VERSION="2.2"`, both quoted. Ours had the string without its
  author clause and the version as a number.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
2026-10-25 12:30:55 -06:00
gromandClaude Opus 5 68f397af72 app+protocol: nampower's combat-log family — 35 events off one decode
Every packet this family reports was already decoded: `combat_log/`
builds its chat lines from exactly these structs. `ui_np_events.rs` is a
second consumer of that decode, not a second decoder, which is also why
its arguments cannot drift from the chat log's.

Built: `SPELL_DAMAGE_EVENT_*`, `AUTO_ATTACK_*`, the three `SPELL_HEAL_*`,
the three `SPELL_ENERGIZE_*`, `SPELL_MISS_*`, `DAMAGE_SHIELD_*`,
`ENVIRONMENTAL_DMG_*`, `SPELL_DISPEL_BY_*`, `UNIT_DIED`, the
`*_UPDATE_DURATION_SELF` pair, the thirteen `UNIT_*_GUID`/
`PLAYER_GUILD_UPDATE_GUID` rows and `UNIT_COMBAT_GUID`.

**A registration is the switch.** Upstream's README calls every
`NP_Enable*Events` CVar deprecated — "registering the event is
preferred" — and three of the eleven families have no CVar at all, so a
CVar-only reading would leave `SPELL_DAMAGE_EVENT_*` unreachable for
every addon and `SPELL_ENERGIZE_*` unreachable for all of them, since
nothing installed writes its CVar. `refresh_listeners` reads the VM's own
registration table (the same one `GetFramesRegisteredForEvent` reads), so
a family is wanted when its CVar is set OR something is listening. That
is also what keeps the cost honest: with nobody registered a damage
packet queues nothing, rather than building eight arguments for a fire
that reaches no frame.

Nine `NP_*` CVars move from stored-only to LIVE, and
`NP_EnableSpellEnergizeEvents` is registered for the first time — the
first pass took the names off `NampowerSettings`' writes rather than off
the DLL's own CVar table. The two reasons written at the auto-attack and
heal rows were both about the *cast* path, and both were answered by the
combat-log decode that was already there.

The descriptor indices the GUID-keyed family watches are written out and
then cross-checked against the two tables the tree already keeps —
`unit_rec::FIELDS` and the `FIELD_UNIT_*` constants. A hand-written
descriptor index is exactly the number that is plausibly wrong and
silently so: the event simply never fires, which no amount of play
distinguishes from "the server never sent one".

`AttackerState` gains `sub_damage_count`, which is `AUTO_ATTACK_*`'s
arg6; vmangos writes one block, but a server splitting a swing across
schools sends more.

Two test notes worth keeping: the edge tests run through the schedule,
because `run_system_once` builds a fresh system each call and the
`MessageReader` cursor restarts — a test written that way passes whatever
the system does with the second edge. And the data-gated suite held at
exactly 269, after the event gate correctly named all 35 new names.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
2026-10-24 16:53:28 -06:00
gromandClaude Opus 5 702eb475a4 app+ui: the queue, the disenchant sequencer — 62/62
QueueSpellByName, QueueScript, ChannelStopCastingNextTick,
DisenchantAll. Every one of nampower's 62 registered Lua functions is
now defined; the `comm -23` in the doc prints nothing.

**Upstream's queue has two halves and one is deliberately not built.**
The Lua surface — remember this spell, send it when the client will
accept it — is `spell/queue.rs`. The other half intercepts *every
ordinary press*, so a keypress during a cast is held rather than
refused, and it is governed by NP_QueueCastTimeSpells,
NP_QueueInstantSpells, the buffer rows and the quickcast pair. That half
is not a binding: it changes how every key in the game feels, its bugs
are double-casts and dropped casts, and it is not something to switch on
as a side effect of porting three function names. Those twenty CVars
stay stored-only, each row saying so; the machinery they would sit on is
now here, and turning it on is its own piece of work with its own live
test.

Two of the twenty-two went live, because they are the explicit queue's
own knobs: NP_SpellQueueWindowMs and NP_QueueSpellsOnCooldown.

Three slots, because upstream has three and its own event says so:
SPELL_QUEUE_EVENT's code is 0/1 on-swing, 2/3 normal, 4/5 non-GCD, so
the queue is keyed by what kind of cast a spell is — and the kind comes
off its own Spell.dbc row through the same three tests the send path
makes. Each pops on its own condition: non-GCD and on-swing as soon as
no cast guards (neither uses the global cooldown), normal when the GCD
has run out too.

Three decisions at the queue, each pinned:

- A replacement raises no popped event. A second queue of one kind
  replaces the first — upstream's own log line is `Replacing queued
  nonGcd spell X with Y` — and the replaced entry was never sent, so a
  consumer counting pops against queues would see one too many.
- An expiry raises nothing either. `*_POPPED` means "this was sent".
- The expiry is measured from the end of the cast in flight, not from
  the moment of queueing, so queueing early during a long cast is not
  punished. A lapsed deadline cannot shorten the window.

**`DisenchantAll` is specified completely by the DLL's own strings**,
which is why it is built this way rather than designed. `0x93087`
onward carries the argument note, the precondition, the three quality
keywords, every stop reason and the three chat lines — one of them as a
Lua chunk that builds the item link itself.

"move during cast to cancel" is the abort, and it needed nothing new:
moving already cancels a cast here, the cast then fails, and a failed
cast stops the run. The one piece of machinery a sequencer usually
needs is the client's own, and the player's way out is the one upstream
documents.

How a step's outcome is read: the item is gone, or it is not. There is
no "disenchant succeeded" packet — the item leaves the bag. Each step
remembers the guid it cast at and waits for it to vanish with a
ten-second deadline. An observable test with no new wire plumbing,
right for the same reason the cast bar's own edges are.

The queue's drain and the sequencer both live inside
`drain_spell_casts`, which that file's own note explains: only one
system may hold `CastLadder`, because a second would share its
resources with every other cast-send system in an order the ambiguity
gate cannot know is safe.

`spell/queue.rs` names no VM — the first draft mentioned `UiScript` in
one comment and `ui_script_wall.rs` counted it as gameplay code naming
the VM, which is the gate working as intended: that module really is
gameplay state and not a feed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
2026-10-24 13:25:27 -06:00
gromandClaude Opus 5 b652c90a4b app+ui+protocol: the four measured-out names, and the unit descriptor by name
Each of these had a reason for being out. Each reason turned out to be
answerable, and two of them were simply wrong.

`GetSpellPower(school)` is **delegated to `GetSpellBonusDamage`**, which
this client already answered — one number under two mods' names, as
SuperCleveRoidMacros says itself ("Reads the same client field
nampower's GetSpellPower does"). Delegating is the only way to guarantee
they cannot drift, and the test asserts equality across all seven
schools and the off-range values rather than a literal. The earlier
"needs a descriptor band fed" was wrong: it was already fed.

`GetRaidTargets()` is the 8-slot **board**, now pushed beside the party
roster. The earlier reason was right about the scan it rejected and
wrong about the alternative: `GroupState::raid_targets` is the
reference's own table and copying it costs eight words a push. A mark
with nothing on it is absent, because a zero GUID is a string that
passes `if t[4]`.

`LearnTalentRank` is one `LearnTalent` press per missing rank, which is
what a player clicking the button does. The earlier reason — the burst
cannot be checked before it is sent — is true and is not a reason to
withhold it: the check that matters is the server's and the client has
never had another. What can be checked locally is, so a rank already
held queues nothing and one past the maximum is clamped.

`GetUnitData` is the whole descriptor, from a new raw block on
`UnitState`. The earlier reason — "a decision about which fields" — is
now a table of seventy columns under the DLL's own names, read off its
string pool in UnitFields order; `GetUnitField`'s scalar half reads the
same block, so the two cannot answer differently for one name, and a
test walks the table asserting exactly that.

**The indices are derived and the derivation is checked.** Only thirteen
`FIELD_UNIT_*` constants existed here; the rest come from 1.12's block
sizes. Those thirteen span index 22 to 147 and every one lands where the
layout puts it — thirteen independent confirmations, run as a test
rather than asserted in a comment, with two more that no columns overlap
and that nothing falls inside the aura blocks' 47..=124.

Three decisions at that mapping:

- An unstreamed column is absent, not zero. `ObjectFields::at` is
  `get_raw`'s strictness rather than `get_u32`'s absent-is-zero
  convenience: `health = 0` for a unit whose health has not arrived is a
  number a caller divides by.
- `aura`/`auraApplications` come from the filtered per-unit aura list,
  not the raw slots — the same place `GetUnitField` has always answered
  them from. An unfiltered second answer would be a different unit by
  the same name.
- `status` and `zoneId` are left out of nampower's seven computed
  columns: nothing says what `status` is a status *of*, and a zone is
  only knowable for the player. The other five are filled by the caller,
  which knows them.

`SpellRecValue` is now `RecValue`: it serves both record readers, and a
second copy of the same four shapes would drift.

58 of 62 registered names. The four left are `DisenchantAll` and the
queue trio.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
2026-10-24 12:08:46 -06:00
gromandClaude Opus 5 e716709bca app+ui: the nine file and credential verbs, fenced and keyed
Built on grom's explicit say-so, after a round where they were withheld
and the reason recorded. Both halves of that decision stay in the doc,
because the reasoning is what makes the second half a decision rather
than a drift.

ReadCustomFile/ImportFile (one operation, two upstream names),
WriteCustomFile/ExportFile, CustomFileExists, ExecuteCustomLuaFile,
StartItemExport, EncryptPassword, EncryptedServerLogin.

**The fence is app-side and it is not a `..` scan.**
`local_state::custom_file_path` admits one relative name whose every
component is an ordinary name, inside `benilla-config/CustomFiles/`, and
refuses a leaf that is already a symlink. Each escape a string check
misses has its own rule and its own test row: an absolute path (`join`
*replaces* the base rather than appending), a Windows prefix, a `..`
nested inside a longer path, and a planted symlink — the one no amount
of sanitising the string will catch. A subdirectory rather than the
config root, because a verb that could create `config.toml` or
`autologin.toml` by name would let an addon rewrite the client's own
settings; that is a different power from "keep a file of your own".

The VM side holds three closures and no paths, so no spelling of a
`filename` reaches outside that directory.

**The credential pair's deviation is forced, not chosen.** Both are
registered by nampower under "Registering *Glue* Lua functions" and this
client has no glue Lua VM at all — login, character-select and
character-create are Rust. `EncryptPassword` is pure and works the same
anywhere. `EncryptedServerLogin` cannot mean "submit the login form" in
the only VM there is, so it stores the credential for the next login:
`enabled` on, the plaintext field cleared, the blob kept, and
`AutoLogin::effective_password` reads it back at the refill.

What that protects is an `autologin.toml` or a SavedVariables file read
by someone else or pasted into a forum: the blob is authenticated and
keyed on 32 random bytes in a different file, mode 0600. What it does
not protect is anything running as this user, which can read the key as
easily as the blob — there is no user secret in the chain and there
cannot be, because the point is logging in without typing one. Both
halves are at the module.

Encrypt-then-MAC with HMAC-SHA1 as the PRF for both halves, from the two
primitives the tree already had (sha1 moves from a dev-dependency to a
real one). The HMAC is tested against RFC 2202's own vectors, because a
hand-written one still round-trips with itself when it is wrong — and a
first draft of the key reader did exactly that kind of silent wrong: it
returned raw bytes on creation and hex text on read, and the caller
guessed between them with "32 bytes and not all hex digits". It guessed
wrong the moment a random key came out all-hex. Returning one thing is
the fix, and the note is at the function.

**The existing plaintext path is untouched.** Storing the password in
plaintext was grom's own explicit choice when the alternatives were a
keyring or nothing, so this is additive and nothing migrates by itself.
The machinery to make the Remember Password tick-box write a blob
instead is now all here — one line — and that is his call.

Base64 was missing from the whole tree; it is in `benilla-ui`'s
`script/encoding.rs` with RFC 4648's own vectors, and
`C_EncodingUtil.EncodeBase64` will want the same one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
2026-10-24 11:30:49 -06:00
gromandClaude Opus 5 f87ef148b8 app+ui: the trinket cooldown — and a stock verb that always said "cold"
Built: GetTrinketCooldown, UseTrinket, GetSpellRangeData,
SetLocalRaidTargetIndex, GetQuestLogQuestIds, GetQuestDialogQuestId,
CombatLogFlush.

The find on the way through is not a nampower verb.
**`GetInventoryItemCooldown` is a stock 1.12 verb and it answered
`(0, 0, 1)` unconditionally** — right shape, always "cold", so nothing
errored and no worn trinket ever drew its cooldown swirl on the
character pane. Stock PaperDollFrame.lua:691 calls it on every slot
update. Feeding an equipped slot's cooldown for GetTrinketCooldown fixes
that one off the same column, and the test runs
`CooldownFrame_SetTimer`'s own arithmetic against it.

The feed keys its query on the (use spell, item entry) pair exactly as
the bag feed does, so one trinket reads the same cooldown from the doll
and from a bag.

**The risk was the gate, and my first test for it was vacuous.**
`feed_char` sits behind a change gate and no descriptor moves when a
cooldown is armed, so `Cooldowns::feed_epoch` had to become a gate
input. The first test passed with that input deleted: `run_system_once`
builds a fresh system each call, so the gate's `Local` memo resets and
`vm_reset` opens the gate every time. A test for a gate input has to run
the system the way the schedule does. Rewritten with `add_systems` +
`update`, it fails with the input removed — verified by probe, and the
reason is written at the test.

`feed_char` also hit Bevy's sixteen-parameter ceiling, which was a fair
signal its parameters wanted grouping by what they are for:
`ItemCooldownInputs` and `RepairInputs` replace six.

Two decisions recorded at the code:

- `GetSpellRangeData(rangeIndex)` answers three values, not a table.
  Every table-returning verb in this DLL has its key names in the string
  pool and this row's are not there. `flags` bit 0 is the melee family,
  where the engine takes the combat-reach sum instead of the authored
  pair — a caller ignoring the bit reads 0..5 for every melee ability.

- `CombatLogFlush()` is a deliberate no-op. Upstream buffers its
  combat-log writes and this empties the buffer, so what an addon asks
  for is "my lines are on disk now" — and `ui_chat/logging.rs` writes
  each line to an unbuffered File as the window shows it, so that holds
  at all times. Defining it is the honest answer: the capability is
  present, and a missing global makes `HasFeature` report it absent.

Also fed, both one wire column each: `QuestState::quest_id` (the wire
names the quest on all three giver panels and 1.12's Lua names it
nowhere — a title is not a key) and `SpellDisplay::stack_amount`'s
neighbour work from the previous commit.

Five names stay out, each now with the field list that makes it a
decision rather than a shrug — `GetUnitData` (80 raw descriptor columns,
read off the pool, zero call sites, and a partial answer is worse than
none), `GetRaidTargets` (the reference keeps a board; this client feeds
the mark per unit, and a scan over named tokens would silently omit a
marked unit), `DisenchantAll` (a server-paced sequencer, which is the
queue), `GetSpellPower`, `StartItemExport` (writes a file — it belongs
with the withheld seven).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
2026-10-24 10:21:31 -06:00
gromandClaude Opus 5 f06caa9968 ui+app+formats: the Spell.dbc row, and seven columns that answered nil
`GetSpellRecField` knew three names. Counting what the corpus actually
asks it for: `name` (4 sites), `school` (3), `stackAmount` (2),
`spellIconID` (2), then one each of `rank`, `rangeIndex`, `mechanic`,
`manaCost`, `effectMechanic`, `effectApplyAuraName`. Seven of those ten
answered nil, and each was a feature quietly off rather than an error —
DoiteAuras caps its stack display on `stackAmount`
(DoitePlayerAuras.lua:539) and SuperCleveRoidMacros classifies a
crowd-control effect off the two arrays (Utility.lua:7776-7794).

The eighty field names are the DLL's own, read off its pool in Spell.dbc
column order. The mapping to `SpellDisplay` lives in
`ui_script/spell_rec.rs`, beside the struct it reads — a copy on the VM
side would be free to drift — and `GetSpellRec(spellId)` is that same
field list walked through that same per-field reader, so the two verbs
cannot answer differently for one name. A test asserts exactly that,
key by key, over one row.

The array columns are a 1-based table of three, which is measured, not
guessed: SuperCleveRoidMacros walks `effectMechanic` as
`for i = 1, 3 do local em = effectMechanics[i]`. `totem`, `reagent` and
`reagentCount` take the same shape, two and eight long.

One new DBC column — `StackAmount`, 39, bracketed by two constants this
tree already had (38 ModalNextSpell, 40 Totem[0]). It is the only one
added: fifteen further names upstream answers are left nil rather than
putting fifteen unused u32s on a struct the spell catalog holds one of
per row. They are listed at `FIELDS` with that reason, so the gap is a
decision rather than an omission.

`GetSpellRecField`'s three string columns keep the spellbook fallback
the verb had before the probe, so a VM with a book and no catalog — a
capture, or the boot VM before the catalog loads — still answers a
learnt spell's name and rank. Every other column is a DBC row and has
nowhere else to come from, which is why the fallback is exactly three
names wide and says so.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
2026-10-24 09:28:05 -06:00