the wheel binds, one AddOns screen, text at the true scale

- SetBinding accepts the mouse wheel — the refusal was ours, not the client's — so wheel-bound action buttons press and release like the real thing
- one addon load law: a single arbiter for "can this addon load, and why not", with the out-of-date version gate live
- one AddOns screen — the character-select list, wearing the authored reference layout: roster dropdown, per-character tri-state checkboxes, force-load; the ESC-menu panel is retired, since 1.12's own menu has no AddOns row
- the glyph atlas follows the window scale, so maximised text stops washing out and a title stops measuring wider than it draws; an unpositioned frame positions nothing, instead of landing at the screen origin

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-08-14 19:31:56 +07:00
co-authored by Claude Fable 5
parent 6ec7cc915b
commit ebb9a43cc9
40 changed files with 3762 additions and 2081 deletions
-408
View File
@@ -1,408 +0,0 @@
<Ui>
<!--
The in-game AddOns list (decision 1197) — the ESC-menu half of the AddOns UI, and the
button `GameMenuFrame.xml` has been carrying as `GameMenuButton_Pending(self, ADDONS)`
since 0997 ("goes live by deleting one Pending call and writing its OnClick").
SCOPE — this frame has no reference. 1.12 ships an AddOns list only on the GLUE side
(`GlueXML/AddonList.xml/.lua`, opened by CharacterSelectAddonsButton); in-world there is
no such window and the ESC menu has no such entry. This is a deliberate benilla addition
the director asked for, so nothing here is transcribed and nothing pretends to be. What IS
borrowed from the reference is the BEHAVIOUR of its glue twin, because it is the same job:
- the row shape and count: ADDON_BUTTON_HEIGHT = 16, MAX_ADDONS_DISPLAYED = 19
- AddonList_Update's three-colour title rule — gold loadable, red enabled-but-broken,
grey disabled (SetTextColor 1/0.78/0, 1/0.1/0.1, 0.5/0.5/0.5)
- the status column carrying `reason` (our GetAddOnInfo returns the reference's own
token set — decision 1191 §6)
- Okay saves, Cancel discards (SaveAddOns / ResetAddOns)
It is written entirely against the ELEVEN AddOn API verbs 1191 landed — GetNumAddOns,
GetAddOnInfo, GetAddOnDependencies, EnableAddOn, DisableAddOn, IsAddOnLoaded — with no
host support of its own. That is the point of it as much as the window is: if this panel
works, the addon API an addon author would use works, and it is exercised by anyone who
opens the ESC menu rather than by a test nobody runs.
WHEN CHANGES TAKE EFFECT — next login, not now. Enabling an addon mid-session would mean
running its files into a VM that has already fired ADDON_LOADED/VARIABLES_LOADED/
PLAYER_LOGIN, which is not a state the reference's own loader has a path for; disabling
one cannot unload it at all. The panel says so on its face rather than leaving a player to
discover it. The enable state is written at the session's end by the shutdown tail
(decision 1191 §7, the reference's own last step at 0x490bd0).
-->
<Script><![CDATA[
-- Strings. The `X = X or "..."` idiom is MicroMenu.xml's: a real GlobalStrings table
-- loaded later wins, and these are the fallbacks for a run without one.
ADDONS = ADDONS or "AddOns"
ADDON_LIST_ENABLE_ALL = ADDON_LIST_ENABLE_ALL or "Enable All"
ADDON_LIST_DISABLE_ALL = ADDON_LIST_DISABLE_ALL or "Disable All"
ADDON_LIST_APPLY_NOTE = ADDON_LIST_APPLY_NOTE or "Changes take effect at your next login."
OKAY = OKAY or "Okay"
CANCEL = CANCEL or "Cancel"
ADDON_BUTTON_HEIGHT = 16
MAX_ADDONS_DISPLAYED = 19
-- The interface benilla implements. Kept as a name rather than a literal at the call site
-- because `ui_script::addons::INTERFACE_VERSION` is the same constant on the Rust side and
-- the two must not drift.
BENILLA_INTERFACE_VERSION = 11200
-- **`## Interface` mismatch, which `GetAddOnInfo`'s `reason` cannot carry.** 1191 §6 reports
-- the mismatch and never enforces it — enforcing would refuse most of a real vanilla corpus,
-- whose manifests say 11100, 10900, anything — so it is not a reason the addon *cannot*
-- load, and `not_loadable` rightly has no arm for it. That left this screen silent about a
-- status its char-select twin has always shown, for the same addon: two views of one folder
-- (1197 §3) disagreeing. This is the missing half.
--
-- Stated divergence, measured: `GetAddOnMetadata` returns the FIRST `## Interface` line
-- while the Rust `out_of_date()` accepts a match among ALL of them. **Zero of the 218 corpus
-- addons declare it twice**, so the two agree everywhere it is currently observable; a
-- manifest that declared 11100 and 11200 would read out-of-date here and current there.
function AddonList_OutOfDate(name)
local declared = GetAddOnMetadata(name, "Interface")
if not declared then
-- No `## Interface` at all is SILENT, not out of date — the same rule the char
-- select panel pins in `a_manifest_with_no_interface_is_silent`.
return nil
end
return tonumber(declared) ~= BENILLA_INTERFACE_VERSION
end
-- The staged edit. `AddonList.original` is the enable state as the panel found it, so
-- Cancel can put it back — the reference's `ResetAddOns` over the client's own records,
-- which we do not have an engine verb for (SaveAddOns/ResetAddOns are GLUE-namespace
-- globals and deliberately absent in-world, decision 1191 §6).
AddonList = { offset = 0, original = {} }
function AddonList_Toggle()
if AddonListFrame:IsVisible() then
AddonList_OnCancel()
else
AddonList_Show()
end
end
function AddonList_Show()
AddonList.offset = 0
AddonList.original = {}
for i = 1, GetNumAddOns() do
local name, _, _, _, loadable, reason = GetAddOnInfo(i)
-- "enabled" is everything except the player's own DISABLED — a dependency problem
-- is not a checkbox state, it is a status line.
AddonList.original[name] = not (reason == "DISABLED")
end
AddonListFrame:Show()
AddonList_Update()
end
-- One row's worth of facts, so the row painter below reads as a list of assignments
-- rather than as a pile of GetAddOnInfo returns.
-- Slot 4 is `enabled`, and `url` is not in the in-game return at all: the client registers
-- GetAddOnInfo twice and the in-game one (`0x48e390`) answers seven values without a URL,
-- where glue's (`0x46d460`) answers eight with one. We had the glue shape here, so this
-- read `url` out of slot 4 and then had to RECONSTRUCT `enabled` from the reason token —
-- a workaround that existed only because the slot it wanted was occupied by the wrong
-- value. `## URL` lives in the manifest either way, so the tooltip asks for it directly.
local function rowFacts(index)
local name, title, notes, enabled, loadable, reason = GetAddOnInfo(index)
return name, (title or name), loadable, reason, enabled, notes,
GetAddOnMetadata(name, "URL")
end
function AddonList_Update()
local total = GetNumAddOns()
-- Clamp first: a Disable All that shortens nothing still must not leave the offset
-- past the end after the list is re-read.
local maxOffset = total - MAX_ADDONS_DISPLAYED
if maxOffset < 0 then maxOffset = 0 end
if AddonList.offset > maxOffset then AddonList.offset = maxOffset end
if AddonList.offset < 0 then AddonList.offset = 0 end
for i = 1, MAX_ADDONS_DISPLAYED do
local entry = getglobal("AddonListEntry" .. i)
local index = AddonList.offset + i
if index > total then
entry:Hide()
else
local name, title, loadable, reason, enabled = rowFacts(index)
entry:SetID(index)
local check = getglobal("AddonListEntry" .. i .. "Check")
check:SetChecked(enabled)
-- AddonList_Update's own colour rule, its own three values.
local label = getglobal("AddonListEntry" .. i .. "Title")
label:SetText(title)
if loadable then
label:SetTextColor(1.0, 0.78, 0.0)
elseif enabled and reason ~= "DEP_DISABLED" then
label:SetTextColor(1.0, 0.1, 0.1)
else
label:SetTextColor(0.5, 0.5, 0.5)
end
-- The status column: the reference splices its token into a GlobalStrings
-- lookup (`getglobal("ADDON_"..reason)`), which is why the token spelling is
-- load-bearing (1191 §6). A token with no string falls back to the token, so
-- a player sees something rather than a blank.
local status = getglobal("AddonListEntry" .. i .. "Status")
if reason then
status:SetText(getglobal("ADDON_" .. reason) or reason)
elseif AddonList_OutOfDate(name) then
-- INTERFACE_VERSION ("Out of date"), which `reason` can never carry here
-- and which the char-select twin has always shown. The two screens are the
-- same job (1197 §1) and were answering DIFFERENTLY about the same addon:
-- glue read `## Interface` off the folder, this one asked `GetAddOnInfo`,
-- and `not_loadable` has no interface arm — because 1191 §6 reports the
-- mismatch and never enforces it, so it is not a reason the addon cannot
-- load. Surfaced separately for exactly that reason: `loadable` stays true
-- and most of a real vanilla corpus keeps loading.
status:SetText(ADDON_INTERFACE_VERSION or "Out of date")
else
status:SetText("")
end
entry:Show()
end
end
-- The scroll pair exists only while the list overflows — the reference's own
-- GlueScrollFrame_Update condition.
if total > MAX_ADDONS_DISPLAYED then
AddonListScrollUp:Show()
AddonListScrollDown:Show()
else
AddonListScrollUp:Hide()
AddonListScrollDown:Hide()
end
end
function AddonList_Scroll(delta)
AddonList.offset = AddonList.offset + delta
AddonList_Update()
end
function AddonList_ToggleEntry(entryIndex)
local name, _, _, _, _, reason = GetAddOnInfo(entryIndex)
if reason == "DISABLED" then
EnableAddOn(name)
else
DisableAddOn(name)
end
AddonList_Update()
end
function AddonList_SetAll(on)
if on then EnableAllAddOns() else DisableAllAddOns() end
AddonList_Update()
end
function AddonList_OnOk()
PlaySound("igMainMenuOption")
AddonListFrame:Hide()
end
-- Cancel restores what the panel found, addon by addon — `ResetAddOns` written out,
-- because that verb is glue-only and this window is not the glue.
function AddonList_OnCancel()
PlaySound("igMainMenuQuit")
for i = 1, GetNumAddOns() do
local name = GetAddOnInfo(i)
local was = AddonList.original[name]
if was == true then
EnableAddOn(name)
elseif was == false then
DisableAddOn(name)
end
end
AddonListFrame:Hide()
end
-- The hover tooltip: title, notes, dependencies — AddonTooltip_Update's three lines in
-- its order, on the shared GameTooltip rather than a private frame.
function AddonList_RowEnter(row)
local name, title, loadable, reason, enabled, notes = rowFacts(row:GetID())
GameTooltip:SetOwner(row, "ANCHOR_RIGHT")
GameTooltip:AddLine(title)
if notes and notes ~= "" then
GameTooltip:AddLine(notes, 1, 1, 1)
end
local deps = ""
local list = { GetAddOnDependencies(row:GetID()) }
for i = 1, getn(list) do
if i == 1 then
deps = (ADDON_DEPENDENCIES or "Dependencies: ") .. list[i]
else
deps = deps .. ", " .. list[i]
end
end
if deps ~= "" then
GameTooltip:AddLine(deps, 1, 0.82, 0)
end
GameTooltip:Show()
end
function AddonList_RowLeave()
GameTooltip:Hide()
end
]]></Script>
<!-- The panel button face. This engine ships no shared UIPanelButtonTemplate — every panel
defines its own (the MacroFrame/TrainerFrame precedent), and a local one also keeps this
window independent of any other file's template names. -->
<Button name="AddonListPanelButtonTemplate" virtual="true">
<Size><AbsDimension x="90" y="22"/></Size>
<NormalFont inherits="GameFontNormal"/>
<HighlightFont inherits="GameFontHighlight"/>
<DisabledFont inherits="GameFontDisable"/>
<NormalTexture file="Interface\Buttons\UI-Panel-Button-Up"><TexCoords left="0" right="0.625" top="0" bottom="0.6875"/></NormalTexture>
<PushedTexture file="Interface\Buttons\UI-Panel-Button-Down"><TexCoords left="0" right="0.625" top="0" bottom="0.6875"/></PushedTexture>
<DisabledTexture file="Interface\Buttons\UI-Panel-Button-Disabled"><TexCoords left="0" right="0.625" top="0" bottom="0.6875"/></DisabledTexture>
<HighlightTexture file="Interface\Buttons\UI-Panel-Button-Highlight" alphaMode="ADD"><TexCoords left="0" right="0.625" top="0" bottom="0.6875"/></HighlightTexture>
</Button>
<!-- The two scroll arrows, the client's own `UI-ScrollBar-ScrollUpButton-*` art. -->
<Button name="AddonListScrollUpTemplate" virtual="true">
<Size><AbsDimension x="16" y="16"/></Size>
<NormalTexture file="Interface\Buttons\UI-ScrollBar-ScrollUpButton-Up"><TexCoords left="0.25" right="0.75" top="0.25" bottom="0.75"/></NormalTexture>
<PushedTexture file="Interface\Buttons\UI-ScrollBar-ScrollUpButton-Down"><TexCoords left="0.25" right="0.75" top="0.25" bottom="0.75"/></PushedTexture>
<HighlightTexture file="Interface\Buttons\UI-ScrollBar-ScrollUpButton-Highlight" alphaMode="ADD"><TexCoords left="0.25" right="0.75" top="0.25" bottom="0.75"/></HighlightTexture>
</Button>
<Button name="AddonListScrollDownTemplate" virtual="true">
<Size><AbsDimension x="16" y="16"/></Size>
<NormalTexture file="Interface\Buttons\UI-ScrollBar-ScrollDownButton-Up"><TexCoords left="0.25" right="0.75" top="0.25" bottom="0.75"/></NormalTexture>
<PushedTexture file="Interface\Buttons\UI-ScrollBar-ScrollDownButton-Down"><TexCoords left="0.25" right="0.75" top="0.25" bottom="0.75"/></PushedTexture>
<HighlightTexture file="Interface\Buttons\UI-ScrollBar-ScrollDownButton-Highlight" alphaMode="ADD"><TexCoords left="0.25" right="0.75" top="0.25" bottom="0.75"/></HighlightTexture>
</Button>
<!-- One row: a checkbox, a title, a status column. 16 tall, the reference's own
ADDON_BUTTON_HEIGHT. -->
<Button name="AddonListEntryTemplate" virtual="true">
<Size><AbsDimension x="440" y="16"/></Size>
<Layers>
<Layer level="OVERLAY">
<FontString name="$parentTitle" inherits="GameFontNormalSmall" justifyH="LEFT">
<Anchors>
<Anchor point="LEFT"><Offset><AbsDimension x="26" y="0"/></Offset></Anchor>
</Anchors>
</FontString>
<FontString name="$parentStatus" inherits="GameFontDisableSmall" justifyH="RIGHT">
<Anchors>
<Anchor point="RIGHT"><Offset><AbsDimension x="-4" y="0"/></Offset></Anchor>
</Anchors>
</FontString>
</Layer>
</Layers>
<Frames>
<CheckButton name="$parentCheck">
<Size><AbsDimension x="20" y="20"/></Size>
<Anchors><Anchor point="LEFT"><Offset><AbsDimension x="0" y="0"/></Offset></Anchor></Anchors>
<NormalTexture file="Interface\Buttons\UI-CheckBox-Up"/>
<PushedTexture file="Interface\Buttons\UI-CheckBox-Down"/>
<CheckedTexture file="Interface\Buttons\UI-CheckBox-Check"/>
<Scripts>
<OnClick>AddonList_ToggleEntry(this:GetParent():GetID())</OnClick>
</Scripts>
</CheckButton>
</Frames>
<Scripts>
<OnEnter>AddonList_RowEnter(this)</OnEnter>
<OnLeave>AddonList_RowLeave()</OnLeave>
<OnClick>AddonList_ToggleEntry(this:GetID())</OnClick>
</Scripts>
</Button>
<Frame name="AddonListFrame" frameStrata="DIALOG" enableMouse="true" hidden="true">
<Size><AbsDimension x="480" y="420"/></Size>
<Anchors><Anchor point="CENTER"/></Anchors>
<Backdrop bgFile="Interface\DialogFrame\UI-DialogBox-Background"
edgeFile="Interface\DialogFrame\UI-DialogBox-Border" tile="true">
<BackgroundInsets><AbsInset left="11" right="12" top="12" bottom="11"/></BackgroundInsets>
<TileSize><AbsValue val="32"/></TileSize>
<EdgeSize><AbsValue val="32"/></EdgeSize>
</Backdrop>
<Layers>
<Layer level="ARTWORK">
<FontString name="$parentTitle" inherits="GameFontNormalLarge">
<Anchors><Anchor point="TOP"><Offset><AbsDimension x="0" y="-18"/></Offset></Anchor></Anchors>
</FontString>
<FontString name="$parentNote" inherits="GameFontDisableSmall">
<Anchors><Anchor point="BOTTOM"><Offset><AbsDimension x="0" y="60"/></Offset></Anchor></Anchors>
</FontString>
</Layer>
</Layers>
<Frames>
<Button name="AddonListEntry1" inherits="AddonListEntryTemplate">
<Anchors><Anchor point="TOPLEFT"><Offset><AbsDimension x="20" y="-46"/></Offset></Anchor></Anchors>
</Button>
<Button name="AddonListEntry2" inherits="AddonListEntryTemplate"><Anchors><Anchor point="TOPLEFT" relativeTo="AddonListEntry1" relativePoint="BOTTOMLEFT"/></Anchors></Button>
<Button name="AddonListEntry3" inherits="AddonListEntryTemplate"><Anchors><Anchor point="TOPLEFT" relativeTo="AddonListEntry2" relativePoint="BOTTOMLEFT"/></Anchors></Button>
<Button name="AddonListEntry4" inherits="AddonListEntryTemplate"><Anchors><Anchor point="TOPLEFT" relativeTo="AddonListEntry3" relativePoint="BOTTOMLEFT"/></Anchors></Button>
<Button name="AddonListEntry5" inherits="AddonListEntryTemplate"><Anchors><Anchor point="TOPLEFT" relativeTo="AddonListEntry4" relativePoint="BOTTOMLEFT"/></Anchors></Button>
<Button name="AddonListEntry6" inherits="AddonListEntryTemplate"><Anchors><Anchor point="TOPLEFT" relativeTo="AddonListEntry5" relativePoint="BOTTOMLEFT"/></Anchors></Button>
<Button name="AddonListEntry7" inherits="AddonListEntryTemplate"><Anchors><Anchor point="TOPLEFT" relativeTo="AddonListEntry6" relativePoint="BOTTOMLEFT"/></Anchors></Button>
<Button name="AddonListEntry8" inherits="AddonListEntryTemplate"><Anchors><Anchor point="TOPLEFT" relativeTo="AddonListEntry7" relativePoint="BOTTOMLEFT"/></Anchors></Button>
<Button name="AddonListEntry9" inherits="AddonListEntryTemplate"><Anchors><Anchor point="TOPLEFT" relativeTo="AddonListEntry8" relativePoint="BOTTOMLEFT"/></Anchors></Button>
<Button name="AddonListEntry10" inherits="AddonListEntryTemplate"><Anchors><Anchor point="TOPLEFT" relativeTo="AddonListEntry9" relativePoint="BOTTOMLEFT"/></Anchors></Button>
<Button name="AddonListEntry11" inherits="AddonListEntryTemplate"><Anchors><Anchor point="TOPLEFT" relativeTo="AddonListEntry10" relativePoint="BOTTOMLEFT"/></Anchors></Button>
<Button name="AddonListEntry12" inherits="AddonListEntryTemplate"><Anchors><Anchor point="TOPLEFT" relativeTo="AddonListEntry11" relativePoint="BOTTOMLEFT"/></Anchors></Button>
<Button name="AddonListEntry13" inherits="AddonListEntryTemplate"><Anchors><Anchor point="TOPLEFT" relativeTo="AddonListEntry12" relativePoint="BOTTOMLEFT"/></Anchors></Button>
<Button name="AddonListEntry14" inherits="AddonListEntryTemplate"><Anchors><Anchor point="TOPLEFT" relativeTo="AddonListEntry13" relativePoint="BOTTOMLEFT"/></Anchors></Button>
<Button name="AddonListEntry15" inherits="AddonListEntryTemplate"><Anchors><Anchor point="TOPLEFT" relativeTo="AddonListEntry14" relativePoint="BOTTOMLEFT"/></Anchors></Button>
<Button name="AddonListEntry16" inherits="AddonListEntryTemplate"><Anchors><Anchor point="TOPLEFT" relativeTo="AddonListEntry15" relativePoint="BOTTOMLEFT"/></Anchors></Button>
<Button name="AddonListEntry17" inherits="AddonListEntryTemplate"><Anchors><Anchor point="TOPLEFT" relativeTo="AddonListEntry16" relativePoint="BOTTOMLEFT"/></Anchors></Button>
<Button name="AddonListEntry18" inherits="AddonListEntryTemplate"><Anchors><Anchor point="TOPLEFT" relativeTo="AddonListEntry17" relativePoint="BOTTOMLEFT"/></Anchors></Button>
<Button name="AddonListEntry19" inherits="AddonListEntryTemplate"><Anchors><Anchor point="TOPLEFT" relativeTo="AddonListEntry18" relativePoint="BOTTOMLEFT"/></Anchors></Button>
<Button name="AddonListScrollUp" inherits="AddonListScrollUpTemplate" hidden="true">
<Anchors><Anchor point="TOPRIGHT"><Offset><AbsDimension x="-2" y="-42"/></Offset></Anchor></Anchors>
<Scripts><OnClick>AddonList_Scroll(-1)</OnClick></Scripts>
</Button>
<Button name="AddonListScrollDown" inherits="AddonListScrollDownTemplate" hidden="true">
<Anchors><Anchor point="BOTTOMRIGHT"><Offset><AbsDimension x="-2" y="86"/></Offset></Anchor></Anchors>
<Scripts><OnClick>AddonList_Scroll(1)</OnClick></Scripts>
</Button>
<Button name="AddonListEnableAll" inherits="AddonListPanelButtonTemplate">
<Size><AbsDimension x="104" y="22"/></Size>
<Anchors><Anchor point="BOTTOMLEFT"><Offset><AbsDimension x="20" y="20"/></Offset></Anchor></Anchors>
<Scripts>
<OnLoad>this:SetText(ADDON_LIST_ENABLE_ALL)</OnLoad>
<OnClick>AddonList_SetAll(1)</OnClick>
</Scripts>
</Button>
<Button name="AddonListDisableAll" inherits="AddonListPanelButtonTemplate">
<Size><AbsDimension x="104" y="22"/></Size>
<Anchors><Anchor point="LEFT" relativeTo="AddonListEnableAll" relativePoint="RIGHT"><Offset><AbsDimension x="6" y="0"/></Offset></Anchor></Anchors>
<Scripts>
<OnLoad>this:SetText(ADDON_LIST_DISABLE_ALL)</OnLoad>
<OnClick>AddonList_SetAll(nil)</OnClick>
</Scripts>
</Button>
<Button name="AddonListOkay" inherits="AddonListPanelButtonTemplate">
<Size><AbsDimension x="90" y="22"/></Size>
<Anchors><Anchor point="BOTTOMRIGHT"><Offset><AbsDimension x="-20" y="20"/></Offset></Anchor></Anchors>
<Scripts>
<OnLoad>this:SetText(OKAY)</OnLoad>
<OnClick>AddonList_OnOk()</OnClick>
</Scripts>
</Button>
<Button name="AddonListCancel" inherits="AddonListPanelButtonTemplate">
<Size><AbsDimension x="90" y="22"/></Size>
<Anchors><Anchor point="RIGHT" relativeTo="AddonListOkay" relativePoint="LEFT"><Offset><AbsDimension x="-6" y="0"/></Offset></Anchor></Anchors>
<Scripts>
<OnLoad>this:SetText(CANCEL)</OnLoad>
<OnClick>AddonList_OnCancel()</OnClick>
</Scripts>
</Button>
</Frames>
<Scripts>
<OnLoad>
AddonListFrameTitle:SetText(ADDONS)
AddonListFrameNote:SetText(ADDON_LIST_APPLY_NOTE)
</OnLoad>
<OnShow>AddonList_Update()</OnShow>
<OnMouseWheel>AddonList_Scroll(-arg1)</OnMouseWheel>
</Scripts>
</Frame>
</Ui>
@@ -74,8 +74,8 @@
OTHER DEVIATIONS, each forced:
· **`<OnKeyDown>` (ref l.177-184) is not transcribed** — this engine gives plain frames no
raw key events (SCRIPT_KINDS has no `OnKeyDown`), the same wall AddonList.xml hit
(decision 1197). Its ESCAPE arm is `HideUIPanel(this) + cancelFunc(previousValues)`, i.e.
raw key events (SCRIPT_KINDS has no `OnKeyDown`), the same wall StackSplit.xml hit
(its own header note). Its ESCAPE arm is `HideUIPanel(this) + cancelFunc(previousValues)`, i.e.
exactly what the Cancel button does, so the arm is carried in UiPanels.xml's ESC chain as
`ColorPickerCancelButton:Click()` — the reference's own idiom for this (its game-menu
chain cancel-clicks `OptionsFrameCancel` the same way). ESC therefore CANCELS, it does not
+20 -36
View File
@@ -65,18 +65,23 @@
Rewards / What's New / Ratings are structurally off in Era — store disabled, no
account store, IsStandard() false, koKR only.)
· The layout math those constants produce, precomputed here (this engine has no layout
frames): width 28+144+28 = 200; button tops 32/73/94/115/136/177/198/239; height
239+21+28 = 288. Anchored as a TOPLEFT chain so the gaps read in the XML.
frames) — for OUR seven rungs, the era's eight minus AddOns (next bullet): width
28+144+28 = 200; button tops 32/73/94/115/156/177/218; height 218+21+28 = 267.
Anchored as a TOPLEFT chain so the gaps read in the XML.
· No Key Bindings rung — the era menu's own shape: 1.15 ceded the entry to the
Settings panel, and since decision 1008 ours lives there too (the Options window's
Keybindings category, KeyBindingsPage.xml). 0997 had carved the 1.13/1.14 seat back
in for its standalone window; that seat left with the window.
· No AddOns rung — the director's call: the char-select AddOns screen
(`char_select::addons`, the reference's own glue-side home for this list) is the only
addon UI. The in-game panel 1197 put behind this rung (AddonList.xml) is retired,
and the rung left with it — 1.12's own ESC menu has no AddOns row either.
· The header — Classic/Dialog/DialogTemplates.xml l.11-43 (ClassicDialogHeaderTemplate):
the same 256x64 UI-DialogBox-Header art, text = MAINMENU_BUTTON, swapped to
GameFontNormalMed1 (13px) by MainMenuFrameMixin:OnLoad. Both header idioms (1.12's
TOP+12 texture / era's 39-tall frame with the art centered) seat the art within half
a unit of each other; the 1.12 transcription is kept, the font and string are era's.
- **AddOns / Edit Mode / Support / Macros ship DISABLED** — nothing behind them yet
- **Edit Mode / Support ship DISABLED** — nothing behind them yet
(`GameMenuButton_Pending`): the template's own `-Disabled` art + grey label, exactly
how the era menu greys a dead entry (SetEnabled(false) → DisabledFont). Each goes live
by deleting one `GameMenuButton_Pending` call and writing its OnClick.
@@ -96,7 +101,6 @@
-- The reference GlobalStrings (patch-2.MPQ Interface\FrameXML\GlobalStrings.lua), quoted
-- verbatim. `X = X or "..."` so a later real strings table wins, the MicroMenu.xml idiom.
MAIN_MENU = MAIN_MENU or "Main Menu"
ADDONS = ADDONS or "AddOns"
HUD_EDIT_MODE_MENU = HUD_EDIT_MODE_MENU or "Edit Mode"
GAMEMENU_SUPPORT = GAMEMENU_SUPPORT or "Support"
MACROS = MACROS or "Macros"
@@ -108,9 +112,9 @@
QUIT_NOW = QUIT_NOW or "Exit now"
CANCEL = CANCEL or "Cancel"
-- One label setter for a button with nothing behind it yet (only Macros, since 0950 made
-- Options live): set the ref's text, then disable (grey label + the template's -Disabled
-- art). Making it live is deleting the call and writing its ref OnClick.
-- One label setter for a button with nothing behind it yet (Edit Mode and Support): set
-- the ref's text, then disable (grey label + the template's -Disabled art). Making it
-- live is deleting the call and writing its ref OnClick.
function GameMenuButton_Pending(button, text)
button:SetText(text)
button:Disable()
@@ -205,9 +209,8 @@
<Frame name="GameMenuFrame" frameStrata="DIALOG" enableMouse="true" hidden="true">
<!-- The era layout engine's own numbers (header SCOPE note): 28+144+28 wide, 32-top /
28-bottom padding around nine 21-unit rungs with three 20-unit section gaps (the
ninth rung is the 0997 Key Bindings seat). -->
<Size><AbsDimension x="200" y="288"/></Size>
28-bottom padding around our seven 21-unit rungs with three 20-unit section gaps. -->
<Size><AbsDimension x="200" y="267"/></Size>
<Anchors><Anchor point="CENTER"/></Anchors>
<Backdrop bgFile="Interface\DialogFrame\UI-DialogBox-Background" edgeFile="Interface\DialogFrame\UI-DialogBox-Border" tile="true">
<BackgroundInsets><AbsInset left="11" right="12" top="12" bottom="11"/></BackgroundInsets>
@@ -231,11 +234,12 @@
</Layer>
</Layers>
<Frames>
<!-- The era ladder (header SCOPE note): Options · gap · AddOns, Edit Mode, Support,
Macros · gap · Logout, Exit Game · gap · Return to Game. A TOPLEFT chain at the
era layout engine's numbers — x 28, first top -32, 0 between group members, -20
at each section break. Live entries wire the era OnClick intent; pending ones
grey out through GameMenuButton_Pending until something exists behind them. -->
<!-- The era ladder minus its AddOns rung (header SCOPE notes): Options · gap ·
Edit Mode, Support, Macros · gap · Logout, Exit Game · gap · Return to Game.
A TOPLEFT chain at the era layout engine's numbers — x 28, first top -32, 0
between group members, -20 at each section break. Live entries wire the era
OnClick intent; pending ones grey out through GameMenuButton_Pending until
something exists behind them. -->
<!-- Options — live: the era GameMenuFrame.lua l.88 entry (ShowUIPanel(SettingsPanel)
there, OptionsFrame here) plus an explicit HideUIPanel first. The ref leans on
@@ -256,29 +260,9 @@
</Scripts>
</Button>
<!-- Key Bindings — live (decision 0997; the carved 1.13/1.14 seat, header SCOPE
note): the era menu entry's own body shape — hide the menu, show the window
(whose OnHide brings the menu back, both references' law). -->
<Button name="GameMenuButtonAddOns" inherits="GameMenuButtonTemplate">
<Anchors>
<Anchor point="TOPLEFT" relativeTo="GameMenuButtonOptions" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-20"/></Offset></Anchor>
</Anchors>
<!-- LIVE since decision 1197 (AddonList.xml). The Macros entry's own body shape:
hide the menu, show the window. This entry has no reference — 1.12's ESC menu
has no AddOns row, its list is glue-side only — so it is a deliberate benilla
addition, and AddonList.xml's header says so rather than implying otherwise. -->
<Scripts>
<OnLoad>self:SetText(ADDONS)</OnLoad>
<OnClick>
PlaySound("igMainMenuOption")
HideUIPanel(getglobal("GameMenuFrame"))
AddonList_Toggle()
</OnClick>
</Scripts>
</Button>
<Button name="GameMenuButtonEditMode" inherits="GameMenuButtonTemplate">
<Anchors>
<Anchor point="TOPLEFT" relativeTo="GameMenuButtonAddOns" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="0"/></Offset></Anchor>
<Anchor point="TOPLEFT" relativeTo="GameMenuButtonOptions" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-20"/></Offset></Anchor>
</Anchors>
<Scripts><OnLoad>GameMenuButton_Pending(self, HUD_EDIT_MODE_MENU)</OnLoad></Scripts>
</Button>
@@ -30,9 +30,10 @@
mouse-3/4/5 press binds IMMEDIATELY (the host capture seam hands the canonical
ALT-CTRL-SHIFT chord to KeyBindings_OnHostKey); a stolen key names its victim in red
(KEY_UNBOUND_ERROR) only when the victim goes bare; left/right clicks select/deselect,
never bind; lone modifiers are ignored; the wheel binds click-commands but refuses
press+release ones (SetBinding returns nil → KEYBINDINGFRAME_MOUSEWHEEL_ERROR, old key
restored); ESC while armed BINDS Escape (1.12's OnKeyDown has no ESCAPE filter — the
never bind; lone modifiers are ignored; the wheel binds like any other key, to ANY
command (decision 1295 — SetBinding refuses nothing but a key string that is not a key,
and the KEYBINDINGFRAME_MOUSEWHEEL_ERROR arm below is that refusal's message, not a
wheel rule); ESC while armed BINDS Escape (1.12's OnKeyDown has no ESCAPE filter — the
era's ESC-cancels law is the one era behavior NOT taken, recorded in 1008; the Unbind
Key button is the clearing affordance). ESC with nothing armed never reaches any of
this: the ladder is the TOGGLEGAMEMENU binding, and the armed seam swallows the press.
@@ -444,8 +445,11 @@
KeyBindings_RepaintVisible()
end
-- 1.12 KeyBindingFrame_SetBinding: a refused bind (the wheel on a press+release
-- command) restores the slot's old key and says why.
-- 1.12 KeyBindingFrame_SetBinding, kept verbatim: a refused bind restores the slot's old
-- key and says why. The reference wrote this arm with the wheel in mind and named its
-- string for it, but the engine refuses only a key string that is not a key
-- (decision 1295), so this fires for a chord the capture seam could not name — never for
-- a wheel notch. The message stays 1.12's; ours is not the place to correct Blizzard.
function KeyBindings_SetOrRestore(key, command, oldKey)
if not SetBinding(key, command) then
if oldKey then SetBinding(oldKey, command) end
@@ -2486,7 +2486,7 @@
on uncheck only), then the section list — 19 dual-mode rows under
the shared faux-scroll kit, sections collapsed by default — and
the 1.12 window's message line (KEY_BOUND / the red steal / the
wheel refusal) at the foot. -->
refused key string) at the foot. -->
<!-- enableMouse: the page catches the wheel ITSELF on any point no
child claims (hit-testing only sees mouse-enabled frames —
pointer.rs; rows are plain Frames, so a spin over a row's name
@@ -7,7 +7,7 @@
and the two Lua handlers are not.
WHY THIS FILE EXISTS AT ALL. Every window benilla has built so far declared its own private
copy of these — `AddonListPanelButtonTemplate`, `BenillaTrainerPanelButtonTemplate`,
copy of these — `BenillaQuestPanelButtonTemplate`, `BenillaTrainerPanelButtonTemplate`,
`BenillaMacroPanelButtonTemplate` and a dozen siblings, each the same four state textures on
the same UI-Panel-Button art. That was fine while the only consumer was our own XML. It
stopped being fine the day `CreateFrame`'s fourth argument started working (decision 1203):
@@ -42,11 +42,11 @@
now, in the reference's own file for it, and GameMenuFrame.xml's private copy is gone.)
AND WHAT THIS SUPERSEDES BUT DOES NOT DELETE: `UIPanelButtonTemplate` below is the shared
original of `AddonListPanelButtonTemplate`, `BenillaQuestPanelButtonTemplate`,
original of `BenillaQuestPanelButtonTemplate`,
`BenillaCraftPanelButtonTemplate`, `BenillaQuestLogPanelButtonTemplate`,
`BenillaFriendsPanelButtonTemplate`, `BenillaTrainerPanelButtonTemplate`,
`BenillaMacroPanelButtonTemplate`, `BenillaTradeSkillPanelButtonTemplate` and
`BenillaDressUpPanelButtonTemplate` — nine copies of the same eight lines. Re-pointing those
`BenillaDressUpPanelButtonTemplate` — eight copies of the same eight lines. Re-pointing those
windows at this template is a separate, mechanical pass; nothing here changes them.
-->
+2 -13
View File
@@ -604,8 +604,8 @@
-- The colour picker next (ColorPickerFrame.xml). The reference hooks ESC on the frame
-- itself (`<OnKeyDown>` l.177-184, whose ESCAPE arm is `HideUIPanel(this)` +
-- `cancelFunc(previousValues)`); this engine gives plain frames no raw key events, so
-- it routes through this chain exactly like the AddOns list on the next rung.
-- **Cancel-CLICK, not hide** — the ref's own arm restores the previous colour, and
-- it routes through this shared chain like the stack-split spinner and the world map
-- do. **Cancel-CLICK, not hide** — the ref's own arm restores the previous colour, and
-- clicking the real button is how the options rung below already spells this (the ref's
-- `OptionsFrameCancel:Click()`), so there is one cancel path, not two.
local picker = getglobal("ColorPickerFrame")
@@ -613,17 +613,6 @@
ColorPickerCancelButton:Click()
return
end
-- The AddOns list next (decision 1197). The reference's glue twin hooks ESC directly
-- (`AddonList_OnKeyDown`, whose ESCAPE arm is `AddonList_OnCancel`); this engine gives
-- plain frames no raw key events, so it routes through this shared chain like the
-- stack-split spinner and the world map do. **Cancel, not hide** — ESC on that window
-- means "discard what I just ticked", which is the reference's own arm and the reason
-- the panel stages its edits at all.
local addons = getglobal("AddonListFrame")
if addons and addons:IsVisible() then
AddonList_OnCancel()
return
end
-- The options window next (l.1483-1484, live since decision 0950 — this rung sat
-- unfilled while GameMenuFrame.xml's disabled buttons were the gap). The ref
-- cancel-clicks (`OptionsFrameCancel:Click()`); ours hides: benilla applies changes
-4
View File
@@ -362,10 +362,6 @@ KeyBindingsPage.xml
# (all far above); sits right before GameMenuFrame so the menu — which reaches it by
# name at click time — stays the last, outermost layer.
OptionsFrame.xml
# The in-game AddOns list (decision 1197) — the ESC menu's AddOns entry. Before GameMenuFrame,
# which reaches AddonList_Toggle by name at click time; it depends only on the AddOn API verbs
# (1191) and defines its own templates, so it can sit anywhere after the fonts.
AddonList.xml
# The game menu (decision 0674): the frame ESC opens, plus the CAMP/QUIT dialogs and their
# event driver. LAST on purpose — it is the outermost layer of the shell, it depends on
# everything it touches being loaded (UiPanels' popup engine + panel manager, MicroMenu's
+1 -1
View File
@@ -192,7 +192,7 @@ fn surface_report(deep: bool, dump_path: Option<String>) {
}
println!(
" ^ read these: an unprefixed verb 1.12 lacks is what `if SomeName then` finds.\n \
Most are our own UI's helpers (AddonList_*, KeyBindings_*, Options*), which are only\n \
Most are our own UI's helpers (KeyBindings_*, Options*), which are only\n \
a naming question — but a POST-1.12 API name here is 1189's hazard exactly, because an\n \
addon that feature-tests it takes a branch written for a client we are not."
);
+85 -4
View File
@@ -205,8 +205,10 @@ pub(crate) fn key_token(k: KeyCode) -> Option<&'static str> {
NumpadDecimal => "NUMPADDECIMAL",
NumLock => "NUMLOCK",
PrintScreen => "PRINTSCREEN",
ScrollLock => "SCROLLLOCK",
Pause => "PAUSE",
// ScrollLock and Pause are deliberately absent: the reference's namer calls their key
// codes (`0x210`/`0x211`) `UNKNOWN`, so they are unbindable there (wow-re
// `keybinding-dispatch-law.md` §2.3), and `IsValidBindingKeyString` would refuse the
// names anyway — arm 4's 26-name table holds neither.
CapsLock => "CAPSLOCK",
Minus => "-",
Equal => "=",
@@ -346,8 +348,6 @@ fn token_key(t: &str) -> Option<BindKey> {
"NUMPADDECIMAL" => NumpadDecimal,
"NUMLOCK" => NumLock,
"PRINTSCREEN" => PrintScreen,
"SCROLLLOCK" => ScrollLock,
"PAUSE" => Pause,
"CAPSLOCK" => CapsLock,
"-" => Minus,
"=" => Equal,
@@ -369,6 +369,87 @@ fn token_key(t: &str) -> Option<BindKey> {
mod tests {
use super::*;
/// **Every token this codec names is one `SetBinding` will actually take** (decision 1295).
/// The namer here and `IsValidBindingKeyString` in the engine table are two transcriptions of
/// the same reference, and nothing but this ties them together — a token we name but the
/// validator refuses is a key the capture seam happily offers and `SetBinding` then drops on
/// the floor, which is exactly what `SCROLLLOCK` and `PAUSE` were until 1295.
///
/// The registry's default chords are covered exhaustively; `KeyCode` cannot be enumerated, so
/// the namer is covered by one key of every SHAPE it produces (letter, digit, F-key, numpad
/// digit, numpad name, arrow, the named editing/lock keys, punctuation).
#[test]
fn every_token_the_codec_names_is_one_setbinding_accepts() {
use benilla_ui::script::keybind::normalize_binding_key;
for spec in super::super::commands::SPECS {
for default in [spec.d1, spec.d2].into_iter().flatten() {
assert_eq!(
normalize_binding_key(default).as_deref(),
Some(default),
"the default chord '{default}' ({}) is not a bindable key string",
spec.name
);
}
}
for k in [
KeyCode::KeyW,
KeyCode::Digit0,
KeyCode::F1,
KeyCode::F12,
KeyCode::Numpad7,
KeyCode::NumpadAdd,
KeyCode::NumpadDecimal,
KeyCode::ArrowUp,
KeyCode::Space,
KeyCode::Enter,
KeyCode::Escape,
KeyCode::Backspace,
KeyCode::Tab,
KeyCode::Insert,
KeyCode::Delete,
KeyCode::Home,
KeyCode::End,
KeyCode::PageUp,
KeyCode::PageDown,
KeyCode::NumLock,
KeyCode::CapsLock,
KeyCode::PrintScreen,
KeyCode::Minus,
KeyCode::Equal,
KeyCode::BracketLeft,
KeyCode::Backslash,
KeyCode::Semicolon,
KeyCode::Quote,
KeyCode::Comma,
KeyCode::Period,
KeyCode::Slash,
KeyCode::Backquote,
] {
let token = key_token(k).expect("named");
assert!(
normalize_binding_key(token).is_some(),
"{k:?} names '{token}', which SetBinding refuses"
);
}
for b in [
MouseButton::Left,
MouseButton::Right,
MouseButton::Middle,
MouseButton::Forward,
MouseButton::Back,
] {
let token = mouse_token(b).expect("named");
assert!(normalize_binding_key(token).is_some(), "{b:?} → '{token}'");
}
for token in ["MOUSEWHEELUP", "MOUSEWHEELDOWN"] {
assert!(normalize_binding_key(token).is_some());
}
// The other half of the same law: the reference's namer calls these two `UNKNOWN`
// (`0x210`/`0x211`), so we must not name them either — nothing downstream could bind them.
assert_eq!(key_token(KeyCode::ScrollLock), None);
assert_eq!(key_token(KeyCode::Pause), None);
}
#[test]
fn the_codec_round_trips_every_keyboard_token() {
// Every key with a token parses back to itself (through the alias fold).
+7 -5
View File
@@ -49,7 +49,9 @@ pub(crate) struct Spec {
}
impl Spec {
/// Press+release semantics — the mousewheel-refusal law rides this at `SetBinding` time.
/// Press+release semantics — the reference's `<Binding runOnUp=…>`, whose ONE reader is
/// `RunCommand 0x4b7bf1`: it gates the release half and nothing else. (It used to gate a
/// mousewheel refusal at `SetBinding` time here; that refusal is not in the client — 1295.)
pub(crate) fn run_on_up(&self) -> bool {
matches!(self.kind, Kind::Held | Kind::EdgeUpDown(..))
}
@@ -1127,9 +1129,9 @@ mod tests {
/// **The registry against the real client's own two files.** Two columns of this table are
/// pure transcription from the 1.12 install, and both are load-bearing in a way nothing else
/// here can see: `runOnUp` — the mousewheel-refusal law rides it, and B265 turned entirely on
/// whether ours matched — and every command's default chords. A hand-typed table drifts; this
/// reads the source of truth instead of trusting the typing.
/// here can see: `runOnUp` — which decides whether a wheel notch delivers a release half at
/// all (1295), and which B265 turned entirely on — and every command's default chords. A
/// hand-typed table drifts; this reads the source of truth instead of trusting the typing.
///
/// `Interface\FrameXML\Bindings.xml` carries the flag, `WTF\DefaultBindings.wtf` the chords.
/// Both are install content, read at runtime, never committed; the test skips (passes)
@@ -1183,7 +1185,7 @@ mod tests {
s.run_on_up(),
reference_run_on_up,
"{}: runOnUp disagrees with the install's Bindings.xml — that flag decides \
whether the mousewheel may be bound to this command (B265)",
whether a press of this command delivers a release half (1295)",
s.name
);
let ours: Vec<&str> = [s.d1, s.d2].into_iter().flatten().collect();
@@ -1118,6 +1118,16 @@ pub(super) fn seed_ui_fixture(
warn!("capture: ui-macro seed failed: {e}");
}
}
UiFixture::Social => {
// Nothing to seed: the stray capsule (B264) rode the pane's *declaration*, not its
// contents — an empty friends list opens the same frames a full one does.
let Some(script) = script.as_mut() else {
return;
};
if let Err(e) = script.run("ToggleFriendsFrame(1)") {
warn!("capture: ui-social seed failed to open the pane: {e}");
}
}
UiFixture::ChatEdit => {
let Some(script) = script.as_mut() else {
return;
@@ -99,6 +99,12 @@ pub(super) enum UiFixture {
/// capture checks the header (left-flush, Say-white), the typed text past the live insets,
/// and the three-piece input border.
ChatEdit,
/// The social pane (`FriendsFrame`), opened through the live toggle — the instrument for B264
/// (decision 1298). Its `BenillaFriendsDropDown` host is declared with no anchors, exactly as
/// the reference's own `FriendsDropDown` is; while an unpositioned owner stood in a zero rect
/// for its regions, the dropdown template's texture chain drew a stray capsule at the screen
/// origin every time the pane opened. Needs no server state to show it.
Social,
/// The era-styled Options window (decisions 0950/0951), opened through the live panel path —
/// the look-pass instrument for the whole options arc: chrome nine-slice seams, tab plates,
/// search-box seat, category list art, the window scale. Static (no server state touched),
@@ -926,6 +932,16 @@ pub(super) const ON_DEMAND: &[Scenario] = &[
minute: 720,
ui: Some(UiFixture::ChatEdit),
},
// The social pane, for the stray-dropdown regression (B264, decision 1298). Run with
// `WOW_CAPTURE_UI=1 WOW_CAPTURE=ui-social`.
Scenario {
name: "ui-social",
map: MAP_AZEROTH,
eye: GROUND_EYE,
look: GROUND_LOOK,
minute: 720,
ui: Some(UiFixture::Social),
},
// The era Options window over the ground scene. Run with
// `WOW_CAPTURE_UI=1 WOW_CAPTURE=ui-options`.
Scenario {
File diff suppressed because it is too large Load Diff
+11 -1
View File
@@ -82,7 +82,17 @@ pub(super) fn select_input(
// The reference plays `gsCharacterSelectionOpen`-family click here; ours reuses
// the create-screen open sound rather than inventing a name the client lacks.
sounds.write(GlueSound("gsCharacterSelectionCreateNew"));
panel.open_for(crate::ui_macro::identity(&roster).as_ref());
// The whole roster rides along so the panel's "Configure Addons For:" dropdown
// can fan out over every character (decision 1293). The realm resolves exactly
// as `ui_macro::identity`'s does — same fallback, so the enable files the panel
// writes stay keyed the way the world-entry walk reads them (0997, 1191 §7).
let realm = roster
.realm
.as_ref()
.map(|r| r.name.clone())
.unwrap_or_else(|| "Realm".into());
let chars = roster.chars.iter().map(|c| c.name.clone()).collect();
panel.open_for(realm, chars);
}
SelectAction::Back => back_to_login = true,
_ => {}
+38
View File
@@ -122,6 +122,7 @@ impl Plugin for CharSelectPlugin {
dialog::drive_delete_dialog,
// Before the list refresh, and before `select_input` reads a click that
// landed on the panel rather than the screen (decision 1196).
debug_select_addons,
addons::drive_addons_panel,
refresh::refresh_list,
refresh::refresh_banner_and_buttons,
@@ -586,6 +587,43 @@ fn debug_select_dialog(
*done = true;
}
/// The shot instrument's AddOns dial (`WOW_CHARSELECT_ADDONS=1`): open the AddOns panel a few
/// seconds after the screen is up, exactly as the screen's own button would — so the reference
/// AddonList layout is exercised and capturable headlessly (pair with
/// `WOW_CHARSELECT_SHOT_OUT`). Inert without the env; fires once.
fn debug_select_addons(
roster: Res<Roster>,
mut panel: ResMut<addons::AddonsPanel>,
time: Res<Time>,
mut entered_at: Local<Option<f32>>,
mut done: Local<bool>,
) {
if *done {
return;
}
if std::env::var("WOW_CHARSELECT_ADDONS").is_err() {
*done = true;
return;
}
let start = *entered_at.get_or_insert(time.elapsed_secs());
if time.elapsed_secs() - start < 4.0 {
return;
}
if roster.chars.is_empty() {
return; // roster not in yet — keep waiting
}
// The same open the AddOns button performs (`input::select_input`): realm + full roster.
let realm = roster
.realm
.as_ref()
.map(|r| r.name.clone())
.unwrap_or_else(|| "Realm".into());
let chars = roster.chars.iter().map(|c| c.name.clone()).collect();
panel.open_for(realm, chars);
info!("char select: addons instrument opened the AddOn List");
*done = true;
}
/// Default delay before the select-screen shot fires (seconds from the screen coming up) — long
/// enough for the art, the glue scene and the geared model to settle.
const SELECT_SHOT_AT: f32 = 8.0;
+25
View File
@@ -124,6 +124,13 @@ pub(crate) const REGISTERED: &[(&str, &str)] = &[
// [`crate::minimap::MinimapZoom`], the widget's live index is seeded from it at UI load.
("minimapZoom", "3"),
("minimapInsideZoom", "3"),
// The addon version gate (decision 1292): 1.12's own `checkAddonVersion`, the *Load out of
// date AddOns* checkbox INVERTED. Registrar default "1" = check enforced = box unticked —
// byte-verified (wow-re `addon-version-gate.md` §1.1: the key appears in Config.wtf exactly
// while force-load is on and vanishes when it is turned off, `SaveConfig 0x63d980`'s
// skip-default rule). No host knob: its consumers are the load walk (via the persisted value,
// [`CvarPersist::addon_version_check`]) and the gate's live per-query read in the VM.
("checkAddonVersion", "1"),
];
/// `config.toml`'s shape: a `[cvars]` table of `Name = "value"` strings (CVars are strings in
@@ -154,6 +161,20 @@ pub(crate) struct CvarPersist {
last_change: Option<Instant>,
}
impl CvarPersist {
/// The persisted `checkAddonVersion` (decision 1292) — what the addon load walk gates on.
/// Read from the persist state rather than the VM because the walk runs while the VM's CVar
/// table does not exist yet (registration is a per-VM `Update` seed, 1291); the 1291 fold
/// keeps this current across reloads, so it is the value the reference's live read would see.
/// Absent = the registrar default: check ON.
pub(crate) fn addon_version_check(&self) -> bool {
self.file
.iter()
.find(|(k, _)| k.eq_ignore_ascii_case("checkAddonVersion"))
.is_none_or(|(_, v)| v != "0")
}
}
/// How long a dirty config sits before the save fires — long enough to coalesce a slider drag,
/// short enough that a crash loses one gesture, not a session ("write-on-change, debounced").
const SAVE_QUIET: std::time::Duration = std::time::Duration::from_secs(1);
@@ -229,6 +250,10 @@ fn apply_to_knobs(name: &str, value: &str, knobs: &mut Knobs) -> bool {
// in range whichever path it takes.
"minimapzoom" => knobs.minimap.outdoor = zoom_index(v),
"minimapinsidezoom" => knobs.minimap.inside = zoom_index(v),
// The addon version gate (1292): no host knob — the load walk reads the persisted value
// and the gate reads the live table — but a KNOWN key, so a toggle dirties the config
// and persists (the statusBarText posture).
"checkaddonversion" => {}
_ => return false,
}
true
+102
View File
@@ -136,6 +136,43 @@ pub(crate) struct GlueArt {
/// center) and the Save Account Name checkbox states.
pub(crate) blizzard_logo: Option<Handle<Image>>,
pub(crate) checkbox: Option<CheckboxArt>,
/// The AddOn List screen's set (the reference `GlueXML/AddonList.xml`, read off the patch
/// chain): the six `HelpFrame-*` plate pieces that ARE the whole framed panel (top band, dark
/// inset, bottom band baked in), the `UI-DialogBox-Header` title plate, the `GlueCloseButton`
/// states, the `GlueDropDownMenuTemplate` arrow (`UI-ChatIcon-ScrollDown-*`), the open list's
/// row highlight (`UI-QuestTitleHighlight`, ADD), the tri-state's grey check
/// (`UI-CheckBox-Check-Disabled`), the tooltip's own border (`UI-Tooltip-Border`, edge 16 —
/// the in-game edge file, not `Glue-Tooltip-Border`), and the decorative scrollbar track
/// (`UI-Character-ScrollBar`).
pub(crate) help_frame: Option<HelpFrameArt>,
pub(crate) dialog_header: Option<(Handle<Image>, Vec2)>,
pub(crate) close_btn: Option<CloseBtnArt>,
pub(crate) dropdown_arrow_up: Option<Handle<Image>>,
pub(crate) dropdown_arrow_down: Option<Handle<Image>>,
pub(crate) quest_hilight: Option<Handle<AddUiMaterial>>,
pub(crate) check_disabled: Option<Handle<Image>>,
pub(crate) tooltip_border: Option<BackdropEdges>,
pub(crate) char_scrollbar: Option<(Handle<Image>, Vec2)>,
}
/// The `Interface\HelpFrame\HelpFrame-*` plate: six pieces tiling a 640×512 framed panel
/// (TopLeft/Top 256², TopRight 128×256 across the top row; BotLeft/Bottom/BotRight below).
pub(crate) struct HelpFrameArt {
pub(crate) tl: Handle<Image>,
pub(crate) top: Handle<Image>,
pub(crate) tr: Handle<Image>,
pub(crate) bl: Handle<Image>,
pub(crate) bottom: Handle<Image>,
pub(crate) br: Handle<Image>,
/// Each piece's native size, for the divider strip's texcoord sub-rects.
pub(crate) sizes: [Vec2; 3],
}
/// `GlueCloseButton` (GlueTemplates.xml): `UI-Panel-MinimizeButton-Up/Down/Highlight(ADD)`.
pub(crate) struct CloseBtnArt {
pub(crate) up: Handle<Image>,
pub(crate) down: Handle<Image>,
pub(crate) hi: Option<Handle<AddUiMaterial>>,
}
impl GlueArt {
@@ -322,6 +359,71 @@ impl GlueArt {
),
})
})();
// The AddOn List screen's set (reference `GlueXML/AddonList.xml` off the patch chain).
const HF: &str = "Interface\\HelpFrame\\HelpFrame-";
self.help_frame = (|| {
let tl = sized(assets, &format!("{HF}TopLeft"), images)?;
let top = sized(assets, &format!("{HF}Top"), images)?;
let tr = sized(assets, &format!("{HF}TopRight"), images)?;
Some(HelpFrameArt {
sizes: [tl.1, top.1, tr.1],
tl: tl.0,
top: top.0,
tr: tr.0,
bl: assets.sprite_texture(&format!("{HF}BotLeft"), images)?,
bottom: assets.sprite_texture(&format!("{HF}Bottom"), images)?,
br: assets.sprite_texture(&format!("{HF}BotRight"), images)?,
})
})();
self.dialog_header = sized(
assets,
"Interface\\DialogFrame\\UI-DialogBox-Header",
images,
);
self.close_btn = (|| {
const MB: &str = "Interface\\Buttons\\UI-Panel-MinimizeButton-";
Some(CloseBtnArt {
up: assets.sprite_texture(&format!("{MB}Up"), images)?,
down: assets.sprite_texture(&format!("{MB}Down"), images)?,
hi: add_overlay(assets, &format!("{MB}Highlight"), FULL_TC, images, add_mats),
})
})();
self.dropdown_arrow_up =
assets.sprite_texture("Interface\\ChatFrame\\UI-ChatIcon-ScrollDown-Up", images);
self.dropdown_arrow_down =
assets.sprite_texture("Interface\\ChatFrame\\UI-ChatIcon-ScrollDown-Down", images);
self.quest_hilight = add_overlay(
assets,
"Interface\\QuestFrame\\UI-QuestTitleHighlight",
FULL_TC,
images,
add_mats,
);
self.check_disabled =
assets.sprite_texture("Interface\\Buttons\\UI-CheckBox-Check-Disabled", images);
self.tooltip_border = super::backdrop::backdrop_edges(
assets,
"Interface\\Tooltips\\UI-Tooltip-Border",
images,
);
self.char_scrollbar = sized(
assets,
"Interface\\PaperDollInfoFrame\\UI-Character-ScrollBar",
images,
);
debug!(
"glue art: addonlist set — helpframe {} header {} close {} droparrow {}/{} \
questhl {} greycheck {} tipborder {} scrolltrack {}",
self.help_frame.is_some(),
self.dialog_header.is_some(),
self.close_btn.is_some(),
self.dropdown_arrow_up.is_some(),
self.dropdown_arrow_down.is_some(),
self.quest_hilight.is_some(),
self.check_disabled.is_some(),
self.tooltip_border.is_some(),
self.char_scrollbar.is_some(),
);
debug!(
"glue art: races {} classes {} gender {} factions {} banners {} hilight {} logo {} \
arrows {}/{} button {}/{}/{}/{} tower {}/{} shadow {} label {} rotate {}/{} \
+3 -2
View File
@@ -126,13 +126,14 @@ pub(crate) fn glue_button_visuals(
&mut ImageNode,
&mut BackgroundColor,
Has<widgets::FallbackFace>,
Option<&widgets::BtnTexCoords>,
),
(With<GlueBtn>, Without<ArtSwap>),
>,
mut captions: Query<&mut TextColor, With<GlueCaption>>,
mut hilights: Query<&mut Visibility, With<widgets::Hilight>>,
) {
for (disabled, interaction, children, mut node, mut bg, fallback) in &mut glue_btns {
for (disabled, interaction, children, mut node, mut bg, fallback, tc) in &mut glue_btns {
let disabled = disabled.0;
let pressed = !disabled && *interaction == Interaction::Pressed;
let hovered = !disabled && *interaction != Interaction::None;
@@ -154,7 +155,7 @@ pub(crate) fn glue_button_visuals(
(up.clone(), Color::WHITE)
};
node.image = image;
node.rect = Some(tc_rect(*size, BUTTON_TC));
node.rect = Some(tc_rect(*size, tc.map(|t| t.0).unwrap_or(BUTTON_TC)));
node.color = color;
} else if fallback {
bg.0 = if hovered { BTN_HOVER } else { BTN_BG };
+74 -18
View File
@@ -34,6 +34,11 @@ pub(crate) struct GlueBtn;
/// [`super::glue_button_visuals`] renders it (disabled art, gray caption, no hover).
#[derive(Component, Default)]
pub(crate) struct GlueDisabled(pub(crate) bool);
/// A glue button's own authored `TexCoords` crop, where a template overrides the shared
/// `GlueButtons.xml` region (`AddonListButtonTemplate` crops `0.025–0.535`). Read by
/// [`super::glue_button_visuals`] in place of [`BUTTON_TC`].
#[derive(Component, Clone, Copy)]
pub(crate) struct BtnTexCoords(pub(crate) [f32; 4]);
/// A glue button's caption (gold at rest, white on hover — the ref's `HighlightFont`).
#[derive(Component)]
pub(crate) struct GlueCaption;
@@ -112,9 +117,39 @@ pub(crate) fn outlined_text<W: Bundle, T: Bundle>(
spec: GlueText,
font: &Handle<Font>,
s: f32,
) -> Entity {
outlined_spans(
parent,
node,
wrapper_extra,
text_extra,
&[(spec.text.to_string(), spec.color)],
spec.size,
spec.wrap,
font,
s,
)
}
/// [`outlined_text`] over **coloured spans** — WoW's `|cAARRGGBB…|r` inline markup rendered the
/// way the reference FontString renders it: one string, colour switching mid-run (the escape
/// grammar is `benilla_ui::markup`, byte-verified). The real text is a span tree (Bevy's
/// `Text` root + `TextSpan` children); the eight outline copies carry the flattened plain string,
/// which is identical geometry because the copies only exist to be a black ring.
#[allow(clippy::too_many_arguments)]
pub(crate) fn outlined_spans<W: Bundle, T: Bundle>(
parent: &mut ChildSpawnerCommands,
node: Node,
wrapper_extra: W,
text_extra: T,
spans: &[(String, Color)],
size: f32,
wrap: bool,
font: &Handle<Font>,
s: f32,
) -> Entity {
let layout = TextLayout {
linebreak: if spec.wrap {
linebreak: if wrap {
LineBreak::WordBoundary
} else {
LineBreak::NoWrap
@@ -123,9 +158,10 @@ pub(crate) fn outlined_text<W: Bundle, T: Bundle>(
};
let tf = TextFont {
font: font.clone(),
font_size: spec.size * s,
font_size: size * s,
..default()
};
let flat: String = spans.iter().map(|(t, _)| t.as_str()).collect();
let mut real = Entity::PLACEHOLDER;
parent
.spawn((node, wrapper_extra))
@@ -150,7 +186,7 @@ pub(crate) fn outlined_text<W: Bundle, T: Bundle>(
OutlineCopy {
dir: Vec2::new(dx, dy),
},
Text::new(spec.text),
Text::new(flat.clone()),
tf.clone(),
layout,
TextColor(Color::BLACK),
@@ -164,19 +200,24 @@ pub(crate) fn outlined_text<W: Bundle, T: Bundle>(
));
}
}
real = inner
.spawn((
Text::new(spec.text),
tf,
layout,
TextColor(spec.color),
TextShadow {
offset: Vec2::splat(s),
color: Color::BLACK,
},
text_extra,
))
.id();
let (first, rest) = spans.split_first().expect("outlined_spans: empty spans");
let mut e = inner.spawn((
Text::new(first.0.clone()),
tf.clone(),
layout,
TextColor(first.1),
TextShadow {
offset: Vec2::splat(s),
color: Color::BLACK,
},
text_extra,
));
e.with_children(|spans| {
for (text, color) in rest {
spans.spawn((TextSpan::new(text.clone()), tf.clone(), TextColor(*color)));
}
});
real = e.id();
});
});
real
@@ -351,6 +392,9 @@ pub(crate) enum GlueBtnKind {
Small,
/// `GlueDialogButtonTemplate` (200×40) — GlueFontNormal, ButtonText CENTER (0, 2).
Dialog,
/// `AddonListButtonTemplate` (160×35) — GlueFontNormal, ButtonText CENTER (0, 2), and its own
/// narrower art crop (TexCoords 0.025–0.535 of the `Glue-Panel-Button` sheets).
List,
}
impl GlueBtnKind {
@@ -359,7 +403,15 @@ impl GlueBtnKind {
match self {
Self::Normal => (15.0, Vec2::new(-3.0, 3.0)),
Self::Small => (12.0, Vec2::new(0.0, 3.0)),
Self::Dialog => (15.0, Vec2::new(0.0, 2.0)),
Self::Dialog | Self::List => (15.0, Vec2::new(0.0, 2.0)),
}
}
/// The template's art crop where it overrides the shared `GlueButtons.xml` region.
fn tex_coords(self) -> Option<BtnTexCoords> {
match self {
Self::List => Some(BtnTexCoords([0.025, 0.535, 0.0, 0.75])),
_ => None,
}
}
}
@@ -622,11 +674,15 @@ pub(crate) fn glue_button<A: Component>(
..default()
},
));
let tc = kind.tex_coords();
if let Some(tc) = tc {
b.insert(tc);
}
match &art.button_up {
Some((up, size)) => {
b.insert(ImageNode {
image: up.clone(),
rect: Some(tc_rect(*size, BUTTON_TC)),
rect: Some(tc_rect(*size, tc.map(|t| t.0).unwrap_or(BUTTON_TC))),
..default()
});
}
@@ -1,480 +0,0 @@
//! The shipped `assets/ui/AddonList.xml` — the in-game AddOns list (decision 1197).
//!
//! **The window is the test of the API as much as the API is the test of the window.** This panel
//! is written entirely against the eleven AddOn verbs 1191 landed — `GetNumAddOns`,
//! `GetAddOnInfo`, `GetAddOnDependencies`, `EnableAddOn`, `DisableAddOn`, `EnableAllAddOns`,
//! `DisableAllAddOns` — with no host support of its own. So driving it drives them, through the
//! same Lua an addon author would write, which is worth more than a Rust test of each verb.
//!
//! What these guard: the row painter's three-colour rule and its status column against a registry
//! with a real dependency graph; the checkbox round-trip through `EnableAddOn`/`DisableAddOn`;
//! Cancel restoring what the panel found (`ResetAddOns` written out, since that verb is
//! glue-namespace and absent in-world); the scroll clamp; and the Enable All / Disable All pair.
use benilla_ui::script::{AddOnInfo, UiScript};
/// Fonts + the panel, and a registry with a shape worth painting: one plain addon, a library, a
/// dependent on that library, and one whose dependency is not installed.
fn harness() -> UiScript {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
for file in [
"Fonts.xml",
"UiPanels.xml",
"GameTooltip.xml",
"AddonList.xml",
] {
let text = std::fs::read_to_string(
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("assets/ui")
.join(file),
)
.unwrap();
let doc = benilla_ui::framexml::parse(&text).unwrap();
let report = benilla_ui::loader::load(&s, &doc, &|_| None);
assert!(
report.errors.is_empty(),
"{file}: loader errors: {:?}",
report.errors
);
}
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
let info = |name: &str, title: &str, deps: &[&str]| AddOnInfo {
name: name.into(),
title: Some(title.into()),
notes: Some(format!("{name}'s notes")),
url: None,
secure: false,
load_on_demand: false,
dependencies: deps.iter().map(|d| (*d).to_string()).collect(),
directives: Default::default(),
files: Vec::new(),
saved_variables: Vec::new(),
saved_variables_per_character: Vec::new(),
enabled: true,
loaded: false,
};
s.register_addons(
vec![
info("Plain", "Plain Addon", &[]),
info("Lib", "The Library", &[]),
info("Needs", "Needs The Library", &["Lib"]),
info("Orphan", "Orphaned", &["Nowhere"]),
],
None,
None,
None,
);
s
}
/// The text of row `i`'s title and status, as the panel painted them.
fn row(s: &UiScript, i: usize) -> (String, String) {
let title = s
.eval::<String>(&format!("return AddonListEntry{i}Title:GetText() or ''"))
.unwrap();
let status = s
.eval::<String>(&format!("return AddonListEntry{i}Status:GetText() or ''"))
.unwrap();
(title, status)
}
/// The panel paints the registry: title (`## Title` else the folder name), the reference's status
/// token, and a checkbox per row — all through `GetAddOnInfo`.
#[test]
fn the_panel_paints_the_registry_through_the_addon_api() {
let s = harness();
s.run("AddonList_Show()").unwrap();
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
assert!(s.eval::<bool>("return AddonListFrame:IsVisible()").unwrap());
assert_eq!(
row(&s, 1).0,
"Plain Addon",
"`## Title` wins over the folder"
);
assert_eq!(row(&s, 3).0, "Needs The Library");
// Rows past the registry are hidden, not left showing a stale addon.
assert!(
!s.eval::<bool>("return AddonListEntry5:IsVisible()")
.unwrap(),
"row 5 has no addon behind it"
);
// Four addons is well under MAX_ADDONS_DISPLAYED, so the scroll pair stays hidden — the
// reference's own GlueScrollFrame_Update condition.
assert!(!s
.eval::<bool>("return AddonListScrollUp:IsVisible()")
.unwrap());
// A dependency that is not installed is a status, not a checkbox state.
assert_eq!(
row(&s, 4).1,
"DEP_MISSING",
"the reference's own reason token, shown when GlobalStrings has no ADDON_ string for it"
);
}
/// The checkbox round-trips through `EnableAddOn`/`DisableAddOn`, and the effect on a *dependent*
/// shows up in its status column — which is the whole reason the panel re-reads rather than
/// caching.
#[test]
fn a_checkbox_click_disables_through_the_api_and_the_dependent_says_so() {
let s = harness();
s.run("AddonList_Show()").unwrap();
// Row 2 is the library. Click its checkbox the way the XML does.
s.run("AddonList_ToggleEntry(2)").unwrap();
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
assert_eq!(row(&s, 2).1, "DISABLED", "the player turned it off");
assert_eq!(
row(&s, 3).1,
"DEP_DISABLED",
"its dependent reports the reference's dependency token, not a stale clean row"
);
assert!(
!s.eval::<bool>("return AddonListEntry2Check:GetChecked()")
.unwrap(),
"and the checkbox follows the registry rather than its own memory"
);
// Clicking again re-enables, and the dependent recovers.
s.run("AddonList_ToggleEntry(2)").unwrap();
assert_eq!(row(&s, 2).1, "");
assert_eq!(row(&s, 3).1, "");
}
/// **Cancel restores what the panel found** — the reference's `ResetAddOns`, written out in Lua
/// because that verb is glue-namespace and deliberately absent in-world (1191 §6).
#[test]
fn cancel_restores_the_state_the_panel_opened_with() {
let s = harness();
s.run("AddonList_Show()").unwrap();
s.run("AddonList_ToggleEntry(1) AddonList_ToggleEntry(2)")
.unwrap();
assert_eq!(row(&s, 1).1, "DISABLED");
s.run("AddonList_OnCancel()").unwrap();
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
assert!(!s.eval::<bool>("return AddonListFrame:IsVisible()").unwrap());
// Re-open: nothing the cancelled session did survived.
s.run("AddonList_Show()").unwrap();
assert_eq!(row(&s, 1).1, "");
assert_eq!(row(&s, 2).1, "");
}
/// Okay keeps the edit — the other half of the pair, and the one that would silently pass if
/// `AddonList_OnOk` accidentally called the restore loop.
#[test]
fn okay_keeps_the_edit() {
let s = harness();
s.run("AddonList_Show()").unwrap();
s.run("AddonList_ToggleEntry(1)").unwrap();
s.run("AddonList_OnOk()").unwrap();
assert!(!s.eval::<bool>("return AddonListFrame:IsVisible()").unwrap());
s.run("AddonList_Show()").unwrap();
assert_eq!(row(&s, 1).1, "DISABLED", "Okay is not a disguised Cancel");
}
/// Enable All / Disable All go through the API's own pair, and the panel repaints.
#[test]
fn enable_all_and_disable_all_move_every_row() {
let s = harness();
s.run("AddonList_Show()").unwrap();
s.run("AddonList_SetAll(nil)").unwrap();
for i in 1..=4 {
assert_eq!(row(&s, i).1, "DISABLED", "row {i} after Disable All");
}
s.run("AddonList_SetAll(1)").unwrap();
assert_eq!(row(&s, 1).1, "");
assert_eq!(
row(&s, 4).1,
"DEP_MISSING",
"Enable All cannot fix a dependency that is not installed — the status is not a checkbox"
);
}
/// The scroll offset clamps at both ends, and a list that fits never scrolls.
#[test]
fn the_scroll_offset_clamps() {
let s = harness();
s.run("AddonList_Show()").unwrap();
s.run("AddonList_Scroll(5)").unwrap();
assert_eq!(
s.eval::<i64>("return AddonList.offset").unwrap(),
0,
"four addons fit in nineteen rows — there is nothing to scroll"
);
s.run("AddonList_Scroll(-5)").unwrap();
assert_eq!(s.eval::<i64>("return AddonList.offset").unwrap(), 0);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// **The ESC-menu path, end to end** — the director's ask stated as a test: open the game menu,
/// click AddOns, and the list is up with the menu gone.
///
/// Loaded in the shipped manifest's own order (`AddonList.xml` before `GameMenuFrame.xml`), which
/// is what makes the button's `AddonList_Toggle()` resolve at click time. A test that loaded them
/// the other way round would pass on the button's `IsEnabled()` and fail on the click, which is
/// exactly the seam worth covering.
#[test]
fn the_esc_menu_addons_entry_opens_the_list() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
for file in [
"Fonts.xml",
"UiPanels.xml",
"GameTooltip.xml",
"AddonList.xml",
"GameMenuFrame.xml",
] {
let text = std::fs::read_to_string(
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("assets/ui")
.join(file),
)
.unwrap();
let doc = benilla_ui::framexml::parse(&text).unwrap();
let report = benilla_ui::loader::load(&s, &doc, &|_| None);
assert!(report.errors.is_empty(), "{file}: {:?}", report.errors);
}
s.run("ShowUIPanel(GameMenuFrame)").unwrap();
assert!(
s.eval::<bool>("return GameMenuButtonAddOns:IsEnabled()")
.unwrap(),
"the entry is live — it stopped being GameMenuButton_Pending in 1197"
);
s.run("GameMenuButtonAddOns:Click()").unwrap();
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
assert!(
s.eval::<bool>("return AddonListFrame:IsVisible()").unwrap(),
"the list came up"
);
assert!(
!s.eval::<bool>("return GameMenuFrame:IsVisible()").unwrap(),
"and the menu went away — the Macros entry's own body shape"
);
}
/// **ESC cancels the list**, the reference's own `AddonList_OnKeyDown` arm — and it discards.
///
/// This engine gives plain frames no raw key events, so the window joins `ToggleGameMenu`'s
/// shared ESC chain like the stack-split spinner and the world map. The rung sits above the
/// options window and below the popup engine, so a confirm dialog still wins the key.
#[test]
fn escape_cancels_the_list_and_discards_the_edit() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
for file in [
"Fonts.xml",
"UiPanels.xml",
"GameTooltip.xml",
"AddonList.xml",
] {
let text = std::fs::read_to_string(
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("assets/ui")
.join(file),
)
.unwrap();
let doc = benilla_ui::framexml::parse(&text).unwrap();
let report = benilla_ui::loader::load(&s, &doc, &|_| None);
assert!(report.errors.is_empty(), "{file}: {:?}", report.errors);
}
s.register_addons(
vec![benilla_ui::script::AddOnInfo {
name: "Solo".into(),
title: Some("Solo".into()),
notes: None,
url: None,
secure: false,
load_on_demand: false,
dependencies: Vec::new(),
directives: Default::default(),
files: Vec::new(),
saved_variables: Vec::new(),
saved_variables_per_character: Vec::new(),
enabled: true,
loaded: false,
}],
None,
None,
None,
);
s.run("AddonList_Show()").unwrap();
s.run("AddonList_ToggleEntry(1)").unwrap();
assert_eq!(
s.eval::<String>("return AddonListEntry1Status:GetText() or ''")
.unwrap(),
"DISABLED"
);
s.run("ToggleGameMenu()").unwrap();
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
assert!(
!s.eval::<bool>("return AddonListFrame:IsVisible()").unwrap(),
"ESC put the list away"
);
s.run("AddonList_Show()").unwrap();
assert_eq!(
s.eval::<String>("return AddonListEntry1Status:GetText() or ''")
.unwrap(),
"",
"and discarded the edit — ESC is Cancel, not Okay"
);
}
/// The hover tooltip runs — `AddonTooltip_Update`'s three lines (title, notes, dependencies) on
/// the shared `GameTooltip` rather than a private frame.
///
/// Worth its own test because it is the one path the other seven never touch: a load error would
/// have shown up in the harness, but a *runtime* error in a hover handler goes to
/// `UiScript::errors` and nothing else, which is exactly the silent kind.
#[test]
fn hovering_a_row_raises_the_tooltip_with_notes_and_dependencies() {
let s = harness();
s.run("AddonList_Show()").unwrap();
// Row 3 is `Needs`, which depends on `Lib` — so it exercises both optional lines.
s.run("AddonList_RowEnter(AddonListEntry3)").unwrap();
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
assert!(s
.eval::<bool>("return GameTooltip:IsOwned(AddonListEntry3)")
.unwrap());
s.run("AddonList_RowLeave()").unwrap();
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
// And a row with no dependencies takes the other branch without erroring.
s.run("AddonList_RowEnter(AddonListEntry1)").unwrap();
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// **The ESC-menu list reports an out-of-date `## Interface`, exactly like its char-select twin.**
///
/// The two screens are the same job over two views of one folder (1197 §1, §3), and they were
/// answering *differently* about the same addon: the glue panel read `## Interface` off the manifest
/// and showed "Out of date", while this one asked `GetAddOnInfo` — whose `reason` has no interface
/// arm, and correctly so, because 1191 §6 reports the mismatch and never enforces it. Not being a
/// reason the addon cannot load, it was simply never shown here. Nothing errored; the column was
/// just blank, which is the silent-gap shape this arc keeps finding.
///
/// The three arms are the whole rule: mismatched reports, matching does not, and **absent is silent
/// rather than out-of-date** — the same case `a manifest with no ## Interface is silent` pins on the
/// glue side.
/// Asserted on the STATUS COLUMN the player reads, not on the helper — the helper being right
/// while the column stayed blank is precisely the bug this replaces.
#[test]
fn the_list_reports_an_out_of_date_interface_like_the_char_select_twin() {
let mut s = harness();
// Three real manifests, differing only in `## Interface` — the directive `GetAddOnMetadata`
// reads, so this drives the production path rather than stubbing it.
let with_interface = |name: &str, declared: Option<&str>| AddOnInfo {
name: name.into(),
title: Some(name.into()),
directives: declared
.map(|v| vec![("Interface".to_string(), v.to_string())])
.unwrap_or_default(),
enabled: true,
..Default::default()
};
s.register_addons(
vec![
with_interface("Old", Some("11100")),
with_interface("Current", Some("11200")),
with_interface("Silent", None),
],
None,
None,
None,
);
s.run("AddonList_Show()").unwrap();
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
assert_eq!(
row(&s, 1).1,
"Out of date",
"an older `## Interface` must reach the status column — it was blank here while the \
char-select twin said so about the same addon"
);
assert_eq!(row(&s, 2).1, "", "our own interface is current");
assert_eq!(
row(&s, 3).1,
"",
"no `## Interface` at all is silent, not out of date"
);
}
/// `UIParentLoadAddOn(name)` — the on-demand loader 5 corpus addons name (FuBar_MCPFu,
/// Fubar_RareTrackerFu, MCP, MikScrollingBattleText, SpecialTalent), and which raised as a nil
/// global until every piece it needs was joined up in `BasicControls.xml`.
///
/// Two things are asserted, and the second is the one a reconstruction from the name would miss:
/// it answers `LoadAddOn`'s own falsey result so a caller can branch, and `FailedAddOnLoad` shows
/// the failure **once per addon name**, not once per attempt (ref `UIParent.lua` l.641-643).
#[test]
fn ui_parent_load_addon_reports_once_and_returns_the_load_result() {
let s = UiScript::new().unwrap();
let text = std::fs::read_to_string(
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("assets/ui/BasicControls.xml"),
)
.unwrap();
let doc = benilla_ui::framexml::parse(&text).unwrap();
let report = benilla_ui::loader::load(&s, &doc, &|_| None);
assert!(
report.errors.is_empty(),
"loader errors: {:?}",
report.errors
);
// The two GlobalStrings this leans on. The real client has them from FrameXML's first file and
// the corpus survey runs the real `GlobalStrings.lua`; a bare VM does not, so they are seated
// here rather than left to make `format` raise on a nil.
s.run(
r#"
ADDON_LOAD_FAILED = "Couldn't load %s: %s"
ADDON_MISSING = "Missing"
shown = {}
message = function(t) table.insert(shown, t) end
"#,
)
.unwrap();
// A name no registry has: LoadAddOn answers (nil, "MISSING"), so this answers falsey.
assert!(
s.eval::<bool>(r#"return UIParentLoadAddOn("NoSuchAddonAnywhere") == nil"#)
.unwrap(),
"must hand back LoadAddOn's own falsey result for a caller to branch on"
);
assert_eq!(
s.eval::<String>("return shown[1]").unwrap(),
"Couldn't load NoSuchAddonAnywhere: Missing",
"the reason word resolves through ADDON_..reason"
);
// The latch: a retrying addon gets one modal, not one per attempt.
for _ in 0..3 {
s.run(r#"UIParentLoadAddOn("NoSuchAddonAnywhere")"#)
.unwrap();
}
assert_eq!(
s.eval::<i64>("return table.getn(shown)").unwrap(),
1,
"FailedAddOnLoad must suppress every repeat for the same name"
);
// A DIFFERENT name is a different latch entry, so it still reports.
s.run(r#"UIParentLoadAddOn("AlsoNotThere")"#).unwrap();
assert_eq!(s.eval::<i64>("return table.getn(shown)").unwrap(), 2);
}
+126 -26
View File
@@ -382,7 +382,11 @@ pub(crate) fn info_from_toc(name: &str, toc: &Toc) -> benilla_ui::script::AddOnI
.map(str::to_owned)
.collect(),
enabled: true, // an addon nobody has disabled is enabled; the file below overrides
saved_enabled: true, // re-stamped from `enabled` at registration — see `register_addons`
loaded: false,
// The version gate's dword (decision 1292) — the client's own parse: leading integer,
// 0 when the manifest is silent (and 0 is out of date, not "unknown").
interface: toc.interface_version(),
}
}
@@ -442,11 +446,14 @@ pub(crate) struct InstalledAddOn {
pub(crate) notes: Option<String>,
pub(crate) url: Option<String>,
pub(crate) dependencies: Vec<String>,
/// `## Interface`, as written — the out-of-date check, reported and not enforced (1191 §6).
pub(crate) interface: Vec<u32>,
/// `## Interface` as the version gate reads it (decision 1292): the leading integer, `0`
/// when absent — and now ENFORCED by the load walk when `checkAddonVersion` is on,
/// superseding 1191 §6's report-only interim (the RE answer it was waiting for landed:
/// wow-re `addon-version-gate.md`).
pub(crate) interface: u32,
/// `## LoadOnDemand: 1` — shown as a status hint rather than a checkbox state, because a
/// LoadOnDemand addon is not "off", it is waiting for a `LoadAddOn` call (1191 §6).
#[allow(dead_code)] // read by the in-game AddOns panel's status column (decision 1197)
/// Read by the char-select AddOns screen's rows (`char_select::addons`).
pub(crate) load_on_demand: bool,
/// From this character's enable file; an addon the file never mentions is **enabled** (1191 §7).
pub(crate) enabled: bool,
@@ -455,21 +462,15 @@ pub(crate) struct InstalledAddOn {
impl InstalledAddOn {
/// The list's display name — `## Title` when it has one, else the folder name. The reference's
/// own `if (title) … else name` (`AddonList_Update`).
///
/// (An `out_of_date()` convenience lived here too until 1293 moved the glue screen onto the
/// gate — the version compare has exactly one home now, `addon_gate::can_load`'s check 6,
/// and a screen-side copy is the per-asker drift 1292 §Rejected names.)
pub(crate) fn display_title(&self) -> &str {
self.title.as_deref().unwrap_or(&self.name)
}
/// Is this addon's `## Interface` the one we implement? Reported, never enforced — 1191 §6
/// chose that deliberately, and the corpus says enforcing would silently drop one addon in
/// eight. A manifest declaring no `## Interface` at all is not out of date; it is silent.
pub(crate) fn out_of_date(&self) -> bool {
!self.interface.is_empty() && !self.interface.contains(&INTERFACE_VERSION)
}
}
/// The `## Interface` benilla implements — 1.12.1.
const INTERFACE_VERSION: u32 = 11200;
/// Every installed addon, in load order, with `character`'s enable state applied.
///
/// The AddOns screens' whole data source. `identity` is `(realm, character)`; `None` reads the
@@ -490,7 +491,7 @@ pub(crate) fn installed(identity: Option<&(String, String)>) -> Vec<InstalledAdd
.into_iter()
.map(str::to_owned)
.collect(),
interface: addon.toc.interface_versions(),
interface: addon.toc.interface_version(),
load_on_demand: addon.toc.load_on_demand(),
name: addon.name,
})
@@ -614,6 +615,7 @@ const SAVED_HEADER: &str = "\
pub(super) fn load_third_party(
script: &mut UiScript,
identity: Option<&(String, String)>,
version_check: bool,
) -> Vec<String> {
let addons = discover();
// Register the AddOn API's view even when the list is empty: `GetNumAddOns()` must answer 0
@@ -655,6 +657,7 @@ pub(super) fn load_third_party(
);
let mut state = Walk {
disabled: disabled_names,
version_check,
..Walk::default()
};
for addon in &addons {
@@ -688,6 +691,12 @@ struct Walk {
/// happened first, and an empty set means "everything enabled" — which is exactly what an
/// absent enable-state file means too.
disabled: HashSet<String>,
/// The `checkAddonVersion` gate (decision 1292), resolved by the caller from the persisted
/// CVar — passed in for the same explicitness reason as `disabled`. When on, an addon whose
/// `## Interface` is not exactly the client's is skipped like a disabled one (the
/// reference's `AddOn_CanLoad` check 6, before the dependency loop), and a dependent gets
/// `Err` — its `DEP_INTERFACE_VERSION`.
version_check: bool,
}
impl Walk {
@@ -729,6 +738,24 @@ impl Walk {
self.failed.insert(addon.name.clone());
return Err(());
}
// **The version gate** (decision 1292; `AddOn_CanLoad` check 6, in the checks' own order —
// after the enable state, before the dependency loop). Exact `==` against the client's
// build; a missing `## Interface` parses as 0 and is out of date. Not a `failures` entry
// for the same reason disabled is not: the state is the player's to see on the AddOns
// screens (`ADDON_INTERFACE_VERSION`), and the *Load out of date AddOns* checkbox — the
// `checkAddonVersion` CVar this flag carries — is the reference's own escape.
if self.version_check
&& addon.toc.interface_version() != benilla_ui::script::addon_gate::CLIENT_INTERFACE
{
info!(
"ui_script: {key} is out of date (## Interface: {}, client {}) — not loaded \
(the AddOns screen's 'Load out of date AddOns' loads it anyway)",
addon.toc.interface_version(),
benilla_ui::script::addon_gate::CLIENT_INTERFACE
);
self.failed.insert(addon.name.clone());
return Err(());
}
if self.loading.iter().any(|n| n == key) {
let chain = self.loading.join(" → ");
let e = format!("{key}: dependency cycle ({chain} → {key})");
@@ -1310,7 +1337,7 @@ mod tests {
let mut script = UiScript::new().unwrap();
script.set_screen_size(1024.0, 768.0);
script.run("EventLog = {}").unwrap();
let failures = load_third_party(&mut script, None);
let failures = load_third_party(&mut script, None, true);
assert!(failures.is_empty(), "load errors: {failures:?}");
crate::ui_script::finish_ui_load(&mut script);
@@ -1346,6 +1373,79 @@ mod tests {
(home, guard)
}
/// **The version gate holds the startup walk, and force-load opens it** (decision 1292):
/// the byte-verified `AddOn_CanLoad` check 6 — exact `==`, missing `## Interface` = 0 = out
/// of date, a dependent of a gated addon blocked like a dependent of a disabled one — and
/// the `checkAddonVersion` flag (the *Load out of date AddOns* checkbox inverted) loading
/// the very same folder in full.
#[test]
fn the_version_gate_holds_the_walk_and_force_load_opens_it() {
let _l = crate::local_state::test_env::ENV_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let (home, _guard) = hermetic_root("version-gate");
write_addon(
&home,
"Fresh",
"## Interface: 11200\nmain.lua\n",
&[("main.lua", "FreshRan = true\n")],
);
write_addon(
&home,
"Old",
"## Interface: 11100\nmain.lua\n",
&[("main.lua", "OldRan = true\n")],
);
write_addon(
&home,
"Silent",
"main.lua\n", // no ## Interface at all — parses as 0, out of date (not "unknown")
&[("main.lua", "SilentRan = true\n")],
);
write_addon(
&home,
"NeedsOld",
"## Interface: 11200\n## Dependencies: Old\nmain.lua\n",
&[("main.lua", "NeedsOldRan = true\n")],
);
let mut script = UiScript::new().unwrap();
let failures = load_third_party(&mut script, None, true);
assert!(
failures.iter().any(|f| f.contains("NeedsOld")),
"the gated dependency is the dependent's failure: {failures:?}"
);
assert_eq!(
script.eval::<bool>("return FreshRan == true").ok(),
Some(true)
);
assert_eq!(
script
.eval::<bool>("return OldRan == nil and SilentRan == nil")
.ok(),
Some(true),
"out-of-date and interface-less addons are held by the gate"
);
assert_eq!(
script.eval::<bool>("return NeedsOldRan == nil").ok(),
Some(true),
"…and so is their dependent (DEP_INTERFACE_VERSION territory)"
);
// Force-load: the same folder, the checkbox's other state — everything loads.
let mut open = UiScript::new().unwrap();
let failures = load_third_party(&mut open, None, false);
assert!(failures.is_empty(), "force-load load errors: {failures:?}");
assert_eq!(
open.eval::<bool>(
"return OldRan == true and SilentRan == true and NeedsOldRan == true"
)
.ok(),
Some(true),
"'Load out of date AddOns' loads the very same folder in full"
);
}
fn write_addon(home: &Path, name: &str, toc: &str, files: &[(&str, &str)]) {
let dir = home.join("AddOns").join(name);
std::fs::create_dir_all(&dir).unwrap();
@@ -1408,7 +1508,7 @@ mod tests {
);
let mut script = UiScript::new().unwrap();
let failures = load_third_party(&mut script, None);
let failures = load_third_party(&mut script, None, true);
assert!(failures.is_empty(), "load errors: {failures:?}");
// Discovery saw it at all — the `.toc` decoded rather than read as absent.
@@ -1508,7 +1608,7 @@ mod tests {
&[],
);
let mut script = UiScript::new().unwrap();
let _ = load_third_party(&mut script, None);
let _ = load_third_party(&mut script, None, true);
assert_eq!(script.eval::<i64>("return GetNumAddOns()").ok(), Some(1));
assert_eq!(
@@ -1605,7 +1705,7 @@ mod tests {
let mut script = UiScript::new().unwrap();
let id = ("Realm".to_string(), "Char".to_string());
let failures = load_third_party(&mut script, Some(&id));
let failures = load_third_party(&mut script, Some(&id), true);
assert!(
failures.is_empty(),
@@ -1663,7 +1763,7 @@ mod tests {
);
let mut script = UiScript::new().unwrap();
script.set_screen_size(1024.0, 768.0);
let _ = load_third_party(&mut script, None);
let _ = load_third_party(&mut script, None, true);
// Discovered and described, but NOT run.
assert_eq!(script.eval::<i64>("return GetNumAddOns()").ok(), Some(1));
@@ -1745,7 +1845,7 @@ mod tests {
let mut script = UiScript::new().unwrap();
let id = ("Realm".to_string(), "Char".to_string());
let _ = load_third_party(&mut script, Some(&id));
let _ = load_third_party(&mut script, Some(&id), true);
for (call, want) in [
("LoadAddOn('NoSuchAddon')", "MISSING"),
@@ -1786,7 +1886,7 @@ mod tests {
let id = ("Realm".to_string(), "Char".to_string());
let mut script = UiScript::new().unwrap();
let _ = load_third_party(&mut script, Some(&id));
let _ = load_third_party(&mut script, Some(&id), true);
script.run("DisableAddOn('Drop')").unwrap();
save_enable_state(&script, Some(&id));
@@ -1798,7 +1898,7 @@ mod tests {
// A fresh session reads it back and the addon stays off.
let mut next = UiScript::new().unwrap();
let _ = load_third_party(&mut next, Some(&id));
let _ = load_third_party(&mut next, Some(&id), true);
assert_eq!(
next.eval::<bool>("return DropRan == nil").ok(),
Some(true),
@@ -1849,7 +1949,7 @@ mod tests {
// ── session one: defaults, then the addon changes them ──
let mut script = UiScript::new().unwrap();
script.set_screen_size(1024.0, 768.0);
let failures = load_third_party(&mut script, Some(&id));
let failures = load_third_party(&mut script, Some(&id), true);
assert!(failures.is_empty(), "{failures:?}");
assert_eq!(
script.eval::<i64>("return KeeperSawAtEvent").ok(),
@@ -1864,7 +1964,7 @@ mod tests {
// ── session two: a fresh VM reads them back ──
let mut next = UiScript::new().unwrap();
next.set_screen_size(1024.0, 768.0);
let failures = load_third_party(&mut next, Some(&id));
let failures = load_third_party(&mut next, Some(&id), true);
assert!(failures.is_empty(), "{failures:?}");
assert_eq!(
next.eval::<i64>("return KeeperDB.count").ok(),
@@ -1912,7 +2012,7 @@ mod tests {
);
let id = ("Realm".to_string(), "Char".to_string());
let mut script = UiScript::new().unwrap();
let _ = load_third_party(&mut script, Some(&id));
let _ = load_third_party(&mut script, Some(&id), true);
script
.run("GramDB = { ['on'] = true, ['n'] = 2, ['s'] = 'a\\\"b', ['t'] = { 1 } } GramChar = 5")
.unwrap();
@@ -1974,7 +2074,7 @@ mod tests {
let id = ("Realm".to_string(), "Char".to_string());
let mut script = UiScript::new().unwrap();
script.set_screen_size(1024.0, 768.0);
let _ = load_third_party(&mut script, Some(&id));
let _ = load_third_party(&mut script, Some(&id), true);
crate::ui_script::shutdown_ui_state(&mut script, Some(&id));
let written = std::fs::read_to_string(home.join("saved/Last.lua")).unwrap();
@@ -83,6 +83,9 @@ pub(super) fn drive_script(
// VM has no cached measures to invalidate and no measurer at all, and the re-seat below already
// catches that on its own test (`!script.has_text_measurer()`).
mut last_seam: Local<f32>,
// The atlas bake this pass last seated a measurer from (decision 1296) — the other half of the
// same staleness edge. `None` until an atlas exists, which is also a real edge to catch.
mut last_bake: Local<Option<u64>>,
// The uiScale dial folded into the seam scale (decision 0584).
ui_scale: Res<super::UiScaleCvar>,
// ── The extract gate's memory (decision 0740): last frame's conversion inputs ─────────────
@@ -124,12 +127,19 @@ pub(super) fn drive_script(
// percent — enough that a boot-size measure fails the fullscreen fit test and the ellipsis
// eats fitting text: the director's "Contr..." rows, reproduced by `WOW_RESIZE`). Declare the
// staleness at the seam and let every round-trip re-answer under the new `s`.
let seam_moved = *last_seam != s;
//
// A **re-bake** is the same staleness by the other route: the atlas now follows the seam
// (decision 1296), and a fresh bake replaces the whole step table — so a cached metric answered
// off the old table is stale even when `s` itself did not move (a pure DPI hop, a monitor
// change). Watch the generation beside the seam and treat either edge identically.
let generation = font_atlas.as_deref().map(|a| a.generation);
let seam_moved = *last_seam != s || *last_bake != generation;
if seam_moved {
if *last_seam != 0.0 {
script.invalidate_text_measures();
}
*last_seam = s;
*last_bake = generation;
}
// …and re-seat the VM's own font engine at the same edge, for the same reason: an
// [`crate::ui_text::AtlasMeasurer`] answers only for the seam it was built under. This is what
@@ -1,8 +1,9 @@
//! The shipped `assets/ui/GameMenuFrame.xml` — the frame ESC opens (decision 0674).
//!
//! What these guard, in order: the ladder geometry (the ERA menu shape — 200×288, eight rungs in
//! three 20-gapped sections, the era layout engine's own numbers precomputed in the XML's header
//! note); the four greyed pending entries; the live buttons' wire intents and sounds;
//! What these guard, in order: the ladder geometry (the ERA menu shape minus its AddOns rung —
//! 200×267, seven rungs in three 20-gapped sections, the era layout engine's own numbers
//! precomputed in the XML's header note); the greyed pending entries; the live buttons' wire
//! intents and sounds;
//! the ESC ladder's two new rungs (open when nothing is left to eat, close before everything
//! else); the micro button's `clicked` toggle; the native-center rule that makes the menu take
//! the screen (windows close on the way in, and nothing opens while it is up); and the camp/quit
@@ -50,13 +51,14 @@ fn harness() -> UiScript {
harness_with(&[])
}
/// The eight buttons, top to bottom — the ERA ladder, its own shape again (the director's call
/// on the 0951 review; GameMenuFrame.xml's header SCOPE note quotes the era source). 0997's
/// carved Key Bindings seat left with its standalone window — decision 1008 folded key
/// bindings into the Options window, where the era menu always pointed.
const LADDER: [&str; 8] = [
/// The seven buttons, top to bottom — the ERA ladder, its own shape (the director's call on the
/// 0951 review; GameMenuFrame.xml's header SCOPE note quotes the era source), minus two seats:
/// 0997's carved Key Bindings seat left with its standalone window (decision 1008 folded key
/// bindings into the Options window, where the era menu always pointed), and the AddOns rung
/// left with the in-game panel when the director made the char-select AddOns screen the only
/// addon UI (it had been live since 1197).
const LADDER: [&str; 7] = [
"GameMenuButtonOptions",
"GameMenuButtonAddOns",
"GameMenuButtonEditMode",
"GameMenuButtonSupport",
"GameMenuButtonMacros",
@@ -66,10 +68,10 @@ const LADDER: [&str; 8] = [
];
/// The ladder geometry — the era layout engine's own numbers (MainMenuFrameTemplates: padding
/// 32/28/28/28, spacing 0, AddSection gap 20): 200×288, each button 144×21 at x=28, tops at
/// 32/73/94/115/136/177/198/239 — three sections split by the 20-unit gaps after Options,
/// after Macros, and before Return to Game. First thing to break if the era shape is ever
/// "tidied".
/// 32/28/28/28, spacing 0, AddSection gap 20) over our seven rungs: 200×267, each button
/// 144×21 at x=28, tops at 32/73/94/115/156/177/218 — three sections split by the 20-unit
/// gaps after Options, after Macros, and before Return to Game. First thing to break if the
/// era shape is ever "tidied".
#[test]
fn the_menu_has_the_era_frame_and_button_ladder() {
let mut s = harness();
@@ -79,11 +81,11 @@ fn the_menu_has_the_era_frame_and_button_ladder() {
let (w, h) = s
.eval::<(f64, f64)>("return GameMenuFrame:GetWidth(), GameMenuFrame:GetHeight()")
.unwrap();
assert_eq!((w, h), (200.0, 288.0), "the era frame size");
assert_eq!((w, h), (200.0, 267.0), "the era frame size");
let top = s.eval::<f64>("return GameMenuFrame:GetTop()").unwrap();
let left = s.eval::<f64>("return GameMenuFrame:GetLeft()").unwrap();
const TOPS: [f64; 8] = [32.0, 73.0, 94.0, 115.0, 136.0, 177.0, 198.0, 239.0];
const TOPS: [f64; 7] = [32.0, 73.0, 94.0, 115.0, 156.0, 177.0, 218.0];
for (name, down) in LADDER.iter().zip(TOPS) {
let (bw, bh, btop, bleft) = s
.eval::<(f64, f64, f64, f64)>(&format!(
@@ -112,7 +114,8 @@ fn the_menu_has_the_era_frame_and_button_ladder() {
/// The two entries with nothing behind them — Edit Mode, Support — are DISABLED (the pending
/// idiom: grey label, `-Disabled` art, exactly how the era menu greys a dead entry). Everything
/// else in the ladder is live. **Macros left this list in decision 0983**, when the macro window
/// landed behind it; **AddOns left it in 1197**, when `AddonList.xml` did.
/// landed behind it. (AddOns had left it in 1197 and then left the ladder entirely with its
/// panel — the char-select AddOns screen is the only addon UI.)
#[test]
fn the_unbacked_entries_are_disabled_and_the_rest_are_live() {
let s = harness();
@@ -127,7 +130,6 @@ fn the_unbacked_entries_are_disabled_and_the_rest_are_live() {
}
for name in [
"GameMenuButtonOptions",
"GameMenuButtonAddOns",
"GameMenuButtonMacros",
"GameMenuButtonLogout",
"GameMenuButtonQuit",
@@ -139,13 +141,8 @@ fn the_unbacked_entries_are_disabled_and_the_rest_are_live() {
"{name} is live"
);
}
// The labels read the era strings (AddOns through the ADDONS global — live now, and its
// label still has to come from the string rather than a literal).
assert_eq!(
s.eval::<String>("return GameMenuButtonAddOns:GetText()")
.unwrap(),
"AddOns"
);
// The labels read the era strings (Edit Mode through the HUD_EDIT_MODE_MENU global — the
// label has to come from the string rather than a literal).
assert_eq!(
s.eval::<String>("return GameMenuButtonEditMode:GetText()")
.unwrap(),
@@ -9,8 +9,9 @@
//! flow — select a capsule → the host arm arms → the canonical chord lands through
//! `KeyBindings_OnHostKey` — binds LIVE-COMMIT (every mutation queues the host persist,
//! 1008's law; closing the window keeps everything), steals with the red 1.12 message only
//! when the victim goes bare, refuses the wheel on press+release commands and restores the
//! old key; the character-specific checkbox runs 1.12's set model with the era's
//! when the victim goes bare, takes the wheel onto ANY command (1295 — the engine refuses
//! nothing but a key string that is not a key, and that refusal restores the old key); the
//! character-specific checkbox runs 1.12's set model with the era's
//! confirm-on-uncheck-only; search surfaces binding matches as LIVE rows under the
//! Keybindings redirect head; and the action bar's abbreviation law (`GetBindingText`
//! transcribed in UIParent.xml) reads `s-2`, the ref's own Lua.
@@ -233,12 +234,31 @@ fn the_capture_flow_binds_steals_and_refuses_like_112() {
.contains("ATTACKTARGET"),
"the newly-bare victim is named"
);
// The wheel refusal: MOVEFORWARD has press+release state — SetBinding refuses the wheel
// and the slot's old key is restored (1.12's KeyBindingFrame_SetBinding).
// **The wheel binds like any other key** (B265, decision 1295) — including onto MOVEFORWARD,
// which has press+release state. This block used to assert the opposite; `0x4b7490` never
// reads a command node, so the refusal it asserted is not in the client.
s.run(&format!("{ROW}3Key1Button:Click()")).unwrap();
s.run(r#"KeyBindings_OnHostKey("MOUSEWHEELUP")"#).unwrap();
assert!(
s.eval::<bool>(r#"local k1 = GetBindingKey("MOVEFORWARD"); return k1 == "F""#)
s.eval::<bool>(r#"local k1 = GetBindingKey("MOVEFORWARD"); return k1 == "MOUSEWHEELUP""#)
.unwrap(),
"the notch takes the slot"
);
// …and the notch STEALS the wheel from the camera, whose last key it was — so the page says
// so in red, exactly as it would for any other stolen key. That the wheel's own default
// victim is a real command is half of why the old refusal read as plausible.
assert_eq!(
s.eval::<String>("return OptionsFrameContainerBodyKeybindingsOutput:GetText()")
.unwrap(),
"|cffff0000CAMERAZOOMIN Function is Now Unbound!|r"
);
// The refusal that IS there is the key-string validator, and this is the page arm that
// reports it: a chord the engine will not take restores the slot's old key and says why
// (1.12's KeyBindingFrame_SetBinding, its wheel-shaped message and all).
s.run(&format!("{ROW}3Key1Button:Click()")).unwrap();
s.run(r#"KeyBindings_OnHostKey("SCROLLLOCK")"#).unwrap();
assert!(
s.eval::<bool>(r#"local k1 = GetBindingKey("MOVEFORWARD"); return k1 == "MOUSEWHEELUP""#)
.unwrap(),
"the refused slot restored its key"
);
@@ -660,13 +680,14 @@ fn the_pet_lane_is_registered_under_the_action_bar_header() {
.unwrap();
assert_eq!(key, format!("CTRL-{}", i % 10), "{name}'s 1.12 default");
}
// …and it is a runOnUp pair like the action bar's own rows, which is what makes the wheel
// refusal apply to it (SetBinding answers nil rather than binding MOUSEWHEELUP).
assert!(
// …and it is a runOnUp pair like the action bar's own rows — which decides that a press
// delivers a release half, and NOT whether the row may wear a wheel chord (1295). It takes
// the wheel like any other command; the notch then runs both halves of the pair.
assert_eq!(
s.eval::<Option<u32>>(r#"return SetBinding("MOUSEWHEELUP", "BONUSACTIONBUTTON1")"#)
.unwrap()
.is_none(),
"press+release commands refuse the wheel"
.unwrap(),
Some(1),
"a press+release command takes the wheel — the refusal was ours, B265"
);
// Page side: search finds it and paints a live row with the capsule filled.
@@ -0,0 +1,499 @@
//! **The UI session's lifecycle** — the VM's birth, its identity, its death, and the reload
//! (decisions 1051/1290/1291). Split from `mod.rs` when the reload verb landed and the file held
//! two concerns: this, and the per-frame extract/input bridge that stayed behind.
//!
//! The shape, end to end: `Startup` installs a **boot VM** (strings, emote tokens, fonts — the
//! state the glue screens run against); entering the world loads the in-game UI and every addon
//! onto it under the picked character's identity; leaving the world runs the reference's ordered
//! shutdown tail and installs a fresh boot VM; and `ReloadUI()` is the two edges back to back
//! without leaving the world. Every function here is an edge — nothing per-frame lives here.
use bevy::prelude::*;
use benilla_assets::LockRecover;
use benilla_ui::script::UiScript;
use super::{
load_font_registry, load_ingame_ui, CursorPayloadHeld, PlayerUiHover, UiClock,
UiKeyboardCapture,
};
use crate::ui_script::addons;
pub(crate) fn setup_script(world: &mut World) {
install_boot_vm(world);
}
/// Build a **boot VM** and install it: a Lua state carrying the strings, the emote tokens and the
/// font-object registry, and no frames at all.
///
/// This is the state the client sits at outside the world — the login and character screens — and
/// the state a world entry loads the in-game UI *onto* ([`load_ingame_ui_on_world_entry`]). It is
/// installed twice over a session: once at `Startup`, and again at every [`end_ui_session`], which
/// is what makes a second login a genuine second load rather than a re-entry into the first
/// login's Lua state.
///
/// ONLY the font-object registry at boot (1051). The glyph atlas bakes once, on the first `Update`,
/// from `script.font_objects()` — and our native glue screens share that one atlas, so the registry
/// has to exist before the login screen or in-game text loses its outlined variants and its
/// registry-declared sizes for the whole session. The other 55 files are in-game UI and load at
/// world entry ([`load_ingame_ui`]); the reference splits at exactly this seam, with GlueXML
/// carrying its own `GlueFonts.xml`.
///
/// **Since 1290 this runs per login, and that is load-bearing rather than incidental.** The
/// reference re-loads `Fonts.xml` on every rebuild and *must*: `0x48fbf0`'s toc loop has no
/// already-loaded gate, and both halves of a font object die with the old session — the Lua handle
/// with `_G`, and the native `CSimpleFont` registry with the frame-script owner (`0x7839c0`, torn
/// down at `0x490c97`). Because [`end_ui_session`] routes every login through here, every session's
/// VM gets the registry and every `inherits="GameFontNormal"` in FrameXML resolves. A design that
/// dropped the VM and skipped index 0 on the in-game load would fail exactly there — verified in
/// wow-re's §5 for this change (1291).
fn install_boot_vm(world: &mut World) {
let script = match UiScript::new() {
Ok(s) => s,
Err(e) => {
error!("ui_script: VM init failed: {e}");
world.remove_non_send_resource::<UiScript>();
return;
}
};
load_global_strings(world, &script);
load_emote_tokens(world, &script);
if ui_wanted(world) {
// Errors are already logged per file as they happen; the returned list is the test-side
// assertion (`shipped_xml_tests`), not a second reporting channel.
let _ = load_font_registry(&script);
}
world.insert_non_send_resource(script);
}
/// Does this run want the player UI at all? Captures stay pristine — their baselines regression-test
/// the WORLD render — unless `WOW_CAPTURE_UI=1` opts the UI in.
fn ui_wanted(world: &World) -> bool {
!world.contains_resource::<crate::run_mode::CaptureMode>()
|| std::env::var("WOW_CAPTURE_UI").as_deref() == Ok("1")
}
/// `OnEnter(InWorld)`: materialize the in-game UI for **this** session.
///
/// **Once per world entry, not once per process** (decision 1290). The reference builds the whole
/// in-game UI at `CGGameUI::Initialize 0x48fbf0` and destroys it again at `0x490bd0` on the way
/// out, so every login runs every addon's file scope afresh — and that file scope is where the
/// corpus reads the character it is looking at (`local currentPlayer = UnitName("player")`, the
/// idiom [`seat_from_roster`] already documents). While this was latched per process, a second
/// login kept the first login's captured name: the director's "the selected one is always
/// Onewarrior no matter what char I log into", and, worse, its saved variables went to the first
/// character's file. [`world_entry_tests`] holds both ends.
///
/// The load runs onto the fresh boot VM [`end_ui_session`] left behind (or, on the first login,
/// `Startup`'s) — so nothing here has to unpick the previous session; there is nothing to unpick.
///
/// Safe on the state edge only because 1038 moved the initial transition after `PostStartup` — a
/// capture boots straight into `InWorld`, so before that this would have run ahead of
/// [`benilla_assets::AssetSet::Open`] and loaded against no patch chain.
pub(crate) fn load_ingame_ui_on_world_entry(world: &mut World) {
if !ui_wanted(world) {
return;
}
let Some(mut script) = world.remove_non_send_resource::<UiScript>() else {
warn!("ui_script: entering the world with no VM — the in-game UI will not load");
return;
};
// The character whose AddOn enable state applies. Resolved before the load because the
// enable file gates which addons run at all.
let identity = world
.get_resource::<crate::char_select::Roster>()
.and_then(crate::ui_macro::identity);
// The realm name goes in BEFORE the UI loads, because `GetRealmName()` is read at addon file
// scope — `MyAddonDB[GetRealmName()] = …` is the corpus idiom, and 24 addons stop on it
// (decision 1195). The roster carries the auth realm-list entry this session connected to.
let realm = world
.get_resource::<crate::char_select::Roster>()
.and_then(|r| r.realm.as_ref().map(|r| r.name.clone()))
.unwrap_or_default();
script.set_realm_name(&realm);
// …and so does the PLAYER, for the same reason and with more riding on it — see
// [`seat_from_roster`], which is where the why lives.
if let Some(seat) = world
.get_resource::<crate::char_select::Roster>()
.and_then(seat_from_roster)
{
script.set_unit("player", Some(seat));
}
// The addon version gate (decision 1292): the live table when this VM has one, else the
// persisted value. On a first login the boot VM was seeded during the glue phase, so a
// *Load out of date AddOns* click at character select reads back here even inside the
// save debounce; on a reload the fresh boot VM has no table yet and the persisted value is
// current by construction (1291's session-edge fold ran moments ago). Absent both (a bare
// test world) is the registrar default: check ON.
let version_check = script
.cvar("checkAddonVersion")
.map(|v| v != "0")
.unwrap_or_else(|| {
world
.get_resource::<crate::cvars::CvarPersist>()
.is_none_or(crate::cvars::CvarPersist::addon_version_check)
});
let _ = load_ingame_ui(&mut script, identity.as_ref(), version_check);
// The Minimap widget was born a moment ago with `MinimapState::default()`; seed its two live
// zoom indices from the persisted CVars now, before anything reads them — the reference's own
// minimap reset path copying each CVar object's int into its live index (decision 1131). Once
// only: from here the widget's index is the live truth and `Minimap:SetZoom` writes the CVar
// back. Startup always precedes this state edge (1038), so the knob is already loaded.
let zoom = world.resource::<crate::minimap::MinimapZoom>();
script.set_minimap_zoom(zoom.outdoor, zoom.inside);
// The saved-variables chunk runs HERE — after the XML assigned its file-scope defaults, before
// any consumer reads them — then `VARIABLES_LOADED`. That is the reference's own load order
// (`AddOn_Load 0x51f240` steps 2 → 4 → 6, decision 1128); reversing it means the defaults
// always win and nothing can ever be remembered.
finish_ui_load(&mut script);
world.insert_non_send_resource(script);
world.insert_resource(AddOnIdentity(identity));
}
/// The `"player"` snapshot the UI loads **under**, built from the roster row of the pick in
/// flight — `None` when there is no pick (a capture, a scenario, a test world).
///
/// **The reference's invariant is that addon file scope always sees a real character**:
/// `AddOn_Load 0x51f240` runs from inside `UI_Init 0x48fbf0`, which is after the world is entered.
/// benilla's does not. `Connected` flips us `InWorld` a whole server round-trip before the self
/// descriptor streams in ([`crate::ui_unit`]'s own comment measures that gap in *seconds*), and
/// `feed_units` — the only writer of the `"player"` token — is gated on that descriptor existing.
/// So until this existed, every addon's file scope ran in a VM where `UnitName("player")` was
/// **nil**, which is a state a real session cannot present. It is the same argument, at the same
/// line, as the `set_realm_name` above it (decision 1195) — and this is the more load-bearing half.
///
/// **The failure it fixes is silent, which is why it survived every instrument.** The director
/// installed Bagnon, opened their bags, and got a window with a title, a gold line and **no bag
/// slots at all**. `Bagnon_Core/core/Utility.lua:5` opens `local currentPlayer =
/// UnitName("player")`, and every one of Bagnon's "am I looking at a cached snapshot of some OTHER
/// character?" predicates is `currentPlayer ~= frame.player`. With `currentPlayer` nil, Bagnon
/// concluded the live player's own bags belonged to somebody else, took every bag size from
/// Bagnon_Forever's (empty) offline cache instead of `GetContainerNumSlots`, created zero item
/// buttons — and raised nothing, so `loaded`, `session` and the UI probe all scored it a pass.
/// Reproduced both ways in [`bagnon_render_tests`].
///
/// **What is filled is what the roster actually knows**: name, race, class, gender and level, all
/// a round-trip ahead of the descriptor (the same fact [`crate::char_select::Roster::pending_entry`]
/// already exploits for the streamers). Health and power are deliberately left at zero — those are
/// the descriptor's to say, they land within the second, and inventing them would be a different
/// lie from the one being fixed.
pub(crate) fn seat_from_roster(
roster: &crate::char_select::Roster,
) -> Option<benilla_ui::script::UnitState> {
let row = roster.pending_row()?;
let race = crate::ui_unit::race_names(row.race);
let class = crate::ui_unit::class_names(row.class);
Some(benilla_ui::script::UnitState {
exists: true,
name: Some(row.name.clone()),
level: u32::from(row.level),
race: race.map(|(n, _)| n.to_string()),
race_file: race.map(|(_, f)| f.to_string()),
class: class.map(|(n, _)| n.to_string()),
class_file: class.map(|(_, f)| f.to_string()),
// The wire's 0/1 on `UnitSex`'s 2/3 scale — `ui_unit::snapshot`'s own mapping.
sex: match row.gender {
0 => 2,
1 => 3,
_ => 0,
},
is_player: true,
// Nil here is not "no faction", it is a state a player character cannot be in, and
// AceDB-2.0 concatenates it at file scope — see [`crate::ui_unit::race_faction_group`].
faction_group: crate::ui_unit::race_faction_group(row.race).map(str::to_string),
..Default::default()
})
}
/// The character the loaded AddOn enable state belongs to, remembered so the shutdown write goes
/// back to the file it came from — the roster's pick can be gone by then.
#[derive(Resource, Default)]
pub(crate) struct AddOnIdentity(pub(crate) Option<(String, String)>);
/// **The UI shutdown, in the reference's own order** — `0x490bd0`, whose ordered tail wow-5875-re
/// carves as (`system/ui/ui.md`):
///
/// > `PLAYER_LEAVING_WORLD` (273) → **`PLAYER_LOGOUT`** (271, `0x490c2a`) → `layout-cache.txt` →
/// > **the flat saved file** (`0x490c7e`) → **the per-addon files** (`0x490c83`) → `AddOns.txt`
/// > (`0x490c88`) → destroy the frame-script owner (`0x490c97`) → nil all 216 C bindings out of
/// > `_G` (`0x490cba` → `0x490ce0`)
///
/// **The last step is not "destroy the Lua state"** — which is what three of wow-re's own notes
/// said, until this client's teardown made the question load-bearing and a §5 cross-check settled
/// it (`system/ui/scratch/lua-state-lifecycle.md`; 1291). `0x490c97` is the frame-script owner's
/// scalar-deleting destructor — the widget tree and the native virtual-font registry — and the Lua
/// state outlives it. The state is closed and re-opened at `0x703b80`, which reaches `InitLua` by a
/// **tail-`jmp`** (`0x703b8e`) rather than a call, which is exactly why a call-census missed it.
/// Its three callers are `UI_Init 0x48fbf0` (at its *head*), `ShutdownGame 0x491180`, and the glue
/// builder `0x46a7b0` — so a logout/login cycle runs through four distinct states, and the glue
/// screen gets its own.
///
/// **`PLAYER_LOGOUT` fires before any write, and that is the point**: it is an addon's last chance
/// to mutate a saved global, so a handler that stores "where I left off" runs while the write is
/// still ahead of it. Firing it after would make the event useless and the bug invisible.
///
/// One function, called from every root, because the steps are ordered *against each other* —
/// three independent Bevy systems on one state edge cannot express that, and until this landed the
/// flat write and the `AddOns.txt` write were exactly that.
///
/// **There is no autosave**, deliberately: the reference has none (decision 1128, and
/// `ds:0xb4b3f4` has three references image-wide). These are a handful of scalars a player toggles
/// a few times a session, and every file is written whole from the live globals.
pub(crate) fn shutdown_ui_state(script: &mut UiScript, identity: Option<&(String, String)>) {
script.fire_event("PLAYER_LEAVING_WORLD", vec![]);
script.fire_event("PLAYER_LOGOUT", vec![]);
crate::ui_saved::save(script);
addons::save_addon_variables(script, identity);
addons::save_enable_state(script, identity);
}
/// `OnExit(InWorld)`: a `/logout` back to the glue, or a disconnect — two of the reference's five
/// roots — and, with the writes done, **the end of this session's Lua state**.
///
/// The reference's own shutdown ends by destroying the state (`0x490bd0`'s tail, after
/// `AddOns.txt`); ours ends by replacing it with a fresh boot VM ([`install_boot_vm`]), which is the
/// same guarantee expressed the way our two-phase load wants it — the character screen still needs
/// a font-object registry for the shared glyph atlas, and the next login needs somewhere to load
/// onto. What matters is that **no frame, no global and no addon upvalue crosses this edge**: that
/// is what makes the next login a real login (decision 1290) instead of a re-entry into the
/// previous character's UI.
///
/// Exclusive rather than a `NonSendMut` system because it both drops and installs a `NonSend`, and
/// because the shutdown writes must be ordered against each other — see [`shutdown_ui_state`].
pub(crate) fn end_ui_session(world: &mut World) {
let identity = world
.get_resource::<AddOnIdentity>()
.and_then(|id| id.0.clone());
if let Some(mut script) = world.get_non_send_resource_mut::<UiScript>() {
shutdown_ui_state(&mut script, identity.as_ref());
}
// The CVar bridge (decision 1291): the dying VM's table folds into the persist state — after
// the shutdown events above (a `PLAYER_LOGOUT` handler may `SetCVar`, and in the reference
// that lands in an engine-side store that survives), before the VM is replaced. The next
// VM's registration seeds from what this writes ([`crate::cvars`]'s saved base).
crate::cvars::fold_dying_vm_cvars(world);
world.insert_resource(AddOnIdentity(None));
// **Everything the host is holding that came OUT of the dying VM goes with it.** A change memo
// handles itself — it is keyed on [`benilla_ui::script::UiScript::session`] ([`VmMemo`]) — but
// these are plain values other systems read through `Res<…>`, with no VM in hand to key
// against, so the edge clears them. Each is a fact about a frame tree that is about to stop
// existing: a hovered frame id, the minimap's extracted hole, a payload the cursor is carrying,
// and the VM-relative clock the cooldown conversions run through.
//
// The two input latches were cleared by `char_select`'s logout and disconnect handlers, one
// copy each. They belong here: the reason they need clearing is that `feed_ui_input` stops
// running outside `InWorld`, which is this edge and nothing to do with *why* we left it.
if let Some(mut hover) = world.get_resource_mut::<PlayerUiHover>() {
hover.0 = None;
}
if let Some(mut keys) = world.get_resource_mut::<UiKeyboardCapture>() {
keys.0 = false;
}
if let Some(mut held) = world.get_resource_mut::<CursorPayloadHeld>() {
*held = CursorPayloadHeld::default();
}
if let Some(mut minimap) = world.get_resource_mut::<crate::minimap::MinimapWidget>() {
minimap.0 = None;
}
if let Some(mut clock) = world.get_resource_mut::<UiClock>() {
*clock = UiClock::default();
}
install_boot_vm(world);
}
/// A `ReloadUI()` waiting to run — set when [`crate::ui_logout`] drains
/// [`benilla_ui::script::SessionRequest::ReloadUi`], consumed by [`run_pending_reload`] at the
/// top of the next frame's `Update`.
///
/// A flag rather than an immediate call for the reference's own reason (`ds:0xb4b3f4`, its only
/// writer `0x491380` and its only reader the per-frame callback `0x495590`): the VM that queued
/// the request must not be mid-call when it is destroyed. Our drain already runs outside any VM
/// dispatch, but the flag keeps the whole rebuild at one point in the frame — before the input
/// pass — instead of wherever the drain happens to sit.
#[derive(Resource, Default)]
pub(crate) struct ReloadUiPending(pub(crate) bool);
/// Run a pending `ReloadUI()`: the reference's teardown/rebuild pair (`0x495664 call 0x490bd0`,
/// `0x495669 call 0x48fbf0`), which for us is [`end_ui_session`] then
/// [`load_ingame_ui_on_world_entry`] — the same two functions the logout/login edges run, called
/// back to back without leaving the world (decision 1291).
///
/// Everything that makes a login correct makes the reload correct **by construction**: the
/// shutdown tail fires `PLAYER_LEAVING_WORLD`/`PLAYER_LOGOUT` and writes the four files (so a
/// `DisableAddOn` staged in the dying VM reaches `AddOns.txt` before the rebuild reads it), the
/// rebuild is a real login's load (fresh file scope, saved variables, `VARIABLES_LOADED`,
/// `PLAYER_LOGIN`), and every host memory keyed on the VM's identity ([`VmMemo`], decision 1290)
/// expires with the old session id. `PLAYER_ENTERING_WORLD` refires from [`crate::ui_unit`]'s
/// feed once it notices the new VM, with the self descriptor already present — the reference's
/// own ordering, where the event follows the rebuild.
///
/// **In-world only.** At the glue there is no in-game UI to rebuild and no identity to load
/// addons under; the reference's own gate (`0x494a50(0xa)`) refuses there too. Dropped with a log
/// line rather than deferred — a reload asked for at the character screen answers nothing.
pub(crate) fn run_pending_reload(world: &mut World) {
if !std::mem::take(&mut world.resource_mut::<ReloadUiPending>().0) {
return;
}
let in_world = *world
.resource::<State<crate::char_select::ClientState>>()
.get()
== crate::char_select::ClientState::InWorld;
if !in_world {
info!("ui_script: ReloadUI outside the world — dropped");
return;
}
info!("ui_script: ReloadUI — ending the UI session and building a new one");
end_ui_session(world);
load_ingame_ui_on_world_entry(world);
}
/// `AppExit`: quitting the client — the quit / application-exit roots. Reads the message rather
/// than a state edge because a quit from in-world never leaves `InWorld`.
pub(crate) fn shutdown_on_exit(
script: Option<NonSendMut<UiScript>>,
id: Res<AddOnIdentity>,
mut exits: MessageReader<AppExit>,
) {
if exits.read().next().is_none() {
return;
}
if let Some(mut script) = script {
shutdown_ui_state(&mut script, id.0.as_ref());
}
}
/// The UI-init sequence's ordered tail, once every file — ours and every addon's — has loaded:
/// the saved-variables chunk and `VARIABLES_LOADED`, then `PLAYER_LOGIN`.
///
/// **The order is the reference's**, byte-verified in wow-5875-re (`system/ui/ui.md`, and the
/// cascade in `system/ui/scratch/mail-pending-countdown.md`). Inside `UI_Init 0x48fbf0`, in
/// straight-line address order:
///
/// | | |
/// |---|---|
/// | `0x4900a3` → `0x51f600` | load every non-LoadOnDemand addon — each fires its own **`ADDON_LOADED`** (429, `0x51f5ad`) |
/// | `0x4900b2` → `0x4913b0` | read the flat saved file, fire **`VARIABLES_LOADED`** (430) |
/// | `0x490168` → `0x4908c0` | the world-enter cascade: **`PLAYER_LOGIN`** (`0x49094b`, `0x10e`) then **`PLAYER_ENTERING_WORLD`** (`0x490965`, `0x110`) |
///
/// So every non-LoD addon's `ADDON_LOADED` precedes `VARIABLES_LOADED`, which precedes
/// `PLAYER_LOGIN`. It is one function rather than three inline calls because that sequence is the
/// mechanism — an addon restores state on `ADDON_LOADED` and expects the saved chunk to have run,
/// and a window that waits on `PLAYER_LOGIN` expects both — so it is worth being able to assert.
///
/// **`PLAYER_LOGIN` is the conditional one; `PLAYER_ENTERING_WORLD` is not.** The cascade fires
/// the former only when `[0xb4e260]` is set, and only the FrameXML-loader path sets it, clearing
/// it immediately after — so it means "the UI came up". That is once per **UI build**, which since
/// 1290 is once per world entry here too: this runs from
/// [`load_ingame_ui_on_world_entry`], on the same edge that built the tree.
/// `PLAYER_ENTERING_WORLD` keeps its own per-entry latch in [`crate::ui_unit`] and still lands
/// after this, since it waits on the self descriptor arriving over the wire.
pub(crate) fn finish_ui_load(script: &mut UiScript) {
crate::ui_saved::load_saved_variables(script);
script.fire_event("PLAYER_LOGIN", vec![]);
}
/// Execute the real `Interface\FrameXML\GlobalStrings.lua` off the patch chain into the VM —
/// the reference boots FrameXML with exactly this file FIRST, and it is the source of every
/// localized string global the UI reads (the cast-fail display's whole message set, 0427).
/// Loaded before our own `assets/ui` files, matching the reference order. Failures are LOUD:
/// a silently missing GlobalStrings once suppressed every red error line (the 0427 fold's
/// absent-key face is faithful data suppression — but only when the file actually loaded).
fn load_global_strings(world: &mut World, script: &UiScript) {
let Some(assets) = world.get_resource::<benilla_assets::WorldAssets>() else {
warn!("ui_script: no patch chain — GlobalStrings absent, error lines will be empty");
return;
};
let bytes = {
let mut chain = assets.chain.lock_recover();
chain.read_file("Interface\\FrameXML\\GlobalStrings.lua")
};
let src = match bytes {
Ok(b) => String::from_utf8_lossy(&b).into_owned(),
Err(e) => {
error!("ui_script: GlobalStrings.lua read failed — error lines will be empty: {e:#}");
return;
}
};
if let Err(e) = script.run(&src) {
error!("ui_script: GlobalStrings.lua failed to run: {e}");
return;
}
// The sentinel: the exact lookup the cast-fail drain performs. If this misses, every
// message would silently vanish — turn that failure mode into a diagnosable line. Presence
// only, not the enUS text: a non-enUS install is still a loaded GlobalStrings.
let sentinel: Option<String> = script.lua().globals().get("SPELL_FAILED_NO_AMMO").ok();
match sentinel {
Some(s) if !s.is_empty() => info!("ui_script: GlobalStrings loaded"),
other => {
error!("ui_script: GlobalStrings sentinel missing ({other:?}) — error lines broken")
}
}
}
/// Execute the reference's own **emote token table** into the VM (`EMOTE87_TOKEN = "SIT"`, …) —
/// the second half of the emote slash grammar (decision 0881). The *aliases* are in
/// `GlobalStrings.lua` above (`EMOTE87_CMD1 = "/sit"`), but the alias → `EmotesText.Name` mapping
/// lives in `ChatFrame.lua`: the reference's chat **code**, which benilla replaces in Rust. So we
/// take that file's **data** and none of its code — only whole lines matching
/// `EMOTE<digits>_TOKEN = "<UPPER>";` ([`is_emote_token_line`]) are executed, and the file's ~2400
/// lines of frame logic never run. Reading the shipped table beats transcribing 170 tokens into
/// Rust: a transcription can be wrong, and a hand-kept alias list is exactly what left 61 real
/// commands (`/lol`, `/hi`, `/ty`, …) unresolvable before 0881.
fn load_emote_tokens(world: &mut World, script: &UiScript) {
let Some(assets) = world.get_resource::<benilla_assets::WorldAssets>() else {
return; // already WARNed by load_global_strings
};
let bytes = {
let mut chain = assets.chain.lock_recover();
chain.read_file("Interface\\FrameXML\\ChatFrame.lua")
};
let src = match bytes {
Ok(b) => String::from_utf8_lossy(&b).into_owned(),
Err(e) => {
error!("ui_script: ChatFrame.lua read failed — emote commands will be dead: {e:#}");
return;
}
};
let table: Vec<&str> = src
.lines()
.map(str::trim)
.filter(|l| is_emote_token_line(l))
.collect();
let count = table.len();
if let Err(e) = script.run(&table.join("\n")) {
error!("ui_script: emote token table failed to run: {e}");
return;
}
// The sentinel is the command this whole seam exists for: EMOTE87 is `/sit`.
let sentinel: Option<String> = script.lua().globals().get("EMOTE87_TOKEN").ok();
match sentinel.as_deref() {
Some("SIT") => info!("ui_script: {count} emote tokens loaded"),
other => error!(
"ui_script: emote token sentinel is {other:?}, not \"SIT\" ({count} lines) — \
emote slash commands are broken"
),
}
}
/// Is this line one of `ChatFrame.lua`'s emote-token assignments — `EMOTE<digits>_TOKEN = "<NAME>";`
/// with `NAME` in `[A-Z0-9_]`? The whole-line shape is the filter that makes running the matched
/// lines equivalent to reading data (no calls, no expressions, no side effects).
pub(crate) fn is_emote_token_line(line: &str) -> bool {
let Some(rest) = line.strip_prefix("EMOTE") else {
return false;
};
let digits = rest.len() - rest.trim_start_matches(|c: char| c.is_ascii_digit()).len();
let Some(rest) = rest[digits..].strip_prefix("_TOKEN = \"") else {
return false;
};
let Some(name) = rest.strip_suffix("\";") else {
return false;
};
digits > 0
&& !name.is_empty()
&& name
.bytes()
.all(|b| b.is_ascii_uppercase() || b.is_ascii_digit() || b == b'_')
}
+12 -1
View File
@@ -100,9 +100,16 @@ pub(crate) fn load_font_registry(script: &UiScript) -> Vec<String> {
/// `identity` is `(realm, character)`, which names this character's AddOn enable-state file — the
/// reference keys `AddOns.txt` per character too. `None` (no pick yet, a capture) means every
/// discovered addon is enabled, the same answer an absent file gives.
///
/// `version_check` is the persisted `checkAddonVersion` — the *Load out of date AddOns* toggle,
/// inverted — resolved by the caller because at load time this VM's own CVar table does not
/// exist yet (registration is a per-VM `Update` seed, decision 1291); the persisted value is the
/// truth the reference's live read would land on, since the session edge folds the dying VM's
/// table into it before any rebuild reads it.
pub(crate) fn load_ingame_ui(
script: &mut UiScript,
identity: Option<&(String, String)>,
version_check: bool,
) -> Vec<String> {
// The reference FrameXML this client SOURCES off the patch chain rather than transcribing
// ([`super::reference_ui`], whose header is the rule). It runs FIRST, before our own files,
@@ -114,7 +121,11 @@ pub(crate) fn load_ingame_ui(
failures.extend(bootstrap_positions(script));
// `&mut` from here down: each addon's `ADDON_LOADED` fires as that addon finishes, which is
// the reference's own interleaving (`0x51f5ad`, per addon) rather than a batch at the end.
failures.extend(super::addons::load_third_party(script, identity));
failures.extend(super::addons::load_third_party(
script,
identity,
version_check,
));
failures
}
+13 -466
View File
@@ -23,7 +23,6 @@ use bevy::prelude::*;
use benilla_ui::script::{ActionSlot, ScriptValue, UiScript, UnitState};
use crate::ui_unit::UnitFeed;
use benilla_assets::LockRecover;
use benilla_world::schedule::WorldStage;
/// The addon folder: discovery, manifests, the enable file, and the load walk. `pub(crate)`
@@ -351,175 +350,19 @@ fn arbitrate_pointer_over_ui(
over.0 = egui.is_some_and(|e| e.0) || hover.0.is_some();
}
fn setup_script(world: &mut World) {
install_boot_vm(world);
}
/// Build a **boot VM** and install it: a Lua state carrying the strings, the emote tokens and the
/// font-object registry, and no frames at all.
///
/// This is the state the client sits at outside the world — the login and character screens — and
/// the state a world entry loads the in-game UI *onto* ([`load_ingame_ui_on_world_entry`]). It is
/// installed twice over a session: once at `Startup`, and again at every [`end_ui_session`], which
/// is what makes a second login a genuine second load rather than a re-entry into the first
/// login's Lua state.
///
/// ONLY the font-object registry at boot (1051). The glyph atlas bakes once, on the first `Update`,
/// from `script.font_objects()` — and our native glue screens share that one atlas, so the registry
/// has to exist before the login screen or in-game text loses its outlined variants and its
/// registry-declared sizes for the whole session. The other 55 files are in-game UI and load at
/// world entry ([`load_ingame_ui`]); the reference splits at exactly this seam, with GlueXML
/// carrying its own `GlueFonts.xml`.
///
/// **Since 1290 this runs per login, and that is load-bearing rather than incidental.** The
/// reference re-loads `Fonts.xml` on every rebuild and *must*: `0x48fbf0`'s toc loop has no
/// already-loaded gate, and both halves of a font object die with the old session — the Lua handle
/// with `_G`, and the native `CSimpleFont` registry with the frame-script owner (`0x7839c0`, torn
/// down at `0x490c97`). Because [`end_ui_session`] routes every login through here, every session's
/// VM gets the registry and every `inherits="GameFontNormal"` in FrameXML resolves. A design that
/// dropped the VM and skipped index 0 on the in-game load would fail exactly there — verified in
/// wow-re's §5 for this change (1291).
fn install_boot_vm(world: &mut World) {
let script = match UiScript::new() {
Ok(s) => s,
Err(e) => {
error!("ui_script: VM init failed: {e}");
world.remove_non_send_resource::<UiScript>();
return;
}
};
load_global_strings(world, &script);
load_emote_tokens(world, &script);
if ui_wanted(world) {
// Errors are already logged per file as they happen; the returned list is the test-side
// assertion (`shipped_xml_tests`), not a second reporting channel.
let _ = load_font_registry(&script);
}
world.insert_non_send_resource(script);
}
/// Does this run want the player UI at all? Captures stay pristine — their baselines regression-test
/// the WORLD render — unless `WOW_CAPTURE_UI=1` opts the UI in.
fn ui_wanted(world: &World) -> bool {
!world.contains_resource::<crate::run_mode::CaptureMode>()
|| std::env::var("WOW_CAPTURE_UI").as_deref() == Ok("1")
}
/// `OnEnter(InWorld)`: materialize the in-game UI for **this** session.
///
/// **Once per world entry, not once per process** (decision 1290). The reference builds the whole
/// in-game UI at `CGGameUI::Initialize 0x48fbf0` and destroys it again at `0x490bd0` on the way
/// out, so every login runs every addon's file scope afresh — and that file scope is where the
/// corpus reads the character it is looking at (`local currentPlayer = UnitName("player")`, the
/// idiom [`seat_from_roster`] already documents). While this was latched per process, a second
/// login kept the first login's captured name: the director's "the selected one is always
/// Onewarrior no matter what char I log into", and, worse, its saved variables went to the first
/// character's file. [`world_entry_tests`] holds both ends.
///
/// The load runs onto the fresh boot VM [`end_ui_session`] left behind (or, on the first login,
/// `Startup`'s) — so nothing here has to unpick the previous session; there is nothing to unpick.
///
/// Safe on the state edge only because 1038 moved the initial transition after `PostStartup` — a
/// capture boots straight into `InWorld`, so before that this would have run ahead of
/// [`benilla_assets::AssetSet::Open`] and loaded against no patch chain.
fn load_ingame_ui_on_world_entry(world: &mut World) {
if !ui_wanted(world) {
return;
}
let Some(mut script) = world.remove_non_send_resource::<UiScript>() else {
warn!("ui_script: entering the world with no VM — the in-game UI will not load");
return;
};
// The character whose AddOn enable state applies. Resolved before the load because the
// enable file gates which addons run at all.
let identity = world
.get_resource::<crate::char_select::Roster>()
.and_then(crate::ui_macro::identity);
// The realm name goes in BEFORE the UI loads, because `GetRealmName()` is read at addon file
// scope — `MyAddonDB[GetRealmName()] = …` is the corpus idiom, and 24 addons stop on it
// (decision 1195). The roster carries the auth realm-list entry this session connected to.
let realm = world
.get_resource::<crate::char_select::Roster>()
.and_then(|r| r.realm.as_ref().map(|r| r.name.clone()))
.unwrap_or_default();
script.set_realm_name(&realm);
// …and so does the PLAYER, for the same reason and with more riding on it — see
// [`seat_from_roster`], which is where the why lives.
if let Some(seat) = world
.get_resource::<crate::char_select::Roster>()
.and_then(seat_from_roster)
{
script.set_unit("player", Some(seat));
}
let _ = load_ingame_ui(&mut script, identity.as_ref());
// The Minimap widget was born a moment ago with `MinimapState::default()`; seed its two live
// zoom indices from the persisted CVars now, before anything reads them — the reference's own
// minimap reset path copying each CVar object's int into its live index (decision 1131). Once
// only: from here the widget's index is the live truth and `Minimap:SetZoom` writes the CVar
// back. Startup always precedes this state edge (1038), so the knob is already loaded.
let zoom = world.resource::<crate::minimap::MinimapZoom>();
script.set_minimap_zoom(zoom.outdoor, zoom.inside);
// The saved-variables chunk runs HERE — after the XML assigned its file-scope defaults, before
// any consumer reads them — then `VARIABLES_LOADED`. That is the reference's own load order
// (`AddOn_Load 0x51f240` steps 2 → 4 → 6, decision 1128); reversing it means the defaults
// always win and nothing can ever be remembered.
finish_ui_load(&mut script);
world.insert_non_send_resource(script);
world.insert_resource(AddOnIdentity(identity));
}
/// The `"player"` snapshot the UI loads **under**, built from the roster row of the pick in
/// flight — `None` when there is no pick (a capture, a scenario, a test world).
///
/// **The reference's invariant is that addon file scope always sees a real character**:
/// `AddOn_Load 0x51f240` runs from inside `UI_Init 0x48fbf0`, which is after the world is entered.
/// benilla's does not. `Connected` flips us `InWorld` a whole server round-trip before the self
/// descriptor streams in ([`crate::ui_unit`]'s own comment measures that gap in *seconds*), and
/// `feed_units` — the only writer of the `"player"` token — is gated on that descriptor existing.
/// So until this existed, every addon's file scope ran in a VM where `UnitName("player")` was
/// **nil**, which is a state a real session cannot present. It is the same argument, at the same
/// line, as the `set_realm_name` above it (decision 1195) — and this is the more load-bearing half.
///
/// **The failure it fixes is silent, which is why it survived every instrument.** The director
/// installed Bagnon, opened their bags, and got a window with a title, a gold line and **no bag
/// slots at all**. `Bagnon_Core/core/Utility.lua:5` opens `local currentPlayer =
/// UnitName("player")`, and every one of Bagnon's "am I looking at a cached snapshot of some OTHER
/// character?" predicates is `currentPlayer ~= frame.player`. With `currentPlayer` nil, Bagnon
/// concluded the live player's own bags belonged to somebody else, took every bag size from
/// Bagnon_Forever's (empty) offline cache instead of `GetContainerNumSlots`, created zero item
/// buttons — and raised nothing, so `loaded`, `session` and the UI probe all scored it a pass.
/// Reproduced both ways in [`bagnon_render_tests`].
///
/// **What is filled is what the roster actually knows**: name, race, class, gender and level, all
/// a round-trip ahead of the descriptor (the same fact [`crate::char_select::Roster::pending_entry`]
/// already exploits for the streamers). Health and power are deliberately left at zero — those are
/// the descriptor's to say, they land within the second, and inventing them would be a different
/// lie from the one being fixed.
fn seat_from_roster(roster: &crate::char_select::Roster) -> Option<benilla_ui::script::UnitState> {
let row = roster.pending_row()?;
let race = crate::ui_unit::race_names(row.race);
let class = crate::ui_unit::class_names(row.class);
Some(benilla_ui::script::UnitState {
exists: true,
name: Some(row.name.clone()),
level: u32::from(row.level),
race: race.map(|(n, _)| n.to_string()),
race_file: race.map(|(_, f)| f.to_string()),
class: class.map(|(n, _)| n.to_string()),
class_file: class.map(|(_, f)| f.to_string()),
// The wire's 0/1 on `UnitSex`'s 2/3 scale — `ui_unit::snapshot`'s own mapping.
sex: match row.gender {
0 => 2,
1 => 3,
_ => 0,
},
is_player: true,
// Nil here is not "no faction", it is a state a player character cannot be in, and
// AceDB-2.0 concatenates it at file scope — see [`crate::ui_unit::race_faction_group`].
faction_group: crate::ui_unit::race_faction_group(row.race).map(str::to_string),
..Default::default()
})
}
/// The session lifecycle — the VM's birth, identity, death, and the reload. Every edge function
/// lives there; this module keeps the per-frame bridge. See its header.
mod lifecycle;
pub(crate) use lifecycle::{
end_ui_session, run_pending_reload, setup_script, AddOnIdentity, ReloadUiPending,
};
// Consumed only from other modules' test code (the emote-table checks, the harness's UI-init
// tail, the quit-once pin) — a plain re-export would warn unused in a non-test build.
#[cfg(test)]
pub(crate) use lifecycle::{
finish_ui_load, is_emote_token_line, seat_from_roster, shutdown_ui_state,
};
use lifecycle::{load_ingame_ui_on_world_entry, shutdown_on_exit};
/// `GetFramerate()`'s host half: push a **smoothed** frames-per-second into the VM each frame
/// (decision 1195).
@@ -554,299 +397,6 @@ fn feed_framerate(
script.set_framerate(*smoothed);
}
/// The character the loaded AddOn enable state belongs to, remembered so the shutdown write goes
/// back to the file it came from — the roster's pick can be gone by then.
#[derive(Resource, Default)]
pub(crate) struct AddOnIdentity(pub(crate) Option<(String, String)>);
/// **The UI shutdown, in the reference's own order** — `0x490bd0`, whose ordered tail wow-5875-re
/// carves as (`system/ui/ui.md`):
///
/// > `PLAYER_LEAVING_WORLD` (273) → **`PLAYER_LOGOUT`** (271, `0x490c2a`) → `layout-cache.txt` →
/// > **the flat saved file** (`0x490c7e`) → **the per-addon files** (`0x490c83`) → `AddOns.txt`
/// > (`0x490c88`) → destroy the frame-script owner (`0x490c97`) → nil all 216 C bindings out of
/// > `_G` (`0x490cba` → `0x490ce0`)
///
/// **The last step is not "destroy the Lua state"** — which is what three of wow-re's own notes
/// said, until this client's teardown made the question load-bearing and a §5 cross-check settled
/// it (`system/ui/scratch/lua-state-lifecycle.md`; 1291). `0x490c97` is the frame-script owner's
/// scalar-deleting destructor — the widget tree and the native virtual-font registry — and the Lua
/// state outlives it. The state is closed and re-opened at `0x703b80`, which reaches `InitLua` by a
/// **tail-`jmp`** (`0x703b8e`) rather than a call, which is exactly why a call-census missed it.
/// Its three callers are `UI_Init 0x48fbf0` (at its *head*), `ShutdownGame 0x491180`, and the glue
/// builder `0x46a7b0` — so a logout/login cycle runs through four distinct states, and the glue
/// screen gets its own.
///
/// **`PLAYER_LOGOUT` fires before any write, and that is the point**: it is an addon's last chance
/// to mutate a saved global, so a handler that stores "where I left off" runs while the write is
/// still ahead of it. Firing it after would make the event useless and the bug invisible.
///
/// One function, called from every root, because the steps are ordered *against each other* —
/// three independent Bevy systems on one state edge cannot express that, and until this landed the
/// flat write and the `AddOns.txt` write were exactly that.
///
/// **There is no autosave**, deliberately: the reference has none (decision 1128, and
/// `ds:0xb4b3f4` has three references image-wide). These are a handful of scalars a player toggles
/// a few times a session, and every file is written whole from the live globals.
pub(crate) fn shutdown_ui_state(script: &mut UiScript, identity: Option<&(String, String)>) {
script.fire_event("PLAYER_LEAVING_WORLD", vec![]);
script.fire_event("PLAYER_LOGOUT", vec![]);
crate::ui_saved::save(script);
addons::save_addon_variables(script, identity);
addons::save_enable_state(script, identity);
}
/// `OnExit(InWorld)`: a `/logout` back to the glue, or a disconnect — two of the reference's five
/// roots — and, with the writes done, **the end of this session's Lua state**.
///
/// The reference's own shutdown ends by destroying the state (`0x490bd0`'s tail, after
/// `AddOns.txt`); ours ends by replacing it with a fresh boot VM ([`install_boot_vm`]), which is the
/// same guarantee expressed the way our two-phase load wants it — the character screen still needs
/// a font-object registry for the shared glyph atlas, and the next login needs somewhere to load
/// onto. What matters is that **no frame, no global and no addon upvalue crosses this edge**: that
/// is what makes the next login a real login (decision 1290) instead of a re-entry into the
/// previous character's UI.
///
/// Exclusive rather than a `NonSendMut` system because it both drops and installs a `NonSend`, and
/// because the shutdown writes must be ordered against each other — see [`shutdown_ui_state`].
pub(crate) fn end_ui_session(world: &mut World) {
let identity = world
.get_resource::<AddOnIdentity>()
.and_then(|id| id.0.clone());
if let Some(mut script) = world.get_non_send_resource_mut::<UiScript>() {
shutdown_ui_state(&mut script, identity.as_ref());
}
// The CVar bridge (decision 1291): the dying VM's table folds into the persist state — after
// the shutdown events above (a `PLAYER_LOGOUT` handler may `SetCVar`, and in the reference
// that lands in an engine-side store that survives), before the VM is replaced. The next
// VM's registration seeds from what this writes ([`crate::cvars`]'s saved base).
crate::cvars::fold_dying_vm_cvars(world);
world.insert_resource(AddOnIdentity(None));
// **Everything the host is holding that came OUT of the dying VM goes with it.** A change memo
// handles itself — it is keyed on [`benilla_ui::script::UiScript::session`] ([`VmMemo`]) — but
// these are plain values other systems read through `Res<…>`, with no VM in hand to key
// against, so the edge clears them. Each is a fact about a frame tree that is about to stop
// existing: a hovered frame id, the minimap's extracted hole, a payload the cursor is carrying,
// and the VM-relative clock the cooldown conversions run through.
//
// The two input latches were cleared by `char_select`'s logout and disconnect handlers, one
// copy each. They belong here: the reason they need clearing is that `feed_ui_input` stops
// running outside `InWorld`, which is this edge and nothing to do with *why* we left it.
if let Some(mut hover) = world.get_resource_mut::<PlayerUiHover>() {
hover.0 = None;
}
if let Some(mut keys) = world.get_resource_mut::<UiKeyboardCapture>() {
keys.0 = false;
}
if let Some(mut held) = world.get_resource_mut::<CursorPayloadHeld>() {
*held = CursorPayloadHeld::default();
}
if let Some(mut minimap) = world.get_resource_mut::<crate::minimap::MinimapWidget>() {
minimap.0 = None;
}
if let Some(mut clock) = world.get_resource_mut::<UiClock>() {
*clock = UiClock::default();
}
install_boot_vm(world);
}
/// A `ReloadUI()` waiting to run — set when [`crate::ui_logout`] drains
/// [`benilla_ui::script::SessionRequest::ReloadUi`], consumed by [`run_pending_reload`] at the
/// top of the next frame's `Update`.
///
/// A flag rather than an immediate call for the reference's own reason (`ds:0xb4b3f4`, its only
/// writer `0x491380` and its only reader the per-frame callback `0x495590`): the VM that queued
/// the request must not be mid-call when it is destroyed. Our drain already runs outside any VM
/// dispatch, but the flag keeps the whole rebuild at one point in the frame — before the input
/// pass — instead of wherever the drain happens to sit.
#[derive(Resource, Default)]
pub(crate) struct ReloadUiPending(pub(crate) bool);
/// Run a pending `ReloadUI()`: the reference's teardown/rebuild pair (`0x495664 call 0x490bd0`,
/// `0x495669 call 0x48fbf0`), which for us is [`end_ui_session`] then
/// [`load_ingame_ui_on_world_entry`] — the same two functions the logout/login edges run, called
/// back to back without leaving the world (decision 1291).
///
/// Everything that makes a login correct makes the reload correct **by construction**: the
/// shutdown tail fires `PLAYER_LEAVING_WORLD`/`PLAYER_LOGOUT` and writes the four files (so a
/// `DisableAddOn` staged in the dying VM reaches `AddOns.txt` before the rebuild reads it), the
/// rebuild is a real login's load (fresh file scope, saved variables, `VARIABLES_LOADED`,
/// `PLAYER_LOGIN`), and every host memory keyed on the VM's identity ([`VmMemo`], decision 1290)
/// expires with the old session id. `PLAYER_ENTERING_WORLD` refires from [`crate::ui_unit`]'s
/// feed once it notices the new VM, with the self descriptor already present — the reference's
/// own ordering, where the event follows the rebuild.
///
/// **In-world only.** At the glue there is no in-game UI to rebuild and no identity to load
/// addons under; the reference's own gate (`0x494a50(0xa)`) refuses there too. Dropped with a log
/// line rather than deferred — a reload asked for at the character screen answers nothing.
pub(crate) fn run_pending_reload(world: &mut World) {
if !std::mem::take(&mut world.resource_mut::<ReloadUiPending>().0) {
return;
}
let in_world = *world
.resource::<State<crate::char_select::ClientState>>()
.get()
== crate::char_select::ClientState::InWorld;
if !in_world {
info!("ui_script: ReloadUI outside the world — dropped");
return;
}
info!("ui_script: ReloadUI — ending the UI session and building a new one");
end_ui_session(world);
load_ingame_ui_on_world_entry(world);
}
/// `AppExit`: quitting the client — the quit / application-exit roots. Reads the message rather
/// than a state edge because a quit from in-world never leaves `InWorld`.
fn shutdown_on_exit(
script: Option<NonSendMut<UiScript>>,
id: Res<AddOnIdentity>,
mut exits: MessageReader<AppExit>,
) {
if exits.read().next().is_none() {
return;
}
if let Some(mut script) = script {
shutdown_ui_state(&mut script, id.0.as_ref());
}
}
/// The UI-init sequence's ordered tail, once every file — ours and every addon's — has loaded:
/// the saved-variables chunk and `VARIABLES_LOADED`, then `PLAYER_LOGIN`.
///
/// **The order is the reference's**, byte-verified in wow-5875-re (`system/ui/ui.md`, and the
/// cascade in `system/ui/scratch/mail-pending-countdown.md`). Inside `UI_Init 0x48fbf0`, in
/// straight-line address order:
///
/// | | |
/// |---|---|
/// | `0x4900a3` → `0x51f600` | load every non-LoadOnDemand addon — each fires its own **`ADDON_LOADED`** (429, `0x51f5ad`) |
/// | `0x4900b2` → `0x4913b0` | read the flat saved file, fire **`VARIABLES_LOADED`** (430) |
/// | `0x490168` → `0x4908c0` | the world-enter cascade: **`PLAYER_LOGIN`** (`0x49094b`, `0x10e`) then **`PLAYER_ENTERING_WORLD`** (`0x490965`, `0x110`) |
///
/// So every non-LoD addon's `ADDON_LOADED` precedes `VARIABLES_LOADED`, which precedes
/// `PLAYER_LOGIN`. It is one function rather than three inline calls because that sequence is the
/// mechanism — an addon restores state on `ADDON_LOADED` and expects the saved chunk to have run,
/// and a window that waits on `PLAYER_LOGIN` expects both — so it is worth being able to assert.
///
/// **`PLAYER_LOGIN` is the conditional one; `PLAYER_ENTERING_WORLD` is not.** The cascade fires
/// the former only when `[0xb4e260]` is set, and only the FrameXML-loader path sets it, clearing
/// it immediately after — so it means "the UI came up". That is once per **UI build**, which since
/// 1290 is once per world entry here too: this runs from
/// [`load_ingame_ui_on_world_entry`], on the same edge that built the tree.
/// `PLAYER_ENTERING_WORLD` keeps its own per-entry latch in [`crate::ui_unit`] and still lands
/// after this, since it waits on the self descriptor arriving over the wire.
pub(crate) fn finish_ui_load(script: &mut UiScript) {
crate::ui_saved::load_saved_variables(script);
script.fire_event("PLAYER_LOGIN", vec![]);
}
/// Execute the real `Interface\FrameXML\GlobalStrings.lua` off the patch chain into the VM —
/// the reference boots FrameXML with exactly this file FIRST, and it is the source of every
/// localized string global the UI reads (the cast-fail display's whole message set, 0427).
/// Loaded before our own `assets/ui` files, matching the reference order. Failures are LOUD:
/// a silently missing GlobalStrings once suppressed every red error line (the 0427 fold's
/// absent-key face is faithful data suppression — but only when the file actually loaded).
fn load_global_strings(world: &mut World, script: &UiScript) {
let Some(assets) = world.get_resource::<benilla_assets::WorldAssets>() else {
warn!("ui_script: no patch chain — GlobalStrings absent, error lines will be empty");
return;
};
let bytes = {
let mut chain = assets.chain.lock_recover();
chain.read_file("Interface\\FrameXML\\GlobalStrings.lua")
};
let src = match bytes {
Ok(b) => String::from_utf8_lossy(&b).into_owned(),
Err(e) => {
error!("ui_script: GlobalStrings.lua read failed — error lines will be empty: {e:#}");
return;
}
};
if let Err(e) = script.run(&src) {
error!("ui_script: GlobalStrings.lua failed to run: {e}");
return;
}
// The sentinel: the exact lookup the cast-fail drain performs. If this misses, every
// message would silently vanish — turn that failure mode into a diagnosable line. Presence
// only, not the enUS text: a non-enUS install is still a loaded GlobalStrings.
let sentinel: Option<String> = script.lua().globals().get("SPELL_FAILED_NO_AMMO").ok();
match sentinel {
Some(s) if !s.is_empty() => info!("ui_script: GlobalStrings loaded"),
other => {
error!("ui_script: GlobalStrings sentinel missing ({other:?}) — error lines broken")
}
}
}
/// Execute the reference's own **emote token table** into the VM (`EMOTE87_TOKEN = "SIT"`, …) —
/// the second half of the emote slash grammar (decision 0881). The *aliases* are in
/// `GlobalStrings.lua` above (`EMOTE87_CMD1 = "/sit"`), but the alias → `EmotesText.Name` mapping
/// lives in `ChatFrame.lua`: the reference's chat **code**, which benilla replaces in Rust. So we
/// take that file's **data** and none of its code — only whole lines matching
/// `EMOTE<digits>_TOKEN = "<UPPER>";` ([`is_emote_token_line`]) are executed, and the file's ~2400
/// lines of frame logic never run. Reading the shipped table beats transcribing 170 tokens into
/// Rust: a transcription can be wrong, and a hand-kept alias list is exactly what left 61 real
/// commands (`/lol`, `/hi`, `/ty`, …) unresolvable before 0881.
fn load_emote_tokens(world: &mut World, script: &UiScript) {
let Some(assets) = world.get_resource::<benilla_assets::WorldAssets>() else {
return; // already WARNed by load_global_strings
};
let bytes = {
let mut chain = assets.chain.lock_recover();
chain.read_file("Interface\\FrameXML\\ChatFrame.lua")
};
let src = match bytes {
Ok(b) => String::from_utf8_lossy(&b).into_owned(),
Err(e) => {
error!("ui_script: ChatFrame.lua read failed — emote commands will be dead: {e:#}");
return;
}
};
let table: Vec<&str> = src
.lines()
.map(str::trim)
.filter(|l| is_emote_token_line(l))
.collect();
let count = table.len();
if let Err(e) = script.run(&table.join("\n")) {
error!("ui_script: emote token table failed to run: {e}");
return;
}
// The sentinel is the command this whole seam exists for: EMOTE87 is `/sit`.
let sentinel: Option<String> = script.lua().globals().get("EMOTE87_TOKEN").ok();
match sentinel.as_deref() {
Some("SIT") => info!("ui_script: {count} emote tokens loaded"),
other => error!(
"ui_script: emote token sentinel is {other:?}, not \"SIT\" ({count} lines) — \
emote slash commands are broken"
),
}
}
/// Is this line one of `ChatFrame.lua`'s emote-token assignments — `EMOTE<digits>_TOKEN = "<NAME>";`
/// with `NAME` in `[A-Z0-9_]`? The whole-line shape is the filter that makes running the matched
/// lines equivalent to reading data (no calls, no expressions, no side effects).
pub(crate) fn is_emote_token_line(line: &str) -> bool {
let Some(rest) = line.strip_prefix("EMOTE") else {
return false;
};
let digits = rest.len() - rest.trim_start_matches(|c: char| c.is_ascii_digit()).len();
let Some(rest) = rest[digits..].strip_prefix("_TOKEN = \"") else {
return false;
};
let Some(name) = rest.strip_suffix("\";") else {
return false;
};
digits > 0
&& !name.is_empty()
&& name
.bytes()
.all(|b| b.is_ascii_uppercase() || b.is_ascii_digit() || b == b'_')
}
/// Feed synthetic `"player"`/`"target"` snapshots each frame (overriding the real feed, which finds
/// no avatar in a server-less capture) and fire the initial events once — proving the full chain
/// end-to-end on a screenshot: snapshot → `Unit*` bindings → Lua `OnEvent` → bars + text, for both
@@ -1163,9 +713,6 @@ mod tooltip_template_tests;
#[cfg(test)]
mod escape_tests;
#[cfg(test)]
mod addon_list_tests;
#[cfg(test)]
mod game_menu_tests;
@@ -432,7 +432,7 @@ fn the_options_check_button_resolves_its_whole_inheritance_chain() {
/// real 1.12.1 client has, read from `reference/1.12-globals.tsv`.
///
/// Scoped to these two files on purpose: the rest of `assets/ui` is full of deliberately
/// benilla-shaped template names (`BenillaScrollBarTemplate`, `AddonListPanelButtonTemplate`,
/// benilla-shaped template names (`BenillaScrollBarTemplate`, `BenillaMacroPanelButtonTemplate`,
/// `OptionsRedButtonTemplate`), and a whole-tree sweep would be asserting something else. These
/// two files make the opposite claim — *these are the reference's own names, which is why an addon
/// can find them* — so that claim is the one worth gating. A template renamed to something
@@ -183,6 +183,14 @@ mod tests {
// re-seats off `!script.has_text_measurer()` — it interrogates the VM instead of a memo,
// which is the same guarantee arrived at the other way.
("ui_script/extract/mod.rs", "last_seam"),
// The atlas bake generation beside it (decision 1296) — a fact about the glyph atlas, a
// Bevy resource that outlives any VM, not about the VM. It gates the same re-seat
// `last_seam` does, and a fresh VM re-seats on `!has_text_measurer()` regardless.
("ui_script/extract/mod.rs", "last_bake"),
// How long the raster environment has differed from the baked one (decision 1296's
// re-bake debounce): a stopwatch about the WINDOW, zeroed by its own equality test every
// frame. A new VM neither reads it nor is described by it.
("ui_text/atlas.rs", "drifted_for"),
// Pushed unconditionally every frame; there is nothing remembered to go stale.
("ui_script/mod.rs", "smoothed"),
// The cursor systems, both platform arms: these track the OS cursor and an `NSCursor` raw
+247 -26
View File
@@ -596,6 +596,14 @@ impl TextMetrics {
/// Quadrata TTF. See the module doc's v1 simplifications for why only one face/size is baked.
#[derive(Resource)]
pub(crate) struct UiFontAtlas {
/// The raster environment these cells were baked for ([`BakeEnv`]) — compared against the live
/// one every frame, and the atlas re-bakes when they part.
pub(super) env: BakeEnv,
/// Bumped on every re-bake. The host's measure seam watches it: an
/// [`crate::ui_text::AtlasMeasurer`] holds a snapshot of the step table, so a new bake must
/// re-seat it and drop the engine's cached metrics even when the seam itself is unchanged
/// (a pure DPI hop moves the table without moving `seam`).
pub(crate) generation: u64,
/// Live shaper state — [`layout_text_quads`] needs `&mut FontSystem` to shape each run (kerned
/// advances), even though the bitmap glyphs themselves are pre-baked; see the module doc's
/// metric-fidelity seam note.
@@ -652,15 +660,84 @@ pub(crate) struct UiTextPlugin;
impl Plugin for UiTextPlugin {
fn build(&self, app: &mut App) {
// Runs each Update but builds the atlas exactly **once** — it needs both the patch chain
// (opened at `AssetSet::Open`) *and* the primary window's real `scale_factor`, which winit
// only reports once the OS window exists (a Startup read can still see the placeholder 1.0).
// Gating on the window being present + guarding on `UiFontAtlas` already existing lets the
// bake happen on the first frame the real scale is known, and never again.
// Runs each Update: the first bake needs both the patch chain (opened at `AssetSet::Open`)
// *and* the primary window's real `scale_factor`, which winit only reports once the OS
// window exists (a Startup read can still see the placeholder 1.0), so gating on the window
// being present lets it happen on the first frame the real scale is known. Every later
// frame it compares the live raster environment against the baked one and re-bakes when
// they part ([`BakeEnv`]).
app.add_systems(Update, load_fonts_and_build_atlas);
}
}
/// The raster environment a set of glyph cells is only valid inside — the two numbers that decide
/// what pixel size each logical size bakes at.
///
/// **Why the atlas must follow it.** Text draws at `unit height × seam` ([`super::drawn_px`],
/// decisions 0582/0584) and is rasterized at `× dpi`; the bake plans its size list from the same
/// two numbers. When they move and the bake does not, every request lands *between* baked sizes:
/// the layout snaps to the nearest one and rescales the finished quads to the true height
/// (`drawn_k`), so each glyph is a bitmap resampled by a non-integer factor instead of a cell
/// drawn one texel per pixel. That is the reported washed-out text (B244) — the default window is
/// 1600×900, so anyone who maximises moves the seam and every glyph on screen starts resampling —
/// and, before `measure_text` learned the same rescale, it also armed the measure/render split
/// that truncated fitting strings (B209).
///
/// 0582 accepted keeping the bake ("slightly soft — the same accepted policy as a monitor DPI
/// hop"); at the ratios a real player produces it is not slight, and it is not a policy anyone
/// opted into by maximising a window.
#[derive(Clone, Copy, Debug, PartialEq)]
pub(super) struct BakeEnv {
/// The 768-virtual seam scale `windowH/768 × uiScale` ([`crate::ui_script::seam_scale`]).
pub(super) seam: f32,
/// The window's `scale_factor` — the physical/logical ratio the cells are rasterized at.
pub(super) dpi: f32,
}
impl BakeEnv {
/// Same raster environment? Compared with a tolerance because both terms are recomputed from
/// float window metrics every frame; a difference this small cannot move any baked size to a
/// different integer pixel.
fn matches(self, other: Self) -> bool {
(self.seam - other.seam).abs() < 1e-4 && (self.dpi - other.dpi).abs() < 1e-4
}
}
/// How long the raster environment must hold still before the atlas re-bakes.
///
/// A window drag delivers a new size every frame and a bake rasterizes the whole charset at every
/// registry size — baking each intermediate size would rasterize hundreds of times across one
/// drag. Waiting for the environment to settle costs a fraction of a second of the old (resampled)
/// cells at the end of a resize, which is exactly the state we already shipped permanently.
const REBAKE_SETTLE: f32 = 0.25;
/// What the glyph cells must be baked for, read live — the window's two raster terms plus the
/// uiScale dial, bundled because they answer one question and are read at one place.
#[derive(bevy::ecs::system::SystemParam)]
pub(super) struct RasterEnv<'w, 's> {
/// The primary window: `scale_factor` (physical/logical) and height (the 768-virtual seam).
windows: Query<'w, 's, &'static Window, With<PrimaryWindow>>,
/// The uiScale dial folded into the bake scale (decision 0584); optional so a UI-less app
/// (no `UiScriptPlugin`) still bakes at the 768 base.
scale_cvar: Option<Res<'w, crate::ui_script::UiScaleCvar>>,
time: Res<'w, Time>,
}
impl RasterEnv<'_, '_> {
/// The environment to bake for, or `None` before winit has made the window — a Startup read
/// can still see the placeholder `scale_factor` 1.0, so the first bake waits for the real one.
fn probe(&self) -> Option<BakeEnv> {
let window = self.windows.single().ok()?;
Some(BakeEnv {
seam: crate::ui_script::seam_scale(
window.height(),
self.scale_cvar.as_deref().map_or(1.0, |c| c.0),
),
dpi: window.scale_factor(),
})
}
}
/// Read the client TTFs through the app's own patch chain ([`WorldAssets::chain`] — never
/// `std::fs`), register them into one `fontdb`, and bake **every** registered face at every
/// [`FONT_SIZES`] into a [`UiFontAtlas`] (so a font object naming ARIALN/MORPHEUS at 10..20px resolves
@@ -671,34 +748,49 @@ fn load_fonts_and_build_atlas(
mut commands: Commands,
world_assets: Option<Res<WorldAssets>>,
mut images: ResMut<Assets<Image>>,
windows: Query<&Window, With<PrimaryWindow>>,
env: RasterEnv,
existing: Option<Res<UiFontAtlas>>,
// The engine host (loaded at Startup, so present by this first-Update bake) — read-only, for
// the outlined-cell census off its font-object registry. `None` (a UI-less run) just means no
// outlined variants: the stamped-halo fallback covers.
script: Option<bevy::prelude::NonSend<benilla_ui::script::UiScript>>,
// The uiScale dial folded into the bake scale (decision 0584); optional so a UI-less app
// (no `UiScriptPlugin`) still bakes at the 768 base.
scale_cvar: Option<Res<crate::ui_script::UiScaleCvar>>,
// Seconds the live environment has differed from the baked one — the [`REBAKE_SETTLE`] debounce.
mut drifted_for: Local<f32>,
) {
// Build once (this runs every Update); a later monitor hop that changes the scale is ignored.
if existing.is_some() {
return;
}
// Wait for the window so the DPI-aware bake reads the *real* scale, not winit's placeholder 1.0.
let Ok(window) = windows.single() else {
let Some(want) = env.probe() else {
return;
};
let scale = window.scale_factor();
let (scale, ui_scale) = (want.dpi, want.seam);
// Re-bake when the raster environment moves ([`BakeEnv`]) — a resize, a fullscreen toggle, a
// monitor hop, the uiScale slider — but only once it has held still, so a window drag doesn't
// rasterize the charset once per frame.
let generation = match existing.as_deref() {
Some(atlas) if atlas.env.matches(want) => {
*drifted_for = 0.0;
return;
}
Some(atlas) => {
*drifted_for += env.time.delta_secs();
if *drifted_for < REBAKE_SETTLE {
return;
}
atlas.generation + 1
}
None => 0,
};
*drifted_for = 0.0;
let Some(world_assets) = world_assets else {
// No client data open — text won't render (the missing-atlas graceful path in `ui_script`).
// Nothing to log-throttle here: this returns before inserting, so it retries next frame until
// the chain opens; the chain-open failure is already logged by the asset foundation.
return;
};
// Timed because this is the one cost a resize now pays, and a number nobody has is a number
// that gets guessed at the next time someone wonders whether the debounce is long enough.
let bake_started = std::time::Instant::now();
info!(
"ui_text: baking glyph atlas at scale_factor {scale} (physical-resolution glyphs; a \
runtime monitor hop to a different scale is ignored, not rebaked)"
"ui_text: baking glyph atlas (generation {generation}) at scale_factor {scale}, seam \
{ui_scale:.4} — physical-resolution glyphs, one texel per device pixel at these sizes"
);
let mut font_system = FontSystem::new_with_fonts(std::iter::empty());
@@ -749,14 +841,6 @@ fn load_fonts_and_build_atlas(
return;
};
// The 768-virtual scale at bake time (decision 0582, × 0584's uiScale dial): UI text draws
// at unit-height × s (`drawn_px`), so the census below bakes the SCALED sizes — crisp at
// the startup window. A runtime window-height change re-derives s per frame at the seam but
// keeps this bake (the snapped-nearest + exact-height rescale covers, slightly soft — the
// same accepted policy as a monitor DPI hop). The dial resource is absent only in a UI-less
// app, where the census it scales is empty anyway.
let ui_scale = crate::ui_script::seam_scale(window.height(), scale_cvar.map_or(1.0, |c| c.0));
// The registry-size census (decision 0581): every height the shipped `Fonts.xml` font-object
// registry declares joins its face's baked set — the hardcoded FONT_SIZES list had drifted
// from the registry (no 25/30), so NumberFontNormalHuge 30 snapped to 24 and the portrait
@@ -867,7 +951,15 @@ fn load_fonts_and_build_atlas(
.cloned()
.unwrap_or_else(|| FONT_SIZES.to_vec());
let steps = build_char_steps(&mut font_system, &built.glyphs, &faces, &charset, scale);
info!(
"ui_text: glyph atlas generation {generation} baked in {:.0} ms ({}×{} texels)",
bake_started.elapsed().as_secs_f32() * 1000.0,
built.width,
built.height,
);
commands.insert_resource(UiFontAtlas {
env: want,
generation,
font_system,
glyphs: built.glyphs,
image,
@@ -1036,10 +1128,139 @@ mod outlined_cell_tests {
}
}
#[cfg(test)]
mod bake_env_tests {
use super::*;
/// The re-bake edge, both terms. A window resize moves `seam`; a monitor hop moves `dpi`; the
/// tolerance exists only to absorb float recomputation, never a real change.
#[test]
fn a_moved_raster_environment_is_a_different_bake() {
let boot = BakeEnv {
seam: 1.0546875,
dpi: 2.0,
};
assert!(boot.matches(boot));
assert!(
boot.matches(BakeEnv {
seam: 1.0546875 + 1e-6,
..boot
}),
"float noise in the same environment is not a re-bake"
);
// The reported case: booting 1600x900 and maximising to 1440 tall (B244).
assert!(
!boot.matches(BakeEnv {
seam: 1.6875,
..boot
}),
"a window resize must re-bake"
);
// A retina/non-retina monitor hop at an unchanged window height — the term that moves the
// step table without moving the seam, which is why the measurer watches the generation.
assert!(
!boot.matches(BakeEnv { dpi: 1.0, ..boot }),
"a DPI hop must re-bake"
);
}
}
#[cfg(test)]
mod metrics_tests {
use super::*;
/// **Measure answers in DRAWN space** — the half of the rescale decision 0989 left open
/// ("fold into `drawn_k` if a fit ever visibly breaks") and B209 broke.
///
/// The draw shapes at the nearest baked size and scales the finished quads by `k = req/snapped`
/// (`layout_text_quads_inner`'s tail, decision 0581). So the drawn width of an off-ladder
/// request IS `snapped_width × k`, and a measure that answers the un-rescaled number disagrees
/// with the render by exactly that factor. When the nearest bake sits ABOVE the request the
/// measure over-reports, the wrap test fires, and the height-gated ellipsis eats text that
/// fits — the reported "Spellbook" → "Spellbo...".
///
/// The second assertion is the **mutation check**: with the rescale dropped, the two calls
/// below return the same number, which is precisely what shipped. Skips without an install.
#[test]
fn a_measure_between_bakes_answers_the_drawn_width() {
let data = benilla_formats::wow_data_or_skip!();
let chain = match benilla_formats::open_chain(&data) {
Ok(c) => c,
Err(e) => {
eprintln!("skipping: patch chain would not open: {e:#}");
return;
}
};
let path = CLIENT_FONTS[0];
let Ok(bytes) = chain.read(path) else {
eprintln!("skipping {path}: not in the chain");
return;
};
let charset = default_charset();
let mut font_system = FontSystem::new_with_fonts(std::iter::empty());
let mut swash = SwashCache::new();
let (id, family) = register_font(&mut font_system, bytes).expect("register");
// A deliberately sparse ladder, so a request can land between bakes the way every request
// does once the window has moved off the size the atlas was baked for.
let sizes = vec![10.0f32, 20.0];
let faces = vec![(id, family.clone(), sizes.clone())];
let scale = 1.0f32;
let built = build_atlas(
&mut font_system,
&mut swash,
&faces,
&charset,
1024,
scale,
&HashMap::new(),
);
let metrics = TextMetrics {
steps: build_char_steps(&mut font_system, &built.glyphs, &faces, &charset, scale),
path_to_family: HashMap::from([(path.to_ascii_lowercase(), family.clone())]),
default_family: family.clone(),
sizes: sizes.clone(),
sizes_by_family: HashMap::from([(family.clone(), sizes.clone())]),
scale,
};
let spec = |h: f32| crate::ui_text::FontSpec {
path: Some(path),
height: Some(h),
outline: benilla_ui::script::Outline::None,
paint_halo: true,
alpha_gradient: None,
};
// 12 snaps to the 10 bake: the draw magnifies its quads by 1.2.
const REQ: f32 = 12.0;
const SNAP: f32 = 10.0;
let k = REQ / SNAP;
assert_eq!(
metrics.snap_for(&family, REQ),
SNAP,
"the ladder under test"
);
let (drawn_w, drawn_h) =
crate::ui_text::measure_text(&metrics, "Spellbook", None, spec(REQ));
let (snap_w, snap_h) =
crate::ui_text::measure_text(&metrics, "Spellbook", None, spec(SNAP));
// The headroom pixel `measure_text` adds is drawn-space and is not itself rescaled, so it
// comes off both sides before the comparison; the tolerance is the one `ceil`.
assert!(
((drawn_w - 1.0) - (snap_w - 1.0) * k).abs() <= 1.0,
"measured width must be the drawn width: got {drawn_w}, snapped {snap_w} × {k}"
);
assert!(
(drawn_h - snap_h * k).abs() < 1e-3,
"measured height must be the drawn height: got {drawn_h}, snapped {snap_h} × {k}"
);
assert!(
drawn_w > snap_w + 1.0,
"the mutation check: dropping the rescale makes these one number ({drawn_w} vs \
{snap_w}), which is the measure/render split that truncated fitting text"
);
}
/// **The differential test: the step table must measure exactly what shaping the whole string
/// measured**, over the real client fonts.
///
+30 -11
View File
@@ -393,10 +393,20 @@ pub(crate) fn measure_text(
let lines = parse_markup(text, [1.0, 1.0, 1.0, 1.0]);
let family = metrics.family_for(font.path);
let font_size = metrics.snap_for(&family, font.height.unwrap_or(DEFAULT_FONT_SIZE));
// The exact-height rescale the DRAW applies ([`drawn_k`]): everything here is computed in
// SNAPPED space (the size the step table is keyed by) and the draw multiplies the finished
// quads by `k`, so a drawn-space width enters at `/k` and the answer leaves at `×k`. Without
// it this reports `drawn/k` and measure and render disagree by exactly the rescale — decision
// 0989 left that as a named residual ("fold into `drawn_k` if a fit ever visibly breaks"), and
// it broke: an off-ladder request whose nearest bake sits ABOVE it measures wider than it
// draws, so the height-gated ellipsis eats text that fits ("Spellbook" → "Spellbo...", B209);
// the other sign spills past the backdrop the measure sized (B231). Identity at every baked
// size, so all on-ladder UI text is bit-unchanged.
let k = drawn_k(font.height, font_size);
// The step-law bias ([`client_step`]): measure MUST see the same outline-biased steps the
// render pass lays, or wrap points and measured widths drift from what's drawn.
let step_extra = step_extra_of(font.outline);
let render_lines: Vec<Vec<ColorRun>> = match wrap_width {
let render_lines: Vec<Vec<ColorRun>> = match wrap_width.map(|w| w / k) {
Some(w) if w > WRAP_MIN_WIDTH => lines
.iter()
.flat_map(|line| wrap_line(metrics, &family, font_size, step_extra, line, w))
@@ -421,7 +431,13 @@ pub(crate) fn measure_text(
// the +2r lives only in the atlas CELL, never the block math —
// `fontstring-vertical-placement.md`). An outlined line's ring pokes past this height by
// design, exactly as it does in the client.
(max_w.ceil() + 1.0, render_lines.len() as f32 * font_size)
//
// Both leave in DRAWN space (`×k`, above) — the headroom pixel is added after, because it is
// a drawn-space pixel and must not itself be rescaled.
(
(max_w * k).ceil() + 1.0,
render_lines.len() as f32 * font_size * k,
)
}
/// How many display rows `text` wraps into at `wrap_width` — the message-line half of the measure
@@ -442,6 +458,9 @@ pub(crate) fn measure_wrapped_rows(
.metrics
.snap_for(&family, font.height.unwrap_or(DEFAULT_FONT_SIZE));
let step_extra = step_extra_of(font.outline);
// Into snapped space for the wrap walk ([`drawn_k`] — the caller's width is drawn px, the step
// table is keyed by the snapped size); the row COUNT is scale-free, so nothing converts back.
let wrap_width = wrap_width / drawn_k(font.height, font_size);
let rows = wrapped_rows(
&atlas.metrics,
&family,
@@ -500,21 +519,21 @@ pub(crate) fn ellipsize_to_fit(
.metrics
.snap_for(&family, font.height.unwrap_or(DEFAULT_FONT_SIZE));
let step_extra = step_extra_of(font.outline);
// The whole fit test runs in SNAPPED space, so the box enters divided by the draw's exact-
// height rescale ([`drawn_k`]) — the rect is drawn px, the pitch and step table below are the
// snapped size, and the draw multiplies its quads by `k`. Measuring the box against unscaled
// glyphs is what let an off-ladder request truncate text that fits (B209; decision 0989's
// named residual). Identity at every baked size.
let k = drawn_k(font.height, font_size);
let (box_w, box_h) = (rect.width() / k, rect.height() / k);
// The line pitch — identical to the emit pass's ([`layout_text_quads`]: the font em, NO
// outline pad — the byte law, `fontstring-vertical-placement.md`), so the fit test and the
// drawn stack agree line for line.
// The line-count law lives in `overflow` (it is the FIT law here, not the render stack's) —
// see `overflow::ellipsize_in_box`.
let metrics = &atlas.metrics;
overflow::ellipsize_in_box(text, rect.height(), font_size, |candidate| {
wrapped_rows(
metrics,
&family,
font_size,
step_extra,
candidate,
rect.width(),
)
overflow::ellipsize_in_box(text, box_h, font_size, |candidate| {
wrapped_rows(metrics, &family, font_size, step_extra, candidate, box_w)
})
}
+123 -72
View File
@@ -25,31 +25,22 @@
//!
//! **Index or name, everywhere.** Every verb takes either, 1-based, because the reference's do.
//!
//! ## What we deliberately do not enforce: `INTERFACE_VERSION`
//! ## The load law lives next door
//!
//! The reference refuses to load an addon whose `## Interface` does not match the build, unless the
//! glue AddOn list's *Load out of date AddOns* checkbox is ticked. Enforcing that here would
//! silently drop most of a real vanilla corpus (whose manifests say 11100, 10900, anything), so the
//! version is reported (`GetAddOnMetadata(name, "Interface")`) and never acted on — 1191 §6.
//!
//! **That is a choice, not an absence, and the sentence that used to stand here had gone stale.**
//! It read *"we have no such checkbox — decision 0465 hides the glue's AddOns button entirely …
//! revisit when the AddOn list lands and there is somewhere to put the toggle"*. The list landed
//! (1197: both screens, and 0465's hidden button is the one it un-hid), so the stated precondition
//! expired and the comment went on telling every reader the question was settled. 1212's shape, in
//! an instrument, for the fifth time this arc.
//!
//! What is still true and still open: the mismatch is REPORTED — the ESC-menu list shows
//! `ADDON_INTERFACE_VERSION` beside the row and the char-select panel shows the same string — and
//! `loadable` deliberately stays true, so `not_loadable` below has no interface arm. What is not
//! built is the *Load out of date AddOns* toggle itself, and it needs an RE answer we do not have:
//! how the reference couples `loadable`/`reason` when force-load is ON. Neither `AddonList.lua` nor
//! `GlueXML` is in the wow-re extract, so that question is dispatched, not guessed.
//! Every "can this load, and why not" answer comes from [`super::addon_gate`] — the reference's
//! `AddOn_CanLoad 0x51e780` as one pure function (decision 1292). The RE answer 1191 §6 was
//! missing has landed (wow-re `addon-version-gate.md`, §5-verified): the version gate is an
//! exact `== 11200` whose refusal the `checkAddonVersion` CVar suppresses by **actively
//! resetting** the reason — so `INTERFACE_VERSION` is now enforced here exactly as the client
//! enforces it, with the *Load out of date AddOns* toggle as the player's escape, instead of
//! 1191's report-but-never-act interim. The CVar is read live per query (§2.2), which is why a
//! checkbox click needs nothing but a list repaint.
use std::path::PathBuf;
use mlua::{Lua, MultiValue, Value};
use super::addon_gate::{can_load, GateRow, Verdict};
use super::Model;
/// One addon, as the AddOn API sees it — the host fills this at discovery
@@ -83,8 +74,14 @@ pub struct AddOnInfo {
pub saved_variables_per_character: Vec<String>,
/// Enable state, from `AddOns.txt`. An addon nobody has ever disabled is enabled.
pub enabled: bool,
/// `enabled` as registration found it — the last-SAVED state, which `ResetDisabledAddOns`
/// reverts to. Stamped by [`super::UiScript::register_addons`]; callers need not set it.
pub saved_enabled: bool,
/// Has it loaded this session?
pub loaded: bool,
/// `## Interface` as the client parses it (`Toc::interface_version` — the leading integer,
/// `0` when the line is absent). What the version gate compares (decision 1292).
pub interface: u32,
}
/// Resolve a Lua index-or-name argument to a position in the registry.
@@ -111,27 +108,43 @@ fn resolve(model: &Model, key: &Value) -> Option<usize> {
.filter(|i| *i < model.addons.len())
}
/// Why this addon cannot be loaded right now, as the reference's token — or `None` when it can.
///
/// Dependencies are checked one level here; `LoadAddOn` recurses for the real load. `DEP_DISABLED`
/// exists as a distinct token from `DISABLED` because the glue colours the row differently for it.
fn not_loadable(model: &Model, i: usize) -> Option<&'static str> {
let a = &model.addons[i];
if !a.enabled {
return Some("DISABLED");
/// Lower the registry into the gate's rows — ONE adapter, so every verb consults the same law
/// ([`super::addon_gate`], decision 1292) over the same facts.
fn gate_rows(model: &Model) -> Vec<GateRow<'_>> {
model
.addons
.iter()
.map(|a| GateRow {
name: &a.name,
enabled: a.enabled,
interface: a.interface,
load_on_demand: a.load_on_demand,
loaded: a.loaded,
dependencies: a.dependencies.iter().map(String::as_str).collect(),
})
.collect()
}
/// The live `checkAddonVersion` read — the gate's CVar half, re-read per query like the
/// reference's (`IsAddonVersionCheckEnabled 0x51f180` inside `AddOn_CanLoad`, §2.2). An absent
/// table (a bare test VM, or a query before the host's seed) answers the registrar default:
/// check ON, `"1"`.
fn version_check(model: &Model) -> bool {
model
.cvars
.get("checkaddonversion")
.is_none_or(|s| s.value != "0")
}
/// [`can_load`] over the registry, in the in-game flavour (`dl=1` — the surface these verbs
/// are): the addon's own verdict, with the loaded short-circuit the in-game `GetAddOnInfo`
/// applies (`0x48e390`: an already-loaded addon reports loadable/nil before the gate runs, so
/// flipping its toggle mid-session cannot retroactively mark it unloadable).
fn verdict(model: &Model, i: usize) -> Verdict {
if model.addons[i].loaded {
return Verdict::Loadable;
}
for dep in &a.dependencies {
match model
.addons
.iter()
.find(|d| d.name.eq_ignore_ascii_case(dep))
{
None => return Some("DEP_MISSING"),
Some(d) if !d.enabled => return Some("DEP_DISABLED"),
Some(_) => {}
}
}
None
can_load(&gate_rows(model), i, true, version_check(model))
}
/// `1`/`nil` — the client's boolean shape, which every addon tests with a bare `if`.
@@ -189,7 +202,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
// `loadable`. `flag` already answers in that shape.
//
// `url` is not lost, it moves to where the client keeps it in-game: `GetAddOnMetadata(name,
// "URL")`, which is how our own AddonList tooltip reads it now.
// "URL")`.
g.set(
"GetAddOnInfo",
lua.create_function(|lua, key: Value| {
@@ -197,7 +210,10 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
let Some(i) = resolve(&model, &key) else {
return Ok(MultiValue::new());
};
let reason = not_loadable(&model, i);
// The one arbiter (decision 1292): loaded short-circuit, then `AddOn_CanLoad` in the
// in-game flavour — so NOT_DEMAND_LOADED and INTERFACE_VERSION are reachable here,
// exactly as `0x48e390` reports them.
let reason = verdict(&model, i).token();
let a = &model.addons[i];
Ok(MultiValue::from_vec(vec![
lua_str(lua, &a.name)?,
@@ -205,7 +221,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
opt_str(lua, &a.notes)?,
flag(a.enabled),
flag(reason.is_none()),
match reason {
match &reason {
Some(r) => lua_str(lua, r)?,
None => Value::Nil,
},
@@ -270,21 +286,27 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
})?,
)?;
// `EnableAddOn(index)` and `EnableAddOn(character, index)` are both real — the glue calls the
// two-argument form from its per-character checkbox and the one-argument form from
// `AddonList_DisableOutOfDate`. We have one enable scope, so the character is accepted and
// ignored; the addon-facing behaviour is identical, and refusing the arity would break the
// callers that pass it.
// **One argument, index or name — the `(character, index)` form is GLUE-ONLY** (byte-carved,
// wow-re `addon-enable-store.md`: in-game `0x48e690`/`0x48e760` read arg1 alone and key the
// store on `0x5abdc0()`, the logged-in character; the two-argument shape belongs to the glue
// registrars `0x46d7b0`/`0x46d8a0`, and our glue screen is native — no caller exists).
// A numeric index out of range is a **Lua error** in the reference (`luaL_error` via
// `0x51df00`); an unknown NAME is where we diverge, disclosed: the reference creates a
// phantom enable-hash entry for the typo, we no-op — the safer direction, and `resolve`'s
// established semantics (1191).
for (name, on) in [("EnableAddOn", true), ("DisableAddOn", false)] {
g.set(
name,
lua.create_function(move |lua, args: MultiValue| {
let key = match args.len() {
0 => return Ok(()),
1 => args[0].clone(),
_ => args[1].clone(),
};
lua.create_function(move |lua, key: Value| {
let mut model = lua.app_data_mut::<Model>().expect("model");
if let Value::Integer(_) | Value::Number(_) = key {
if resolve(&model, &key).is_none() {
return Err(mlua::Error::runtime(format!(
"{}: addon index out of range",
if on { "EnableAddOn" } else { "DisableAddOn" }
)));
}
}
if let Some(i) = resolve(&model, &key) {
model.addons[i].enabled = on;
}
@@ -293,6 +315,8 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
)?;
}
// No arguments read at all (`0x48e720`/`0x48e7f0` — the loop re-evaluates the bound and the
// current character each iteration; ours is one pass over the same store).
for (name, on) in [("EnableAllAddOns", true), ("DisableAllAddOns", false)] {
g.set(
name,
@@ -306,6 +330,24 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
)?;
}
// `ResetDisabledAddOns 0x48e830` — **revert-to-last-saved**, byte-carved (wow-re
// `addon-enable-store.md`): the reference destroys the current character's enable hash and
// reloads it from the on-disk `AddOns.txt` — so unsaved toggles of BOTH polarities revert,
// and a disable already on disk stays disabled. Within one session the current character's
// file is immutable until the shutdown tail writes it (the glue can only edit it with no
// session up), so the registration-time `enabled` — read from that very file — IS the
// last-saved state, and reverting to it is the same operation without re-reading disk.
g.set(
"ResetDisabledAddOns",
lua.create_function(|lua, ()| {
let mut model = lua.app_data_mut::<Model>().expect("model");
for a in &mut model.addons {
a.enabled = a.saved_enabled;
}
Ok(())
})?,
)?;
// loaded, reason — see `load_addon`.
g.set(
"LoadAddOn",
@@ -343,7 +385,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
///
/// `Err` carries the reference's own reason token.
fn load_addon(lua: &Lua, i: usize) -> Result<(), String> {
let (name, root, files, deps, already, enabled, lod) = {
let (name, root, files, deps, already) = {
let model = lua.app_data_ref::<Model>().expect("model");
let a = &model.addons[i];
(
@@ -352,25 +394,27 @@ fn load_addon(lua: &Lua, i: usize) -> Result<(), String> {
a.files.clone(),
a.dependencies.clone(),
a.loaded,
a.enabled,
a.load_on_demand,
)
};
if already {
return Ok(()); // the reference answers a redundant load with success, not an error
}
if !enabled {
return Err("DISABLED".into());
}
// A non-LoadOnDemand addon that is not loaded did not load at startup, and demand-loading is
// exactly what it did not declare itself capable of.
if !lod {
return Err("NOT_DEMAND_LOADED".into());
// The one arbiter (decision 1292): the reference's own shape — `LoadAddOn 0x48e980` refuses
// through `AddOn_CanLoad` (via `AddOn_Load`'s step 3) and re-derives the reason from the
// same gate on failure. The version gate is in here now: an out-of-date addon demand-loads
// only under force-load, exactly as `0x48ea8c`'s carve records.
{
let model = lua.app_data_ref::<Model>().expect("model");
if let refused @ Verdict::Refused { .. } = verdict(&model, i) {
return Err(refused.token().expect("a refusal always carries a token"));
}
}
let Some(root) = root else {
return Err("MISSING".into()); // no addon root: a hermetic capture has nothing to load
};
// The gate said yes, so a dependency failing HERE is a load-time failure (its files errored,
// it was uninstalled mid-session) — still mapped to the DEP_ mirror, applied once.
for dep in &deps {
let d = {
let model = lua.app_data_ref::<Model>().expect("model");
@@ -382,17 +426,18 @@ fn load_addon(lua: &Lua, i: usize) -> Result<(), String> {
match d {
None => return Err("DEP_MISSING".into()),
Some(d) => {
let (loaded, enabled) = {
let loaded = {
let model = lua.app_data_ref::<Model>().expect("model");
(model.addons[d].loaded, model.addons[d].enabled)
model.addons[d].loaded
};
if !enabled {
return Err("DEP_DISABLED".into());
}
// A dependency that is not LoadOnDemand is already loaded, or disabled; either way
// recursing is the same answer, mapped to the DEP_ mirror of its own reason.
if !loaded {
load_addon(lua, d).map_err(|r| format!("DEP_{r}"))?;
load_addon(lua, d).map_err(|r| {
if r.starts_with("DEP_") {
r // §2.3: the prefix applies exactly once at any nesting depth
} else {
format!("DEP_{r}")
}
})?;
}
}
}
@@ -556,11 +601,17 @@ impl super::UiScript {
/// engine owns the verbs).
pub fn register_addons(
&mut self,
addons: Vec<AddOnInfo>,
mut addons: Vec<AddOnInfo>,
root: Option<PathBuf>,
saved_account: Option<PathBuf>,
saved_character: Option<PathBuf>,
) {
// The registration-time enable state IS the last-saved state (the host read it off the
// enable file moments ago) — snapshot it here so `ResetDisabledAddOns` has its revert
// point and no caller has to remember to provide one.
for a in &mut addons {
a.saved_enabled = a.enabled;
}
let mut model = self.model_mut();
model.addons = addons;
model.addons_root = root;
+275
View File
@@ -0,0 +1,275 @@
//! **The one load law — `AddOn_CanLoad 0x51e780`, as a pure function** (decision 1292).
//!
//! Every question of the form "can this addon load, and if not, why" has exactly one answer in
//! the reference: a single arbiter whose checks run in a fixed order, whose version gate and
//! CVar read re-run on every query, and whose two out-params (`reasonOut`, `depReasonOut`)
//! render through one formatter. benilla had three partial copies of it — the API verbs'
//! `not_loadable`, `LoadAddOn`'s inline checks, and the startup walk's disabled set — and the
//! screens were about to become a fourth. This module is the one copy they all consult.
//!
//! The check order, byte-verified (wow-re `system/ui/scratch/addon-version-gate.md` §2, a §5
//! pair + arbitration): **missing → visiting-guard (returns loadable — a cycle resolves
//! optimistically) → enabled → banned → corrupt → the version gate → required-deps recursion →
//! demand gate**. The first check that fires decides.
//!
//! ## The version gate (§2.1, byte for byte)
//!
//! `cmp [rec+0x1c], 11200` — an **exact `==` against a hard-coded immediate** (`0x51d7d0` is
//! `mov eax,0x2bc0; ret`, one caller image-wide). `## Interface: 11201` is as out of date as
//! `10000`, and a manifest with **no** `## Interface` line compares as `0` — out of date, not
//! "unknown". On mismatch the reason `7` is written and then, when the check is OFF
//! (`checkAddonVersion`, the *Load out of date AddOns* checkbox inverted), **actively reset to
//! `0`** — an out-of-date addon under force-load is byte-indistinguishable from an up-to-date
//! one; there is no "loadable but flagged" state. Force-load is a fall-through, not a success:
//! the dependency and demand checks still run.
//!
//! ## The reason rendering (§2.3)
//!
//! `reason != 0` → its token; `reason == 0 && depReason != 0` → `DEP_<token>`, applied
//! **exactly once at any nesting depth** (the recursion passes the caller's `depReasonOut` as
//! the callee's *both* out-params, so the deepest failure's raw token lands in the parent's dep
//! slot); both zero → nil (all three callers guard it — `LOADABLE` is unreachable as a reason).
//! `BANNED`/`CORRUPT`/`INSECURE` gate on the server's `SMSG_ADDON_INFO` signature state, which
//! benilla does not model (nothing a player installs is Blizzard-signed) — the arms exist in
//! the table for honesty and are never produced.
/// The `## Interface` this client implements — the reference's own hard-coded `0x2bc0`.
pub const CLIENT_INTERFACE: u32 = 11200;
/// One addon as the gate reads it — the adapter shape every registry (the VM's `AddOnInfo`
/// rows, the app's discovered manifests, the glue screen's folder view) lowers into.
pub struct GateRow<'a> {
pub name: &'a str,
/// This character's enable state (`AddOns.txt`; an addon nobody disabled is enabled).
pub enabled: bool,
/// `## Interface` as the client parses it ([`crate::toc::Toc::interface_version`]):
/// the leading integer, `0` when absent.
pub interface: u32,
pub load_on_demand: bool,
/// Loaded this session. A loaded dependency satisfies the recursion outright — VERIFIED at
/// the bytes (wow-re `addon-enable-store.md`, the follow-up carve): the short-circuit lives
/// inside `AddOn_CanLoad` itself (`0x51e8ba call AddOn_IsLoaded; jne` skips the recursion),
/// and the recursion propagates the caller's own `demandOnly` (`0x51e790` spill →
/// `0x51e8c9` reload). The concrete verdicts match this implementation exactly: a
/// LoadOnDemand addon over a loaded ordinary dep is loadable/nil; over an
/// enabled-but-UNLOADED ordinary dep it reports `DEP_NOT_DEMAND_LOADED`.
pub loaded: bool,
/// `## Dependencies` / `## RequiredDeps` / any `## Dep*` (one list in the reference).
pub dependencies: Vec<&'a str>,
}
/// The arbiter's answer: loadable, or the two out-params.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Verdict {
Loadable,
Refused {
/// The addon's own reason token (`DISABLED`, `INTERFACE_VERSION`, …), when the failure
/// is its own.
reason: Option<&'static str>,
/// The dependency's raw token when the failure is a dep's — rendered as `DEP_<token>`.
dep: Option<&'static str>,
},
}
impl Verdict {
pub fn loadable(self) -> bool {
self == Verdict::Loadable
}
/// The formatter (`0x51e930`) plus its callers' nil-guard: the reason token, else
/// `DEP_<dep>`, else `None` (loadable — `LOADABLE` is unreachable as a reason).
pub fn token(self) -> Option<String> {
match self {
Verdict::Loadable => None,
Verdict::Refused { reason, dep } => reason
.map(str::to_owned)
.or_else(|| dep.map(|d| format!("DEP_{d}"))),
}
}
}
/// `AddOn_CanLoad` over a registry: can `rows[index]` load right now?
///
/// `demand_only` is the in-game flavour (`dl=1`): an enabled, up-to-date, **non**-LoadOnDemand
/// addon that has not loaded reports `NOT_DEMAND_LOADED` — a state the glue (`dl=0`) never
/// produces. `version_check` is the live `checkAddonVersion` read; both re-run per query, which
/// is why toggling the checkbox needs no rescan (§2.2).
pub fn can_load(rows: &[GateRow], index: usize, demand_only: bool, version_check: bool) -> Verdict {
let mut visiting = vec![false; rows.len()];
walk(rows, index, demand_only, version_check, &mut visiting)
}
/// One level of the arbiter — the checks in the reference's evaluation order.
fn walk(
rows: &[GateRow],
i: usize,
demand_only: bool,
version_check: bool,
visiting: &mut [bool],
) -> Verdict {
// Check 2: the in-progress guard — a cycle resolves optimistically (`[rec+0x2d]` → TRUE).
if visiting[i] {
return Verdict::Loadable;
}
let row = &rows[i];
// Check 3: this character's enable state.
if !row.enabled {
return Verdict::Refused {
reason: Some("DISABLED"),
dep: None,
};
}
// Checks 4/5 (banned / corrupt): no server signature state to read — never produced.
// Check 6: the version gate — exact ==, then refuse or actively fall through (§2.1).
if row.interface != CLIENT_INTERFACE && version_check {
return Verdict::Refused {
reason: Some("INTERFACE_VERSION"),
dep: None,
};
}
// Check 7: required dependencies, recursively; the first failure decides, and the deepest
// failure's raw token is the one `DEP_` wraps (§2.3's shared out-param).
visiting[i] = true;
for dep in &row.dependencies {
let found = rows.iter().position(|r| r.name.eq_ignore_ascii_case(dep));
let verdict = match found {
// The recursion's check 1: a name not in the registry is MISSING.
None => Verdict::Refused {
reason: Some("MISSING"),
dep: None,
},
// A loaded dependency satisfies the walk outright — `0x51e8ba`, before the
// recursion (the row doc has the carve).
Some(d) if rows[d].loaded => Verdict::Loadable,
Some(d) => walk(rows, d, demand_only, version_check, visiting),
};
if let Verdict::Refused { reason, dep } = verdict {
visiting[i] = false;
return Verdict::Refused {
reason: None,
dep: reason.or(dep),
};
}
}
visiting[i] = false;
// Check 8: the demand gate — reachable only from the in-game surface (`dl=1`).
if demand_only && !row.load_on_demand && !row.loaded {
return Verdict::Refused {
reason: Some("NOT_DEMAND_LOADED"),
dep: None,
};
}
Verdict::Loadable
}
#[cfg(test)]
mod tests {
use super::*;
fn row<'a>(name: &'a str, deps: Vec<&'a str>) -> GateRow<'a> {
GateRow {
name,
enabled: true,
interface: CLIENT_INTERFACE,
load_on_demand: false,
loaded: false,
dependencies: deps,
}
}
/// The check ORDER is the law: a disabled addon reports only DISABLED, whatever else is
/// wrong with it — the glue colours that row grey, never red (1197's precedence).
#[test]
fn a_disabled_addon_reports_only_disabled() {
let mut a = row("A", vec!["Ghost"]);
a.enabled = false;
a.interface = 0; // out of date too — and still invisible behind DISABLED
let rows = vec![a];
assert_eq!(
can_load(&rows, 0, false, true).token().as_deref(),
Some("DISABLED")
);
}
/// §2.1 byte for byte: exact `==` (11201 is out of date), missing `## Interface` is 0 and
/// out of date, and force-load RESETS the reason — indistinguishable from up to date.
#[test]
fn the_version_gate_is_exact_and_force_load_erases_it() {
let mut a = row("A", vec![]);
a.interface = 11201;
let rows = vec![a];
assert_eq!(
can_load(&rows, 0, false, true).token().as_deref(),
Some("INTERFACE_VERSION")
);
assert_eq!(can_load(&rows, 0, false, false), Verdict::Loadable);
let mut b = row("B", vec![]);
b.interface = 0; // no ## Interface line
let rows = vec![b];
assert_eq!(
can_load(&rows, 0, false, true).token().as_deref(),
Some("INTERFACE_VERSION")
);
}
/// §2.3: `DEP_` is applied exactly once at any depth — the deepest failure's raw token is
/// what the top level wraps.
#[test]
fn dep_prefix_applies_once_at_any_depth() {
let a = row("A", vec!["B"]);
let b = row("B", vec!["C"]);
let mut c = row("C", vec![]);
c.enabled = false;
let rows = vec![a, b, c];
assert_eq!(
can_load(&rows, 0, false, true).token().as_deref(),
Some("DEP_DISABLED"),
"not DEP_DEP_DISABLED — the shared out-param collapses the nesting"
);
// And a dep that is not installed at all is the recursion's own check 1.
let rows = vec![row("A", vec!["Ghost"])];
assert_eq!(
can_load(&rows, 0, false, true).token().as_deref(),
Some("DEP_MISSING")
);
}
/// Check 2: a dependency cycle resolves optimistically (the visiting guard returns TRUE),
/// so neither side reports a failure from the query side.
#[test]
fn a_cycle_is_loadable_to_the_query() {
let a = row("Ping", vec!["Pong"]);
let b = row("Pong", vec!["Ping"]);
let rows = vec![a, b];
assert_eq!(can_load(&rows, 0, false, true), Verdict::Loadable);
}
/// Check 8 is in-game only (`dl=1`), runs AFTER the dep loop, and force-load does not skip
/// it — an out-of-date addon under force-load can still be NOT_DEMAND_LOADED.
#[test]
fn the_demand_gate_is_in_game_only_and_survives_force_load() {
let mut a = row("A", vec![]);
a.interface = 11507;
let rows = vec![a];
assert_eq!(can_load(&rows, 0, false, false), Verdict::Loadable);
assert_eq!(
can_load(&rows, 0, true, false).token().as_deref(),
Some("NOT_DEMAND_LOADED"),
"force-load falls THROUGH, it does not succeed (§2.1 fact 2)"
);
}
/// A LoadOnDemand addon whose required dep already loaded at startup is loadable from the
/// in-game surface — the loaded short-circuit, VERIFIED at `0x51e8ba` (inside `AddOn_CanLoad`,
/// before the recursion; the carve's own concrete A/B verdict is this assertion).
#[test]
fn a_loaded_dependency_satisfies_the_demand_query() {
let mut a = row("A", vec!["B"]);
a.load_on_demand = true;
let mut b = row("B", vec![]);
b.loaded = true;
let rows = vec![a, b];
assert_eq!(can_load(&rows, 0, true, true), Verdict::Loadable);
}
}
+8
View File
@@ -120,6 +120,14 @@ impl super::UiScript {
pub fn take_cvar_changes(&mut self) -> Vec<(String, String)> {
std::mem::take(&mut self.model_mut().cvar_changes)
}
/// A native surface's write — [`set_from_engine`]'s public face: sets the value AND queues
/// the change like a Lua `SetCVar`, so the host's sync dirties the config file. The glue
/// AddOns screen's *Load out of date AddOns* checkbox is the caller (decision 1293): a
/// native widget editing a CVar is the minimap-zoom pattern, reached from outside the crate.
pub fn set_cvar_engine(&mut self, name: &str, value: &str) {
set_from_engine(&mut self.model_mut(), name, value.to_string());
}
}
/// An **engine-side** write: a value the engine owns moved, and the CVar table mirrors it. The
+175 -36
View File
@@ -39,7 +39,8 @@ use super::Model;
/// One host-registered command: the 1.12 name, its category header's global-string key
/// (`BINDING_HEADER_MOVEMENT`), whether it has press+release semantics (`runOnUp` — the
/// mousewheel-refusal law rides this), and the 1.12 default chords.
/// release half of a press rides this — `RunCommand 0x4b7bf1`, and nothing else: it is NOT a
/// bindability rule, see [`normalize_binding_key`]), and the 1.12 default chords.
#[derive(Clone, Copy, Debug)]
pub struct KeybindCommand {
pub name: &'static str,
@@ -75,6 +76,103 @@ pub enum KeybindRequest {
Save(u32),
}
/// The reference's key-string gate — the whole of `SetBinding`'s refusal (decision 1295), in its
/// two halves: the alias normalization at the head of `CBindings::SetBinding 0x4b7490`, and the
/// validator `IsValidBindingKeyString 0x4b7890` it then calls.
///
/// `Some` is the string to STORE — upper-cased, because the table's hash and its bucket compare
/// both fold case (`0x64b3f0` / `0x64a4c0`), so `"shift-w"` and `"SHIFT-W"` are one key. `None` is
/// the refusal, and it is the *only* one the reference has: no command is ever consulted.
///
/// The two halves disagree about case on purpose, and that is verified, not a guess: the alias
/// compare is case-insensitive, the validator is case-SENSITIVE at all three of its sites
/// (`0x64a480` → `0x40de80`). So `SetBinding("mousewheelup", …)` answers nil while
/// `SetBinding("MOUSEWHEELUP", …)` binds — and a *lookup* for either finds it.
pub fn normalize_binding_key(key: &str) -> Option<String> {
// Eleven punctuation NAMES become their literal character. A WHOLE-STRING compare, so
// `SHIFT-LEFTBRACKET` is not normalized — and then fails the validator, where the accepted
// spelling `SHIFT-[` passes.
const ALIASES: [(&str, &str); 11] = [
("LEFTBRACKET", "["),
("RIGHTBRACKET", "]"),
("SLASH", "/"),
("BACKSLASH", "\\"),
("SEMICOLON", ";"),
("APOSTROPHE", "'"),
("COMMA", ","),
("PERIOD", "."),
("TILDE", "`"),
("PLUS", "="),
("MINUS", "-"),
];
let key = ALIASES
.iter()
.find(|(name, _)| name.eq_ignore_ascii_case(key))
.map_or(key, |(_, literal)| literal);
is_valid_binding_key(key).then(|| key.to_ascii_uppercase())
}
/// `IsValidBindingKeyString 0x4b7890`, arm for arm.
fn is_valid_binding_key(key: &str) -> bool {
// 1. Strip `SHIFT-`/`CTRL-`/`ALT-` repeatedly — the loop restarts while any matched
// (`0x4b78a0`–`0x4b78d0`), so the validator takes modifiers in ANY order and REPEATED,
// unlike the fixed ALT-CTRL-SHIFT order the chord builder emits.
let mut rest = key;
while let Some(next) = ["SHIFT-", "CTRL-", "ALT-"]
.iter()
.find_map(|p| rest.strip_prefix(p))
{
rest = next;
}
// 2. One character passes (`0x41aab0` decode, `0x4b78e6`) — which is how every letter, digit
// and punctuation key is bindable without being named anywhere. The empty string lands in
// the same arm and passes too; `SHIFT-` alone is therefore a legal (unpressable) key.
if rest.chars().count() <= 1 {
return true;
}
// 3. `F`/`NUMPAD`/`BUTTON` (the table at `0x846c04`) followed by digits and nothing else.
// `NUMPADPLUS` falls through here to arm 4, its remainder not being digits.
for prefix in ["F", "NUMPAD", "BUTTON"] {
if let Some(digits) = rest.strip_prefix(prefix) {
if !digits.is_empty() && digits.bytes().all(|b| b.is_ascii_digit()) {
return true;
}
}
}
// 4. The 26 names at `0x846c20`…`0x846c84`. `SHIFT`/`CTRL`/`ALT`/`UNKNOWN` are in none of the
// arms, which is why a lone modifier can never be bound — and `SCROLLLOCK`/`PAUSE` are in
// none either, matching the namer that calls their key codes `UNKNOWN` (`0x210`/`0x211`).
const NAMES: [&str; 26] = [
"SPACE",
"NUMPADPLUS",
"NUMPADMINUS",
"NUMPADMULTIPLY",
"NUMPADDIVIDE",
"NUMPADDECIMAL",
"ESCAPE",
"ENTER",
"BACKSPACE",
"TAB",
"LEFT",
"UP",
"RIGHT",
"DOWN",
"INSERT",
"DELETE",
"HOME",
"END",
"PAGEUP",
"PAGEDOWN",
"NUMLOCK",
"CAPSLOCK",
"PRINTSCREEN",
"NUMPADEQUALS",
"MOUSEWHEELDOWN",
"MOUSEWHEELUP",
];
NAMES.contains(&rest)
}
#[derive(Clone, Debug)]
struct Entry {
name: String,
@@ -138,40 +236,33 @@ impl KeybindState {
self.entries.iter().filter(|e| !e.hidden)
}
/// Strip the reference's modifier prefixes; what remains is the base token (which may itself
/// be `-` — `CTRL--` is Ctrl+minus, so the strip walks prefixes, never splits on `-`).
fn base_token(chord: &str) -> &str {
let mut rest = chord;
loop {
let Some(next) = ["ALT-", "CTRL-", "SHIFT-"]
.iter()
.find_map(|p| rest.strip_prefix(p))
else {
return rest;
};
rest = next;
}
}
/// The reference's `SetBinding(key[, command])`: unbind `key` wherever it is, then (with a
/// command) append it to that command's key list. Returns `false` for the one refusal the
/// reference has — a mousewheel chord on a press+release (`runOnUp`) command
/// (`KEYBINDINGFRAME_MOUSEWHEEL_ERROR`: a wheel tick cannot be released).
/// command) append it to that command's key list.
///
/// **The only thing it refuses is a key string that is not a key** (decision 1295). We used to
/// refuse a mousewheel chord on a press+release (`runOnUp`) command — the reading everyone
/// takes from `Blizzard_BindingUI`'s `if not SetBinding(…) then … KEYBINDINGFRAME_MOUSEWHEEL_
/// ERROR`. It is wrong: `CBindings::SetBinding 0x4b7490` never reads a command node at all
/// (neither the target's nor the incumbent's), and its one reachable falsey return is the
/// validator at `0x4b762c`. `"runOnUp"` has exactly one reader image-wide — `0x4b7bf1`, in
/// `RunCommand`, gating the up half. So 1.12 binds the wheel to an action button happily, and
/// B265 was ours. wow-re `system/ui/scratch/setbinding-refusal-law.md`.
fn set_binding(&mut self, key: &str, command: Option<&str>) -> bool {
let key = key.to_ascii_uppercase();
let Some(key) = normalize_binding_key(key) else {
return false;
};
// The one divergence left here (1295): the reference stores the action string verbatim,
// so `SetBinding("K", "NOSUCHCOMMAND")` answers 1 there and nil here. Our table is
// command-centric — a key list per registered command — and has nowhere to put a binding
// whose command does not exist. Nothing reachable today can hit it: the window offers
// only registered rows, and an addon's own names register from its `Bindings.xml`.
let cmd_idx = match command {
Some(name) => match self.by_name.get(&name.to_ascii_uppercase()) {
Some(&i) => Some(i),
None => return false, // unknown command — refuse, the window never offers one
None => return false,
},
None => None,
};
if let Some(i) = cmd_idx {
let wheel = matches!(Self::base_token(&key), "MOUSEWHEELUP" | "MOUSEWHEELDOWN");
if wheel && self.entries[i].run_on_up {
return false;
}
}
for e in &mut self.entries {
e.keys.retain(|k| k != &key);
}
@@ -642,7 +733,7 @@ mod tests {
}
#[test]
fn set_binding_steals_and_the_wheel_refusal_holds() {
fn set_binding_steals_and_refuses_only_a_key_string_that_is_not_a_key() {
let s = script();
let g0 = s.keybinds_generation();
// Bind W to JUMP: stolen from MOVEFORWARD (its slot 1 empties, UP slides up), appended
@@ -666,13 +757,61 @@ mod tests {
.unwrap(),
""
);
// The one refusal: a wheel chord on a press+release command (1.12's
// KEYBINDINGFRAME_MOUSEWHEEL_ERROR); a wheel on a click command binds fine.
// **The wheel binds to a press+release command** (B265, decision 1295). This assertion
// used to read `== nil`, on the universal misreading of `Blizzard_BindingUI`'s
// `if not SetBinding(…) then … KEYBINDINGFRAME_MOUSEWHEEL_ERROR`. `0x4b7490` never looks
// at a command node, so 1.12 takes it — and the notch runs both halves.
assert!(s
.eval::<bool>(r#"return SetBinding("SHIFT-MOUSEWHEELUP", "MOVEFORWARD") == nil"#)
.eval::<bool>(r#"return SetBinding("SHIFT-MOUSEWHEELUP", "MOVEFORWARD") == 1"#)
.unwrap());
assert!(s
.eval::<bool>(r#"return SetBinding("SHIFT-MOUSEWHEELUP", "CAMERAZOOMIN") == 1"#)
.eval::<bool>(r#"return SetBinding("MOUSEWHEELDOWN", "CAMERAZOOMIN") == 1"#)
.unwrap());
// The refusal that IS there: `IsValidBindingKeyString 0x4b7890`. A lone modifier, a name
// in none of its four arms, and an alias that is not the whole string all fail; the
// pure unbind runs the same validator, so it fails on the same strings.
for bad in ["SHIFT", "ALT", "UNKNOWN", "SCROLLLOCK", "SHIFT-LEFTBRACKET"] {
assert!(
s.eval::<bool>(&format!(r#"return SetBinding("{bad}", "JUMP") == nil"#))
.unwrap(),
"{bad} is not a bindable key string"
);
assert!(
s.eval::<bool>(&format!(r#"return SetBinding("{bad}") == nil"#))
.unwrap(),
"{bad} fails the same validator on the pure unbind"
);
}
// …and the ones that pass: one character (with modifiers in any order and repeated),
// F/NUMPAD/BUTTON + digits, the 26 names, and a whole-string punctuation alias.
for good in [
"SHIFT-CTRL-ALT-K",
"CTRL-SHIFT-K",
"-",
"BUTTON5",
"NUMPAD7",
"F11",
"NUMPADEQUALS",
"PRINTSCREEN",
] {
assert!(
s.eval::<bool>(&format!(r#"return SetBinding("{good}", "JUMP") == 1"#))
.unwrap(),
"{good} is a bindable key string"
);
}
assert!(s
.eval::<bool>(r#"return SetBinding("LEFTBRACKET", "JUMP") == 1"#)
.unwrap());
assert_eq!(
s.eval::<String>(r#"return GetBindingAction("[")"#).unwrap(),
"JUMP",
"the alias is normalized to its literal character before it is stored"
);
// The validator is case-SENSITIVE where the lookup behind it is not: a lower-case key
// string never reaches the table at all.
assert!(s
.eval::<bool>(r#"return SetBinding("shift-w", "JUMP") == nil"#)
.unwrap());
assert!(
s.keybinds_generation() > g0,
@@ -793,13 +932,13 @@ mod tests {
s.eval::<String>(r#"return GetBindingAction("H")"#).unwrap(),
"PROBEHIDDEN"
);
// And it rides the same laws: the wheel refusal reads the addon row's `runOnUp` exactly
// as it reads a host command's press+release class.
// And it rides the same laws: `runOnUp` decides whether the body runs again on the
// release, never whether the row may take a wheel chord (1295) — so both bind.
assert!(s
.eval::<bool>(r#"return SetBinding("MOUSEWHEELUP", "PROBEHOLD") == nil"#)
.eval::<bool>(r#"return SetBinding("MOUSEWHEELUP", "PROBEHOLD") == 1"#)
.unwrap());
assert!(s
.eval::<bool>(r#"return SetBinding("MOUSEWHEELUP", "PROBEEDGE") == 1"#)
.eval::<bool>(r#"return SetBinding("MOUSEWHEELDOWN", "PROBEEDGE") == 1"#)
.unwrap());
// A second addon, declaring no header at all: its rows are its own section, named for it.
+37 -15
View File
@@ -527,15 +527,19 @@ impl UiScript {
// (`CreateFrame("Frame", n, UIParent)` with no size and no SetPoint) holding a
// region anchored elsewhere. That is ordinary addon code — MapCoords builds three
// of them, and its world-map coordinate readout computed the right string every
// frame and was never positioned, with no error anywhere. Degenerate rather than
// absent: an unpositioned owner contributes a zero rect, which is what an
// unpositioned frame IS.
let owner_rect = resolved.get(&owner).copied().unwrap_or(Rect {
left: 0.0,
bottom: 0.0,
right: 0.0,
top: 0.0,
});
// frame and was never positioned, with no error anywhere.
//
// It is only the FALLBACK, though, and an owner with no rect cannot supply one: an
// unpinned axis on an unpositioned owner has nothing to fall back TO, and the
// region is unresolvable exactly as its owner is. Standing a zero rect in there
// instead put the region at the SCREEN ORIGIN, where a template's sibling-chained
// textures resolve off it into real on-screen geometry — the stray dropdown capsule
// at the bottom of the screen when the social pane opened (B264:
// `BenillaFriendsDropDown` carries no anchors, exactly as the reference's
// `FriendsDropDown` does, and the reference draws nothing). `None` keeps the
// MapCoords fix — a region its own anchors fully pin never consults this — without
// inventing a position for a frame that has none.
let owner_rect = resolved.get(&owner).copied();
let scale = arena.frame(owner).map(|f| f.effective_scale).unwrap_or(1.0);
// A FontString with no explicit height takes its host-measured wrapped size
// (the measure round-trip — the client's layout↔font-engine seam).
@@ -572,15 +576,33 @@ impl UiScript {
// the line chain (each line anchors to the previous line's resolved bottom)
// marched every later line out of the plate. An axis with NO pinned edge keeps
// the owner fallback (nothing to collapse onto).
let axis = |lo: Option<f32>, hi: Option<f32>, olo: f32, ohi: f32| -> (f32, f32) {
//
// `None` = this axis needs the owner and the owner has no rect (above): the region
// is unresolvable, like its owner.
let axis = |lo: Option<f32>,
hi: Option<f32>,
owner: Option<(f32, f32)>|
-> Option<(f32, f32)> {
match (is_fontstring, lo, hi) {
(true, Some(l), None) => (l, l),
(true, None, Some(h)) => (h, h),
_ => (lo.unwrap_or(olo), hi.unwrap_or(ohi)),
(true, Some(l), None) => Some((l, l)),
(true, None, Some(h)) => Some((h, h)),
(_, Some(l), Some(h)) => Some((l, h)),
_ => {
let (olo, ohi) = owner?;
Some((lo.unwrap_or(olo), hi.unwrap_or(ohi)))
}
}
};
let (bottom, top) = axis(edges[0], edges[2], owner_rect.bottom, owner_rect.top);
let (left, right) = axis(edges[1], edges[3], owner_rect.left, owner_rect.right);
let vertical = axis(edges[0], edges[2], owner_rect.map(|o| (o.bottom, o.top)));
let horizontal = axis(edges[1], edges[3], owner_rect.map(|o| (o.left, o.right)));
let (Some((bottom, top)), Some((left, right))) = (vertical, horizontal) else {
// Unresolvable: drop any rect it used to have, the way the frame pass does —
// a region that stops resolving must stop drawing, not keep a stale position.
if region_resolved.remove(&rh).is_some() {
changed = true;
}
continue;
};
let rect = Rect::new(bottom, left, top, right);
// Publish into the solver as well as the model: a later region in THIS same sweep
// that anchors to this one must see the fresh rect (the sweep has always worked
+1
View File
@@ -57,6 +57,7 @@ mod death;
mod editbox;
pub(crate) use editbox::adopt_text_region;
pub(crate) mod addon;
pub mod addon_gate;
mod client;
mod cvars;
mod dressup;
@@ -176,6 +176,61 @@ fn a_region_resolves_even_when_its_owner_frame_has_no_rect() {
);
}
/// **…but a region the owner's rect is the ONLY candidate for stays unresolved with it.**
///
/// The other half of the law above, and the half that shipped wrong: an unpinned axis on an
/// unpositioned owner has nothing to fall back to, so standing a zero rect in for the missing owner
/// puts the region at the **screen origin** instead of nowhere. That is not a degenerate rect, it is
/// a wrong position — and a template whose textures chain off each other turns it into real,
/// visible geometry a few links down.
///
/// Reported as B264 (carni, 2026-08-13): opening the social pane drew a stray dropdown capsule at
/// the bottom of the screen next to the action bar. `BenillaFriendsDropDown` carries no anchors —
/// *exactly* as the reference's own `FriendsDropDown` does (`FriendsFrame.xml` l.598), and the
/// reference draws nothing — so every texture of `UIDropDownMenuTemplate` hung off a phantom rect
/// at (0,0). The shape below is that template's first two textures verbatim.
#[test]
fn an_unanchored_owners_region_chain_resolves_nowhere() {
let mut s = crate::script::UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
s.run(
r#"
-- The dropdown host: a child frame with no anchors and no size, as declared.
bare = CreateFrame("Frame", "StrayHost")
-- $parentLeft: anchored to the OWNER (no relativeTo = the owner frame), which has no rect.
cap = bare:CreateTexture("StrayLeft", "ARTWORK")
cap:SetWidth(25) cap:SetHeight(64)
cap:SetPoint("TOPLEFT")
-- $parentMiddle: the sibling chain that turned a zero rect into 115x64 of visible capsule.
mid = bare:CreateTexture("StrayMiddle", "ARTWORK")
mid:SetWidth(115) mid:SetHeight(64)
mid:SetPoint("LEFT", cap, "RIGHT")
"#,
)
.unwrap();
s.resolve();
assert_eq!(
s.eval::<Option<f32>>("return StrayHost:GetLeft()").unwrap(),
None,
"the premise: an unanchored frame has no rect"
);
assert_eq!(
s.eval::<Option<f32>>("return StrayLeft:GetLeft()").unwrap(),
None,
"a region with nothing but its unpositioned owner to derive from has no rect either — \
standing in a zero rect here is what put the capsule at the screen origin"
);
assert_eq!(
s.eval::<Option<f32>>("return StrayMiddle:GetLeft()")
.unwrap(),
None,
"and the chain off it stays unresolved — this is the link that was actually visible"
);
}
/// **Geometry answers inside the same call stack that moved it** — the seam that killed 97 addons.
///
/// A handler creates a frame, anchors it, shows it and then measures it, all before any resolve
+16
View File
@@ -69,6 +69,22 @@ impl Toc {
.unwrap_or_default()
}
/// `## Interface:` **as the 1.12 client reads it** (decision 1292): the leading integer of
/// the value, and a manifest with no `## Interface` line (or a non-numeric one) is `0`.
/// Byte-verified (wow-re `addon-version-gate.md` §2.2): `Toc_Parse 0x51c9b0` stores
/// `SStrToInt` of the value at `[rec+0x1c]`, the record ctor leaves it `0`, and the version
/// gate compares that single dword — so an Era manifest's `11507, 11508` reads as `11507`,
/// exactly as the reference would read it. [`Toc::interface_versions`] stays for display;
/// the GATE runs on this.
pub fn interface_version(&self) -> u32 {
self.directive("Interface")
.map(|v| {
let digits: String = v.trim().chars().take_while(char::is_ascii_digit).collect();
digits.parse().unwrap_or(0)
})
.unwrap_or(0)
}
/// A comma-separated list directive (`SavedVariables`, `OptionalDeps`, `Dependencies`, …),
/// split and trimmed. Missing directive = empty list.
pub fn list(&self, key: &str) -> Vec<&str> {