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:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
-->
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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."
|
||||
);
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
@@ -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,
|
||||
_ => {}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {}/{} \
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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'_')
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
///
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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> {
|
||||
|
||||
Reference in New Issue
Block a user