addons come alive: Bagnon draws, hovers and clicks

- Lua 5.0's generic-for and nested long strings are restored in a forked VM — 60 corpus addons stopped dying on one deleted opcode
- a day of demand-driven carving takes the 218-addon corpus from 24 addons surviving a session start to 114, with 144 loading without a single error
- Bagnon draws a real bag and answers real gestures — hover tooltip, pickup, use, drag — because our frames wear the reference's names, and the reference's own ContainerFrame.lua runs off the player's install instead of being transcribed
- the physics broad phase leaves: nothing in the client reads a contact, and it was measured at up to 41% of main-thread samples in real sessions

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-08-11 18:19:08 +07:00
co-authored by Claude Fable 5
parent b5bc569156
commit 251daa14c5
104 changed files with 14356 additions and 2306 deletions
Generated
-2
View File
@@ -4477,8 +4477,6 @@ dependencies = [
[[package]]
name = "lua-src"
version = "550.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e836dc8ae16806c9bdcf42003a88da27d163433e3f9684c52f0301258004a4fb"
dependencies = [
"cc",
]
+17 -2
View File
@@ -17,8 +17,23 @@ anyhow = "1.0.102"
bevy = "0.18.1"
clap = { version = "4.6.1", features = ["derive"] }
# No `[patch.crates-io]`: the WoW format + protocol stack is fully in-repo (decision 0021), so there
# are no warcraft-rs / wow_messages crates (or forks of them) anywhere in the tree.
# The WoW format + protocol stack is fully in-repo (decision 0021), so there are no warcraft-rs /
# wow_messages crates (or forks of them) anywhere in the tree. That is still true and is why this
# section stood empty for the project's whole life.
#
# The one entry below is not a fork of a WoW crate at all. `lua-src` compiles the Lua the client's
# addon VM runs on, and the 1.12 corpus needs one opcode that Lua 5.1 deleted — the iterator-less
# generic-for (`for k, v in someTable do`), which 183 of 218 corpus addons are reached by and which
# is the first session-start error for 60 of them. It is an OPCODE, so no layer above the VM can
# reach it: not a library function, not a global, not a metamethod.
#
# The fork is currently BYTE-IDENTICAL to upstream's 5.1.5 sources, and is landed that way on
# purpose: the pipeline is proven transparent before the behavioural payload goes into it, so that
# when the suite moves, the patch is the only thing that could have moved it. The payload waits on
# an RE answer about what the real client's own Lua does — stock Lua 5.0 is corroboration, not the
# authority. `third_party/lua-src/BENILLA.md` has the current state and the verifying command.
[patch.crates-io]
lua-src = { path = "third_party/lua-src" }
# Optimize *dependencies* even in dev builds. avian/parry collision math (and wgpu/bevy render) is
# unusably slow at opt-level 0 — a plain `cargo run` crawls to <1 fps once physics is doing real work.
+112
View File
@@ -446,6 +446,34 @@
end
end
-- ── The reference's names, so an addon that hooks them is reached ──────────────────────
--
-- The 1.12 customisation model is "replace a global and the client calls your replacement"
-- (Bagnon does it to `ToggleBackpack`; `zBar`/`zBarEx` do
-- `SetScript("OnShow", ActionButton_Update)`; `CT_BarMod` calls `ActionButton_Update()`
-- outright). Wherever our UI calls a `Benilla`-named function and never the reference's
-- name, every one of those addons is SILENTLY INERT — the override is installed and never
-- invoked. A survey cannot see it; it loads addons and fires events but never clicks.
--
-- These are ADAPTERS, not renames, and the distinction is the whole point: the reference's
-- contract is `this`-shaped and no-argument, ours takes the button explicitly. Renaming
-- would let `TipBuddy`'s `originalUnitFrame_OnEnter()` reach us with a nil frame — reachable
-- but broken, which is worse than inert. The adapter publishes the reference's SHAPE and
-- delegates.
--
-- NOT adapted, and named so nobody assumes it was missed: `ActionButton_OnLoad`. Ours is
-- `(button, index, base, commandPrefix)` and the reference's takes nothing — the extra
-- arguments are real state our per-container registration needs, so no adapter can supply
-- them. That is the per-button event registration change already at the top of the arc
-- queue, not a naming fix.
function ActionButton_Update()
BenillaActionButton_Update(this)
end
function ActionButton_UpdateUsable()
BenillaActionButton_UpdateUsable(this)
end
function BenillaActionButton_Update(button)
local action = BenillaActionButton_Action(button)
if HasAction(action) then
@@ -1361,4 +1389,88 @@
<OnEvent>BenillaActionBar_OnEvent(self)</OnEvent>
</Scripts>
</Frame>
<!-- **The bonus action bar, built but never shown** — the same posture 1219 gave the vertical
multibars, and for the same reason: `ref-BonusActionBarFrame.xml` l.54 instantiates
`BonusActionBarFrame` as a real `parent="MainMenuBar"` frame carrying `hidden="true"`, with
`BonusActionButton1..12` inside (l.88+). It exists from load on the real client and shows
only while a form/stealth page is active.
benilla models the bonus page by RE-PAGING THE MAIN BAR (`set_bonus_bar_offset` +
UPDATE_BONUS_ACTIONBAR — the main bar's own fork), so this bar is never shown by us. It is
declared because addons reach for it by name and die when it is absent: the largest single
session-start row in the corpus was `CT_BarMod\CT_BarModOptions.lua:154`,
getglobal("BonusActionButton" .. i):ClearAllPoints();
which is pure LAYOUT — four addons (Bongos_MapBar, Bongos_Options, Bongos_RollBar,
CT_BottomBar) only need the buttons to exist and accept ClearAllPoints/SetPoint. Five more
index `BonusActionBarFrame` (decision 1218's corrected ranking).
The buttons wire to the MAIN bar's paged slots — `BenillaActionButton_OnLoad(self, i)`,
exactly as ActionButton1..12 do. That is not a placeholder: our bonus model re-pages the
main bar, so a bonus button showing the main bar's current paged slot is the coherent
answer, and it is also the reference's binding story (vanilla's ACTIONBUTTON1-12 drive the
bonus bar while a form is up, so sharing the command prefix is faithful). Wiring them also
means the template's OnUpdate cannot meet a button with no `.index` if an addon shows one.
Geometry quoted from the reference: a 505x43 frame anchored TOPLEFT to MainMenuBar's
BOTTOMLEFT +(40,0); button 1 at BOTTOMLEFT +(5,4), each next LEFT-of-previous-RIGHT +6
(l.55-62, l.88-105). -->
<Frame name="BonusActionBarFrame" frameStrata="HIGH" parent="MainMenuBar" enableMouse="true" toplevel="true" hidden="true">
<Size><AbsDimension x="505" y="43"/></Size>
<Anchors>
<Anchor point="TOPLEFT" relativeTo="MainMenuBar" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="40" y="0"/></Offset></Anchor>
</Anchors>
<Frames>
<CheckButton name="BonusActionButton1" inherits="BenillaActionButtonTemplate" id="1">
<Anchors><Anchor point="BOTTOMLEFT" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="5" y="4"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 1)</OnLoad></Scripts>
</CheckButton>
<CheckButton name="BonusActionButton2" inherits="BenillaActionButtonTemplate" id="2">
<Anchors><Anchor point="LEFT" relativeTo="BonusActionButton1" relativePoint="RIGHT"><Offset><AbsDimension x="6" y="0"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 2)</OnLoad></Scripts>
</CheckButton>
<CheckButton name="BonusActionButton3" inherits="BenillaActionButtonTemplate" id="3">
<Anchors><Anchor point="LEFT" relativeTo="BonusActionButton2" relativePoint="RIGHT"><Offset><AbsDimension x="6" y="0"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 3)</OnLoad></Scripts>
</CheckButton>
<CheckButton name="BonusActionButton4" inherits="BenillaActionButtonTemplate" id="4">
<Anchors><Anchor point="LEFT" relativeTo="BonusActionButton3" relativePoint="RIGHT"><Offset><AbsDimension x="6" y="0"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 4)</OnLoad></Scripts>
</CheckButton>
<CheckButton name="BonusActionButton5" inherits="BenillaActionButtonTemplate" id="5">
<Anchors><Anchor point="LEFT" relativeTo="BonusActionButton4" relativePoint="RIGHT"><Offset><AbsDimension x="6" y="0"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 5)</OnLoad></Scripts>
</CheckButton>
<CheckButton name="BonusActionButton6" inherits="BenillaActionButtonTemplate" id="6">
<Anchors><Anchor point="LEFT" relativeTo="BonusActionButton5" relativePoint="RIGHT"><Offset><AbsDimension x="6" y="0"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 6)</OnLoad></Scripts>
</CheckButton>
<CheckButton name="BonusActionButton7" inherits="BenillaActionButtonTemplate" id="7">
<Anchors><Anchor point="LEFT" relativeTo="BonusActionButton6" relativePoint="RIGHT"><Offset><AbsDimension x="6" y="0"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 7)</OnLoad></Scripts>
</CheckButton>
<CheckButton name="BonusActionButton8" inherits="BenillaActionButtonTemplate" id="8">
<Anchors><Anchor point="LEFT" relativeTo="BonusActionButton7" relativePoint="RIGHT"><Offset><AbsDimension x="6" y="0"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 8)</OnLoad></Scripts>
</CheckButton>
<CheckButton name="BonusActionButton9" inherits="BenillaActionButtonTemplate" id="9">
<Anchors><Anchor point="LEFT" relativeTo="BonusActionButton8" relativePoint="RIGHT"><Offset><AbsDimension x="6" y="0"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 9)</OnLoad></Scripts>
</CheckButton>
<CheckButton name="BonusActionButton10" inherits="BenillaActionButtonTemplate" id="10">
<Anchors><Anchor point="LEFT" relativeTo="BonusActionButton9" relativePoint="RIGHT"><Offset><AbsDimension x="6" y="0"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 10)</OnLoad></Scripts>
</CheckButton>
<CheckButton name="BonusActionButton11" inherits="BenillaActionButtonTemplate" id="11">
<Anchors><Anchor point="LEFT" relativeTo="BonusActionButton10" relativePoint="RIGHT"><Offset><AbsDimension x="6" y="0"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 11)</OnLoad></Scripts>
</CheckButton>
<CheckButton name="BonusActionButton12" inherits="BenillaActionButtonTemplate" id="12">
<Anchors><Anchor point="LEFT" relativeTo="BonusActionButton11" relativePoint="RIGHT"><Offset><AbsDimension x="6" y="0"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 12)</OnLoad></Scripts>
</CheckButton>
</Frames>
</Frame>
</Ui>
+45 -3
View File
@@ -43,6 +43,32 @@
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
@@ -72,10 +98,16 @@
-- 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, url, loadable, reason = GetAddOnInfo(index)
local enabled = not (reason == "DISABLED")
return name, (title or name), loadable, reason, enabled, notes, url
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()
@@ -117,6 +149,16 @@
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
+145 -47
View File
@@ -439,7 +439,7 @@
-- `frame` IS the backpack — preserving that global's observability (existing tests patch it
-- directly to count backpack repaints, e.g. delete_item_tests.rs's stuck-darkening
-- regression check) — and BenillaBagWindow_Update generically for every other window. Every
-- runtime repaint trigger (a slot click, an event, BenillaOpenAllBags' pre-show paint) goes
-- runtime repaint trigger (a slot click, an event, OpenAllBags' pre-show paint) goes
-- through this; BenillaBagBarSlot_OnClick calls BenillaBagWindow_Update directly (it only ever
-- targets bags 1..4).
function BenillaBagWindow_Repaint(frame)
@@ -507,7 +507,23 @@
-- by bag id (BENILLA_BAG_WINDOWS[bagId+1]) — one code path over all of them.
BENILLA_BAG_WINDOWS = { "BenillaBagFrame", "BenillaBagFrame1", "BenillaBagFrame2", "BenillaBagFrame3", "BenillaBagFrame4" }
function BenillaOpenAllBags()
-- THE REFERENCE'S NAMES ARE THE REAL ENTRY POINTS, and our own UI calls THEM.
--
-- This is how a 1.12 addon customises anything: it replaces a global and expects the
-- client to call its replacement. `Bagnon_Core/core/Overrides.lua` is the canonical shape —
-- local bToggleBackpack = ToggleBackpack
-- ToggleBackpack = function() ... end
-- and until now that override was INERT here, because the 'B' binding and the bag button
-- called `BenillaBagToggle_OnClick` directly and nothing ever looked up `ToggleBackpack`.
-- Bagnon loaded, appeared in the AddOns list, replaced the globals, and the player still
-- got the stock bags. The director saw that; no instrument here could, because the corpus
-- survey loads addons and fires events but never CLICKS anything.
--
-- The class is far wider than one addon: every "hook a global to change behaviour" addon is
-- silently inert wherever our UI calls a benilla-named equivalent instead of the
-- reference's name. `CloseAllBags`/`OpenBackpack`/`CloseBackpack` already lived under the
-- ref's names in UiPanels.xml for exactly this reason; these are the three that did not.
function OpenAllBags()
for bagId = 0, 4 do
-- The backpack always exists; an equipped-bag slot only when a bag is in it.
if bagId == 0 or GetContainerNumSlots(bagId) > 0 then
@@ -520,27 +536,79 @@
end
end
function BenillaCloseAllBags()
for _, name in ipairs(BENILLA_BAG_WINDOWS) do
local frame = getglobal(name)
if frame and frame:IsShown() then frame:Hide() end
-- `ToggleBag(id)` — the ref's per-bag toggle (ContainerFrame.lua l.152-170), and the other
-- global Bagnon replaces. Ours drives the same window the all-bags path does.
function ToggleBag(id)
local frame = getglobal(BENILLA_BAG_WINDOWS[(id or 0) + 1])
if not frame then return end
if frame:IsShown() then
frame:Hide()
else
BenillaBagWindow_Repaint(frame)
frame:Show()
end
end
-- The backpack button (BenillaBagToggle) and the 'B' binding: if any bag window is open,
-- close them all; otherwise open every existing bag (ref ToggleBackpack — "if numOpen > 0
-- then CloseAllBags() else OpenAllBags()").
function BenillaBagToggle_OnClick()
-- `OpenBag(id)` / `CloseBag(id)` / `IsBagOpen(id)` — ToggleBag's three siblings from the
-- same reference file (ContainerFrame.lua l.144-185), and the last of that family that was
-- still missing here. `Bagnon_Core/core/Overrides.lua` l.42/51 captures BOTH of the first
-- two and calls the capture back for any bag Bagnon does not control — a nil call, on the
-- ordinary path, for an addon that only *partly* takes the bags over.
--
-- The reference's `OpenBag` opens a bag only when `GetContainerNumSlots(id) > 0`; ours keeps
-- that gate for 1..4 and exempts bag 0, which always exists (the same rule `OpenAllBags`
-- above already applies, for the same reason).
function OpenBag(id)
id = id or 0
if id ~= 0 and GetContainerNumSlots(id) <= 0 then return end
local frame = getglobal(BENILLA_BAG_WINDOWS[id + 1])
if frame and not frame:IsShown() then
BenillaBagWindow_Repaint(frame)
frame:Show()
end
end
function CloseBag(id)
local frame = getglobal(BENILLA_BAG_WINDOWS[(id or 0) + 1])
if frame and frame:IsShown() then frame:Hide() end
end
-- ONE STATED DIVERGENCE. The reference returns the INDEX of the ContainerFrame showing that
-- bag (1..NUM_CONTAINER_FRAMES) so its own code can `getglobal("ContainerFrame"..i)`; we
-- ship no such pool (each bag has one fixed window), so there is no index to name and this
-- answers 1 / nil. Every corpus caller — and the reference's own `ToggleBackpack` — uses it
-- as a boolean; a caller that indexed the result would already be reaching for frames that
-- do not exist here.
function IsBagOpen(id)
local frame = getglobal(BENILLA_BAG_WINDOWS[(id or 0) + 1])
if frame and frame:IsShown() then return 1 end
return nil
end
-- `ToggleBackpack()` — the ref's all-bags toggle ("if numOpen > 0 then CloseAllBags() else
-- OpenAllBags()"). It calls the two GLOBALS by name rather than local bodies, so hooking
-- either of those works too.
function ToggleBackpack()
local anyOpen = false
for _, name in ipairs(BENILLA_BAG_WINDOWS) do
local frame = getglobal(name)
if frame and frame:IsShown() then anyOpen = true end
end
if anyOpen then BenillaCloseAllBags() else BenillaOpenAllBags() end
if anyOpen then CloseAllBags() else OpenAllBags() end
end
-- The backpack button (MainMenuBarBackpackButton) and the 'B' binding: if any bag window is open,
-- close them all; otherwise open every existing bag (ref ToggleBackpack — "if numOpen > 0
-- then CloseAllBags() else OpenAllBags()").
function BenillaBagToggle_OnClick()
-- Through the GLOBAL, so an addon that replaced `ToggleBackpack` gets the click. This
-- one line is the whole Bagnon fix; the rest below is our own button's checked art,
-- which is ours to keep whatever the bag window turns out to be.
ToggleBackpack()
-- The auto-toggle is provisional (ref BackpackButton_OnClick l.58-67): the button
-- lights for the BACKPACK window specifically, whatever the all-toggle just did.
local backpack = getglobal(BENILLA_BAG_WINDOWS[1])
getglobal("BenillaBagToggle"):SetChecked(backpack and backpack:IsShown())
getglobal("MainMenuBarBackpackButton"):SetChecked(backpack and backpack:IsShown())
end
-- The window's bar button (ref ContainerFrame_OnShow/OnHide: bag 0 →
@@ -550,8 +618,8 @@
if name == "BenillaKeyRingFrame" then return getglobal("KeyRingButton") end
for bagId = 0, 4 do
if name == BENILLA_BAG_WINDOWS[bagId + 1] then
if bagId == 0 then return getglobal("BenillaBagToggle") end
return getglobal("BenillaBagBarSlot" .. bagId)
if bagId == 0 then return getglobal("MainMenuBarBackpackButton") end
return getglobal("CharacterBag" .. (bagId - 1) .. "Slot")
end
end
end
@@ -647,7 +715,7 @@
end
end
-- ── The bag bar (decision 0216 slice 2) — four 30×30 buttons left of BenillaBagToggle,
-- ── The bag bar (decision 0216 slice 2) — four 30×30 buttons left of MainMenuBarBackpackButton,
-- mirroring the reference main-bar bag buttons (CharacterBag0..3Slot). No exact reference
-- XML was available to transcribe (not in the session's extracted set) — size (30×30) and
-- placement (left of the backpack button) are per the brief; inter-button spacing is an
@@ -674,7 +742,17 @@
function BenillaBagBarSlot_OnLoad(button, bagId)
button.bagId = bagId
button.icon = getglobal(button:GetName() .. "Icon")
-- The button's ID is the REFERENCE's: its 1-based inventory slot, 20..23 (the real
-- client gets there through PaperDollItemSlotButton_OnLoad, which resolves
-- "CharacterBag0Slot" → "Bag0Slot" → GetInventorySlotInfo → 20). Nothing of ours reads
-- it — every body here goes through `button.bagId` — but every ADDON does:
-- `Bagnon.lua:104` computes its highlight target as `this:GetID() - 19`, and against
-- an unset id that is -19, so Bagnon highlighted the wrong bag (silently, with no
-- raise) on every bag-slot hover. `isBag` is the same story from the same template
-- (ref BagSlotButtonTemplate OnLoad l.33), read by KeyRingItemButton_OnClick.
button:SetID(19 + bagId)
button.isBag = 1
button.icon = getglobal(button:GetName() .. "IconTexture")
if BenillaActionBarArt_SeatAbove then BenillaActionBarArt_SeatAbove(button) end
-- Both buttons (ref BagSlotButtonTemplate inherits PaperDollItemSlotButtonTemplate, and
-- its OnLoad calls PaperDollItemSlotButton_OnLoad — PaperDollFrame.lua:86 — so a bag-bar
@@ -776,6 +854,26 @@
end
end
-- `BagSlotButton_OnEnter()` — THE REFERENCE'S NAME for the hover above, and the entry point
-- the four `CharacterBag<N>Slot` buttons actually call (their XML below). Published for the
-- same standing reason `ToggleBackpack` is: an addon replaces the client's global and
-- expects the client to call the replacement.
--
-- `Bagnon.lua:67` is the exact shape, and it is a hover-frequency call, not a rare one:
-- bBagSlotButton_OnEnter = BagSlotButton_OnEnter -- capture ours
-- BagSlotButton_OnEnter = BagnonBlizBag_OnEnter -- replace the global
-- Bagnon's replacement highlights the matching slots in its own window and then calls the
-- capture. With the global absent that capture was nil, and — because nothing here ever
-- looked the global up — the replacement was never reached to raise on it: Bagnon's
-- highlight was simply, silently inert. Corpus demand is exactly one addon (this one) at
-- two sites; its siblings `BagSlotButton_OnClick`/`_OnDrag`/`_OnShiftClick`/
-- `BackpackButton_OnClick` score ZERO and are deliberately not published (1210).
--
-- The reference's body takes no argument and reads `this`; so does this one.
function BagSlotButton_OnEnter()
BenillaBagBarSlot_OnEnter(this)
end
-- ref OnLeave for both (l.100-102 / the template's own): a plain Hide, no fade — the item
-- half of the split BenillaUnitFrame_OnLeave's comment describes.
function BenillaBagBarButton_OnLeave()
@@ -821,8 +919,8 @@
return
end
end
if BenillaErrorsFrame_AddMessage then
BenillaErrorsFrame_AddMessage(NO_EMPTY_KEYRING_SLOTS_ERROR, 1.0, 0.1, 0.1)
if UIErrorsFrame then
UIErrorsFrame:AddMessage(NO_EMPTY_KEYRING_SLOTS_ERROR, 1.0, 0.1, 0.1)
end
end
@@ -1011,8 +1109,8 @@
-- The bag BAR's five buttons (the backpack toggle + the four equipped-bag slots) — the row
-- Disable_/Enable_BagButtons greys as a unit. Distinct from UiPanels.xml's BAG_WINDOWS,
-- which is the five bag *windows* those buttons open.
BENILLA_BAG_BUTTONS = { "BenillaBagToggle", "BenillaBagBarSlot1", "BenillaBagBarSlot2",
"BenillaBagBarSlot3", "BenillaBagBarSlot4" }
BENILLA_BAG_BUTTONS = { "MainMenuBarBackpackButton", "CharacterBag0Slot", "CharacterBag1Slot",
"CharacterBag2Slot", "CharacterBag3Slot" }
-- Disable_BagButtons / Enable_BagButtons — ref MainMenuBarBagButtons.lua l.102-128, called
-- by GameMenuFrame's OnShow/OnHide (decision 0674) and by nothing else: while the game menu
@@ -1021,8 +1119,8 @@
-- while the menu is up — UiPanels.xml `CanOpenPanels`); without it the bags would look
-- clickable and simply do nothing.
--
-- Mapping onto benilla's row: the ref's MainMenuBarBackpackButton is BenillaBagToggle and
-- CharacterBag0..3Slot are BenillaBagBarSlot1..4 — and in BOTH cases the greying goes onto
-- Mapping onto benilla's row: the ref's MainMenuBarBackpackButton is MainMenuBarBackpackButton and
-- CharacterBag0..3Slot are CharacterBag0Slot..4 — and in BOTH cases the greying goes onto
-- the button's `$parentIcon` LAYER texture, never onto a state texture. That is the
-- reference's own structure and it is load-bearing here: a disabled button draws its
-- DisabledTexture, and with none set it draws no state texture at all
@@ -1048,7 +1146,7 @@
-- The four slots' own paint decides their restored colour: an item locked on the cursor
-- wants 0.4 grey, not the flat white SetDesaturation(nil) just left.
for i = 1, 4 do
BenillaBagBarSlot_Update(getglobal("BenillaBagBarSlot" .. i))
BenillaBagBarSlot_Update(getglobal("CharacterBag" .. (i - 1) .. "Slot"))
end
end
]]></Script>
@@ -1569,7 +1667,7 @@
the bar. (MainMenuBarArtFrame is defined in ActionBar.xml, which the loader loads before
this file; the anchor resolves by name.)
-->
<CheckButton name="BenillaBagToggle">
<CheckButton name="MainMenuBarBackpackButton">
<Size><AbsDimension x="37" y="37"/></Size>
<Anchors>
<Anchor point="BOTTOMRIGHT" relativeTo="MainMenuBarArtFrame" relativePoint="BOTTOMRIGHT">
@@ -1587,7 +1685,7 @@
bag-bar slots' own `$parentIcon`, and the greying is the vertex tint on it. -->
<Layers>
<Layer level="BACKGROUND">
<Texture name="$parentIcon" file="Interface\Buttons\Button-Backpack-Up"/>
<Texture name="$parentIconTexture" file="Interface\Buttons\Button-Backpack-Up"/>
</Layer>
</Layers>
<!-- The item-push drop card (decision 0887, BenillaItemPushAnim_* above) — the ref's
@@ -1616,7 +1714,7 @@
<!-- Seat the backpack icon above the bar art too (same reason as the bag-bar slots —
BenillaActionBarArt_SeatAbove's comment). -->
<OnLoad>
self.icon = getglobal("BenillaBagToggleIcon")
self.icon = getglobal("MainMenuBarBackpackButtonIconTexture")
if BenillaActionBarArt_SeatAbove then BenillaActionBarArt_SeatAbove(self) end
</OnLoad>
<OnClick>BenillaBagToggle_OnClick()</OnClick>
@@ -1627,19 +1725,19 @@
<!--
The bag bar (decision 0216 slice 2, BenillaBagBarSlot_* above): four 36×36 buttons to the
LEFT of BenillaBagToggle, mirroring the reference CharacterBag0..3Slot row. Bar slot N (1..4)
sits at chain position N counting away from the backpack button — BenillaBagBarSlot1 (bagId
1, Bag0Slot) is CLOSEST to the backpack, BenillaBagBarSlot4 (bagId 4, Bag3Slot) FARTHEST —
LEFT of MainMenuBarBackpackButton, mirroring the reference CharacterBag0..3Slot row. Bar slot N (1..4)
sits at chain position N counting away from the backpack button — CharacterBag0Slot (bagId
1, Bag0Slot) is CLOSEST to the backpack, CharacterBag3Slot (bagId 4, Bag3Slot) FARTHEST —
the real bar's own left-to-right order. Sized to MATCH the action buttons (36×36 icon in a
66×66 UI-Quickslot2 ring, 6px chain gap = the 42px stride), so the bag icons fill their slots
exactly like the spell icons instead of floating undersized (the earlier 30×30 guess).
-->
<CheckButton name="BenillaBagBarSlot1">
<CheckButton name="CharacterBag0Slot">
<Size><AbsDimension x="36" y="36"/></Size>
<Anchors>
<Anchor point="RIGHT" relativeTo="BenillaBagToggle" relativePoint="LEFT"><Offset><AbsDimension x="-6" y="0"/></Offset></Anchor>
<Anchor point="RIGHT" relativeTo="MainMenuBarBackpackButton" relativePoint="LEFT"><Offset><AbsDimension x="-6" y="0"/></Offset></Anchor>
</Anchors>
<Layers><Layer level="BACKGROUND"><Texture name="$parentIcon"/></Layer></Layers>
<Layers><Layer level="BACKGROUND"><Texture name="$parentIconTexture"/></Layer></Layers>
<!-- The item-push drop card (decision 0887, BenillaItemPushAnim_* above) — the ref's
$parentItemAnim, one per bag button; container id 1. -->
<Frames>
@@ -1680,7 +1778,7 @@
<OnLoad>BenillaBagBarSlot_OnLoad(self, 1)</OnLoad>
<OnClick>BenillaBagBarSlot_OnClick(self)</OnClick>
<OnEvent>BenillaBagBarSlot_OnEvent(self)</OnEvent>
<OnEnter>BenillaBagBarSlot_OnEnter(self)</OnEnter>
<OnEnter>BagSlotButton_OnEnter()</OnEnter>
<OnLeave>BenillaBagBarButton_OnLeave()</OnLeave>
<!-- The drag trio: OnDragStart picks the equipped bag up, OnReceiveDrag drops a carried
bag in — both the same PickupInventoryItem transition (empty cursor picks up, a held
@@ -1689,12 +1787,12 @@
<OnReceiveDrag>BenillaBagBarSlot_Place(self)</OnReceiveDrag>
</Scripts>
</CheckButton>
<CheckButton name="BenillaBagBarSlot2">
<CheckButton name="CharacterBag1Slot">
<Size><AbsDimension x="36" y="36"/></Size>
<Anchors>
<Anchor point="RIGHT" relativeTo="BenillaBagBarSlot1" relativePoint="LEFT"><Offset><AbsDimension x="-6" y="0"/></Offset></Anchor>
<Anchor point="RIGHT" relativeTo="CharacterBag0Slot" relativePoint="LEFT"><Offset><AbsDimension x="-6" y="0"/></Offset></Anchor>
</Anchors>
<Layers><Layer level="BACKGROUND"><Texture name="$parentIcon"/></Layer></Layers>
<Layers><Layer level="BACKGROUND"><Texture name="$parentIconTexture"/></Layer></Layers>
<!-- The item-push drop card (decision 0887, BenillaItemPushAnim_* above) — the ref's
$parentItemAnim, one per bag button; container id 2. -->
<Frames>
@@ -1734,7 +1832,7 @@
<OnLoad>BenillaBagBarSlot_OnLoad(self, 2)</OnLoad>
<OnClick>BenillaBagBarSlot_OnClick(self)</OnClick>
<OnEvent>BenillaBagBarSlot_OnEvent(self)</OnEvent>
<OnEnter>BenillaBagBarSlot_OnEnter(self)</OnEnter>
<OnEnter>BagSlotButton_OnEnter()</OnEnter>
<OnLeave>BenillaBagBarButton_OnLeave()</OnLeave>
<!-- The drag trio: OnDragStart picks the equipped bag up, OnReceiveDrag drops a carried
bag in — both the same PickupInventoryItem transition (empty cursor picks up, a held
@@ -1743,12 +1841,12 @@
<OnReceiveDrag>BenillaBagBarSlot_Place(self)</OnReceiveDrag>
</Scripts>
</CheckButton>
<CheckButton name="BenillaBagBarSlot3">
<CheckButton name="CharacterBag2Slot">
<Size><AbsDimension x="36" y="36"/></Size>
<Anchors>
<Anchor point="RIGHT" relativeTo="BenillaBagBarSlot2" relativePoint="LEFT"><Offset><AbsDimension x="-6" y="0"/></Offset></Anchor>
<Anchor point="RIGHT" relativeTo="CharacterBag1Slot" relativePoint="LEFT"><Offset><AbsDimension x="-6" y="0"/></Offset></Anchor>
</Anchors>
<Layers><Layer level="BACKGROUND"><Texture name="$parentIcon"/></Layer></Layers>
<Layers><Layer level="BACKGROUND"><Texture name="$parentIconTexture"/></Layer></Layers>
<!-- The item-push drop card (decision 0887, BenillaItemPushAnim_* above) — the ref's
$parentItemAnim, one per bag button; container id 3. -->
<Frames>
@@ -1788,7 +1886,7 @@
<OnLoad>BenillaBagBarSlot_OnLoad(self, 3)</OnLoad>
<OnClick>BenillaBagBarSlot_OnClick(self)</OnClick>
<OnEvent>BenillaBagBarSlot_OnEvent(self)</OnEvent>
<OnEnter>BenillaBagBarSlot_OnEnter(self)</OnEnter>
<OnEnter>BagSlotButton_OnEnter()</OnEnter>
<OnLeave>BenillaBagBarButton_OnLeave()</OnLeave>
<!-- The drag trio: OnDragStart picks the equipped bag up, OnReceiveDrag drops a carried
bag in — both the same PickupInventoryItem transition (empty cursor picks up, a held
@@ -1797,12 +1895,12 @@
<OnReceiveDrag>BenillaBagBarSlot_Place(self)</OnReceiveDrag>
</Scripts>
</CheckButton>
<CheckButton name="BenillaBagBarSlot4">
<CheckButton name="CharacterBag3Slot">
<Size><AbsDimension x="36" y="36"/></Size>
<Anchors>
<Anchor point="RIGHT" relativeTo="BenillaBagBarSlot3" relativePoint="LEFT"><Offset><AbsDimension x="-6" y="0"/></Offset></Anchor>
<Anchor point="RIGHT" relativeTo="CharacterBag2Slot" relativePoint="LEFT"><Offset><AbsDimension x="-6" y="0"/></Offset></Anchor>
</Anchors>
<Layers><Layer level="BACKGROUND"><Texture name="$parentIcon"/></Layer></Layers>
<Layers><Layer level="BACKGROUND"><Texture name="$parentIconTexture"/></Layer></Layers>
<!-- The item-push drop card (decision 0887, BenillaItemPushAnim_* above) — the ref's
$parentItemAnim, one per bag button; container id 4. -->
<Frames>
@@ -1842,7 +1940,7 @@
<OnLoad>BenillaBagBarSlot_OnLoad(self, 4)</OnLoad>
<OnClick>BenillaBagBarSlot_OnClick(self)</OnClick>
<OnEvent>BenillaBagBarSlot_OnEvent(self)</OnEvent>
<OnEnter>BenillaBagBarSlot_OnEnter(self)</OnEnter>
<OnEnter>BagSlotButton_OnEnter()</OnEnter>
<OnLeave>BenillaBagBarButton_OnLeave()</OnLeave>
<!-- The drag trio: OnDragStart picks the equipped bag up, OnReceiveDrag drops a carried
bag in — both the same PickupInventoryItem transition (empty cursor picks up, a held
@@ -1854,7 +1952,7 @@
<!--
The keyring button (decision 0765) — ref KeyRingButton, MainMenuBarBagButtons.xml l.142-199.
Sits at the FAR left of the bag row, past BenillaBagBarSlot4 (the ref's CharacterBag3Slot),
Sits at the FAR left of the bag row, past CharacterBag3Slot (the ref's CharacterBag3Slot),
and is authored HIDDEN: nothing reveals it but MainMenuBar_UpdateKeyRing, and only
while HasKey() — the point being that a character with no key has no keyring at all, and the
bar's plate has no socket for one.
@@ -1871,7 +1969,7 @@
<CheckButton name="KeyRingButton" hidden="true">
<Size><AbsDimension x="18" y="39"/></Size>
<Anchors>
<Anchor point="RIGHT" relativeTo="BenillaBagBarSlot4" relativePoint="LEFT"><Offset><AbsDimension x="-6" y="0"/></Offset></Anchor>
<Anchor point="RIGHT" relativeTo="CharacterBag3Slot" relativePoint="LEFT"><Offset><AbsDimension x="-6" y="0"/></Offset></Anchor>
</Anchors>
<!-- The item-push drop card (decision 0887, BenillaItemPushAnim_* above) — the ref's
$parentItemAnim, one per bag button; container id -2 (KEYRING_CONTAINER). The ref gives
+27 -3
View File
@@ -61,7 +61,14 @@
function BankFrame_OnLoad()
local frame = this
SetPortraitTexture(getglobal(frame:GetName() .. "Portrait"), "npc")
-- Named directly rather than through `$parent .. "Portrait"`: the reference's bank
-- portrait is the UNPREFIXED global `BankPortraitTexture` (ref BankFrame.xml l.93),
-- one of the reference's own naming inconsistencies, and it is the name addons write.
-- `Bagnon_Core/Events.lua:41` does `SetPortraitTexture(BankPortraitTexture, "npc")`
-- when its "show Bagnon's bank instead" option is off, and against a nil first argument
-- that binding raises. Same standing lesson as `ToggleBackpack` / `CharacterBag0Slot`:
-- the name an addon reaches for is the reference's, not ours.
SetPortraitTexture(BankPortraitTexture, "npc")
frame:RegisterEvent("BANKFRAME_OPENED")
frame:RegisterEvent("BANKFRAME_CLOSED")
frame:RegisterEvent("PLAYERBANKSLOTS_CHANGED")
@@ -132,6 +139,21 @@
end
end
-- `UpdateBagSlotStatus()` — THE REFERENCE'S NAME for the pass above, published for the same
-- standing reason `ToggleBackpack` and `BagSlotButton_OnEnter` are: an addon calls the
-- client's global, and it must exist and do the job. `Bagnon_Core/Events.lua:46` calls it
-- from its own `ShowBlizBank()`, two lines after `SetPortraitTexture(BankPortraitTexture, …)`
-- above — the path taken whenever a player turns Bagnon's "show Bagnon at the bank" option
-- OFF and gets the stock window instead. It was nil, so that whole handler died there.
--
-- The reference's own body walks its `BankFrameBag1..6` and repaints
-- `BankFrameDetailMoneyFrame`; ours is the same pass over our own bag row and cost frame,
-- which is exactly what the reference's does for its window (it is not a shim: it is this
-- window's real update, under the name the client publishes it as).
function UpdateBagSlotStatus()
BenillaBankFrame_UpdateBagRow()
end
function BankFrame_OnEvent(event)
local frame = this
if event == "BANKFRAME_OPENED" then
@@ -283,8 +305,10 @@
<Layers>
<Layer level="BACKGROUND">
<!-- NPC portrait 60×60 TOPLEFT (7,-6) (ref l.93-104), bound in OnLoad via
SetPortraitTexture(region, "npc") — the Merchant/Trainer/Gossip convention. -->
<Texture name="BankFramePortrait">
SetPortraitTexture(region, "npc"). The name is the REFERENCE'S, unprefixed —
its bank frame breaks the $parentPortrait convention every other window here
follows, and addons write the name the reference gave them. -->
<Texture name="BankPortraitTexture">
<Size><AbsDimension x="60" y="60"/></Size>
<Anchors><Anchor point="TOPLEFT" relativePoint="TOPLEFT"><Offset><AbsDimension x="7" y="-6"/></Offset></Anchor></Anchors>
</Texture>
@@ -52,6 +52,18 @@
this:RegisterEvent("SPELLCAST_CHANNEL_STOP")
this.casting = nil
this.holdTime = 0
-- The ref's own last line of this function (`CastingBarFrame.lua:16`), and the one
-- line of it we dropped: a plain alias global. `CastingBarFrame` IS the StatusBar in
-- 1.12 — there is no separate child frame — so the reference publishes the name an
-- addon would look for, and then uses it itself for the rest of the file.
--
-- 11 corpus addons index `CastingBarFrameStatusBar`, and it was the largest single
-- name in the whole session-start `attempt to index global` row. Nothing warned:
-- a line dropped INSIDE a function we otherwise transcribed verbatim is invisible to
-- every instrument here. `framexml-transcription-diff.py` compares elements and
-- handlers, not function bodies, and would not even pair these two files (ours is
-- CastingBar.xml, the reference's is CastingBarFrame.xml).
CastingBarFrameStatusBar = CastingBarFrame
this:Hide()
end
+156 -2
View File
@@ -17,6 +17,22 @@
"Say:" with static insets.
-->
<Script><![CDATA[
-- ── NUM_CHAT_WINDOWS — how many chat windows the UI declares ─────────────────────────
-- `NUM_CHAT_WINDOWS = 7` is ChatFrame.lua line 5, verbatim. It is a FrameXML constant, not
-- an engine one, and the number is NOT the engine's: the chat-cache and the per-window
-- struct array `0xb4fe50` (wow-re `chat-color-table.md`, stride 0x98) both hold **10**
-- windows, and FloatingChatFrame.xml duly declares ChatFrame1TabDockRegion..10 — but only
-- seven ChatFrames. 7 is the count an addon iterates.
--
-- Measured demand: **5 corpus addons, all genuinely distinct** (no replicated library —
-- the one clean count in this area). Two idioms, and they need different things from us:
-- getglobal("ChatFrame"..i) Outfitter, _LazyPig — _LazyPig indexes the result
-- UNGUARDED (`ChatFrame:IsVisible()`), so it dies at i=3
-- unless ChatFrame3..7 are real frames. They are, below.
-- GetChatWindowInfo(i) EnhTooltip, MikScrollingBattleText, Enchantrix — the
-- engine getter, `benilla_ui::script::chat_window`.
NUM_CHAT_WINDOWS = 7
-- ── SlashCmdList — the addon slash-command registry (decision 1195) ────────────────────
-- Declared HERE, not in Rust, because the 1.12 client declares it in ChatFrame.lua and the
-- captured `_G` attributes it `table framexml`. An addon's registration is three lines at
@@ -233,6 +249,39 @@
end
end
-- FCF_SelectDockFrame(frame) — the reference's own name for the operation above, taking a
-- FRAME where ours takes a dock id. Both corpus callers pass DEFAULT_CHAT_FRAME:
--
-- if not DEFAULT_CHAT_FRAME:IsVisible() then
-- FCF_SelectDockFrame(DEFAULT_CHAT_FRAME)
-- end
-- DEFAULT_CHAT_FRAME:AddMessage(out)
--
-- verbatim in both CustomNameplates (`CustomNameplates.lua:69`) and Roid-Macros
-- (`Utility.lua:40`) — "un-hide the chat window before printing into it". Two addons,
-- two independent files, not a replicated library.
--
-- Ours does the dock half and NOT the rest of ref FCF_SelectDockFrame l.1204-1217: the ref
-- also assigns `SELECTED_DOCK_FRAME` (a global no corpus addon reads — 0 hits) and calls
-- `FCF_DockUpdate()` (tab geometry, which FCF_OnUpdate below re-derives every frame).
--
-- An UNDOCKED frame RAISES rather than quietly doing nothing. ChatFrame3..7 exist as real
-- frames but benilla has no undock/dock model at all (0288 §2 — window management is chat
-- settings, deliberately out), so "select ChatFrame5" has no meaning here; answering it
-- with silence would leave the caller's next AddMessage landing in a window that is still
-- hidden, which is the bug the guard was written to prevent.
function FCF_SelectDockFrame(frame)
if not frame then
error("FCF_SelectDockFrame: nil frame", 2)
end
local id = frame:GetID()
if id ~= 1 and id ~= 2 then
error("FCF_SelectDockFrame: " .. (frame:GetName() or "?")
.. " is not docked — benilla docks only ChatFrame1/ChatFrame2 (decision 0288 §2)", 2)
end
BenillaFCF_SelectDock(id)
end
function BenillaFCF_TabClick(id)
-- ref FCF_Tab_OnClick (docked, left click): select; UChatScrollButton per click.
BenillaFCF_SelectDock(id)
@@ -568,7 +617,7 @@
them through its resize buttons' textures; benilla authors them as plain textures — the
hover LOOK without the resize mechanics). Everything starts alpha 0; BenillaFCF fades.
-->
<ScrollingMessageFrame name="ChatFrame1" frameStrata="BACKGROUND" displayDuration="120.0" maxLines="128">
<ScrollingMessageFrame name="ChatFrame1" frameStrata="BACKGROUND" displayDuration="120.0" maxLines="128" id="1">
<Size><AbsDimension x="430" y="120"/></Size>
<Anchors>
<Anchor point="BOTTOMLEFT" relativePoint="BOTTOMLEFT">
@@ -750,7 +799,7 @@
<!-- ChatFrame2 "Combat Log": docked — shares ChatFrame1's rect (ref FCF_DockUpdate anchors),
hidden until its tab selects it. Its message-group wiring (the chat-cache combat set)
lands with the router (0288 P4); until then it renders what it's given: nothing. -->
<ScrollingMessageFrame name="ChatFrame2" frameStrata="BACKGROUND" displayDuration="120.0" maxLines="128" hidden="true">
<ScrollingMessageFrame name="ChatFrame2" frameStrata="BACKGROUND" displayDuration="120.0" maxLines="128" hidden="true" id="2">
<Anchors>
<Anchor point="TOPLEFT" relativeTo="ChatFrame1" relativePoint="TOPLEFT"/>
<Anchor point="BOTTOMRIGHT" relativeTo="ChatFrame1" relativePoint="BOTTOMRIGHT"/>
@@ -779,6 +828,111 @@
<FontString inherits="ChatFontNormal" justifyH="LEFT"/>
</ScrollingMessageFrame>
<!-- ── ChatFrame3..ChatFrame7: the five undocked windows ────────────────────────────────
The reference declares seven ScrollingMessageFrames (FloatingChatFrame.xml l.822-832,
`inherits="FloatingChatFrameTemplate"`), of which 3..7 ship HIDDEN and UNDOCKED — the
shipped chat-cache says so in as many words, and it is the state that matters here:
WINDOW 3 … 10: SIZE 0 / COLOR 0 0 0 0 / LOCKED 1 / DOCKED 0 / SHOWN 0
MESSAGES <empty> CHANNELS <empty> ZONECHANNELS 0
(`wow-5875-re/WoW/WTF/.../chat-cache.txt`, a real 1.12 character). So these are not
placeholders standing in for something richer — hidden, empty, unsubscribed IS what
ChatFrame3 is on a stock client, and an `AddMessage` into one lands in a ring nobody is
looking at, exactly as it does there.
**They are load-bearing for two different reasons, both measured:**
1. `_LazyPig/LazyPig.lua:1992` walks `for i = 1, NUM_CHAT_WINDOWS` and calls
`getglobal("ChatFrame"..i):IsVisible()` with NO nil guard — it dies at i=3 the moment
NUM_CHAT_WINDOWS exists without the frames behind it. Declaring the constant alone
would have *created* that crash.
2. `ChatFrame3` and `ChatFrame5` are named directly as debug sinks by five distinct
addons: IgniteStatus (7 unguarded `ChatFrame3:AddMessage` sites), TipBuddy, Radar
(the one guarded read), and FuBar_CorkFu + FuBar_oRA2CooldownFu, which store the frame
as AceDebug's `debugFrame`. A sixth "consumer", RosterLib-2.0's
`local function print(text) ChatFrame3:AddMessage(text) end`, is dead code replicated
into four folders — the count that looks like 12 files is 7 real sites.
No tabs (`ChatFrame3Tab`..`ChatFrame7Tab`) and no dock regions. The reference builds
both; here they would be furniture for a dock model we do not have (0288 §2), and
nothing reads them while these frames stay hidden and undocked. Outfitter.lua:3099 is
the one addon that would touch a tab — `local vTab = getglobal("ChatFrame"..i.."Tab")`
then `vTab:GetText()`, unguarded — but only INSIDE `if vChatFrame:IsVisible() or
vChatFrame.isDocked`, so a hidden, `isDocked`-less window never reaches it. That is also
precisely why `isDocked` is not set on ChatFrame2 either: our Combat Log tab is
deliberately unbuilt (see the tab section below), and marking window 2 docked — which
the reference does — would walk Outfitter straight into a nil tab.
No background/border art: the ref's own defaults put every one of these at window alpha
0 (COLOR's fourth byte), and BenillaFCF only fades the dock's two. A shown ChatFrame5
renders its text over the world with no box — which is what the reference gives you. ── -->
<ScrollingMessageFrame name="ChatFrame3" frameStrata="BACKGROUND" displayDuration="120.0" maxLines="128" hidden="true" id="3">
<Size><AbsDimension x="430" y="128"/></Size>
<Anchors><Anchor point="BOTTOMLEFT"><Offset><AbsDimension x="100" y="100"/></Offset></Anchor></Anchors>
<Scripts>
<OnMouseWheel>
if arg1 > 0 then this:ScrollUp() else this:ScrollDown() end
</OnMouseWheel>
<OnHyperlinkClick>
SetItemRef(arg1, arg2, arg3)
</OnHyperlinkClick>
</Scripts>
<FontString inherits="ChatFontNormal" justifyH="LEFT"/>
</ScrollingMessageFrame>
<ScrollingMessageFrame name="ChatFrame4" frameStrata="BACKGROUND" displayDuration="120.0" maxLines="128" hidden="true" id="4">
<Size><AbsDimension x="430" y="128"/></Size>
<Anchors><Anchor point="BOTTOMLEFT"><Offset><AbsDimension x="100" y="100"/></Offset></Anchor></Anchors>
<Scripts>
<OnMouseWheel>
if arg1 > 0 then this:ScrollUp() else this:ScrollDown() end
</OnMouseWheel>
<OnHyperlinkClick>
SetItemRef(arg1, arg2, arg3)
</OnHyperlinkClick>
</Scripts>
<FontString inherits="ChatFontNormal" justifyH="LEFT"/>
</ScrollingMessageFrame>
<ScrollingMessageFrame name="ChatFrame5" frameStrata="BACKGROUND" displayDuration="120.0" maxLines="128" hidden="true" id="5">
<Size><AbsDimension x="430" y="128"/></Size>
<Anchors><Anchor point="BOTTOMLEFT"><Offset><AbsDimension x="100" y="100"/></Offset></Anchor></Anchors>
<Scripts>
<OnMouseWheel>
if arg1 > 0 then this:ScrollUp() else this:ScrollDown() end
</OnMouseWheel>
<OnHyperlinkClick>
SetItemRef(arg1, arg2, arg3)
</OnHyperlinkClick>
</Scripts>
<FontString inherits="ChatFontNormal" justifyH="LEFT"/>
</ScrollingMessageFrame>
<ScrollingMessageFrame name="ChatFrame6" frameStrata="BACKGROUND" displayDuration="120.0" maxLines="128" hidden="true" id="6">
<Size><AbsDimension x="430" y="128"/></Size>
<Anchors><Anchor point="BOTTOMLEFT"><Offset><AbsDimension x="100" y="100"/></Offset></Anchor></Anchors>
<Scripts>
<OnMouseWheel>
if arg1 > 0 then this:ScrollUp() else this:ScrollDown() end
</OnMouseWheel>
<OnHyperlinkClick>
SetItemRef(arg1, arg2, arg3)
</OnHyperlinkClick>
</Scripts>
<FontString inherits="ChatFontNormal" justifyH="LEFT"/>
</ScrollingMessageFrame>
<ScrollingMessageFrame name="ChatFrame7" frameStrata="BACKGROUND" displayDuration="120.0" maxLines="128" hidden="true" id="7">
<Size><AbsDimension x="430" y="128"/></Size>
<Anchors><Anchor point="BOTTOMLEFT"><Offset><AbsDimension x="100" y="100"/></Offset></Anchor></Anchors>
<Scripts>
<OnMouseWheel>
if arg1 > 0 then this:ScrollUp() else this:ScrollDown() end
</OnMouseWheel>
<OnHyperlinkClick>
SetItemRef(arg1, arg2, arg3)
</OnHyperlinkClick>
</Scripts>
<FontString inherits="ChatFontNormal" justifyH="LEFT"/>
</ScrollingMessageFrame>
<!-- ── The tabs (ref ChatTabTemplate: 64×32, ChatFrameTab 3-slice quarters 16/44/16, label
at the left slice's right edge y −5, GameFontNormalSmall; the flash band at y −7; the
ref's ButtonText also carries a vestigial 50×8 Size that TabResize immediately
@@ -0,0 +1,82 @@
<!--
OUR adapters over the reference's own `Interface\FrameXML\ContainerFrame.lua`.
**This client does not transcribe that file — it EXECUTES it, off the player's own patch
chain**, before any of our `assets/ui` files (`ui_script::reference_ui`, whose header carries
the rule and the licensing reason: Blizzard's Lua is never committed here). What is in THIS
file is only benilla's, and only where a reference body reaches for something this client
genuinely does not have.
The rule, restated in one line so it can be applied without re-reading the module: the sourced
file's globals stand unmodified; ours (loading after) win where both define a name; and where a
reference body needs a benilla answer, it is WRAPPED here — never silently stubbed.
── The one adapter, and why it is not a stub ────────────────────────────────────────────────
`ContainerFrameItemButton_OnEnter` opens with a keyring fork (ref l.615-620):
if this:GetParent():GetID() == KEYRING_CONTAINER then
GameTooltip:SetInventoryItem("player", KeyRingButtonIDToInvSlotID(this:GetID()))
CursorUpdate()
return
end
That fork exists because the REAL client's keyring items are inventory slots, not container
slots, so it has to detour out of the container API to describe one. benilla's keyring is an
ordinary container (id -2, decision 0765): its slots come through `GetContainerItemInfo` and
`GameTooltip:SetBagItem` like every other bag's. So the detour is not merely unavailable here
(neither `KeyRingButtonIDToInvSlotID` nor `CursorUpdate` is a binding) — it is *wrong* here,
and supplying the two verbs would buy an empty tooltip, because our keyring items were never
fed into the inventory-slot space it reads.
So the wrapper below answers the keyring case the way benilla answers every other bag, and
hands EVERY other slot straight to the reference's own body. The path the player is actually
on — hovering an ordinary bag slot — runs the real client's code, unedited.
── Named, not hidden: what still does not work through the sourced file ─────────────────────
· `MerchantItemButton_OnClick`. The reference's `ContainerFrameItemButton_OnClick` closes its
shift-right-click-at-a-vendor arm with a `SplitStack` closure that calls that global, and
benilla's merchant window publishes `BenillaMerchantItem_OnClick` instead. Confirming a stack
split with a vendor open therefore raises. That is the same "a Benilla name shadows the
reference's" class this whole seam exists to close, one window over, and it is left loud
rather than guarded (1203): the fix is to publish the merchant global, not to hide the call.
· `REPAIR_COST` is read by the reference's repair-mode arm. It is a `GlobalStrings.lua` key and
this client sources that file too, so it resolves at runtime — and the arm is unreachable
today regardless, since `GameTooltip:SetBagItem`'s repairCost is an INTERIM 0.
-->
<Ui xmlns="http://www.blizzard.com/wow/ui/">
<Script><![CDATA[
-- The reference's own body, captured before we shadow the name. Nil on a machine with no
-- client data (nothing was sourced) — which must be LOUD at the call, not a quiet no-op:
-- a bag hover that silently does nothing is precisely the failure mode that let Bagnon
-- ship "working" through four green survey columns.
local refItemButtonOnEnter = ContainerFrameItemButton_OnEnter
function ContainerFrameItemButton_OnEnter(button)
if not button then
button = this
end
if not button then return end
-- benilla's keyring is a CONTAINER (0765), so the reference's inventory-slot detour is
-- both unavailable and wrong here; this is the same pair of calls its general path
-- makes for every other bag, which is all a keyring slot ever needed.
if button:GetParent():GetID() == KEYRING_CONTAINER then
if (button:GetRight() or 0) >= (GetScreenWidth() / 2) then
GameTooltip:SetOwner(button, "ANCHOR_LEFT")
else
GameTooltip:SetOwner(button, "ANCHOR_RIGHT")
end
GameTooltip:SetBagItem(KEYRING_CONTAINER, button:GetID())
ResetCursor()
return
end
if not refItemButtonOnEnter then
message("benilla: Interface\\FrameXML\\ContainerFrame.lua was not sourced from the "
.. "game data, so bag-slot tooltips cannot work. Check the client data path.")
return
end
refItemButtonOnEnter(button)
end
]]></Script>
</Ui>
+44 -3
View File
@@ -3,19 +3,60 @@
The shared cooldown-frame machine — the ref's own file split (Cooldown.lua/Cooldown.xml,
loaded before every consumer): CooldownFrame_SetTimer drives BOTH the action buttons
(ActionBar.xml) and the bag slots (BagFrame.xml), exactly as the reference shares it.
The ref's Model template + OnUpdateModel scrub/finish-flash machine live inside our
native <Cooldown> widget instead (decision 0263), armed by SetCooldown.
The ref's OnUpdateModel scrub/finish-flash machine lives inside our native <Cooldown>
widget instead (decision 0263), armed by SetCooldown.
`CooldownFrameTemplate` below is the reference's own declaration, and it is here because
it was MISSING: three corpus addons name it in an XML `inherits=` (Bagnon_Core,
Bongos_ActionBar, BuffCheck2), and Bagnon hangs one off EVERY item button, so a single
session logged roughly sixty
WARN ui_script: unknown template 'CooldownFrameTemplate' referenced by inherits
lines while each of those children came out as a bare, attribute-less frame.
-->
<Script><![CDATA[
-- Ref Cooldown.lua's CooldownFrame_SetTimer, transcribed: show-and-arm only when all three
-- are positive, else hide.
--
-- THE ONE FORK, and why it is two faithful branches rather than a guard. The reference's
-- body arms a Model (`this.start = start … this:SetSequence(0)`); ours arms our native
-- <Cooldown> widget, which is where 0263 put that machine. Both kinds really do reach this
-- function: our own windows pass a <Cooldown>, and an addon inheriting the template below
-- passes a <Model>, because an XML instance is created from ITS OWN tag and every real
-- caller writes the reference's `<Model … inherits="CooldownFrameTemplate"/>`.
--
-- Calling SetCooldown unconditionally is what shipped, and it raised on exactly that case:
-- a Bagnon slot holding an item with a live cooldown died on
-- `attempt to call method 'SetCooldown' (a nil value)` — and, because !OmniCC wraps this
-- very function, it took OmniCC's countdown down with it. A Model has no sweep for this
-- engine to arm (`SetSequence`/`AdvanceTime` are not bindings here), so that branch shows
-- and hides the frame and nothing more: the addon's own cooldown child stays inert, which
-- is a missing visual, not a broken window.
function CooldownFrame_SetTimer(this, start, duration, enable)
if ( start > 0 and duration > 0 and enable > 0 ) then
this:SetCooldown(start, duration)
if ( this.SetCooldown ) then
this:SetCooldown(start, duration)
end
this:Show()
else
this:Hide()
end
end
]]></Script>
<!--
ref Cooldown.xml l.4-14, transcribed. The reference's own attributes are kept verbatim
(its .mdx file, its 0.75 scale, setAllPoints, hidden) even where this engine has no use for
the model file: the template's JOB here is to exist under the reference's name so an
addon's `inherits=` resolves and its cooldown child inherits the geometry the addon is
counting on.
Its two `<Scripts>` — OnUpdateModel / OnAnimFinished — are deliberately NOT transcribed.
Both are real 1.12 script slots that this engine does not fire (see
`benilla-ui/src/script/object/events_regions.rs`, which raises on any name nothing fires,
and measured both at zero corpus call sites), so declaring them would trade sixty
`unknown template` warnings for sixty `SetScript unsupported` ones and change nothing else.
The scrub they drive is our <Cooldown> widget's, not this frame's.
-->
<Model name="CooldownFrameTemplate" file="Interface\Cooldown\UI-Cooldown-Indicator.mdx"
scale="0.75" setAllPoints="true" hidden="true" virtual="true"/>
</Ui>
+39 -94
View File
@@ -1,123 +1,68 @@
<Ui>
<!--
The real client's UIErrorsFrame (ref UIErrorsFrame.xml/.lua; ErrorFont =
GameFontNormalLarge, ref Fonts.xml:258): a 512×60 MessageFrame at TOP (0,-122),
displayDuration 5, insertMode TOP — the newest message takes the top line and older ones
shift down, up to the ~3 lines the 60px box holds. UI_INFO_MESSAGE shows YELLOW (1,1,0) —
the quest objective-progress toasts land here — and UI_ERROR_MESSAGE red (1,0.1,0.1), the
ref handler verbatim (UIErrorsFrame.lua:8-16; its SYSMSG arm is unwired — no engine
producer yet). This engine has no MessageFrame widget, so the message list is this file's
Lua over three stacked FontStrings: hold 5 s (the ref displayDuration), then fade out
(INTERIM 1 s — the ref MessageFrame's post-hold fade curve is unpinned), oldest dropped
past the visible three. `BenillaErrorsFrame_AddMessage(text, r, g, b)` is the Lua-callable
seam (the ref's `UIErrorsFrame:AddMessage`) for FrameXML-side producers — the quest log's
watch guards use it.
The real client's UIErrorsFrame, now transcribed as what it actually IS: a
`<MessageFrame>` (ref UIErrorsFrame.xml:4 — 512×60 at TOP (0,-122), displayDuration="5",
insertMode="TOP", with `<FontString inherits="ErrorFont" justifyH="CENTER"/>` as its line
font). UI_INFO_MESSAGE shows YELLOW (1,1,0) — the quest objective-progress toasts land
here — and UI_ERROR_MESSAGE red (1,0.1,0.1), the ref handler verbatim (UIErrorsFrame.lua:8-16;
its SYSMSG arm is unwired, no engine producer yet).
It used to be a hand-rolled `<Frame>` painting three stacked FontStrings from Lua, because
this engine had no MessageFrame widget. It has one now (`script::messageframe::plain`,
`widget::MessageFrameState`), so the file that stood in for it is gone and the display —
insertMode, the vertical cap, the two-phase fade — is the widget's own, exactly as in the
reference. Two consequences worth naming:
* `UIErrorsFrame:AddMessage(text, r, g, b, a)` is REAL now, so FrameXML producers call it
the way the reference does (the old `BenillaErrorsFrame_AddMessage` seam is retired),
and so does an addon: `EasyCopy`, `QuestHistory` and `QuestItem` each died on that exact
line at load. A sixth argument (all three pass one, believing it is a hold time) is
silently IGNORED — the real binding reads five, and honouring it would be unfaithful.
* The post-hold fade is the class ctor's 3.0 s (msgframe-runtime.md, byte-verified), not
the 1.0 s this file used to carry as an explicitly INTERIM guess while that curve was
unpinned. The 5 s hold is unchanged.
frameStrata HIGH is the ref's own (`UIErrorsFrame.xml:4`) and decision 0597's: this frame
overlaps the right edge of every left-slot panel window (character, quest log, loot,
merchant, mail, trainer, taxi — all at TOPLEFT (0,-104)), so in the default MEDIUM its
toasts draw UNDER whichever panel is open, invisible in exactly the state you most want to
read them. The ref also carries toplevel="true"; that is deliberately not transcribed —
HIGH already wins the strata and 0597's fix is the one that was looked at.
-->
<Script><![CDATA[
BENILLA_ERRORS_LINES = 3
BENILLA_ERRORS_HOLD = 5.0 -- ref displayDuration="5"
BENILLA_ERRORS_FADE = 1.0 -- INTERIM (comment above)
function BenillaErrorsFrame_AddMessage(text, r, g, b)
local frame = getglobal("UIErrorsFrame")
-- insertMode="TOP": newest first, older lines shift down and off the list.
table.insert(frame.messages, 1, { text = text, r = r, g = g, b = b, age = 0 })
while table.getn(frame.messages) > BENILLA_ERRORS_LINES do
table.remove(frame.messages)
end
BenillaErrorsFrame_Paint(frame)
frame:Show()
end
function BenillaErrorsFrame_Paint(frame)
for i = 1, BENILLA_ERRORS_LINES do
local line = getglobal("UIErrorsFrameLine" .. i)
local msg = frame.messages[i]
if msg then
line:SetText(msg.text)
line:SetTextColor(msg.r, msg.g, msg.b)
local over = msg.age - BENILLA_ERRORS_HOLD
if over > 0 then
line:SetAlpha(1.0 - over / BENILLA_ERRORS_FADE)
else
line:SetAlpha(1.0)
end
line:Show()
else
line:Hide()
end
end
end
function UIErrorsFrame_OnLoad()
local frame = this
frame.messages = {}
frame:RegisterEvent("UI_INFO_MESSAGE")
frame:RegisterEvent("UI_ERROR_MESSAGE")
frame:Hide()
this:RegisterEvent("UI_INFO_MESSAGE")
this:RegisterEvent("UI_ERROR_MESSAGE")
end
-- ref UIErrorsFrame_OnEvent(event, message) (UIErrorsFrame.lua:8-15) — the message is the
-- DECLARED second parameter, not the `arg1` global: the XML passes `arg1` into it exactly
-- as the reference's own does, and an addon calling the 1.12 arity is then read correctly.
function UIErrorsFrame_OnEvent(event, message)
local frame = this
if not message then return end
if event == "UI_INFO_MESSAGE" then
BenillaErrorsFrame_AddMessage(message, 1.0, 1.0, 0.0) -- ref UIErrorsFrame.lua:12
this:AddMessage(message, 1.0, 1.0, 0.0, 1.0) -- ref UIErrorsFrame.lua:12
elseif event == "UI_ERROR_MESSAGE" then
BenillaErrorsFrame_AddMessage(message, 1.0, 0.1, 0.1) -- ref UIErrorsFrame.lua:14
end
end
function BenillaErrorsFrame_OnUpdate(frame, elapsed)
for i = table.getn(frame.messages), 1, -1 do
local msg = frame.messages[i]
msg.age = msg.age + elapsed
if msg.age > BENILLA_ERRORS_HOLD + BENILLA_ERRORS_FADE then
table.remove(frame.messages, i)
end
end
BenillaErrorsFrame_Paint(frame)
if table.getn(frame.messages) == 0 then
frame:Hide()
this:AddMessage(message, 1.0, 0.1, 0.1, 1.0) -- ref UIErrorsFrame.lua:14
end
end
]]></Script>
<!-- frameStrata HIGH is the ref's own (`UIErrorsFrame.xml` l.4). It matters here: this frame is
512x60 anchored TOP (0,-122), which overlaps the right edge of every left-slot panel window
(character, quest log, loot, merchant, mail, trainer, taxi — all at TOPLEFT (0,-104)). Its
own content is flat FontString REGIONS at level 0 while those panels carry dozens of nested
child frames at level 1+, so left in the default MEDIUM its toasts draw UNDER whichever
panel is open — invisible in exactly the state you most want to read them. Decision 0597. -->
<Frame name="UIErrorsFrame" frameStrata="HIGH">
<MessageFrame name="UIErrorsFrame" displayDuration="5" insertMode="TOP" frameStrata="HIGH">
<Size><AbsDimension x="512" y="60"/></Size>
<Anchors>
<Anchor point="TOP" relativePoint="TOP">
<Offset><AbsDimension x="0" y="-122"/></Offset>
</Anchor>
</Anchors>
<Layers>
<Layer level="OVERLAY">
<FontString name="UIErrorsFrameLine1" inherits="GameFontNormalLarge" justifyH="CENTER">
<Size><AbsDimension x="512" y="0"/></Size>
<Anchors><Anchor point="TOP" relativePoint="TOP"/></Anchors>
</FontString>
<FontString name="UIErrorsFrameLine2" inherits="GameFontNormalLarge" justifyH="CENTER">
<Size><AbsDimension x="512" y="0"/></Size>
<Anchors><Anchor point="TOP" relativeTo="UIErrorsFrameLine1" relativePoint="BOTTOM"><Offset><AbsDimension x="0" y="-2"/></Offset></Anchor></Anchors>
</FontString>
<FontString name="UIErrorsFrameLine3" inherits="GameFontNormalLarge" justifyH="CENTER">
<Size><AbsDimension x="512" y="0"/></Size>
<Anchors><Anchor point="TOP" relativeTo="UIErrorsFrameLine2" relativePoint="BOTTOM"><Offset><AbsDimension x="0" y="-2"/></Offset></Anchor></Anchors>
</FontString>
</Layer>
</Layers>
<Scripts>
<OnLoad>UIErrorsFrame_OnLoad()</OnLoad>
<OnEvent>UIErrorsFrame_OnEvent(event, arg1)</OnEvent>
<OnUpdate>BenillaErrorsFrame_OnUpdate(self, arg1)</OnUpdate>
</Scripts>
</Frame>
<!-- The line font AND the centring, both: the class draws its messages through this
declared child, so `justifyH="CENTER"` is what keeps the toasts centred in the 512-wide
box (a chat frame's own child says LEFT). ErrorFont = GameFontNormalLarge (ref
Fonts.xml:258), which is the face the hand-rolled version used too. -->
<FontString inherits="ErrorFont" justifyH="CENTER"/>
</MessageFrame>
</Ui>
@@ -305,6 +305,85 @@
<Color r="0.09" g="0.09" b="0.19" a="0.4"/>
</Texture>
</Layer>
<Layer level="ARTWORK">
<!-- **The 30 pre-declared line pairs, exactly as ref-GameTooltipTemplate.xml l.17-627.**
They exist so `GameTooltipTextLeft1` is a REAL GLOBAL FROM LOAD, hidden, before any
line has been added. Ours were engine-created purely on demand, so cold they were
nil — and the corpus pattern that breaks on is a GUARD, not a read:
`Participant/Resurrection.lua:294` and `CT_RaidAssist/CT_RADetectSpells.lua:47` both
say `GameTooltipTextLeft1:IsVisible()` to ask whether the tooltip is showing
anything. On the reference that is safe and answers false; for us it raised on the
check itself. 7 corpus addons index the name (decision 1220).
The engine ADOPTS these (`script::tooltip::ensure_lines` — the same path
ShoppingTooltipTemplate's ladder already uses): their faces ride, the engine owns
justify, visibility and anchors, so no anchors are declared here. Faces match what
`ensure_lines` picks for a created line — header for pair 1, GameTooltipText after —
which is why this changes nothing on screen. `NumLines()` is `num_lines`, not the
pool size, and `layout_tooltips` sizes from `num_lines`, so a declared-but-unfilled
pair adds no height and no width. -->
<FontString name="$parentTextLeft1" inherits="GameTooltipHeaderText" hidden="true"/>
<FontString name="$parentTextRight1" inherits="GameTooltipHeaderText" hidden="true"/>
<FontString name="$parentTextLeft2" inherits="GameTooltipText" hidden="true"/>
<FontString name="$parentTextRight2" inherits="GameTooltipText" hidden="true"/>
<FontString name="$parentTextLeft3" inherits="GameTooltipText" hidden="true"/>
<FontString name="$parentTextRight3" inherits="GameTooltipText" hidden="true"/>
<FontString name="$parentTextLeft4" inherits="GameTooltipText" hidden="true"/>
<FontString name="$parentTextRight4" inherits="GameTooltipText" hidden="true"/>
<FontString name="$parentTextLeft5" inherits="GameTooltipText" hidden="true"/>
<FontString name="$parentTextRight5" inherits="GameTooltipText" hidden="true"/>
<FontString name="$parentTextLeft6" inherits="GameTooltipText" hidden="true"/>
<FontString name="$parentTextRight6" inherits="GameTooltipText" hidden="true"/>
<FontString name="$parentTextLeft7" inherits="GameTooltipText" hidden="true"/>
<FontString name="$parentTextRight7" inherits="GameTooltipText" hidden="true"/>
<FontString name="$parentTextLeft8" inherits="GameTooltipText" hidden="true"/>
<FontString name="$parentTextRight8" inherits="GameTooltipText" hidden="true"/>
<FontString name="$parentTextLeft9" inherits="GameTooltipText" hidden="true"/>
<FontString name="$parentTextRight9" inherits="GameTooltipText" hidden="true"/>
<FontString name="$parentTextLeft10" inherits="GameTooltipText" hidden="true"/>
<FontString name="$parentTextRight10" inherits="GameTooltipText" hidden="true"/>
<FontString name="$parentTextLeft11" inherits="GameTooltipText" hidden="true"/>
<FontString name="$parentTextRight11" inherits="GameTooltipText" hidden="true"/>
<FontString name="$parentTextLeft12" inherits="GameTooltipText" hidden="true"/>
<FontString name="$parentTextRight12" inherits="GameTooltipText" hidden="true"/>
<FontString name="$parentTextLeft13" inherits="GameTooltipText" hidden="true"/>
<FontString name="$parentTextRight13" inherits="GameTooltipText" hidden="true"/>
<FontString name="$parentTextLeft14" inherits="GameTooltipText" hidden="true"/>
<FontString name="$parentTextRight14" inherits="GameTooltipText" hidden="true"/>
<FontString name="$parentTextLeft15" inherits="GameTooltipText" hidden="true"/>
<FontString name="$parentTextRight15" inherits="GameTooltipText" hidden="true"/>
<FontString name="$parentTextLeft16" inherits="GameTooltipText" hidden="true"/>
<FontString name="$parentTextRight16" inherits="GameTooltipText" hidden="true"/>
<FontString name="$parentTextLeft17" inherits="GameTooltipText" hidden="true"/>
<FontString name="$parentTextRight17" inherits="GameTooltipText" hidden="true"/>
<FontString name="$parentTextLeft18" inherits="GameTooltipText" hidden="true"/>
<FontString name="$parentTextRight18" inherits="GameTooltipText" hidden="true"/>
<FontString name="$parentTextLeft19" inherits="GameTooltipText" hidden="true"/>
<FontString name="$parentTextRight19" inherits="GameTooltipText" hidden="true"/>
<FontString name="$parentTextLeft20" inherits="GameTooltipText" hidden="true"/>
<FontString name="$parentTextRight20" inherits="GameTooltipText" hidden="true"/>
<FontString name="$parentTextLeft21" inherits="GameTooltipText" hidden="true"/>
<FontString name="$parentTextRight21" inherits="GameTooltipText" hidden="true"/>
<FontString name="$parentTextLeft22" inherits="GameTooltipText" hidden="true"/>
<FontString name="$parentTextRight22" inherits="GameTooltipText" hidden="true"/>
<FontString name="$parentTextLeft23" inherits="GameTooltipText" hidden="true"/>
<FontString name="$parentTextRight23" inherits="GameTooltipText" hidden="true"/>
<FontString name="$parentTextLeft24" inherits="GameTooltipText" hidden="true"/>
<FontString name="$parentTextRight24" inherits="GameTooltipText" hidden="true"/>
<FontString name="$parentTextLeft25" inherits="GameTooltipText" hidden="true"/>
<FontString name="$parentTextRight25" inherits="GameTooltipText" hidden="true"/>
<FontString name="$parentTextLeft26" inherits="GameTooltipText" hidden="true"/>
<FontString name="$parentTextRight26" inherits="GameTooltipText" hidden="true"/>
<FontString name="$parentTextLeft27" inherits="GameTooltipText" hidden="true"/>
<FontString name="$parentTextRight27" inherits="GameTooltipText" hidden="true"/>
<FontString name="$parentTextLeft28" inherits="GameTooltipText" hidden="true"/>
<FontString name="$parentTextRight28" inherits="GameTooltipText" hidden="true"/>
<FontString name="$parentTextLeft29" inherits="GameTooltipText" hidden="true"/>
<FontString name="$parentTextRight29" inherits="GameTooltipText" hidden="true"/>
<FontString name="$parentTextLeft30" inherits="GameTooltipText" hidden="true"/>
<FontString name="$parentTextRight30" inherits="GameTooltipText" hidden="true"/>
</Layer>
</Layers>
<Frames>
<!-- The mouseover health bar (ref-GameTooltipTemplate.xml l.575-597, quoted): 8px
@@ -0,0 +1,174 @@
<!--
The reference's `ItemButtonTemplate.lua` helper family (transcribed whole, like
TextStatusBar.xml) — the eight `SetItemButton*` globals every 1.12 window uses to paint an item
slot: its icon, its stack count, its merchant stock line, and the four vertex-colour verbs.
**Why this file exists, and what it cost to be without it.** The director installed Bagnon, hit
B, and got a frame with a title and a gold line and NO BAG SLOTS. `Bagnon_Core/core/Item.lua`
ends every slot update with
l.204 SetItemButtonDesaturated(item, locked, 0.5, 0.5, 0.5)
l.209 SetItemButtonTexture(item, texture)
l.210 SetItemButtonCount(item, itemCount)
and all three were nil. The raise lands inside `BagnonFrame_AddBag`'s per-slot loop, so the
FIRST slot aborts the loop, `frame.size` never advances, `BagnonFrame_Layout` never runs and
the window is left exactly as wide as its own border. `ui_script::bagnon_render_tests` is the
reproduction.
These are FrameXML, not engine (decision 1190's split): the real client ships them as Lua in
`Interface\FrameXML\ItemButtonTemplate.lua`, so they belong here beside our other
transcriptions rather than in a Rust binding. Read off the install's own copy with the
`mpqcat` example over `WoW/Data/interface.MPQ` — not from memory.
(An XML comment may not contain a double hyphen, which is why no command line is spelled out
here; `benilla-mpq/examples/mpqcat.rs`'s own header carries the invocation.)
**Corpus demand, counted rather than assumed** (the 218-addon vanilla corpus; `grep -rl`, one
row per verb, addons/sites):
SetItemButtonTexture 4 addons / 19 sites
SetItemButtonDesaturated 3 addons / 8 sites
SetItemButtonCount 3 addons / 7 sites
SetItemButtonTextureVertexColor 3 addons / 7 sites
SetItemButtonNormalTextureVertexColor 1 addon / 2 sites
SetItemButtonNameFrameVertexColor 1 addon / 2 sites
SetItemButtonSlotVertexColor 0 / 0
SetItemButtonStock 0 / 0
Six addons in all — Bagnon_Core, CT_MailMod, CT_MasterMod, AdvancedTradeSkillWindow,
QuestHistory, SpecialTalentUI. The last two rows have **zero** corpus callers and are here
anyway because this is one reference file transcribed whole and they are two lines each; that
is stated rather than dressed up as demand (1210).
**The virtual `ItemButtonTemplate` Button is deliberately NOT here.** The harness's template
demand ranks it at zero on both axes — no corpus addon names it in a `CreateFrame` fourth
argument or an XML `inherits=` (they name their own `*ItemButtonTemplate`s, or
`ContainerFrameItemButtonTemplate`) — and our own bag windows already have
`BenillaBagSlotTemplate`. Building an unwanted template is exactly the unmeasured work 1210
was written about.
-->
<Ui xmlns="http://www.blizzard.com/wow/ui/">
<Script><![CDATA[
-- The naming contract every verb below rests on: an "item button" is a Button whose own
-- name prefixes its regions — `<name>IconTexture`, `<name>Count`, `<name>Stock`,
-- `<name>NormalTexture`, `<name>NameFrame`, `<name>SlotTexture`. That is the reference's
-- `$parent…` template shape, and it is why these are free functions taking a button rather
-- than widget methods: any button that follows the naming works, whoever built it.
--
-- Nil guards are the REFERENCE's, not ours — one on `button` per verb, and one on the icon
-- region in SetItemButtonDesaturated. A missing region anywhere else raises, and that is
-- the right loudness: "this is not an item button" is a real mistake, and 1203's lesson is
-- that the silent version of a failure is the expensive one.
function SetItemButtonCount(button, count)
if not button then
return
end
if not count then
count = 0
end
button.count = count
-- `isBag` is the reference's own flag for a bag button, where even a single item
-- counts as a number worth showing.
if count > 1 or (button.isBag and count > 0) then
if count > 999 then
count = "*"
end
getglobal(button:GetName() .. "Count"):SetText(count)
getglobal(button:GetName() .. "Count"):Show()
else
getglobal(button:GetName() .. "Count"):Hide()
end
end
function SetItemButtonStock(button, numInStock)
if not button then
return
end
-- The reference defaults this to `""` and then compares it with `> 0`, which is a
-- string/number comparison Lua refuses — a raise on the only path the default can
-- reach. Ours defaults to 0, which is what the branch below actually means. Not aping
-- a quirk (method step 3); the behaviour on every reachable path is identical.
if not numInStock then
numInStock = 0
end
button.numInStock = numInStock
if numInStock > 0 then
getglobal(button:GetName() .. "Stock"):SetText(format(MERCHANT_STOCK, numInStock))
getglobal(button:GetName() .. "Stock"):Show()
else
getglobal(button:GetName() .. "Stock"):Hide()
end
end
function SetItemButtonTexture(button, texture)
if not button then
return
end
-- Show/Hide FIRST, then SetTexture — the reference's order, and it matters: a nil
-- texture hides the region rather than leaving an empty quad behind.
if texture then
getglobal(button:GetName() .. "IconTexture"):Show()
else
getglobal(button:GetName() .. "IconTexture"):Hide()
end
getglobal(button:GetName() .. "IconTexture"):SetTexture(texture)
end
function SetItemButtonTextureVertexColor(button, r, g, b)
if not button then
return
end
getglobal(button:GetName() .. "IconTexture"):SetVertexColor(r, g, b)
end
function SetItemButtonDesaturated(button, desaturated, r, g, b)
if not button then
return
end
local icon = getglobal(button:GetName() .. "IconTexture")
if not icon then
return
end
-- The reference asks the texture itself whether the desaturating shader is available
-- (`local shaderSupported = icon:SetDesaturated(desaturated)`) and falls through to a
-- flat grey tint when it is not — 1.12 ran on fixed-function hardware where that was
-- the ordinary answer. benilla has no `Texture:SetDesaturated` at all (it is the top
-- row of the harness's missing-METHODS ranking, 61 corpus addons), so the feature test
-- is on the METHOD's existence and the no-shader branch is the one we take. When the
-- real verb lands this line starts using it with nothing else to change.
local shaderSupported = icon.SetDesaturated and icon:SetDesaturated(desaturated)
if not desaturated then
r = 1.0
g = 1.0
b = 1.0
elseif not r or not shaderSupported then
r = 0.5
g = 0.5
b = 0.5
end
icon:SetVertexColor(r, g, b)
end
function SetItemButtonNormalTextureVertexColor(button, r, g, b)
if not button then
return
end
getglobal(button:GetName() .. "NormalTexture"):SetVertexColor(r, g, b)
end
function SetItemButtonNameFrameVertexColor(button, r, g, b)
if not button then
return
end
getglobal(button:GetName() .. "NameFrame"):SetVertexColor(r, g, b)
end
function SetItemButtonSlotVertexColor(button, r, g, b)
if not button then
return
end
getglobal(button:GetName() .. "SlotTexture"):SetVertexColor(r, g, b)
end
]]></Script>
</Ui>
+147
View File
@@ -35,6 +35,9 @@
-- base = (page - 1) * 12.
BENILLA_MULTIBAR_BOTTOMLEFT_BASE = 60
BENILLA_MULTIBAR_BOTTOMRIGHT_BASE = 48
-- ref ActionButton.lua:8-9 — LEFT_ACTIONBAR_PAGE = 4, RIGHT_ACTIONBAR_PAGE = 3.
BENILLA_MULTIBAR_LEFT_BASE = 36
BENILLA_MULTIBAR_RIGHT_BASE = 24
-- The MULTIACTIONBAR keybind pair — ref MultiActionBars.lua:24-45 MultiActionButtonDown/Up,
-- the same NORMAL→PUSHED debounce dance as the main bar's ActionButtonDown/Up
@@ -185,4 +188,148 @@
</CheckButton>
</Frames>
</Frame>
<!-- **The two VERTICAL bars, built but never shown.**
They were scoped out (see the header's ref-divergence note) on the reasonable reading that
a bar nobody asked for should not exist. Measuring the corpus corrected the reading: the
reference does not choose between "shown" and "absent" — `MultiActionBars.xml` l.515/523
instantiates BOTH as real `parent="UIParent"` frames whose templates (VerticalMultiBar3/4,
l.266/381) carry `hidden="true"`. On the real client they exist from load and stay hidden
until SHOW_MULTI_ACTIONBAR_3/4. So declaring them costs nothing on screen and is what the
reference actually does.
What it buys: Atlas, Bartender2 and FuBartender2 all DIE at session start on
`MultiBarLeft` — Atlas at `Atlas.lua:387` (`MultiBarLeft:SetFrameStrata("MEDIUM")`),
Bartender2 at `Bartender2.lua:74` (`MultiBarLeft:ClearAllPoints()`). Neither shows the bar;
both only need it to be there. Five more addons index `MultiBarRight` (decision 1218's
corrected ranking).
Still no SHOW_MULTI_ACTIONBAR_* state and still no options toggle — 0270's call stands, and
these two are simply hidden rather than optional. An addon that calls `MultiBarLeft:Show()`
gets a working bar on actions 37..48, which is exactly what the reference gives it.
The buttons carry the reference's own MULTIACTIONBAR3/4BUTTON command names, and those
commands stay OUT of `bindings::commands` — 1008's "honest tree" holds: the keybind screen
lists what the player can actually reach, and a bar we never show is not that. Painting a
hotkey from an unregistered command is a no-op (GetBindingKey answers nil), so the label is
simply blank until someone registers them.
Pages from ref-ActionButton.lua l.8-9: LEFT_ACTIONBAR_PAGE = 4 (actions 37..48),
RIGHT_ACTIONBAR_PAGE = 3 (actions 25..36). Geometry from ref-MultiActionBars.xml: a 38x500
frame, button 1 at TOPRIGHT, each next TOP-to-BOTTOM of the previous at -6. -->
<Frame name="MultiBarRight" frameStrata="HIGH" hidden="true">
<Size><AbsDimension x="38" y="500"/></Size>
<Anchors>
<Anchor point="BOTTOMRIGHT"><Offset><AbsDimension x="-7" y="98"/></Offset></Anchor>
</Anchors>
<Frames>
<CheckButton name="MultiBarRightButton1" inherits="BenillaActionButtonTemplate">
<Anchors><Anchor point="TOPRIGHT" relativePoint="TOPRIGHT"><Offset><AbsDimension x="0" y="0"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 1, BENILLA_MULTIBAR_RIGHT_BASE, "MULTIACTIONBAR3BUTTON")</OnLoad></Scripts>
</CheckButton>
<CheckButton name="MultiBarRightButton2" inherits="BenillaActionButtonTemplate">
<Anchors><Anchor point="TOP" relativeTo="MultiBarRightButton1" relativePoint="BOTTOM"><Offset><AbsDimension x="0" y="-6"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 2, BENILLA_MULTIBAR_RIGHT_BASE, "MULTIACTIONBAR3BUTTON")</OnLoad></Scripts>
</CheckButton>
<CheckButton name="MultiBarRightButton3" inherits="BenillaActionButtonTemplate">
<Anchors><Anchor point="TOP" relativeTo="MultiBarRightButton2" relativePoint="BOTTOM"><Offset><AbsDimension x="0" y="-6"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 3, BENILLA_MULTIBAR_RIGHT_BASE, "MULTIACTIONBAR3BUTTON")</OnLoad></Scripts>
</CheckButton>
<CheckButton name="MultiBarRightButton4" inherits="BenillaActionButtonTemplate">
<Anchors><Anchor point="TOP" relativeTo="MultiBarRightButton3" relativePoint="BOTTOM"><Offset><AbsDimension x="0" y="-6"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 4, BENILLA_MULTIBAR_RIGHT_BASE, "MULTIACTIONBAR3BUTTON")</OnLoad></Scripts>
</CheckButton>
<CheckButton name="MultiBarRightButton5" inherits="BenillaActionButtonTemplate">
<Anchors><Anchor point="TOP" relativeTo="MultiBarRightButton4" relativePoint="BOTTOM"><Offset><AbsDimension x="0" y="-6"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 5, BENILLA_MULTIBAR_RIGHT_BASE, "MULTIACTIONBAR3BUTTON")</OnLoad></Scripts>
</CheckButton>
<CheckButton name="MultiBarRightButton6" inherits="BenillaActionButtonTemplate">
<Anchors><Anchor point="TOP" relativeTo="MultiBarRightButton5" relativePoint="BOTTOM"><Offset><AbsDimension x="0" y="-6"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 6, BENILLA_MULTIBAR_RIGHT_BASE, "MULTIACTIONBAR3BUTTON")</OnLoad></Scripts>
</CheckButton>
<CheckButton name="MultiBarRightButton7" inherits="BenillaActionButtonTemplate">
<Anchors><Anchor point="TOP" relativeTo="MultiBarRightButton6" relativePoint="BOTTOM"><Offset><AbsDimension x="0" y="-6"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 7, BENILLA_MULTIBAR_RIGHT_BASE, "MULTIACTIONBAR3BUTTON")</OnLoad></Scripts>
</CheckButton>
<CheckButton name="MultiBarRightButton8" inherits="BenillaActionButtonTemplate">
<Anchors><Anchor point="TOP" relativeTo="MultiBarRightButton7" relativePoint="BOTTOM"><Offset><AbsDimension x="0" y="-6"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 8, BENILLA_MULTIBAR_RIGHT_BASE, "MULTIACTIONBAR3BUTTON")</OnLoad></Scripts>
</CheckButton>
<CheckButton name="MultiBarRightButton9" inherits="BenillaActionButtonTemplate">
<Anchors><Anchor point="TOP" relativeTo="MultiBarRightButton8" relativePoint="BOTTOM"><Offset><AbsDimension x="0" y="-6"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 9, BENILLA_MULTIBAR_RIGHT_BASE, "MULTIACTIONBAR3BUTTON")</OnLoad></Scripts>
</CheckButton>
<CheckButton name="MultiBarRightButton10" inherits="BenillaActionButtonTemplate">
<Anchors><Anchor point="TOP" relativeTo="MultiBarRightButton9" relativePoint="BOTTOM"><Offset><AbsDimension x="0" y="-6"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 10, BENILLA_MULTIBAR_RIGHT_BASE, "MULTIACTIONBAR3BUTTON")</OnLoad></Scripts>
</CheckButton>
<CheckButton name="MultiBarRightButton11" inherits="BenillaActionButtonTemplate">
<Anchors><Anchor point="TOP" relativeTo="MultiBarRightButton10" relativePoint="BOTTOM"><Offset><AbsDimension x="0" y="-6"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 11, BENILLA_MULTIBAR_RIGHT_BASE, "MULTIACTIONBAR3BUTTON")</OnLoad></Scripts>
</CheckButton>
<CheckButton name="MultiBarRightButton12" inherits="BenillaActionButtonTemplate">
<Anchors><Anchor point="TOP" relativeTo="MultiBarRightButton11" relativePoint="BOTTOM"><Offset><AbsDimension x="0" y="-6"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 12, BENILLA_MULTIBAR_RIGHT_BASE, "MULTIACTIONBAR3BUTTON")</OnLoad></Scripts>
</CheckButton>
</Frames>
</Frame>
<Frame name="MultiBarLeft" frameStrata="HIGH" hidden="true">
<Size><AbsDimension x="38" y="500"/></Size>
<Anchors>
<Anchor point="TOPRIGHT" relativeTo="MultiBarRight" relativePoint="TOPLEFT"><Offset><AbsDimension x="-5" y="0"/></Offset></Anchor>
</Anchors>
<Frames>
<CheckButton name="MultiBarLeftButton1" inherits="BenillaActionButtonTemplate">
<Anchors><Anchor point="TOPRIGHT" relativePoint="TOPRIGHT"><Offset><AbsDimension x="0" y="0"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 1, BENILLA_MULTIBAR_LEFT_BASE, "MULTIACTIONBAR4BUTTON")</OnLoad></Scripts>
</CheckButton>
<CheckButton name="MultiBarLeftButton2" inherits="BenillaActionButtonTemplate">
<Anchors><Anchor point="TOP" relativeTo="MultiBarLeftButton1" relativePoint="BOTTOM"><Offset><AbsDimension x="0" y="-6"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 2, BENILLA_MULTIBAR_LEFT_BASE, "MULTIACTIONBAR4BUTTON")</OnLoad></Scripts>
</CheckButton>
<CheckButton name="MultiBarLeftButton3" inherits="BenillaActionButtonTemplate">
<Anchors><Anchor point="TOP" relativeTo="MultiBarLeftButton2" relativePoint="BOTTOM"><Offset><AbsDimension x="0" y="-6"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 3, BENILLA_MULTIBAR_LEFT_BASE, "MULTIACTIONBAR4BUTTON")</OnLoad></Scripts>
</CheckButton>
<CheckButton name="MultiBarLeftButton4" inherits="BenillaActionButtonTemplate">
<Anchors><Anchor point="TOP" relativeTo="MultiBarLeftButton3" relativePoint="BOTTOM"><Offset><AbsDimension x="0" y="-6"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 4, BENILLA_MULTIBAR_LEFT_BASE, "MULTIACTIONBAR4BUTTON")</OnLoad></Scripts>
</CheckButton>
<CheckButton name="MultiBarLeftButton5" inherits="BenillaActionButtonTemplate">
<Anchors><Anchor point="TOP" relativeTo="MultiBarLeftButton4" relativePoint="BOTTOM"><Offset><AbsDimension x="0" y="-6"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 5, BENILLA_MULTIBAR_LEFT_BASE, "MULTIACTIONBAR4BUTTON")</OnLoad></Scripts>
</CheckButton>
<CheckButton name="MultiBarLeftButton6" inherits="BenillaActionButtonTemplate">
<Anchors><Anchor point="TOP" relativeTo="MultiBarLeftButton5" relativePoint="BOTTOM"><Offset><AbsDimension x="0" y="-6"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 6, BENILLA_MULTIBAR_LEFT_BASE, "MULTIACTIONBAR4BUTTON")</OnLoad></Scripts>
</CheckButton>
<CheckButton name="MultiBarLeftButton7" inherits="BenillaActionButtonTemplate">
<Anchors><Anchor point="TOP" relativeTo="MultiBarLeftButton6" relativePoint="BOTTOM"><Offset><AbsDimension x="0" y="-6"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 7, BENILLA_MULTIBAR_LEFT_BASE, "MULTIACTIONBAR4BUTTON")</OnLoad></Scripts>
</CheckButton>
<CheckButton name="MultiBarLeftButton8" inherits="BenillaActionButtonTemplate">
<Anchors><Anchor point="TOP" relativeTo="MultiBarLeftButton7" relativePoint="BOTTOM"><Offset><AbsDimension x="0" y="-6"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 8, BENILLA_MULTIBAR_LEFT_BASE, "MULTIACTIONBAR4BUTTON")</OnLoad></Scripts>
</CheckButton>
<CheckButton name="MultiBarLeftButton9" inherits="BenillaActionButtonTemplate">
<Anchors><Anchor point="TOP" relativeTo="MultiBarLeftButton8" relativePoint="BOTTOM"><Offset><AbsDimension x="0" y="-6"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 9, BENILLA_MULTIBAR_LEFT_BASE, "MULTIACTIONBAR4BUTTON")</OnLoad></Scripts>
</CheckButton>
<CheckButton name="MultiBarLeftButton10" inherits="BenillaActionButtonTemplate">
<Anchors><Anchor point="TOP" relativeTo="MultiBarLeftButton9" relativePoint="BOTTOM"><Offset><AbsDimension x="0" y="-6"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 10, BENILLA_MULTIBAR_LEFT_BASE, "MULTIACTIONBAR4BUTTON")</OnLoad></Scripts>
</CheckButton>
<CheckButton name="MultiBarLeftButton11" inherits="BenillaActionButtonTemplate">
<Anchors><Anchor point="TOP" relativeTo="MultiBarLeftButton10" relativePoint="BOTTOM"><Offset><AbsDimension x="0" y="-6"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 11, BENILLA_MULTIBAR_LEFT_BASE, "MULTIACTIONBAR4BUTTON")</OnLoad></Scripts>
</CheckButton>
<CheckButton name="MultiBarLeftButton12" inherits="BenillaActionButtonTemplate">
<Anchors><Anchor point="TOP" relativeTo="MultiBarLeftButton11" relativePoint="BOTTOM"><Offset><AbsDimension x="0" y="-6"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 12, BENILLA_MULTIBAR_LEFT_BASE, "MULTIACTIONBAR4BUTTON")</OnLoad></Scripts>
</CheckButton>
</Frames>
</Frame>
</Ui>
+10 -15
View File
@@ -59,15 +59,12 @@
`QuestLogTitleButton_OnClick`, `QuestLogFrame.lua:469-505`). The modifier plumbing this
note once named as the missing engine slice (`IsShiftKeyDown` + the app's per-frame
mirror) landed with the cursor arc; the interim right-click substitute is retired.
- **The two ref watch errors (`QUEST_WATCH_NO_OBJECTIVES`/`QUEST_WATCH_TOO_MANY`,
`UIErrorsFrame:AddMessage`) have no seam to reach `UIErrorsFrame` from Lua**: that
frame only reacts to the `UI_ERROR_MESSAGE` *event*, and firing an event is a method on
`UiScript` (`fire_event`) — there is no Lua-callable "fire this event"/"post this error"
global anywhere in this engine (grepped; every `UI_ERROR_MESSAGE` fire in this codebase is
Rust-side, e.g. `ui_merchant.rs`/`ui_action.rs`). The two guards below therefore no-op
silently instead of erroring — a real (if narrow) gap: right-clicking a no-objectives or
6th-watch row does nothing visible rather than explaining why. Worth a small Lua-callable
error-post global as a follow-up once a second caller wants one too.
- The two ref watch errors (`QUEST_WATCH_NO_OBJECTIVES`/`QUEST_WATCH_TOO_MANY`) go out
through the reference's own `UIErrorsFrame:AddMessage`. That was once impossible here —
`UIErrorsFrame` was a hand-rolled `<Frame>` reachable only by firing `UI_ERROR_MESSAGE`
from Rust — and the guards no-opped silently in the interim; the MessageFrame widget
(`script::messageframe::plain`) closed it, and the seam this note asked for turned out
to be the real widget method rather than a benilla global.
-->
<Script><![CDATA[
BENILLA_QUESTLOG_ROWS = 6 -- QUESTS_DISPLAYED (pin §2, QuestLogFrame.lua:1)
@@ -201,8 +198,7 @@
-- Guard order inside the watch arm matches the ref exactly: already watched → unwatch,
-- unconditionally; else zero objectives (QUEST_WATCH_NO_OBJECTIVES) or the watch list
-- full (QUEST_WATCH_TOO_MANY) put the ref's red line on the errors frame
-- (BenillaErrorsFrame_AddMessage — the ref's UIErrorsFrame:AddMessage call sites,
-- lua:490/495), else watch it. The ref calls QuestWatch_Update() itself
-- (UIErrorsFrame:AddMessage, the ref's own call — lua:490/495), else watch it. The ref calls QuestWatch_Update() itself
-- right after each Add/RemoveQuestWatch (same two call sites) — it does NOT lean on
-- the QUEST_LOG_UPDATE/QUEST_WATCH_UPDATE events alone for this, since neither engine
-- fires an event off a plain Add/RemoveQuestWatch call; we mirror that exactly.
@@ -220,12 +216,11 @@
RemoveQuestWatch(index)
QuestWatch_Update()
elseif GetNumQuestLeaderBoards(index) == 0 then
-- ref QUEST_WATCH_NO_OBJECTIVES, red, straight onto the errors frame
-- (the ref calls UIErrorsFrame:AddMessage the same way).
BenillaErrorsFrame_AddMessage("This quest has no objectives to track", 1.0, 0.1, 0.1)
-- ref QUEST_WATCH_NO_OBJECTIVES, red, straight onto the errors frame.
UIErrorsFrame:AddMessage("This quest has no objectives to track", 1.0, 0.1, 0.1)
elseif GetNumQuestWatches() >= BENILLA_QUESTLOG_MAX_WATCHES then
-- ref QUEST_WATCH_TOO_MANY, red.
BenillaErrorsFrame_AddMessage("You may only watch " .. BENILLA_QUESTLOG_MAX_WATCHES .. " quests at a time", 1.0, 0.1, 0.1)
UIErrorsFrame:AddMessage("You may only watch " .. BENILLA_QUESTLOG_MAX_WATCHES .. " quests at a time", 1.0, 0.1, 0.1)
else
-- A manual watch is PERMANENT (ref: QUEST_WATCH_NO_EXPIRE) — drop any timed
-- entry the auto-watch may have armed for it.
+7 -10
View File
@@ -38,12 +38,11 @@
identical gap and all solve it the same way: the app resolves the flight master's name
through the `NameCache` and rides it in as the open event's arg1 — done here too
(`BenillaTaxiFrame_SetTitle`, fed by `TAXIMAP_OPENED`'s arg1).
3. THE "NO SINGLE-HOP DESTINATION" REFUSAL (`DrawOneHopLines`, ref l.161-164) calls
`BenillaErrorsFrame_AddMessage` (`ErrorsFrame.xml`'s real Lua-callable seam, byte-exact
`ERR_TAXINOPATHS` text below) rather than the reference's `UIErrorsFrame:AddMessage` —
this engine's error toast is `UIErrorsFrame`, not an intrinsic `MessageFrame`
object, so the ref's OO call becomes the plain function call. `ErrorsFrame.xml` loads
well before this file in the shipped list (`ui_script/mod.rs`), so the global already
3. (Retired.) The "no single-hop destination" refusal (`DrawOneHopLines`, ref l.161-164)
used to call a plain `BenillaErrorsFrame_AddMessage` global because this engine had no
MessageFrame widget for `UIErrorsFrame` to be. It has one now, so the line below is the
reference's own `UIErrorsFrame:AddMessage(ERR_TAXINOPATHS, ...)`. `ErrorsFrame.xml` loads
well before this file in the shipped list (`ui_script/mod.rs`), so the frame already
exists by the time this ever runs.
`<TaxiRouteFrame>` (an intrinsic type this engine doesn't have) becomes a plain `<Frame>`
@@ -195,9 +194,7 @@
NUM_TAXI_ROUTES = 0
-- Draw all flightpaths within one hop of current location — ref TaxiFrame.lua:129-165,
-- verbatim except the canvas name ("TaxiRouteMap" -> "TaxiRouteMap") and the
-- no-single-hop refusal (deviation 3 above: BenillaErrorsFrame_AddMessage, not
-- UIErrorsFrame:AddMessage).
-- verbatim except the canvas name ("TaxiRouteMap" -> "TaxiRouteMap").
function DrawOneHopLines()
local line
local sX, sY, dX, dY
@@ -231,7 +228,7 @@
getglobal("TaxiRoute" .. i):Hide()
end
if numSingleHops == 0 then
BenillaErrorsFrame_AddMessage(ERR_TAXINOPATHS, 1.0, 0.1, 0.1)
UIErrorsFrame:AddMessage(ERR_TAXINOPATHS, 1.0, 0.1, 0.1)
HideUIPanel(TaxiFrame)
end
end
@@ -1074,7 +1074,54 @@
</Scripts>
</Frame>
<!-- The two shared lists (the ref authors DropDownList1..3; level 3 waits for a customer). -->
<Button name="DropDownList1" inherits="UIDropDownListTemplate" id="1" parent="UIParent"/>
<Button name="DropDownList2" inherits="UIDropDownListTemplate" id="2" parent="UIParent"/>
<!-- The two shared lists (the ref authors DropDownList1..3; level 3 waits for a customer).
The per-instance <Scripts> are the ref's own (UIDropDownMenu.xml l.9-18, l.25-28) and were
missing here until they were measured; each line earns its place separately:
· `CloseDropDownMenus(level+1)` is the SUBMENU CATCH-ALL, and it is why the ref hangs it
off OnHide rather than off the toggle. `ToggleDropDownMenu`'s close arm is a bare
`listFrame:Hide()` (l.15-19 of the function) — so with no OnHide, opening a menu with a
`hasArrow` row, hovering the submenu open and then toggling level 1 shut LEFT
DropDownList2 on screen with its parent gone. OnHide catches that path and every other
one that hides level 1 (a parent Hide, the ESC ladder, an addon calling
`DropDownList1:Hide()` itself) — which is the point of putting it on the event.
· `OPEN_DROPDOWNMENUS[level] = nil` closes a real leak: UnitPopup.xml WRITES the level
(l.191, l.239) and its every-frame driver READS it (l.481), and nothing here ever
cleared it, so an entry survived its menu forever. No player-visible symptom is
reachable today and that is stated rather than implied — `UnitPopup_OnUpdate` returns
early unless DropDownList1 is visible AND a UnitPopupFrame owns the open menu, and
`UIDropDownMenu_AddButton` calls `button:Enable()` unconditionally, so a stale level's
Disable is undone the next time that list is built. Both of those are guards belonging
to other code; the divergence from the ref is here, so it is fixed here.
· UIDROPDOWNMENU_DEFAULT_TEXT_HEIGHT is DERIVED, never a literal. The ref forward-declares
it nil (UIDropDownMenu.lua l.16) and this OnLoad is the only thing that ever assigns it,
reading the height off Button1's own NormalText. `UIDropDownMenu_AddButton` then passes
it straight into `SetFont` (l.187) for every row without an explicit `info.textHeight`.
Dewdrop-2.0 does the same at its own l.489 — 64 corpus addon folders embed a copy, but
it is ONE library file replicated, and only that one of its two sites is unguarded (the
other is `info.textHeight or UIDROPDOWNMENU_DEFAULT_TEXT_HEIGHT or 10`). Writing a
literal here would have been a silent fidelity divergence, not a fix. -->
<Button name="DropDownList1" inherits="UIDropDownListTemplate" id="1" parent="UIParent">
<Scripts>
<OnLoad>
local fontName, fontHeight, fontFlags = getglobal("DropDownList1Button1NormalText"):GetFont();
UIDROPDOWNMENU_DEFAULT_TEXT_HEIGHT = fontHeight;
</OnLoad>
<OnHide>
CloseDropDownMenus(2);
OPEN_DROPDOWNMENUS[1] = nil;
</OnHide>
</Scripts>
</Button>
<Button name="DropDownList2" inherits="UIDropDownListTemplate" id="2" parent="UIParent">
<Scripts>
<OnHide>
CloseDropDownMenus(3);
OPEN_DROPDOWNMENUS[2] = nil;
</OnHide>
</Scripts>
</Button>
</Ui>
+1 -1
View File
@@ -505,7 +505,7 @@
function OpenBackpack()
for bagId = 0, 4 do
-- The backpack (bag 0) always exists; an equipped-bag window only when a bag is in
-- the slot (the same existence test as BagFrame.xml's BenillaOpenAllBags).
-- the slot (the same existence test as BagFrame.xml's OpenAllBags).
local exists = bagId == 0
or (GetContainerNumSlots and GetContainerNumSlots(bagId) > 0)
local bag = exists and getglobal(BAG_WINDOWS[bagId + 1])
@@ -312,6 +312,20 @@
end
end
-- The reference's names for the two unit-frame hover hooks, `this`-shaped, delegating to
-- ours. `TipBuddy.lua:2770-2773` is the exact idiom this exists for:
-- originalUnitFrame_OnEnter = UnitFrame_OnEnter
-- function UnitFrame_OnEnter() originalUnitFrame_OnEnter() ... end
-- Against a missing global that captured nil and raised on the first hover; against a
-- RENAMED one it would have called ours with no frame. See ActionBar.xml for the full note.
function UnitFrame_OnEnter()
BenillaUnitFrame_OnEnter(this)
end
function UnitFrame_OnLeave()
BenillaUnitFrame_OnLeave(this)
end
function BenillaUnitFrame_OnEnter(frame)
local unit = frame.unit
if SpellIsTargeting() then
+10
View File
@@ -25,6 +25,11 @@ Fonts.xml
# Straight after Fonts.xml because those are the only things it needs (GameFontRedLarge,
# DialogButtonNormalText/HighlightText) and everything above it in FrameXML.toc is engine.
BasicControls.xml
# The `SetItemButton*` family (ref ItemButtonTemplate.lua) — script-only, no frames and no
# dependencies of its own, so it sits as early as it can be read. Every window that paints an item
# slot calls these, ours and third-party alike: their absence is what left the director's Bagnon
# window with a title, a gold line and no bag slots at all (see the file's own header).
ItemButtonTemplate.xml
# The UIPanel slot manager (decision 0084 §2): script-only, no frames of its own — must load
# before any panel frame (Gossip/Merchant today) so ShowUIPanel/HideUIPanel already exist
# when those frames' OnLoad/OnEvent reference them.
@@ -194,6 +199,11 @@ BagFrame.xml
# two doesn't matter for correctness (Lua globals resolve at call time, and neither file's
# XML `inherits=` reaches into the other's templates) — kept adjacent for legibility.
StackSplit.xml
# Our ADAPTERS over the reference's own `Interface\\FrameXML\\ContainerFrame.lua`, which this
# client EXECUTES off the patch chain rather than transcribing (`ui_script::reference_ui`) — see
# that file's header for the rule. Must load AFTER the sourced reference file (every builtin does)
# and after BagFrame/StackSplit, whose globals it leans on.
ContainerFrameAdapters.xml
# The character window (decision 0208 phase 1a): the C-key paper doll. Loaded after BagFrame
# (needs nothing from it, but keeps player-state windows grouped) and before the NPC session
# windows (Gossip/Merchant/Quest*) — it depends only on Fonts/UiPanels/GameTooltip, all
+400 -8
View File
@@ -1,7 +1,7 @@
//! `addon_harness` — load a folder of addons, one per VM, and print what happened.
//!
//! ```text
//! cargo run -q -p benilla-app --example addon_harness -- <folder of addons> [--verbose] [--why <substr>]
//! cargo run -q -p benilla-app --example addon_harness -- <folder> [--verbose] [--why <substr>] [--status <file>] [--diff <file>]
//! ```
//!
//! The instrument decision 1188 phase 6 asks for: *"which addons work" is a number that can be
@@ -12,6 +12,11 @@
//! distribution, not a pass/fail: a handful of addons will always want features we have not built.
use benilla_app::addon_harness;
/// How much traceback `--why` prints per addon. Two lines gave the message and mlua's first
/// frame, which was enough to RANK a row and never enough to FIND one: chasing a `SetTexture`
/// failure to its call site needed the frames below it, and the addon/file/line only appear there.
const WHY_TRACEBACK_LINES: usize = 8;
fn main() {
let mut args = std::env::args().skip(1);
let Some(root) = args.next() else {
@@ -22,14 +27,44 @@ fn main() {
};
let rest: Vec<String> = args.collect();
let verbose = rest.iter().any(|a| a == "--verbose");
// `--why <substring>` — the addons behind one blocker row, with their verbatim first errors.
// The ranked table collapses quoted names by design (1193); this is the read-back, and two of
// this arc's decisions came from doing it by hand (1206, 1210).
// `--why <substring>` — the addons behind one row, with their verbatim errors. The
// ranked table collapses quoted names by design (1193); this is the read-back, and two of this
// arc's decisions came from doing it by hand (1206, 1210). It matches the normalised row AND
// the raw text, so `--why <a row>` and `--why <a name>` both work — the second did not, and
// that is what made the whole widget-method class unreadable here.
//
// It reads back through the **method demand tables** too, which nothing did: every ranking here
// is built by scanning, three of them have opened with fiction (1210, 1218, 1227), and the rule
// that came out of that is to open the corpus line before quoting a row. Verifying the per-kind
// table's own first head cost a temporary debug print and a second corpus run for want of this.
let why = rest
.iter()
.position(|a| a == "--why")
.and_then(|i| rest.get(i + 1))
.cloned();
// `--status` writes the per-addon ok/fail roster; `--diff <file>` compares against one.
//
// **The instrument this survey was missing, and the reason it is missing is instructive.**
// Every column here is a TOTAL, so a landing that fixes one addon and breaks another reads as
// a clean zero — indistinguishable from a change that did nothing. That happened: the shadow
// accessors (`7824c154`) gained FuBar_NavigatorFu and held the column at 107, so something was
// lost, and there was no way to ask what. Every "zero delta" recorded in this arc before now
// could have been hiding the same swap.
// `--status <file>` WRITES the roster, rather than printing it. It printed at first, and the
// obvious `--status > roster.txt` then captured the whole report — 53 report lines parsed as
// addon rows, because `--diff` splits on the last space and almost anything satisfies that. An
// instrument whose output needs a `grep` incantation to be usable is one that will be used
// wrong; the file is the artefact, so the tool writes the file.
let status = rest
.iter()
.position(|a| a == "--status")
.and_then(|i| rest.get(i + 1))
.cloned();
let diff = rest
.iter()
.position(|a| a == "--diff")
.and_then(|i| rest.get(i + 1))
.cloned();
let root = std::path::PathBuf::from(root);
let reports = addon_harness::survey(&root);
@@ -93,6 +128,134 @@ fn main() {
" ...and survived a session start : {survived}/{}",
reports.len()
);
// The UI-probe column: of those, how many survive having their OVERRIDES actually invoked.
// The director found this blind spot by playing the game — an addon that replaces
// `ToggleBackpack` and is never called looked identical to one that works.
let probed = reports
.iter()
.filter(|r| r.loaded && r.session_errors.is_empty() && r.probe_errors.is_empty())
.count();
println!(
" ...and survived a UI probe : {probed}/{}",
reports.len()
);
// **The render column** — the only one here that asks whether anything was DRAWN. Every number
// above it asks whether something raised, and the director's Bagnon report raised nothing at
// all: a window with a title, a gold line and no bag slots, scoring a clean pass on all four.
// `addon_harness::render`'s header is the design and its honest bounds.
let drew = reports
.iter()
.filter(|r| r.render.drew() != addon_harness::Drew::Nothing)
.count();
println!(
" ...and DREW something on screen : {drew}/{}",
reports.len()
);
let (own, overlay) = (
reports
.iter()
.filter(|r| r.render.drew() == addon_harness::Drew::Own)
.count(),
reports
.iter()
.filter(|r| r.render.drew() == addon_harness::Drew::Overlay)
.count(),
);
println!(" of those: {own} drew a window of their own, {overlay} painted onto ours");
// The actionable list, and the reason this column exists: an addon that loads clean, survives a
// session start AND a UI probe, and still puts nothing on screen. Nothing else here can name
// one. Bagnon was on this list.
let silent: Vec<&str> = reports
.iter()
.filter(|r| {
r.loaded
&& r.session_errors.is_empty()
&& r.probe_errors.is_empty()
&& r.render.drew() == addon_harness::Drew::Nothing
})
.map(|r| r.name.as_str())
.collect();
if !silent.is_empty() {
println!(
"\n CLEAN ON EVERY OTHER COLUMN AND DREW NOTHING ({}) — read this list first:",
silent.len()
);
for chunk in silent.chunks(4) {
println!(" {}", chunk.join(" "));
}
}
// **The use column** — the only one here that TOUCHES anything. Every number above it asks
// whether something ran or appeared; the director drew the line by hovering Bagnon's freshly
// drawn bag slots and getting a wall of `attempt to call global
// 'ContainerFrameItemButton_OnEnter'` while all five columns above scored it a pass.
//
// Printed as THREE numbers, never one, because "nothing raised" and "nothing was touched" are
// different answers and a probe that quietly drove zero targets reporting "clean" is this
// instrument's own oldest failure mode (`addon_harness::use_probe`'s header).
let (survived_use, raised_use, untouched_use) = (
reports
.iter()
.filter(|r| r.used.verdict() == addon_harness::Used::Survived)
.count(),
reports
.iter()
.filter(|r| r.used.verdict() == addon_harness::Used::Raised)
.count(),
reports
.iter()
.filter(|r| {
r.render.drew() != addon_harness::Drew::Nothing
&& r.used.verdict() == addon_harness::Used::Untouched
})
.count(),
);
println!(
" ...and SURVIVED BEING USED : {survived_use}/{}",
reports.len()
);
println!(
" of the {drew} that drew: {} raised on hover/click/drag, {survived_use} came \
through clean, {untouched_use} had nothing a pointer can reach",
raised_use
);
println!(
" (input driven: hover in+out, left click, right click, drag — up to {} of the \
frames each addon painted itself)",
addon_harness::MAX_USE_TARGETS
);
// The list this column exists to be able to print: an addon whose UI is fully on screen and
// falls over the moment anyone uses it.
let inert: Vec<String> = reports
.iter()
.filter(|r| r.used.verdict() == addon_harness::Used::Raised)
.map(|r| format!("{}({})", r.name, r.used.errors.len()))
.collect();
if !inert.is_empty() {
println!(
"\n DREW SOMETHING AND IS DEAD TO THE TOUCH ({}) — (n) = raises, `--why <name>` for \
the text:",
inert.len()
);
for chunk in inert.chunks(4) {
println!(" {}", chunk.join(" "));
}
}
// ...and what the input broke, ranked like the session-start table above it.
let mut used_rows: std::collections::BTreeMap<String, usize> = Default::default();
for r in &reports {
if let Some(e) = r.used.errors.first() {
*used_rows.entry(addon_harness::normalise(e)).or_default() += 1;
}
}
if !used_rows.is_empty() {
let mut rows: Vec<(String, usize)> = used_rows.into_iter().collect();
rows.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0)));
println!("\n what broke when the UI was USED (first error each):");
for (err, count) in rows.into_iter().take(12) {
println!(" {count:>4} {err}");
}
}
// What the session start broke, ranked the way `blockers` ranks load failures.
let mut session: std::collections::BTreeMap<String, usize> = Default::default();
@@ -170,6 +333,74 @@ fn main() {
}
}
// **Widget METHODS** — the third queue, and the one this report was blind to while the arc
// spent a day building exactly these (GetTexture, SetShadowColor, SetNonSpaceWrap, GetBackdrop
// all landed within hours). A method is not a global and not an indexed table, so neither list
// above could ever hold one; the only trace a missing method left was an error row reading
// `attempt to call method 'X' (a nil value)`, with the name collapsed away by the very
// normalisation that makes the row readable.
//
// Resolved against the live `__index` dispatcher, not a name list — so a whole *kind* left
// unwired shows up here as loudly as a verb never written. It over-reports (a scanner cannot
// type the receiver of a `:` call); `AddonReport::missing_methods` states exactly how far, and
// `--why <name>` now reads any row back by name.
let methods = addon_harness::method_demand(&reports);
if !methods.is_empty() {
println!("\n most-wanted missing METHODS (addons calling each as obj:Name(), no widget has it):");
for (name, count) in methods.into_iter().take(16) {
println!(" {count:>4} {name}");
}
}
// **Per KIND** — the question the table above structurally cannot ask. It resolves a name
// against every probe and stops at the first hit, so a verb wired to one class and forgotten on
// its sibling comes back present: `MessageFrame:AddMessage` scored zero for as long as it
// existed, answered by the ScrollingMessageFrame probe, while three corpus addons had
// `UIErrorsFrame:AddMessage` as their FIRST load error (decision 1228). These rows are the
// call sites whose receiver the survey could TYPE — from a `CreateFrame("Kind", …)` local, or
// from the kind our own arena publishes that name as — asked against that kind alone.
let by_kind = addon_harness::kind_method_demand(&reports);
if !by_kind.is_empty() {
println!(
"\n most-wanted missing METHODS BY KIND (receiver typed from the call site; \"on X\" \
= the sibling that does answer it):"
);
for (name, count) in by_kind.into_iter().take(16) {
println!(" {count:>4} {name}");
}
}
// ...and the residue: a receiver nothing could type, on a name whose answer DEPENDS on the
// kind. An upper bound, printed rather than swallowed — resolving these against the whole probe
// set and calling them present is the exact blindness above. The length of this table measures
// the ATTRIBUTOR, not the widget surface: every receiver shape the scan learns to type moves
// rows out of it.
let ambiguous = addon_harness::ambiguous_method_demand(&reports);
if !ambiguous.is_empty() {
println!(
"\n ...and names an UNTYPABLE receiver may or may not have ({} in all — an upper \
bound, not a queue; NARROWEST first, because a name only a minority of kinds answer \
is the AddMessage shape and one every kind but the two regions answers is not):",
ambiguous.len()
);
for (name, count) in ambiguous.into_iter().take(12) {
println!(" {count:>4} {name}");
}
}
// The other half of the same scan, and never merged into it: methods addons call **behind a
// feature test**, so nobody is stuck on one. `if sliderFrame.SetTopLevel then` is Dewdrop's,
// and `SetTopLevel` is a real 1.12 widget method 60 corpus addons quietly do without. Building
// one of these fixes no error and improves N addons' behaviour, which is a different decision
// from the list above — so it is a different table.
let optional = addon_harness::optional_method_demand(&reports);
if !optional.is_empty() {
println!("\n ...and methods they FEATURE-TEST and work around (not blockers):");
for (name, count) in optional.into_iter().take(8) {
println!(" {count:>4} {name}");
}
}
println!("\n most-wanted missing globals (addons wanting each):");
for (name, count) in addon_harness::demand(&reports).into_iter().take(30) {
println!(" {count:>4} {name}");
@@ -177,8 +408,15 @@ fn main() {
if let Some(pattern) = &why {
let hits = addon_harness::blocked_by(&reports, pattern);
// "first error" was a lie in two directions and both cost this instrument a whole class:
// the match ran against the NORMALISED row only, where every quoted name is already `'X'`,
// so `--why GetBackdrop` answered `(none)` while an addon was dying on exactly that; and
// it read only the first error, while a session start keeps firing handlers after one dies.
// A row with no `#` is the addon's first error — the one the tables above rank — so the
// index-less rows still count out to the ranked row exactly.
println!(
"\n addons whose first error matches {pattern:?} ({}):",
"\n errors matching {pattern:?} ({}) — a row without a '#' is the FIRST error, i.e. \
the one the tables above rank:",
hits.len()
);
for (name, err) in &hits {
@@ -187,12 +425,31 @@ fn main() {
// frame, and for the row this instrument is most often pointed at that frame is the
// whole answer — `in local '(for generator)'` is what tells a generic-for
// (decision 1202) apart from any other call of a table value.
for line in err.lines().take(2) {
for line in err.lines().take(WHY_TRACEBACK_LINES) {
println!(" {}", line.trim());
}
}
if hits.is_empty() {
println!(" (none — the pattern is matched against the NORMALISED row as printed)");
println!(
" (none — no load or session error of any addon contains that text, and no \
normalised row does either)"
);
}
// ...and the same question of the DEMAND tables, which no read-back reached until now.
// Every ranking above is a scan, and this arc has found fiction at the top of three of
// them (1210, 1218, 1227); the rule that came out of it is "open the corpus line before
// quoting the row", and this is what makes that one command instead of a code edit.
let rows = addon_harness::method_rows_matching(&reports, pattern);
println!(
"\n method-table rows matching {pattern:?} ({}) — which addons carry the row, and \
from which table:",
rows.len()
);
for (name, row) in &rows {
println!(" {name:<36} {row}");
}
if rows.is_empty() {
println!(" (none — no addon's method tables contain that text)");
}
}
@@ -205,15 +462,150 @@ fn main() {
.map(u32::to_string)
.collect::<Vec<_>>()
.join(",");
// The SESSION column, not just the load one. `loaded` means "no LOAD errors" and
// always has (1213); an addon that loads clean and dies in its PLAYER_LOGIN handler
// read as a pass here, which is the exact overstatement 1213 measured at four to one.
// "does this addon actually work" is the two columns together, so print both.
let session = match r.session_errors.first() {
None if r.loaded => "session=ok".to_string(),
None => "session=-".to_string(),
Some(e) => format!("session: {}", e.lines().next().unwrap_or(e)),
};
// The render verdict, with the quad count and the frames it was charged to — the row
// that turns "this addon is fine" into "this addon is fine AND you can see it".
let drew = format!(
"drew={}({})",
r.render.drew().word(),
r.render.own_quads + r.render.overlay_quads
);
// The use verdict, ALWAYS with the target count beside it — a bare `used=ok` would be
// unreadable exactly where it matters, because it reads the same whether the probe
// drove eight of the addon's frames or none at all.
let used = format!(
"used={}({}/{})",
r.used.verdict().word(),
r.used.driven,
r.used.touchable
);
println!(
" {:<28} iface={:<12} {:<8} missing={:<4} {}",
" {:<28} iface={:<12} {:<8} missing={:<4} {:<11} {drew:<16} {used:<20} {} {}",
r.name,
if iface.is_empty() { "-".into() } else { iface },
if r.loaded { "loaded" } else { "ERRORS" },
r.missing_globals.len(),
session,
r.render.frames.join(","),
r.errors.first().map(String::as_str).unwrap_or("")
);
}
}
// The per-addon roster: `<name> ok|fail`, sorted, one per line — a shape `--diff` can read back
// and a human can eyeball. `ok` is the SESSION column (loaded AND no session error), because
// that is the one this arc treats as "works" (1213).
let roster: Vec<(String, bool)> = {
let mut v: Vec<(String, bool)> = reports
.iter()
.map(|r| (r.name.clone(), r.loaded && r.session_errors.is_empty()))
.collect();
v.sort();
v
};
if let Some(path) = &status {
let mut out = format!(
"# per-addon status ({} ok / {} fail) — digest {}\n",
roster.iter().filter(|(_, ok)| *ok).count(),
roster.iter().filter(|(_, ok)| !*ok).count(),
addon_harness::framexml_digest()
);
for (name, ok) in &roster {
out.push_str(&format!("{name} {}\n", if *ok { "ok" } else { "fail" }));
}
match std::fs::write(path, out) {
Ok(()) => println!("\n wrote roster to {path} ({} addons)", roster.len()),
Err(e) => println!("\n --status {path}: {e}"),
}
}
if let Some(path) = &diff {
match std::fs::read_to_string(path) {
Ok(text) => {
// **The digest gate.** `--status` stamps the roster's header with the FrameXML
// digest it was taken at, and a delta is only attributable when this run's matches:
// in a dev build `assets/ui` is read from the SOURCE TREE, so any *other* session
// landing an interface change moves the headline under you with no rebuild of
// yours. The stamp was written for this and then never checked, so the check was
// the reader's to remember — which is exactly how 1209's wrong attribution reached
// a decision record, and it nearly happened again on the run that added this gate
// (a rebase pulled in a neighbour's interface change between the baseline and the
// measurement).
//
// So this REFUSES rather than warns. A warning printed beside a `net +0` still
// leaves the number sitting there to be quoted; withholding the number is the only
// form that cannot be misread.
let base_digest = text
.lines()
.find(|l| l.trim_start().starts_with("# per-addon status"))
.and_then(|l| l.split_whitespace().last());
let now = addon_harness::framexml_digest().to_string();
if let Some(was) = base_digest.filter(|d| **d != now) {
println!("\n DIFF vs {path}: REFUSED — the baseline is at a different tree");
println!(" baseline digest {was} this run {now}");
println!(
" A delta across two digests is not attributable to your change \
(decision 1209).\n Re-take the baseline on this tree: \
--status <file>, then re-run --diff."
);
return;
}
let base: std::collections::BTreeMap<&str, bool> = text
.lines()
.filter(|l| !l.trim_start().starts_with('#') && !l.trim().is_empty())
.filter_map(|l| l.rsplit_once(' '))
.map(|(n, v)| (n.trim(), v.trim() == "ok"))
.collect();
let (mut gained, mut lost, mut appeared) = (Vec::new(), Vec::new(), Vec::new());
for (name, ok) in &roster {
match base.get(name.as_str()) {
Some(was) if *was != *ok => {
if *ok {
gained.push(name)
} else {
lost.push(name)
}
}
None => appeared.push(name),
_ => {}
}
}
let vanished: Vec<&&str> = base
.keys()
.filter(|n| !roster.iter().any(|(m, _)| m == **n))
.collect();
println!("\n DIFF vs {path}");
println!(
" net {:+} (gained {}, lost {})",
gained.len() as i64 - lost.len() as i64,
gained.len(),
lost.len()
);
// Both lists always print, even when empty. A silent "lost" section would let a
// regression read as a clean run, which is the fault this whole mode exists for.
for (label, list) in [("GAINED", &gained), ("LOST", &lost)] {
println!(" {label} ({}):", list.len());
for n in list.iter() {
println!(" {n}");
}
}
if !appeared.is_empty() || !vanished.is_empty() {
println!(
" (roster changed: {} not in the baseline, {} baseline rows absent — \
the two runs surveyed different folders)",
appeared.len(),
vanished.len()
);
}
}
Err(e) => println!("\n --diff {path}: {e}"),
}
}
println!();
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,330 @@
//! **The render column — "did this addon put anything on screen?"**
//!
//! Every other column in [`super`] is load- or event-based: they ask whether something *raised*.
//! None of them could ask whether anything was **drawn**, and that gap is not theoretical — the
//! director installed Bagnon, opened their bags, and saw a title and a gold line with **no bag
//! slots at all**, while the survey scored it `loaded, missing=0, session=ok, probe=ok`. The cause
//! raised nothing anywhere (`crate::ui_script::bagnon_render_tests` is the reproduction), so no
//! error-shaped instrument could ever have found it.
//!
//! ## The oracle: a handle diff, not a name and not a total
//!
//! [`RenderBaseline::of`] snapshots every live draw target *before* the addon runs a line; anything
//! new afterwards was created by the addon. Both obvious alternatives are wrong, and wrong in ways
//! this arc has already been bitten by:
//!
//! - **A with/without quad-count diff** reads zero for an addon that *replaces* rather than adds.
//! Bagnon takes the bags over: our own `BenillaBagFrame` stops drawing as Bagnon starts, and the
//! total barely moves whether Bagnon painted sixteen slots or none. (Measured: 16 either way, in
//! the first draft of the reproduction test.)
//! - **A name-prefix match** cannot see an anonymous frame, and `!OmniCC`'s entire visible output
//! hangs off `CreateFrame("Frame", nil, cooldown:GetParent())` — no name at all.
//!
//! Handles are generational, so a destroyed-and-reused slot can never be mistaken for a survivor.
//!
//! ## Three answers, because the interesting one is the middle
//!
//! [`Drew`] separates an addon that built **its own window** from one that **painted over ours**,
//! and that split is usually the most useful thing here: it points straight at the cause. The two
//! addons the director has verified by eye land on opposite sides of it — `!OmniCC` works and is
//! an [`Drew::Overlay`], Bagnon has to build its own slot buttons and was a [`Drew::Nothing`] — and
//! a check that could only see one of the two shapes would have scored the working addon zero.
//!
//! ## What it over- and under-reports, stated rather than hidden
//!
//! - **Over-reports**: any quad from a new widget counts, including one drawn off-screen or under
//! another window. "Something reached the render list" is not "the player can see it"; the
//! director's eye is still the judge of that (the contract §7). Over-reporting is the deliberate
//! direction — a silent under-report is the failure mode this whole column exists to end.
//! - **Under-reports** in one known shape: an addon that changes an *existing* widget in place —
//! `SetTexture` on one of our regions, `SetBackdrop` on one of our frames — creates no new
//! handle and scores [`Drew::Nothing`]. Catching that needs a per-quad content diff, which is a
//! different instrument; it is named here so nobody reads a `nothing` row as proof.
//! - **The world is minimal**, exactly as [`super::drive_session_start`] says: no real bank, no
//! real raid, no real quests. An addon whose window only opens on state we do not simulate draws
//! nothing here and would draw in play.
use std::collections::{BTreeSet, HashSet};
use benilla_ui::layout::Rect;
use benilla_ui::order::ZTarget;
use benilla_ui::script::{QuadContent, UiScript};
use benilla_ui::widget::FrameHandle;
/// Every widget that existed before an addon ran — the set the render probe diffs against.
pub(super) struct RenderBaseline {
targets: HashSet<ZTarget>,
frames: HashSet<FrameHandle>,
}
impl RenderBaseline {
/// Snapshot the VM. Cheap: one walk of the arena, no allocation per quad.
pub(super) fn of(script: &UiScript) -> Self {
let targets: HashSet<ZTarget> = script.live_targets().into_iter().collect();
let frames = targets
.iter()
.filter_map(|t| match t {
ZTarget::Frame(fh) => Some(*fh),
ZTarget::Region(_) => None,
})
.collect();
Self { targets, frames }
}
/// Did this frame exist before the addon ran a line?
///
/// The one question [`super::use_probe`] asks of the baseline: a pointer event is only the
/// addon's to be judged by when the frame that captures it is one the addon itself created.
pub(super) fn is_pre_existing(&self, frame: FrameHandle) -> bool {
self.frames.contains(&frame)
}
}
/// One place on screen the addon **painted** — a candidate for [`super::use_probe`] to point at.
///
/// Emitted by the same walk that attributes the render column, and that is deliberate: "which
/// pixels are this addon's" is one question with one answer, and computing it twice is how two
/// instruments start disagreeing about the same addon.
pub(super) struct Painted {
/// The frame the quad was charged to. Only used to **dedupe** candidates — sixteen quads on
/// one button should not spend sixteen hit-tests — never to decide what gets driven; the
/// hit-test does that, because the frame that owns a painted region is often not the frame
/// that would actually eat a click on it.
pub(super) owner: FrameHandle,
/// The centre of the painted rect: where a player's cursor would be to be "on" this pixel.
pub(super) point: (f32, f32),
}
/// What an addon put on screen.
///
/// Ordered by how much of the screen the addon owns, so a `>` comparison reads the way it looks.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord)]
pub enum Drew {
/// Not one painting quad came from anything this addon created. Bagnon's row before the fix,
/// and the answer this column was built to be able to give.
#[default]
Nothing,
/// It painted, but only from widgets hanging off frames that already existed — an overlay on
/// somebody else's window. `!OmniCC`'s countdown text is the reference case: an anonymous
/// `Frame` + `FontString` parented to one of our action buttons' cooldowns.
Overlay,
/// It painted from a window of its own — a frame tree rooted outside everything that existed
/// before it. Bagnon's inventory window, once it works.
Own,
}
impl Drew {
/// The one-word form for a report row.
pub fn word(self) -> &'static str {
match self {
Drew::Nothing => "nothing",
Drew::Overlay => "overlay",
Drew::Own => "own",
}
}
}
/// What one addon drew, and off which of its frames.
#[derive(Debug, Clone, Default)]
pub struct RenderReport {
/// Painting quads from frame trees the addon created and rooted itself.
pub own_quads: usize,
/// Painting quads from widgets the addon hung off a frame that already existed.
pub overlay_quads: usize,
/// The distinct named frames those quads were charged to, nearest-named-ancestor per quad, so
/// a `nothing`/`own` row can be read back to a place in the addon's own code. Capped, sorted.
pub frames: Vec<String>,
}
impl RenderReport {
pub fn drew(&self) -> Drew {
match (self.own_quads, self.overlay_quads) {
(0, 0) => Drew::Nothing,
(0, _) => Drew::Overlay,
_ => Drew::Own,
}
}
}
/// How many named frames a row carries. Enough to name the window; not enough to be a dump.
const MAX_NAMED_FRAMES: usize = 6;
/// Drive the entry points that make a UI **visible**, then attribute every painting quad.
///
/// **This is a different probe from [`super::drive_ui_probe`], not a duplicate of it**, and the
/// difference is the whole point: that one *toggles* — open then close — because what it measures
/// is whether an override raises on both paths. A render read after it sees a closed window. This
/// one only ever **opens**, and leaves everything up, because what it measures is what is on the
/// screen at the end.
///
/// The entry points are chosen because a corpus addon is measured to replace or hook each:
///
/// | driven | why |
/// |---|---|
/// | `OpenBackpack()` | Bagnon replaces it — and with an *open*, not a toggle, unlike its `ToggleBackpack`/`OpenAllBags` |
/// | `CooldownFrame_SetTimer` on a real button | `!OmniCC` hooks exactly this global; its text exists only while a cooldown runs |
/// | `ActionButton_Update` | zBar, zBarEx, CT_BarMod |
/// | a shown `GameTooltip` | the hover class (decision 1220) — left SHOWN, where the UI probe hides it |
///
/// Then ten ticks, because a great deal of addon painting happens on the first `OnUpdate` —
/// OmniCC's countdown text is written there and nowhere else, so a probe that never ticked would
/// score our one director-verified working addon as blank.
///
/// Returns the report **and the places the addon painted**, in painter order, for
/// [`super::use_probe`] to point at. Nothing else in the survey knows which pixels are the addon's,
/// and it should stay that way: one attribution, one answer.
pub(super) fn measure_render(
script: &mut UiScript,
baseline: &RenderBaseline,
) -> (RenderReport, Vec<Painted>) {
// A REAL ACTION on slot 1, with a real icon and a running cooldown.
//
// Not decoration — without it this column scored `!OmniCC` as blank, and `!OmniCC` is on the
// director's screen. Its `OnUpdate` writes the countdown only `if floor(remain + 0.5) > 0 and
// this.icon:IsVisible()`, and on an empty bar `ActionButton1Icon` is hidden, so the text frame
// is created, shown, and never given a single character. An empty action bar is a state a
// level-60 session does not present; `seat_a_session`'s argument, one slot wide.
//
// Seated HERE rather than in `seat_a_session` on purpose (1225's rule): this runs after every
// other column has been read, so no number beside this one can be perturbed by it.
script.set_action(
1,
Some(benilla_ui::script::ActionSlot {
texture: Some("Interface\\Icons\\Spell_Nature_Lightning".into()),
kind: 0,
action: 403,
count: 0,
}),
);
script.set_action_state(
1,
Some(benilla_ui::script::ActionState {
usable: true,
..Default::default()
}),
);
// A REAL BACKPACK, for the same reason. `GetContainerNumSlots(0)` answers 16 for every
// character in the game; in a VM where it answers 0 a bag addon correctly draws a window with
// no slots in it — which is *exactly* the picture this column was built to be able to catch,
// so leaving it unseated would make the column blind to its own founding case. Sixteen empty
// slots, no items: an addon that paints a slot paints it whether or not something is in it.
script.set_container(
0,
Some(benilla_ui::script::ContainerState {
name: Some("Backpack".into()),
num_slots: 16,
slots: std::collections::HashMap::new(),
}),
);
// Guarded on each global being a FUNCTION and each raise swallowed: a raise here is
// `probe_errors`' column to report, and letting one abort the run would silently zero every
// measurement after it (the mis-attribution 1209 was written about).
let _ = script.run(
r#"
local function try(fn) if type(fn) == "function" then pcall(fn) end end
-- OPEN, never toggle. Bagnon overrides ToggleBackpack and OpenAllBags as toggles, so
-- calling either from an unknown state is a coin flip; its OpenBackpack override is an
-- open, as the reference's is.
try(OpenBackpack)
if ActionButton1 then
this = ActionButton1
try(ActionButton_Update)
end
-- A RUNNING cooldown. Nothing else makes a cooldown-count addon paint: OmniCC's hook only
-- builds its text frame when `start > 0 and duration > OmniCC.min and enable > 0`, and its
-- OnUpdate only writes text while the button's icon is visible.
if ActionButton1Cooldown and type(CooldownFrame_SetTimer) == "function" then
try(function() CooldownFrame_SetTimer(ActionButton1Cooldown, GetTime(), 30, 1) end)
end
if GameTooltip and UIParent then
try(function()
GameTooltip:SetOwner(UIParent, "ANCHOR_NONE")
GameTooltip:SetText("benilla render probe")
GameTooltip:Show()
end)
end
this = nil
"#,
);
for _ in 0..10 {
script.tick(0.1);
}
script.resolve();
let mut report = RenderReport::default();
let mut named: BTreeSet<String> = BTreeSet::new();
let mut painted: Vec<Painted> = Vec::new();
for quad in script.extract() {
// A frame's own slot paints NOTHING (`ui_script::extract`'s converter drops it outright);
// a Backdrop, Texture, Text, Minimap or Cooldown is pixels. Counting the frame slot would
// score every addon that merely *creates* a frame as having drawn, which is the exact
// false pass this column exists to stop.
if matches!(quad.content, QuadContent::Frame) {
continue;
}
// Under-constrained widgets never reach the screen either. Bound here rather than merely
// tested, because the rect is also WHERE the addon painted — the point `use_probe` aims at.
let Some(rect) = quad.rect else {
continue;
};
if baseline.targets.contains(&quad.target) {
continue; // ours, drawing as it always did
}
let Some(owner) = script.target_frame(quad.target) else {
continue;
};
if baseline.frames.contains(&owner) || hangs_off_one_of_ours(script, baseline, owner) {
report.overlay_quads += 1;
} else {
report.own_quads += 1;
}
// The same quad, kept as a place to point at. An off-screen or zero-area one simply
// hit-tests to nothing later, which is the honest answer for it — no filtering here, or
// the two columns would disagree about which pixels are the addon's.
painted.push(Painted {
owner,
point: centre(rect),
});
if named.len() < MAX_NAMED_FRAMES {
if let Some(name) = script.target_owner_name(quad.target) {
named.insert(name);
}
}
}
report.frames = named.into_iter().collect();
(report, painted)
}
/// The middle of a rect, in the y-up UI space `resolve`/`extract`/`hit_test` all share.
fn centre(r: Rect) -> (f32, f32) {
((r.left + r.right) * 0.5, (r.bottom + r.top) * 0.5)
}
/// Does this new frame hang off one of **our widgets**, rather than off the screen?
///
/// The overlay/own split, and it needs more care than "did it reach a pre-existing frame", because
/// *everything* does: `UIParent` is where every window in the game is parented, ours and theirs
/// alike, so that test calls every addon an overlay (it did — this function's first draft failed
/// its own fixture). The seam is one step further in: walk to the nearest ancestor that existed
/// before the addon ran, and ask whether **it** is a top-level frame.
///
/// - nearest pre-existing ancestor is top-level (`UIParent`, `WorldFrame`, the screen root) → the
/// addon put a window on the screen. Bagnon.
/// - nearest pre-existing ancestor is *nested* inside one of our windows → the addon decorated
/// something of ours. `!OmniCC`'s anonymous text frame, whose parent is an action button's
/// cooldown.
/// - no pre-existing ancestor at all (a detached tree) → its own, by the same reading.
///
/// The walk is bounded by the tree's own depth.
fn hangs_off_one_of_ours(script: &UiScript, baseline: &RenderBaseline, frame: FrameHandle) -> bool {
let mut cursor = Some(frame);
while let Some(fh) = cursor {
if baseline.frames.contains(&fh) {
// `UIParent` and friends carry no parent of their own — reaching one of those means
// the addon reached the *screen*, which is not somebody else's window.
return script.frame_parent(fh).is_some();
}
cursor = script.frame_parent(fh);
}
false
}
@@ -0,0 +1,234 @@
//! **The render column's can-it-fail proof.**
//!
//! A probe that cannot fail is not a probe, and this arc has shipped two that could not: the UI
//! probe swallowed every raise it was built to report (`the_ui_probe_records_what_an_override_raises`
//! is that scar), and the method oracle answered "nothing missing" when it could not run at all.
//! So this file pins **both directions**, twice over.
//!
//! **Synthetically**, on three fixture addons written to be unambiguous — one that paints a
//! texture, one that hangs a FontString off a frame it did not create, and one that creates a
//! shown frame with nothing paintable on it. The third is the sharp one: a frame's own draw slot
//! emits a quad and paints **nothing** (`ui_script::extract`'s converter drops it), so an
//! implementation that counted quads instead of *painting* quads would score it as drawing and
//! every `nothing` row in the corpus would be a lie.
//!
//! **And against reality**, on the two addons the director has verified with their own eyes:
//! `!OmniCC`'s countdown numbers are on their screen, and Bagnon's bag slots were not. Two real
//! addons with known, opposite, human-checked outcomes is a better oracle than any fixture — it is
//! the check that would have caught this class months ago — so
//! [`the_directors_two_verified_addons_come_out_on_opposite_sides`] is the one to read first.
use std::path::{Path, PathBuf};
use super::{survey, Drew};
/// One throwaway AddOns root, cleaned up on drop even if a test panics.
struct Fixtures(PathBuf);
impl Fixtures {
fn new(tag: &str) -> Self {
let root = std::env::temp_dir().join(format!(
"benilla-render-{tag}-{}-{:?}",
std::process::id(),
std::thread::current().id()
));
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(&root).unwrap();
Self(root)
}
/// Write `<root>/<name>/{<name>.toc, body.lua}`.
fn addon(&self, name: &str, body: &str) -> &Self {
let dir = self.0.join(name);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(
dir.join(format!("{name}.toc")),
"## Interface: 11200\nbody.lua\n",
)
.unwrap();
std::fs::write(dir.join("body.lua"), body).unwrap();
self
}
fn root(&self) -> &Path {
&self.0
}
}
impl Drop for Fixtures {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
/// **The proof, in both directions.** An addon that paints must register; one that does not must
/// not — and "creates a frame" is not "paints".
#[test]
fn the_render_column_can_fail() {
let fx = Fixtures::new("cannotfail");
// Paints: a texture on a window of its own.
fx.addon(
"PaintsAWindow",
r#"
local f = CreateFrame("Frame", "PaintsAWindowFrame", UIParent)
f:SetWidth(64) f:SetHeight(64)
f:SetPoint("CENTER", UIParent, "CENTER", 0, 0)
local t = f:CreateTexture("PaintsAWindowTexture", "ARTWORK")
t:SetAllPoints(f)
t:SetTexture("Interface\\Icons\\INV_Misc_Bag_08")
f:Show()
"#,
);
// Paints, but onto one of OUR frames — no window of its own anywhere.
fx.addon(
"PaintsOnOurs",
r#"
local host = ActionButton1 or UIParent
local fs = host:CreateFontString("PaintsOnOursText", "OVERLAY", "GameFontNormal")
fs:SetPoint("CENTER", host, "CENTER", 0, 0)
fs:SetText("hooked")
fs:Show()
"#,
);
// Draws NOTHING — but is far from inert: it creates a frame, shows it, and that frame emits a
// `QuadContent::Frame` entry. Nothing paints from it. This is the fixture that fails a naive
// implementation.
fx.addon(
"DrawsNothing",
r#"
DrawsNothingRan = true
local f = CreateFrame("Frame", "DrawsNothingFrame", UIParent)
f:SetWidth(64) f:SetHeight(64)
f:SetPoint("CENTER", UIParent, "CENTER", 0, 0)
f:Show()
"#,
);
let reports = survey(fx.root());
let drew = |name: &str| {
reports
.iter()
.find(|r| r.name == name)
.unwrap_or_else(|| panic!("{name} was not surveyed"))
};
// Every fixture must actually have RUN — otherwise "drew nothing" would be measuring a load
// failure, which is the confusion this column exists to end.
for name in ["PaintsAWindow", "PaintsOnOurs", "DrawsNothing"] {
assert!(
drew(name).loaded,
"{name} must load clean or this test proves nothing: {:?}",
drew(name).errors
);
}
assert_eq!(
drew("PaintsAWindow").render.drew(),
Drew::Own,
"a texture on a frame of its own is a window of its own"
);
assert!(
drew("PaintsAWindow")
.render
.frames
.iter()
.any(|f| f == "PaintsAWindowFrame"),
"…and the row names the frame it came from: {:?}",
drew("PaintsAWindow").render.frames
);
assert_eq!(
drew("PaintsOnOurs").render.drew(),
Drew::Overlay,
"a region hung off one of OUR frames is an overlay, not a window"
);
assert_eq!(
drew("DrawsNothing").render.drew(),
Drew::Nothing,
"creating and showing a frame paints nothing — a frame's own draw slot is dropped by the \
renderer, and an implementation that counted quads rather than PAINTING quads would call \
this a pass"
);
assert_eq!(
(
drew("DrawsNothing").render.own_quads,
drew("DrawsNothing").render.overlay_quads
),
(0, 0)
);
}
/// Where the corpus might be — the `ui_chat::ace_gate_tests` resolver, so a machine without the
/// third-party corpus skips rather than reddens.
fn corpus() -> Option<PathBuf> {
if let Some(over) = std::env::var_os("BENILLA_ADDON_CORPUS") {
let p = PathBuf::from(over);
if p.is_dir() {
return Some(p);
}
}
let manifest = Path::new(env!("CARGO_MANIFEST_DIR"));
(2usize..=4)
.filter_map(|up| manifest.ancestors().nth(up))
.map(|root| root.join("wow-addons-vanilla"))
.find(|c| c.is_dir())
}
/// **The oracle: two real addons, opposite director-verified outcomes.**
///
/// `!OmniCC` puts cooldown countdown numbers on the director's screen — it works, and it works by
/// creating an **anonymous** frame parented to one of our cooldowns, so any check built on name
/// prefixes or on "did it create a top-level window" scores it zero. Bagnon has to build its own
/// item-slot buttons, and the director saw none of them.
///
/// The two are surveyed together in one throwaway root — copies are not made, the corpus folders
/// are surveyed where they lie — and the column must place them on opposite sides:
/// `!OmniCC` an [`Drew::Overlay`], Bagnon a window of its [`Drew::Own`].
///
/// If this test ever disagrees with the director's eyes, **the test is wrong**.
#[test]
fn the_directors_two_verified_addons_come_out_on_opposite_sides() {
let Some(corpus) = corpus() else {
eprintln!("skipping: no vanilla addon corpus (set $BENILLA_ADDON_CORPUS)");
return;
};
// A root holding just these four, SYMLINKED rather than copied. Surveying the whole corpus
// here would put a minute onto `cargo test --workspace` for four rows — the full sweep is the
// `addon_harness` example's job, not a unit test's.
let fx = Fixtures::new("oracle");
for name in ["!OmniCC", "Bagnon", "Bagnon_Core", "Bagnon_Forever"] {
std::os::unix::fs::symlink(corpus.join(name), fx.root().join(name)).unwrap();
}
let reports = survey(fx.root());
let row = |name: &str| {
reports
.iter()
.find(|r| r.name == name)
.unwrap_or_else(|| panic!("{name} is not in the corpus"))
};
// The POSITIVE control. A render check that scores this one blank is broken, whatever else it
// gets right — the director sees its numbers.
assert_eq!(
row("!OmniCC").render.drew(),
Drew::Overlay,
"!OmniCC's countdown text is on the director's screen; it paints via an ANONYMOUS frame \
parented to a cooldown of ours, which is precisely what a name-based check cannot see"
);
// The case that started this. Bagnon builds its own window; before the two fixes it built
// nothing at all and every other column called it fine.
assert_eq!(
row("Bagnon").render.drew(),
Drew::Own,
"Bagnon draws its own inventory window"
);
assert!(
row("Bagnon")
.render
.frames
.iter()
.any(|f| f.starts_with("BagnonItem")),
"…and the slots are what it draws — the exact thing the director could not see: {:?}",
row("Bagnon").render.frames
);
}
@@ -0,0 +1,281 @@
//! **The use column — "does it survive being USED?"**
//!
//! ## Why there is a fifth column, and why it is the fourth of its kind
//!
//! This survey has now been wrong at four successive depths, and a human found every one of them by
//! **playing**, never a number here:
//!
//! 1. `loaded` measured *"the addon's files ran"*. Bagnon scored perfect and did nothing.
//! 2. [`super::drive_session_start`] measured *"the handlers ran"* (1213). Same result.
//! 3. [`super::drive_ui_probe`] measured *"the override body ran"*. Same result.
//! 4. [`super::render`] measured *"something was drawn"* (1230). Bagnon now draws sixteen bag
//! slots — and the director hovered them and got a wall of
//! `attempt to call global 'ContainerFrameItemButton_OnEnter' (a nil value)`.
//!
//! Every one of those columns asks whether something **ran or appeared**. None of them **touches**
//! anything. So an addon whose UI is fully drawn and completely inert scores full marks on all
//! four, which is exactly what Bagnon did on the morning this was written.
//!
//! This column drives real pointer input — hover, left click, right click, drag — at the frames the
//! addon **itself painted**, through the same [`UiScript::mouse_move`]/[`UiScript::mouse_button`]
//! entry points the app feeds from the host window, and reports what raised.
//!
//! ## "Nothing raised" and "nothing was touched" are different answers
//!
//! [`Used::Untouched`] exists because the failure this instrument is most likely to repeat is its
//! own history: the UI probe once shipped with a `pcall` that swallowed every raise and reported a
//! spotless corpus forever. A probe that drove **zero** targets and printed "clean" would be that
//! bug with a different mechanism, so a row that touched nothing says so in the verdict itself and
//! is counted separately in the report. [`UseReport::driven`] is printed beside every verdict for
//! the same reason: a clean row with `driven = 0` is not evidence of anything.
//!
//! `!OmniCC` — director-verified working — is precisely that shape, and it is the reason the
//! distinction is not academic. Its entire visible output is a `FontString` on an **anonymous,
//! mouse-disabled** `Frame` parented to a cooldown; there is nothing on it a pointer can reach, and
//! there is nothing wrong with it either. `untouched`, not `raised`.
//!
//! ## What gets driven: the addon's own frames, and only those
//!
//! The target list is [`super::render`]'s own attribution — the quads that addon painted — turned
//! into points and hit-tested. **A candidate is kept only when the frame that captures the point is
//! one the addon created**; when one of ours is on top, or the painted region belongs to a
//! mouse-disabled frame and our window underneath eats the click, the candidate is dropped rather
//! than driven.
//!
//! That rule is the whole attribution argument. Driving one of *our* frames and charging what it
//! raises to whichever addon happens to be in the VM is the mis-attribution decision 1209 was
//! written about, and it would smear a single FrameXML bug across two hundred rows.
//!
//! The cost of the rule, stated: **an addon that only hooks our frames is invisible here.** A
//! `PlayerFrame:SetScript("OnEnter", …)` replacement is a real corpus shape, and this column scores
//! it `untouched`. Reaching it needs a script-table diff rather than a handle diff — a different
//! instrument, named here so nobody reads an `untouched` row as proof of anything.
//!
//! ## What a raise here means, and what it does not
//!
//! It means **a player doing that would see that error**. It does *not* say whose fault it is: the
//! wall the director hit is our missing `ContainerFrameItemButton_OnEnter`, reached through
//! Bagnon's slot button. That is the same open question 1213 §4 and 1204 §4 left about every other
//! column, and it is deliberately not answered here — "hovering this addon's UI raises" is the
//! finding, whoever owes the fix.
use std::collections::HashSet;
use benilla_ui::script::UiScript;
use benilla_ui::widget::FrameHandle;
use super::render::{Painted, RenderBaseline};
/// How many distinct frames one addon gets driven. **The bound, and what it drops.**
///
/// 218 VMs times this times the seven pointer events below is the whole cost of the column, and
/// each event pays a full [`UiScript::hit_test`] (a traversal + sort of the arena). Eight is what a
/// window's worth of interactive surface looks like — Bagnon's sixteen slots are sixteen instances
/// of one button template, so the ninth tells you nothing the first did not — and it holds the
/// column's share of the survey to seconds rather than minutes.
///
/// What it drops: the 9th..Nth distinct target, in painter order (so the earliest-drawn survive).
/// An addon whose only broken widget is the twelfth thing it painted reads clean here. Stated
/// rather than hidden, and the number is a constant precisely so raising it is one edit.
pub const MAX_USE_TARGETS: usize = 8;
/// How far the drag gesture travels. Must exceed `cursor::DRAG_START_THRESHOLD` (4 px, strict `>`)
/// or `OnDragStart` never fires and the "drag" is silently two clicks; small enough that it stays
/// inside a 16-px item slot, or the release lands on a different frame and `OnReceiveDrag` goes
/// somewhere else.
const DRAG_PX: f32 = 5.0;
/// Where the cursor is parked at the end, to fire the last `OnLeave`.
///
/// Off-screen by a mile: [`UiScript::hit_test`] answers `None` there whatever the addon built, so
/// the final hover boundary is guaranteed to be crossed. A hover half-driven — `OnEnter` fired and
/// `OnLeave` never — is the shape that leaves a tooltip up forever in play, and it is a real corpus
/// bug class, so the probe always closes the pair.
const PARKED: (f32, f32) = (-10_000.0, -10_000.0);
/// What happened when the addon's own UI was used.
///
/// Deliberately **not** ordered: there is no honest ranking between "it raised" and "there was
/// nothing to touch", and deriving one would invite a `>` comparison that quietly asserts it.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum Used {
/// **Nothing was touched** — the addon painted nothing a pointer can reach, so this row is not
/// a pass and not a failure. The verdict exists so a probe that drove zero targets can never
/// be read as one that drove some and found nothing (see the module doc).
#[default]
Untouched,
/// Driven, and it raised. The director's Bagnon: sixteen slots on screen, dead to the touch.
Raised,
/// Driven, and nothing raised.
Survived,
}
impl Used {
/// The one-word form for a report row.
pub fn word(self) -> &'static str {
match self {
Used::Untouched => "untouched",
Used::Raised => "raised",
Used::Survived => "ok",
}
}
}
/// What one addon's UI did when it was used.
#[derive(Debug, Clone, Default)]
pub struct UseReport {
/// Frames actually driven — **the number that makes a clean row mean anything**. Zero here and
/// an empty [`Self::errors`] is [`Used::Untouched`], not a pass.
pub driven: usize,
/// Distinct frames of the addon's own that answered a hit-test, **before** [`MAX_USE_TARGETS`].
/// Greater than [`Self::driven`] means the cap dropped some; printed so the bound is visible in
/// the data rather than only in this comment.
pub touchable: usize,
/// The names of the frames driven (anonymous ones omitted), so a row can be read back to a
/// place in the addon's own code. Capped by [`MAX_USE_TARGETS`] already.
pub frames: Vec<String>,
/// Errors raised by the input, verbatim, in the order they happened.
pub errors: Vec<String>,
}
impl UseReport {
pub fn verdict(&self) -> Used {
match (self.driven, self.errors.is_empty()) {
(0, _) => Used::Untouched,
(_, false) => Used::Raised,
(_, true) => Used::Survived,
}
}
}
/// Drive hover, both clicks and a drag at everything the addon drew, and report what raised.
///
/// **Runs after every other column has been read and before the method oracle**, for the two
/// reasons 1225 and 1230 already established: after, so no number beside it can be perturbed by
/// input this probe invented; before, because the oracle stands up one widget of every kind and
/// those sixteen frames of the harness's own must never become an addon's input targets.
///
/// `painted` is [`super::render`]'s attribution, not a fresh walk — see [`Painted`].
pub(super) fn measure_use(
script: &mut UiScript,
baseline: &RenderBaseline,
painted: &[Painted],
) -> UseReport {
let mut report = UseReport::default();
// PUT THE TOOLTIP AWAY FIRST, before a single hit-test.
//
// `measure_render` deliberately leaves `GameTooltip` **shown** — it is measuring what is on
// screen at the end, and a tooltip is on screen. But it is one of OURS, it lives in the
// TOOLTIP strata (above everything), and it is therefore topmost over whatever it covers: any
// addon frame underneath would hit-test to the tooltip, be dropped by the attribution rule
// below as "one of ours", and leave the addon reading `untouched` for a reason that has
// nothing to do with the addon. That is this column's own founding failure — a probe that
// silently drove nothing and reported clean — so it is closed here rather than left to luck.
//
// Hiding it is also the faithful state: in play the tooltip trails the cursor, it is never the
// thing under it. Done BEFORE the error baseline below, so an `OnHide` hook's raise is not
// charged to input this probe has not driven yet.
let _ = script.run(
r#"
if GameTooltip and type(GameTooltip.Hide) == "function" then pcall(function() GameTooltip:Hide() end) end
"#,
);
// The addon's painted quads, resolved to the frames a cursor over them would actually hit.
//
// Two dedupes, and they answer different questions. `owners` bounds the WORK: an icon, its
// border, its count text and its highlight are four quads on one button, and hit-testing each
// is four traversals for one answer. `hits` bounds the TARGETS: two different buttons can
// stack, and driving the same captured frame twice would double-count a raise.
script.resolve();
let mut owners: HashSet<FrameHandle> = HashSet::new();
let mut hits: HashSet<FrameHandle> = HashSet::new();
let mut targets: Vec<(FrameHandle, (f32, f32))> = Vec::new();
for p in painted {
if !owners.insert(p.owner) {
continue;
}
let Some(hit) = script.hit_test_frame(p.point.0, p.point.1) else {
continue; // painted, but nothing there takes the mouse
};
// THE ATTRIBUTION RULE. One of ours captured the point — our window on top, or the
// addon's own region sitting on a mouse-disabled frame with ours underneath. Driving it
// would charge our handler's raise to this addon, in all 218 VMs (1209).
if baseline.is_pre_existing(hit) {
continue;
}
if !hits.insert(hit) {
continue;
}
targets.push((hit, p.point));
}
report.touchable = targets.len();
targets.truncate(MAX_USE_TARGETS);
report.driven = targets.len();
report.frames = targets
.iter()
.filter_map(|(fh, _)| script.frame_name(*fh))
.collect();
// The engine collects handler raises itself (`UiScript::push_error`), so there is nothing to
// swallow here and nothing to remember to re-read: a `split_off` of the tail is the whole
// capture. This is deliberately NOT the `pcall`-and-collect shape `drive_ui_probe` needs —
// that one calls Lua globals directly, this one goes through the real input path.
let before = script.errors().len();
for (_, (x, y)) in &targets {
drive_one(script, *x, *y);
}
// Close the hover pair on the last target (see [`PARKED`]).
script.mouse_move(PARKED.0, PARKED.1);
report.errors = script.errors().split_off(before);
report
}
/// One target, four gestures: **hover · left click · right click · drag**.
///
/// Every one of them is what it says. `mouse_move` fires the real `OnEnter`/`OnLeave` pair on a
/// hover-boundary crossing; `mouse_button` runs the real press/release path — `OnMouseDown`,
/// `OnMouseUp`, the `RegisterForClicks` adjudication that decides whether `OnClick` fires at all
/// and with which button, the double-click detector, and the drag trio. None of it is simulated:
/// this is the same code the host window's events reach.
///
/// A [`UiScript::resolve`] between gestures because a handler is free to move, show or build
/// things, and the next hit-test must see what it did. It is tier-1 gated (nothing touched the
/// layout ⇒ immediate return), so the quiet case is free.
///
/// **The drag is honest, with one stated degradation.** On a frame that called `RegisterForDrag`
/// the press → move → release below is the real gesture: `OnDragStart` past the 4-px threshold,
/// then `OnDragStop` on the source and `OnReceiveDrag` on whatever the release hit. On a frame
/// that did not, the same three events are simply a second click — and because the probe does not
/// tick, that second click lands inside the 300-ms window and fires `OnDoubleClick` where the
/// widget has one. Both are inputs a player produces; neither is invented.
///
/// **Two of these four lines overlap, and the falsification run found it rather than the design —
/// so it is written down here before somebody "simplifies" one of them away.** Deleting the hover
/// move alone does *not* stop `OnEnter` firing (the drag's move crosses the same boundary), and
/// deleting the left click alone does *not* stop `OnClick` firing (the degenerate drag above *is*
/// a left click). Only removing **every** cursor move, or **every** left-button transition, turns
/// the matching fixture red — which is what `use_probe_tests` actually pins. The overlap is not
/// waste: the ORDER is the player's (you hover a thing before you click it), and a handler that
/// only breaks on the second click is a real bug class.
fn drive_one(script: &mut UiScript, x: f32, y: f32) {
// HOVER. The single most common thing a player does to a bag slot, and the gesture the
// director's error wall came from.
script.mouse_move(x, y);
script.resolve();
// LEFT CLICK — press and release on the same frame, which is what `wants_click` requires
// before `OnClick` fires at all.
script.mouse_button(x, y, "LeftButton", true);
script.mouse_button(x, y, "LeftButton", false);
script.resolve();
// RIGHT CLICK. Not a duplicate of the left one: the default registration is
// `{"LeftButtonUp"}`, so a right click reaches `OnClick` only on a widget that asked for it —
// which every container slot in the game does, because right-click is how you use an item.
script.mouse_button(x, y, "RightButton", true);
script.mouse_button(x, y, "RightButton", false);
script.resolve();
// DRAG.
script.mouse_button(x, y, "LeftButton", true);
script.mouse_move(x + DRAG_PX, y);
script.mouse_button(x + DRAG_PX, y, "LeftButton", false);
script.resolve();
}
@@ -0,0 +1,323 @@
//! **The use column's can-it-fail proof, in both directions and per gesture.**
//!
//! This arc has shipped two instruments that could not fail — the UI probe swallowed every raise it
//! was built to report, and the method oracle answered "nothing missing" when it could not run at
//! all — so a new column arrives with its falsifier or it does not arrive.
//!
//! Two directions, and both are load-bearing:
//!
//! - **It must report.** Four fixtures, one per gesture, each drawing one button and raising from
//! exactly one handler: `OnEnter`, `OnClick` on the left, `OnClick` on the right (registered for
//! `RightButtonUp` only, so a left click cannot reach it), and `OnDragStart`. A column that
//! drove only clicks passes three of these and fails one, which is the point of splitting them.
//! - **It must stay quiet.** A fixture that draws the *same* button with **no handlers at all**
//! must come out [`Used::Survived`] with `driven ≥ 1` and zero errors — the shape that proves the
//! errors above came from the addons and not from the probe.
//!
//! ...and one more, which is the distinction the whole column is built around: a fixture that
//! **paints but takes no mouse** must come out [`Used::Untouched`] with `driven == 0`, never
//! `Survived`. "Nothing raised" and "nothing was touched" are different answers, and an instrument
//! that cannot tell them apart is the one that reported a spotless corpus for a month.
//!
//! Then the real oracle: the director's two verified addons. `!OmniCC` works and must not be
//! reported broken; Bagnon's slots are the reason this file exists and must at minimum be
//! **reachable** — whether they still raise is the parallel handler fix's to change, so the
//! durable assertion is that the probe can get its hands on them at all.
//!
//! ## What the falsification run actually established (2026-08-11)
//!
//! Each was applied to `use_probe`, run, and reverted — a can-it-fail claim is worth what it was
//! measured at, not what it was asserted at:
//!
//! | change to the probe | this file |
//! |---|---|
//! | right-click press/release removed | RED — `RaisesOnRightClick` reads `Survived`, `driven=1` |
//! | drag presses and releases in place (no 4-px move) | RED — `RaisesOnDrag` |
//! | every `mouse_move` removed | RED — `RaisesOnEnter` |
//! | every left-button transition removed | RED — `RaisesOnLeftClick` |
//! | `Untouched` folded into `Survived` | RED — `PaintsButTakesNoMouse` |
//!
//! And the two **negative** results, which are the useful ones: removing only the hover move
//! leaves this file GREEN (the drag's own move crosses the same hover boundary), and removing only
//! the explicit left click leaves it GREEN (the drag press/release *is* a left click on a frame
//! that never registered for drag). So what these fixtures pin is the gesture, not the line —
//! stated in `drive_one` too, where somebody would otherwise delete an "obviously redundant" call.
use std::path::{Path, PathBuf};
use super::{survey, Used};
/// One throwaway AddOns root, cleaned up on drop even if a test panics. (The twin of
/// `render_tests::Fixtures`; kept local so the two files' fixtures cannot collide in `temp_dir`.)
struct Fixtures(PathBuf);
impl Fixtures {
fn new(tag: &str) -> Self {
let root = std::env::temp_dir().join(format!(
"benilla-use-{tag}-{}-{:?}",
std::process::id(),
std::thread::current().id()
));
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(&root).unwrap();
Self(root)
}
/// Write `<root>/<name>/{<name>.toc, body.lua}`.
fn addon(&self, name: &str, body: &str) -> &Self {
let dir = self.0.join(name);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(
dir.join(format!("{name}.toc")),
"## Interface: 11200\nbody.lua\n",
)
.unwrap();
std::fs::write(dir.join("body.lua"), body).unwrap();
self
}
fn root(&self) -> &Path {
&self.0
}
}
impl Drop for Fixtures {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
/// A 64×64 painted Button at the centre of the screen, plus whatever `extra` wires onto it.
///
/// Painted, because an unpainted frame never reaches the target list at all — the use column takes
/// its aim from the render column's attribution, so every fixture here has to draw first. Centred,
/// because the corners of the default UI are full of our own windows and a fixture underneath one
/// of them would be dropped by the attribution rule for a reason the test is not about.
fn painted_button(extra: &str) -> String {
format!(
r#"
local f = CreateFrame("Button", "FixtureButton", UIParent)
f:SetWidth(64) f:SetHeight(64)
f:SetPoint("CENTER", UIParent, "CENTER", 0, 0)
local t = f:CreateTexture("FixtureButtonIcon", "ARTWORK")
t:SetAllPoints(f)
t:SetTexture("Interface\\Icons\\INV_Misc_Bag_08")
f:Show()
{extra}
"#
)
}
/// **The proof: every gesture is really driven, and silence is really silence.**
#[test]
fn the_use_column_can_fail() {
let fx = Fixtures::new("cannotfail");
// One fixture per gesture. Each raises from exactly ONE handler, so a probe that skips that
// gesture reports this addon clean and the assertion below catches it by name.
fx.addon(
"RaisesOnEnter",
&painted_button(r#"f:SetScript("OnEnter", function() error("USEFIXTURE_ENTER") end)"#),
);
fx.addon(
"RaisesOnLeftClick",
&painted_button(r#"f:SetScript("OnClick", function() error("USEFIXTURE_LEFTCLICK") end)"#),
);
// Registered for the RIGHT button ONLY — the default set is `{"LeftButtonUp"}`, so this
// handler is unreachable by any amount of left-clicking. It is the fixture that separates
// "the probe clicks" from "the probe clicks with both buttons", which matters because
// right-click is how every container slot in the game is used.
fx.addon(
"RaisesOnRightClick",
&painted_button(
r#"
f:RegisterForClicks("RightButtonUp")
f:SetScript("OnClick", function() error("USEFIXTURE_RIGHTCLICK") end)
"#,
),
);
// `OnDragStart` fires only past the 4-px threshold, so this one also proves the probe's drag
// actually MOVES rather than pressing and releasing in place.
fx.addon(
"RaisesOnDrag",
&painted_button(
r#"
f:RegisterForDrag("LeftButton")
f:SetScript("OnDragStart", function() error("USEFIXTURE_DRAG") end)
"#,
),
);
// THE OTHER DIRECTION. The same painted, mouse-taking button with nothing wired to it at all:
// driven, and silent. Without this row the four above prove only that the probe can produce
// errors, not that the errors came from the addons.
fx.addon("SilentButTouchable", &painted_button(""));
// ...and the distinction the column exists for. It paints exactly as much as the others and
// takes no mouse, so nothing of the addon's answers a hit-test: `untouched`, never `ok`.
fx.addon(
"PaintsButTakesNoMouse",
r#"
local f = CreateFrame("Frame", "UntouchableFrame", UIParent)
f:SetWidth(64) f:SetHeight(64)
f:SetPoint("CENTER", UIParent, "CENTER", 0, 0)
f:EnableMouse(false)
local t = f:CreateTexture("UntouchableIcon", "ARTWORK")
t:SetAllPoints(f)
t:SetTexture("Interface\\Icons\\INV_Misc_Bag_08")
f:Show()
"#,
);
let reports = survey(fx.root());
let row = |name: &str| {
reports
.iter()
.find(|r| r.name == name)
.unwrap_or_else(|| panic!("{name} was not surveyed"))
};
// Every fixture must have LOADED and DRAWN, or a `driven = 0` below would be measuring a load
// failure or a blank window rather than the thing under test.
for name in [
"RaisesOnEnter",
"RaisesOnLeftClick",
"RaisesOnRightClick",
"RaisesOnDrag",
"SilentButTouchable",
"PaintsButTakesNoMouse",
] {
let r = row(name);
assert!(
r.loaded,
"{name} must load clean or this test proves nothing: {:?}",
r.errors
);
assert_ne!(
r.render.drew(),
super::Drew::Nothing,
"{name} must paint, or the use probe has nothing to aim at"
);
}
// ── It reports, per gesture ────────────────────────────────────────────────────────────────
for (name, marker) in [
("RaisesOnEnter", "USEFIXTURE_ENTER"),
("RaisesOnLeftClick", "USEFIXTURE_LEFTCLICK"),
("RaisesOnRightClick", "USEFIXTURE_RIGHTCLICK"),
("RaisesOnDrag", "USEFIXTURE_DRAG"),
] {
let r = row(name);
assert_eq!(
r.used.verdict(),
Used::Raised,
"{name} raises from the one handler its gesture reaches; a column that does not drive \
that gesture reports it clean. driven={} errors={:?}",
r.used.driven,
r.used.errors
);
assert!(
r.used.errors.iter().any(|e| e.contains(marker)),
"{name}'s error must be ITS error, not something the probe stirred up elsewhere: {:?}",
r.used.errors
);
}
// ── ...and it stays quiet when there is nothing to report ──────────────────────────────────
let quiet = row("SilentButTouchable");
assert_eq!(
quiet.used.verdict(),
Used::Survived,
"a painted, mouse-taking button with no handlers must survive being used: {:?}",
quiet.used.errors
);
assert!(
quiet.used.driven >= 1,
"…and it must actually have been DRIVEN — a `Survived` with driven=0 is the failure this \
whole column is about"
);
// ── ...and it never calls "nothing was touched" a pass ─────────────────────────────────────
let untouched = row("PaintsButTakesNoMouse");
assert_eq!(
untouched.used.verdict(),
Used::Untouched,
"a frame that paints and takes no mouse offers the probe nothing; that is not a pass"
);
assert_eq!(
(untouched.used.driven, untouched.used.touchable),
(0, 0),
"…and it says so in the numbers, not only in the verdict"
);
}
/// Where the corpus might be — the same resolver `render_tests` uses, so a machine without the
/// third-party corpus skips rather than reddens.
fn corpus() -> Option<PathBuf> {
if let Some(over) = std::env::var_os("BENILLA_ADDON_CORPUS") {
let p = PathBuf::from(over);
if p.is_dir() {
return Some(p);
}
}
let manifest = Path::new(env!("CARGO_MANIFEST_DIR"));
(2usize..=4)
.filter_map(|up| manifest.ancestors().nth(up))
.map(|root| root.join("wow-addons-vanilla"))
.find(|c| c.is_dir())
}
/// **The real oracle: the director's two verified addons.**
///
/// - `!OmniCC` **works** — its countdown numbers are on their screen. It must not be reported as
/// broken. It is also the reference [`Used::Untouched`]: its whole visible output is a
/// `FontString` on an anonymous, mouse-disabled `Frame`, so there is nothing on it to click and
/// nothing wrong with it either. If this column ever calls it `raised`, the column is wrong.
/// - **Bagnon** is why this file exists: sixteen bag slots drawn and dead to the touch. The
/// assertion here is the durable half — the probe can **reach** them (`driven ≥ 1`, on a
/// `BagnonItem*` frame). Whether they still raise is the parallel handler fix's to change, so
/// asserting `Raised` would turn somebody else's fix into this file's failure; what must never
/// regress is the probe's ability to get its hands on the slots at all, because a column that
/// silently stops touching them is exactly how this instrument has been wrong four times.
#[test]
fn the_directors_two_verified_addons_are_reachable_and_omnicc_is_not_broken() {
let Some(corpus) = corpus() else {
eprintln!("skipping: no vanilla addon corpus (set $BENILLA_ADDON_CORPUS)");
return;
};
let fx = Fixtures::new("oracle");
for name in ["!OmniCC", "Bagnon", "Bagnon_Core", "Bagnon_Forever"] {
std::os::unix::fs::symlink(corpus.join(name), fx.root().join(name)).unwrap();
}
let reports = survey(fx.root());
let row = |name: &str| {
reports
.iter()
.find(|r| r.name == name)
.unwrap_or_else(|| panic!("{name} is not in the corpus"))
};
let omni = row("!OmniCC");
assert_ne!(
omni.used.verdict(),
Used::Raised,
"!OmniCC is on the director's screen and works; a column that calls it broken is broken \
itself: {:?}",
omni.used.errors
);
let bagnon = row("Bagnon");
assert!(
bagnon.used.driven >= 1,
"Bagnon draws bag slots the director can put a cursor on; the probe must be able to reach \
at least one (drew={:?} touchable={})",
bagnon.render.frames,
bagnon.used.touchable
);
assert!(
bagnon
.used
.frames
.iter()
.any(|f| f.starts_with("BagnonItem")),
"…and the thing it touched must be a SLOT — the exact widget the director hovered: {:?}",
bagnon.used.frames
);
}
+6 -1
View File
@@ -44,7 +44,12 @@ use crate::sound::GlueSound;
/// right of button 1 (+46) and 6–8 chained below it — so the flat engine order fills Alliance then
/// Horde, ascending race id within each. (`RACE_ICON_TCOORDS` is a name→UV lookup; its literal table
/// order never reaches layout — reading it as the button order is what got this wrong before.)
const ALLIANCE: [u8; 4] = [1, 3, 4, 7]; // Human, Dwarf, Night Elf, Gnome
///
/// `pub(crate)` on the Alliance half because it is also the race→side split
/// `ui_unit::race_faction_group` answers `UnitFactionGroup("player")` with during world entry —
/// one home for the mapping, pinned by [`tests::race_columns_match_the_reference_screen`], rather
/// than a second copy that can disagree with this one.
pub(crate) const ALLIANCE: [u8; 4] = [1, 3, 4, 7]; // Human, Dwarf, Night Elf, Gnome
const HORDE: [u8; 4] = [2, 5, 6, 8]; // Orc, Scourge, Tauren, Troll
/// The ref's initial model facing (`SetCharacterCreateFacing(-15)`), reset on every race switch.
const INITIAL_FACING: f32 = -15.0 * std::f32::consts::PI / 180.0;
+59 -6
View File
@@ -137,13 +137,26 @@ impl AddonsPanel {
self.list.len().saturating_sub(MAX_ROWS)
}
/// **Why a row will not load, as the reference's `reason` token** — the string the status
/// column shows and the colour rule keys on.
/// **Why a row will not load** — the string the status column shows and the colour rule keys on.
///
/// `DEP_DISABLED`/`DEP_MISSING` come from the dependency walk; `DISABLED` from the checkbox;
/// `INCOMPATIBLE` from `## Interface`. Reported and never enforced for the last one (1191 §6),
/// which is why an out-of-date addon still shows gold-if-enabled: the status column says so
/// and the client still loads it, and those two facts must not contradict each other.
/// These are the reference's status LABELS, not its tokens, because the glue side has no VM to
/// splice `getglobal("ADDON_"..reason)` against (1197 §3). Each is the exact value its token
/// carries in the shipped `GlobalStrings.lua`, and
/// `the_status_labels_are_the_reference_globalstrings_values` pins all four so they cannot
/// drift from the ESC-menu twin, which DOES go through the globals:
///
/// | here | token | `GlobalStrings.lua` |
/// |---|---|---|
/// | `"Disabled"` | `ADDON_DISABLED` | `"Disabled"` |
/// | `"Dependency missing"` | `ADDON_DEP_MISSING` | `"Dependency missing"` |
/// | `"Dependency disabled"` | `ADDON_DEP_DISABLED` | `"Dependency disabled"` |
/// | `"Out of date"` | `ADDON_INTERFACE_VERSION` | `"Out of date"` |
///
/// The last row is why this comment was rewritten: it used to name the token `INCOMPATIBLE`,
/// **which does not exist** — the reference has no `ADDON_INCOMPATIBLE`, and a reader who
/// trusted the name would have spliced a nil global. Out-of-date is reported and never enforced
/// (1191 §6), which is why such a row still shows gold-if-enabled: the status column says so and
/// the client still loads it, and those two facts must not contradict each other.
fn reason(&self, i: usize) -> Option<&'static str> {
let addon = self.list.get(i)?;
if !self.staged.get(i).copied().unwrap_or(addon.enabled) {
@@ -646,6 +659,46 @@ mod tests {
}
}
/// **Every label this panel shows is the value its reference token carries**, so the glue
/// screen and the ESC-menu screen cannot say different things about the same addon.
///
/// The glue side has no VM to splice `getglobal("ADDON_"..reason)` against (1197 §3), so it
/// hardcodes the English. That is fine and it is also exactly how the two screens drift: the
/// twin resolves through `GlobalStrings.lua` and this one does not, so nothing but a test
/// connects them. The values below are quoted from the shipped 1.12 `GlobalStrings.lua`
/// (l.16-31, the `ADDON_*` block) — which the client itself loads at boot.
///
/// This test exists because the comment on [`AddonsPanel::reason`] used to name the token
/// `INCOMPATIBLE`, and **there is no `ADDON_INCOMPATIBLE`**; the real one is
/// `ADDON_INTERFACE_VERSION`. A wrong token name is invisible until somebody splices it.
#[test]
fn the_status_labels_are_the_reference_globalstrings_values() {
let mut p = panel(vec![
addon("Off", &[], &[11200]),
addon("Lib", &[], &[11200]),
addon("Needs", &["Lib"], &[11200]),
addon("Orphan", &["Nowhere"], &[11200]),
addon("Old", &[], &[11100]),
]);
p.staged[0] = false;
p.staged[1] = false;
// (label, the token it must equal, the value GlobalStrings.lua gives that token)
for (got, token, want) in [
(p.reason(0), "ADDON_DISABLED", "Disabled"),
(p.reason(2), "ADDON_DEP_DISABLED", "Dependency disabled"),
(p.reason(3), "ADDON_DEP_MISSING", "Dependency missing"),
(p.reason(4), "ADDON_INTERFACE_VERSION", "Out of date"),
] {
assert_eq!(
got,
Some(want),
"this label must be {token}'s value verbatim, or the ESC-menu twin — which \
resolves the token through GlobalStrings — will show a different string"
);
}
}
/// The status column's rule, which is also the title-colour rule — the reference's own
/// `reason` token set, in its own precedence.
///
+21 -1
View File
@@ -160,6 +160,20 @@ pub(crate) struct Roster {
}
impl Roster {
/// A roster with a pick already in flight — the state world entry runs in.
///
/// `#[cfg(test)]` and `pub(crate)` because the fields are `pub(super)`: a test outside this
/// module cannot build one, and `ui_script`'s does exactly that to drive
/// `seat_from_roster` over a real row rather than a copy of its logic.
#[cfg(test)]
pub(crate) fn with_pending_pick(chars: Vec<Character>, guid: u64) -> Self {
Self {
chars,
pending_pick: Some(guid),
..Self::default()
}
}
/// Note a character the create screen just made, and select its row.
///
/// **The consume happens here, not on the next roster message** (B119): the IO thread
@@ -219,7 +233,13 @@ impl Roster {
}
/// The roster row for the pick in flight.
fn pending_row(&self) -> Option<&Character> {
///
/// `pub(crate)` because this row is the ONLY description of the character that exists during
/// world entry: `Connected` flips us `InWorld` a whole server round-trip before the self
/// descriptor streams in, and the in-game UI — plus every addon's file scope — materializes
/// inside that window. `ui_script::load_ingame_ui_on_world_entry` seats
/// `UnitName("player")` from here for exactly that reason.
pub(crate) fn pending_row(&self) -> Option<&Character> {
self.pending_pick
.and_then(|g| self.chars.iter().find(|c| c.guid == g))
}
+47 -1
View File
@@ -44,6 +44,20 @@ use benilla_world::view::{ViewDistance, FARCLIP_RANGE};
/// The host-backed CVars: `(registered name, default)`. Grows one row per knob a settings page
/// actually wires — never ahead of the knob (see the module doc).
pub(crate) const REGISTERED: &[(&str, &str)] = &[
// The realm the session is on — a REAL 1.12 CVar (`0x83f2d0`, persisted, wow-re
// `savedvariables-protocol.md`: the client builds its SavedVariables path from it), and a live
// Lua consumer in the strongest sense the honest-tree rule asks for. `Ace/AceState.lua:27` does
// `ace.trim(GetCVar("realmName"))` inside `SetGameState`, which every Ace addon runs at
// PLAYER_ENTERING_WORLD — so a nil there was `gsub(nil)` and took the whole Ace family down.
// 18 corpus folders read the name.
//
// The default is EMPTY, deliberately and not as a guess: the value is written from the session's
// real realm the moment addons load (`ui_script::addons::load_third_party`), so the default only
// ever describes a client that has not connected. wow-re records a string
// `"Last realm connected to"` beside the registration, but that reads like the CVar's HELP text
// rather than its value and nothing here needs to resolve it — `""` is what `ace.trim` handles
// cleanly, and inventing a realm name would be worse than admitting we have none yet.
("realmName", ""),
("MasterVolume", "1"),
("SoundVolume", "1"),
("MusicVolume", "0.4"),
@@ -472,11 +486,18 @@ mod tests {
/// Every registered default IS the code constant it mirrors — parse-compared so "1" vs
/// "1.0" cannot fail it, welded so neither side can drift alone.
///
/// **The numeric ones**, which was every one of them until `realmName` — the first
/// string-valued CVar in the table, and the reason this now filters rather than unwraps. It is
/// asserted on its own terms in
/// [`the_only_string_valued_cvar_is_the_realm_and_it_defaults_empty`]; a `parse::<f32>()` over
/// the whole table would either panic (it did) or quietly need every future string CVar to be
/// numeric.
#[test]
fn registered_defaults_mirror_the_code_truths() {
let d: BTreeMap<&str, f32> = REGISTERED
.iter()
.map(|(n, v)| (*n, v.parse::<f32>().unwrap()))
.filter_map(|(n, v)| v.parse::<f32>().ok().map(|f| (*n, f)))
.collect();
let sound = SoundConfig::default();
assert_eq!(d["MasterVolume"], sound.master);
@@ -804,4 +825,29 @@ mod tests {
let parsed: LocalConfig = toml::from_str(hand).unwrap();
assert_eq!(parsed.cvars.get("Farclip").map(String::as_str), Some("500"));
}
/// **`realmName` is the table's one string-valued CVar, and it defaults EMPTY.**
///
/// Empty rather than a guess: the value is written from the session's real realm by
/// `set_realm_name`, so the default only ever describes a client that has not connected.
/// wow-re records `"Last realm connected to"` beside the registration, but that reads like the
/// CVar's HELP text rather than its value, and nothing here needs it resolved — `""` is what
/// `Ace/AceState.lua:27`'s `ace.trim(GetCVar("realmName"))` handles cleanly, and inventing a
/// realm name would be worse than admitting we have none yet.
///
/// Pinned as "the ONE" so a second string CVar has to come here and think about the numeric
/// test above rather than silently widening it.
#[test]
fn the_only_string_valued_cvar_is_the_realm_and_it_defaults_empty() {
let strings: Vec<&str> = REGISTERED
.iter()
.filter(|(_, v)| v.parse::<f32>().is_err())
.map(|(n, _)| *n)
.collect();
assert_eq!(strings, vec!["realmName"]);
let realm = REGISTERED
.iter()
.find(|(n, _)| *n == "realmName")
.map(|(_, v)| *v);
assert_eq!(realm, Some(""));
}
}
+4
View File
@@ -67,6 +67,10 @@ impl Plugin for UiChatPlugin {
(
edit::open_chat_keys,
edit::open_tell_requests,
// An addon's `ChatFrame_OpenChat` — before the live parse in the same chain,
// so a box opened prefilled `/w Bob ` has its type switched on the very next
// frame rather than showing the raw slash to the user first.
edit::open_chat_requests,
edit::chat_edit_live,
edit::chat_tab_cycle,
input::drain_chat_input,
+51 -9
View File
@@ -527,15 +527,7 @@ pub(super) fn open_chat_keys(
state.chat_type = SendType::Whisper;
state.tell_target = state.last_tell.front().cloned().unwrap_or_default();
} else {
state.chat_type = match state.sticky {
SendType::Party if !group.in_group => SendType::Say,
SendType::Raid | SendType::RaidWarning
if !(group.in_group && group.group_type == 1) =>
{
SendType::Say
}
sticky => sticky,
};
state.chat_type = sticky_on_open(state.sticky, &group);
}
state.header_dirty = true;
state.last_text.clear();
@@ -546,6 +538,56 @@ pub(super) fn open_chat_keys(
}
}
/// The type a freshly opened box starts in: the sticky, **downgraded to SAY when the group it
/// names is gone** (ref `ChatFrame_OpenChat` l.1554-1565 — a sticky PARTY with an empty party opens
/// as SAY, and so does a sticky RAID outside a raid). The sticky itself is untouched, so rejoining
/// restores it. One function because two callers need the identical law: the ENTER binding
/// ([`open_chat_keys`]) and an addon's `ChatFrame_OpenChat` ([`open_chat_requests`]).
pub(super) fn sticky_on_open(sticky: SendType, group: &crate::ui_party::GroupState) -> SendType {
match sticky {
SendType::Party if !group.in_group => SendType::Say,
SendType::Raid | SendType::RaidWarning if !(group.in_group && group.group_type == 1) => {
SendType::Say
}
sticky => sticky,
}
}
/// `ChatFrame_OpenChat(text[, chatFrame])` — an addon asking for the chat box, prefilled
/// (`benilla_ui::script::chat_window` registers the verb and queues the text).
///
/// The reference shows the box, stashes the text on it, and lets `ChatEdit_OnUpdate` type it in
/// (`this.setText == 1` → `this:SetText(this.text)`, ChatFrame.lua l.1795) — the fill is a frame
/// late there too. Ours focuses the box (which shows it) and writes the text now; `last_text` is
/// deliberately left EMPTY rather than mirroring what we just wrote, so the next frame's
/// [`chat_edit_live`] sees a change and runs the live parse over it. That is the whole point for
/// two of the three corpus callers: they prefill `"/w <name> "`, and it is the live parse — not
/// this function — that turns those characters into whisper mode with the target extracted,
/// exactly as it would for a human typing them.
pub(super) fn open_chat_requests(
script: Option<NonSendMut<benilla_ui::script::UiScript>>,
mut state: ResMut<ChatEditState>,
group: Res<crate::ui_party::GroupState>,
) {
let Some(mut script) = script else {
return;
};
// Last request wins — two opens in one frame are one box, and the later caller is the one
// whose text the user is about to see.
let Some(text) = script.take_open_chat_requests().pop() else {
return;
};
state.chat_type = sticky_on_open(state.sticky, &group);
state.header_dirty = true;
state.last_text.clear();
script.focus_editbox("ChatFrameEditBox");
let lua_text = text.replace('\\', "\\\\").replace('"', "\\\"");
run_or_warn(
&script,
&format!("ChatFrameEditBox:SetText(\"{lua_text}\")"),
);
}
/// The unit popup's WHISPER action (`ChatFrame_SendTell` → the engine's tell queue, decision
/// 0434 §5): open the edit box in whisper mode to the named player — the ref fills its box with
/// "/w name "; our box's whisper form is chat_type + tell_target, the reply key's exact shape.
+6
View File
@@ -325,6 +325,12 @@ pub(super) fn feed_chat(
}
_ => {}
}
// Mirror the confirmed list into the VM, where `GetChannelName` reads it
// (17 corpus sites across 6 addons). Here rather than beside either arm
// because this is the ONLY place the list changes, so one push cannot drift
// from it — and unconditional within the notice branch so a notice that
// changed nothing still costs one clone rather than risking a missed edge.
script.set_joined_channels(channels.joined.clone());
}
// A member-line / notice channel renders numbered when we know its slot.
channels.stamp_channel(&mut event);
+34
View File
@@ -1083,3 +1083,37 @@ fn unconditional_and_sleep_dead_rules() {
assert!(!emote_send_eligible(0, 7, false)); // DEAD without the allow bit
assert!(emote_send_eligible(0x0200, 3, false)); // "allowed while asleep/dead"
}
// ── The open-the-box law, shared by the ENTER key and an addon's ChatFrame_OpenChat ──────────
/// A sticky type whose group is gone opens as SAY (ref `ChatFrame_OpenChat` l.1554-1565), and the
/// sticky itself survives. The point of the test is that there is **one** implementation of that
/// law: `ChatFrame_OpenChat` (3 corpus callers) and the ENTER binding both call this, so an addon
/// opening the box lands in the same type the player's own keypress would.
#[test]
fn a_sticky_whose_group_is_gone_opens_as_say() {
use super::edit::{sticky_on_open, SendType};
use crate::ui_party::GroupState;
let solo = GroupState::default();
let party = GroupState {
in_group: true,
group_type: 0,
..GroupState::default()
};
let raid = GroupState {
in_group: true,
group_type: 1,
..GroupState::default()
};
assert_eq!(sticky_on_open(SendType::Party, &solo), SendType::Say);
assert_eq!(sticky_on_open(SendType::Party, &party), SendType::Party);
// RAID needs an actual raid — a plain party is not one.
assert_eq!(sticky_on_open(SendType::Raid, &party), SendType::Say);
assert_eq!(sticky_on_open(SendType::Raid, &raid), SendType::Raid);
assert_eq!(sticky_on_open(SendType::RaidWarning, &party), SendType::Say);
// Everything ungated passes through untouched.
assert_eq!(sticky_on_open(SendType::Guild, &solo), SendType::Guild);
assert_eq!(sticky_on_open(SendType::Say, &solo), SendType::Say);
}
@@ -24,8 +24,9 @@ fn shipped_action_bar_drives_end_to_end() {
);
if file == "ActionBar.xml" {
assert_eq!(
report.frames, 34,
"bar + XP StatusBar (+ its numerals overlay) + exhaustion tick + max-level rail + art frame + 12 buttons (each with a Cooldown child) + 2 page buttons + the performance meter and its hover button"
report.frames, 59,
"bar + XP StatusBar (+ its numerals overlay) + exhaustion tick + max-level rail + art frame + 12 buttons (each with a Cooldown child) + 2 page buttons + the performance meter and its hover button, \
+ BonusActionBarFrame and its 12 buttons with their Cooldown children (25 — hidden, as the reference's is; decision 1223)"
);
}
}
@@ -681,6 +682,19 @@ fn shipped_bag_frame_drives_end_to_end() {
let abdoc = benilla_ui::framexml::parse(&abtext).unwrap();
benilla_ui::loader::load(&s, &abdoc, &|_| None);
}
// UiPanels.xml before the bag file: `ToggleBackpack` calls the GLOBAL `CloseAllBags`, which
// lives there (homed in the panel glue rather than the bag's own file, deliberately — see its
// comment). Production loads both; this test was under-loading relative to it, and started
// failing the moment the toggle went through the reference's names instead of a local body.
// Loading it is the honest fix: the dependency is real, not an artifact.
{
let panels = std::fs::read_to_string(
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("assets/ui/UiPanels.xml"),
)
.unwrap();
let pdoc = benilla_ui::framexml::parse(&panels).unwrap();
let _ = benilla_ui::loader::load(&s, &pdoc, &|_| None);
}
let text = std::fs::read_to_string(
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("assets/ui/BagFrame.xml"),
)
@@ -964,3 +978,54 @@ fn an_occupied_slot_going_empty_leaves_no_white_plate() {
}
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// **The bonus action bar exists and stays hidden** — the same posture 1219 gave the vertical
/// multibars, and the largest single session-start row in the corpus.
///
/// `ref-BonusActionBarFrame.xml` l.54 instantiates `BonusActionBarFrame` as a real
/// `parent="MainMenuBar"` frame carrying `hidden="true"`, with `BonusActionButton1..12` inside.
/// benilla models the bonus page by re-paging the MAIN bar, so we never show this one — but four
/// addons died at `CT_BarMod\CT_BarModOptions.lua:154`,
/// `getglobal("BonusActionButton" .. i):ClearAllPoints()`, which is pure layout and needs only
/// that the buttons be there.
///
/// The last assertion is the one that keeps it honest: declaring a hidden bar must not change what
/// the visible bar shows.
#[test]
fn the_bonus_action_bar_exists_hidden_and_takes_layout_calls() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_action_bar(&s);
assert!(
s.eval::<bool>("return BonusActionBarFrame ~= nil").unwrap(),
"5 corpus addons index BonusActionBarFrame by name"
);
assert!(
!s.eval::<bool>("return BonusActionBarFrame:IsShown()")
.unwrap(),
"it must ship HIDDEN — benilla re-pages the main bar instead of showing this one"
);
for i in [1, 12] {
assert!(
s.eval::<bool>(&format!("return BonusActionButton{i} ~= nil"))
.unwrap(),
"BonusActionButton{i} must exist"
);
// CT_BarModOptions.lua:154's exact pair, on a bar that has never been shown.
s.run(&format!("BonusActionButton{i}:ClearAllPoints()"))
.unwrap();
s.run(&format!(
"BonusActionButton{i}:SetPoint(\"TOP\", \"ActionButton1\", \"BOTTOM\", 0, -4)"
))
.unwrap();
}
// A hidden bar changes nothing about the visible one.
assert!(
s.eval::<bool>("return ActionButton1:IsShown()").unwrap(),
"the main bar is untouched"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
@@ -358,3 +358,60 @@ fn hovering_a_row_raises_the_tooltip_with_notes_and_dependencies() {
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"
);
}
+75 -34
View File
@@ -185,7 +185,10 @@ impl Addon {
// The same execution `<Script file=>` gets (`loader::mod.rs`): one chunk, run in
// the one global state, in manifest order. The reference does exactly this —
// `AddOn_Load 0x51f240` hands each listed file to `0x6edb90` regardless of kind.
match script.run_chunk(&bytes) {
match script.run_chunk_named(
&bytes,
&benilla_ui::script::addon_chunk_name(&self.name, file),
) {
Ok(()) => info!("ui_script: {}/{file} ran", self.name),
Err(e) => {
let e = format!("{}/{file}: {e}", self.name);
@@ -206,12 +209,10 @@ impl Addon {
};
// The document's OWN directory is what its relative references resolve against — a
// manifest listing `src\main.xml` means `<Include file="templates.xml">` inside it is
// `<Addon>/src/templates.xml`, and `..\..\Lib\lib.xml` is a sibling addon (1186).
let base = match path.rfind('/') {
Some(i) => &path[..i],
None => "",
};
let report = benilla_ui::loader::load_in(script, &doc, base, &provider);
// `<Addon>/src/templates.xml`, and `..\..\Lib\lib.xml` is a sibling addon (1186). The
// loader takes the path and does that itself, so it can also name the file a raise
// came from (1217).
let report = benilla_ui::loader::load_in(script, &doc, &path, &provider);
for w in &report.warnings {
warn!("ui_script({}/{file}): {w}", self.name);
}
@@ -348,31 +349,34 @@ fn manifest_in(dir: &Path, name: &str) -> Option<String> {
/// Build the AddOn API's registry row for one discovered addon — every `.toc` directive the
/// eleven verbs can be asked about, read once here rather than re-parsed per call.
fn info_for(addon: &Addon) -> benilla_ui::script::AddOnInfo {
info_from_toc(&addon.name, &addon.toc)
}
/// [`info_for`]'s body, over the two things it actually reads.
///
/// Split out because `addon_harness` needs the same row and must not build its own: a survey that
/// seats a DIFFERENT registry shape from production is measuring a client nobody runs, which is the
/// fault 1193 was written to fix. One converter, two callers — the harness's own `Toc` goes through
/// this, so a directive added here reaches the survey for free.
pub(crate) fn info_from_toc(name: &str, toc: &Toc) -> benilla_ui::script::AddOnInfo {
benilla_ui::script::AddOnInfo {
name: addon.name.clone(),
title: addon.toc.directive("Title").map(str::to_owned),
notes: addon.toc.directive("Notes").map(str::to_owned),
url: addon.toc.directive("URL").map(str::to_owned),
name: name.to_owned(),
title: toc.directive("Title").map(str::to_owned),
notes: toc.directive("Notes").map(str::to_owned),
url: toc.directive("URL").map(str::to_owned),
// `## Secure: 1` is Blizzard's own marker; nothing a player installs carries it honestly,
// and we do not treat it as granting anything — it only picks the glue's icon.
secure: addon.toc.directive("Secure").map(str::trim) == Some("1"),
load_on_demand: addon.toc.load_on_demand(),
dependencies: addon
.toc
.dependencies()
.into_iter()
.map(str::to_owned)
.collect(),
directives: addon.toc.directives.clone(),
files: addon.toc.files.clone(),
saved_variables: addon
.toc
secure: toc.directive("Secure").map(str::trim) == Some("1"),
load_on_demand: toc.load_on_demand(),
dependencies: toc.dependencies().into_iter().map(str::to_owned).collect(),
directives: toc.directives.clone(),
files: toc.files.clone(),
saved_variables: toc
.list("SavedVariables")
.into_iter()
.map(str::to_owned)
.collect(),
saved_variables_per_character: addon
.toc
saved_variables_per_character: toc
.list("SavedVariablesPerCharacter")
.into_iter()
.map(str::to_owned)
@@ -1469,10 +1473,27 @@ mod tests {
/// **`GetAddOnInfo` returns the manifest's own `## Title` and `## Notes`** — 1188 phase 2's
/// first acceptance test, and the reason the registry carries directives rather than a name.
///
/// The return shape is the reference's, read off `Interface\GlueXML\AddonList.lua`:
/// `name, title, notes, url, loadable, reason, security, newVersion`. A row that shifts by one
/// puts an addon's notes in its URL field, which nothing would notice until a manager rendered
/// it, so the whole tuple is asserted rather than the two fields the phase names.
/// The return shape is the **in-game** one — `name, title, notes, enabled, loadable, reason,
/// security`, SEVEN values — because that is the VM this runs in.
///
/// **The client registers `GetAddOnInfo` twice, as two different functions**, and this test
/// used to assert the wrong one. It was written off `Interface\GlueXML\AddonList.lua`, which
/// describes glue's `0x46d460`: eight values, `url` at slot 4, `newVersion` at 8. The in-game
/// binding `0x48e390` answers seven, with **`enabled`** at slot 4 and no `url` at all
/// (wow-re `system/ui/scratch/addon-version-gate.md`).
///
/// Its own doc comment named the failure it then failed to catch — *"a row that shifts by one
/// puts an addon's notes in its URL field, which nothing would notice"*. The row was shifted at
/// slot 4 the whole time, and the test asserted the shift. That is what a falsifier copied from
/// the wrong reference buys: it pins the bug.
///
/// What it cost: `local name, _, _, enabled, loadable = GetAddOnInfo(major)` is AceLibrary's
/// and AceAddon's shape, reached by 70 corpus folders. They read `url` as `enabled`, and since
/// `## URL` is rare that is nil for nearly every addon, so `if enabled and loadable` refused to
/// load the dependency. Silent — a nil where a flag belongs is legal Lua.
///
/// The whole tuple is still asserted rather than the fields the phase names, for the original
/// and still-correct reason.
#[test]
fn get_addon_info_returns_the_manifests_own_title_and_notes() {
let _l = crate::local_state::test_env::ENV_LOCK
@@ -1493,21 +1514,41 @@ mod tests {
assert_eq!(
script
.eval::<Vec<String>>(
"local n,t,no,u,l,r,s,nv = GetAddOnInfo(1) \
return { n, t, no, u, tostring(l), tostring(r), s, tostring(nv) }"
"local n,t,no,e,l,r,s,extra = GetAddOnInfo(1) \
return { n, t, no, tostring(e), tostring(l), tostring(r), s, \
tostring(extra) }"
)
.ok(),
Some(vec![
"Probe".into(),
"Probe Title".into(),
"What it does".into(),
"http://example".into(),
"1".into(), // loadable
"1".into(), // enabled — slot 4 in-game, NOT url (which glue's binding returns)
"1".into(), // loadable
"nil".into(), // no reason — it loads
"INSECURE".into(),
"nil".into(), // no version-check service to report a new version
// The in-game binding returns SEVEN values, so there is no eighth. Asserted as
// absent rather than trimmed off the query: glue's `newVersion` sat here, and a
// future edit that re-adds an eighth return has to face this line.
"nil".into(),
])
);
// AceLibrary's and AceAddon's literal shape, which is how 70 corpus folders reach this
// verb: `local name, _, _, enabled, loadable = GetAddOnInfo(major)`. Asserted as the
// GUARD they actually write, not as a tuple, because the guard is what silently failed —
// with `url` in slot 4 and no `## URL` in the manifest, `enabled` was nil and Ace declined
// to load its own dependency without erroring.
assert_eq!(
script
.eval::<bool>(
"local _, _, _, enabled, loadable = GetAddOnInfo('Probe') \
if enabled and loadable then return true else return false end"
)
.ok(),
Some(true),
"Ace's `if enabled and loadable` must pass for an enabled, loadable addon"
);
// Both spellings of the argument, and the raw directives.
assert_eq!(
script
+172 -41
View File
@@ -224,10 +224,13 @@ fn bag_bar_buttons_light_while_their_bag_is_open() {
// Open-all: the backpack button and the equipped bag's slot light; the empty slots don't.
s.run("BenillaBagToggle_OnClick()").unwrap();
assert!(checked(&mut s, "BenillaBagToggle"), "backpack ring lights");
assert!(checked(&mut s, "BenillaBagBarSlot2"), "bag 2's ring lights");
assert!(
!checked(&mut s, "BenillaBagBarSlot1"),
checked(&mut s, "MainMenuBarBackpackButton"),
"backpack ring lights"
);
assert!(checked(&mut s, "CharacterBag1Slot"), "bag 2's ring lights");
assert!(
!checked(&mut s, "CharacterBag0Slot"),
"empty slot stays dark"
);
@@ -261,11 +264,11 @@ fn bag_bar_buttons_light_while_their_bag_is_open() {
// Closing ONE window (its close button / any Hide path) clears just its ring.
s.run("BenillaBagFrame2:Hide()").unwrap();
assert!(
!checked(&mut s, "BenillaBagBarSlot2"),
!checked(&mut s, "CharacterBag1Slot"),
"closing bag 2 clears its ring"
);
assert!(
checked(&mut s, "BenillaBagToggle"),
checked(&mut s, "MainMenuBarBackpackButton"),
"the backpack ring stays"
);
@@ -276,21 +279,21 @@ fn bag_bar_buttons_light_while_their_bag_is_open() {
// no button, so either one opens the bag. Ours registered LeftButtonUp only until 0908.
let (cx, cy): (f64, f64) = s
.eval(
"return (BenillaBagBarSlot2:GetLeft() + BenillaBagBarSlot2:GetRight()) / 2, \
(BenillaBagBarSlot2:GetTop() + BenillaBagBarSlot2:GetBottom()) / 2",
"return (CharacterBag1Slot:GetLeft() + CharacterBag1Slot:GetRight()) / 2, \
(CharacterBag1Slot:GetTop() + CharacterBag1Slot:GetBottom()) / 2",
)
.unwrap();
s.mouse_button(cx as f32, cy as f32, "RightButton", true);
s.mouse_button(cx as f32, cy as f32, "RightButton", false);
assert!(
checked(&mut s, "BenillaBagBarSlot2"),
checked(&mut s, "CharacterBag1Slot"),
"a RIGHT-click on the bar slot reopens bag 2, exactly as a left one does"
);
// ESC's sweep closes everything → every ring dark.
s.run("CloseAllWindows()").unwrap();
assert!(
!checked(&mut s, "BenillaBagToggle") && !checked(&mut s, "BenillaBagBarSlot2"),
!checked(&mut s, "MainMenuBarBackpackButton") && !checked(&mut s, "CharacterBag1Slot"),
"close-all clears every ring"
);
let _ = s.take_sounds();
@@ -830,8 +833,8 @@ fn a_second_bag_window_feeds_and_paints_via_the_bag_bar() {
!s.eval::<bool>("return BenillaBagFrame1:IsShown()").unwrap(),
"hidden by default"
);
// BenillaBagBarSlot1 == bag id 1 (BenillaBagBarSlot_OnLoad(self, 1) in BagFrame.xml).
s.run("BenillaBagBarSlot_OnClick(BenillaBagBarSlot1)")
// CharacterBag0Slot == bag id 1 (BenillaBagBarSlot_OnLoad(self, 1) in BagFrame.xml).
s.run("BenillaBagBarSlot_OnClick(CharacterBag0Slot)")
.unwrap();
let _ = s.take_sounds();
assert!(
@@ -1120,7 +1123,7 @@ fn bag_slot_cooldown_sweeps_through_the_xml() {
};
// 12 s remain of the potion category's 60 s: started at GetTime 52 (absolute-start triple).
s.set_container(0, Some(backpack(Some((52_000, 60_000, true)))));
s.run("BenillaOpenAllBags()").unwrap();
s.run("OpenAllBags()").unwrap();
s.fire_event("BAG_UPDATE", vec![benilla_ui::script::ScriptValue::Int(0)]);
s.resolve();
@@ -1171,7 +1174,8 @@ fn the_bar_bag_buttons_name_themselves_on_hover() {
s.resolve();
// The backpack: its label plus OPENALLBAGS's live key (default B; 0997's divergence note).
s.run("BenillaBagToggle_OnEnter(BenillaBagToggle)").unwrap();
s.run("BenillaBagToggle_OnEnter(MainMenuBarBackpackButton)")
.unwrap();
let line = s
.eval::<String>("return GameTooltipTextLeft1:GetText()")
.unwrap();
@@ -1185,13 +1189,13 @@ fn the_bar_bag_buttons_name_themselves_on_hover() {
"a bag button's plate is owner-anchored, never the default corner"
);
assert!(
s.eval::<bool>("return GameTooltip:IsOwned(BenillaBagToggle)")
s.eval::<bool>("return GameTooltip:IsOwned(MainMenuBarBackpackButton)")
.unwrap(),
"…owned by the button it opened from"
);
// An empty bag slot falls back to the ref's EQUIP_CONTAINER rather than showing nothing.
s.run("BenillaBagBarSlot_OnEnter(BenillaBagBarSlot1)")
s.run("BenillaBagBarSlot_OnEnter(CharacterBag0Slot)")
.unwrap();
assert_eq!(
s.eval::<String>("return GameTooltipTextLeft1:GetText()")
@@ -1219,7 +1223,7 @@ fn the_bar_bag_buttons_name_themselves_on_hover() {
enchants: Vec::new(),
});
s.set_inventory_slots(inv);
s.run("BenillaBagBarSlot_OnEnter(BenillaBagBarSlot1)")
s.run("BenillaBagBarSlot_OnEnter(CharacterBag0Slot)")
.unwrap();
assert_eq!(
s.eval::<String>("return GameTooltipTextLeft1:GetText()")
@@ -1512,11 +1516,16 @@ fn a_key_dropped_on_the_button_files_itself() {
s.take_container_moves().is_empty(),
"a full keyring queues no move"
);
assert_eq!(
s.eval::<String>("return UIErrorsFrameLine1:GetText()")
.unwrap(),
"Your keyring is full.",
"and says so — the reference's own dangling string name prints nothing"
// ...and says so, on the errors frame — read where a player reads it, off the drawn quads.
// (`UIErrorsFrame` is a real `<MessageFrame>`; its lines are the widget's, not FontStrings.)
s.resolve();
assert!(
s.extract().iter().any(|q| matches!(
&q.content,
QuadContent::Text { text: Some(t), .. } if t == "Your keyring is full."
)),
"the refusal posts to UIErrorsFrame — the reference's own dangling string name prints \
nothing"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
@@ -1569,7 +1578,11 @@ fn an_item_push_drops_its_icon_into_the_bag_that_took_it() {
};
// Nothing is animating until a push arrives.
for b in ["BenillaBagToggle", "BenillaBagBarSlot2", "KeyRingButton"] {
for b in [
"MainMenuBarBackpackButton",
"CharacterBag1Slot",
"KeyRingButton",
] {
assert!(!shown(&s, b), "{b}'s card starts hidden");
}
@@ -1582,8 +1595,12 @@ fn an_item_push_drops_its_icon_into_the_bag_that_took_it() {
],
);
s.resolve();
assert!(shown(&s, "BenillaBagBarSlot2"), "bag 2's card plays");
for b in ["BenillaBagToggle", "BenillaBagBarSlot1", "KeyRingButton"] {
assert!(shown(&s, "CharacterBag1Slot"), "bag 2's card plays");
for b in [
"MainMenuBarBackpackButton",
"CharacterBag0Slot",
"KeyRingButton",
] {
assert!(!shown(&s, b), "{b} took nothing, so {b} animates nothing");
}
// The pushed icon reaches the RENDERER, not just the Lua state — exactly one quad carries it.
@@ -1603,13 +1620,13 @@ fn an_item_push_drops_its_icon_into_the_bag_that_took_it() {
// t=0: invisible, full size, held a whole fall above the button (and a drift to its left) —
// the .m2's alpha key (0.000, 0.0), scale key (0.000, 1.0), translation (0.000, (0,0)).
let (dx, dy) = offset(&s, "BenillaBagBarSlot2");
let (dx, dy) = offset(&s, "CharacterBag1Slot");
assert!(
(dy - 48.0).abs() < 0.5 && (dx + 12.0).abs() < 0.5,
"starts one fall (48px) above and one drift (12px) left of the button: got ({dx}, {dy})"
);
assert!(
alpha(&s, "BenillaBagBarSlot2") < 0.01,
alpha(&s, "CharacterBag1Slot") < 0.01,
"fades in from nothing"
);
@@ -1617,11 +1634,11 @@ fn an_item_push_drops_its_icon_into_the_bag_that_took_it() {
s.tick(0.133);
s.resolve();
assert!(
(alpha(&s, "BenillaBagBarSlot2") - 1.0).abs() < 0.02,
(alpha(&s, "CharacterBag1Slot") - 1.0).abs() < 0.02,
"opaque by the alpha track's second key"
);
assert!(
(size(&s, "BenillaBagBarSlot2") - 36.0 * 1.2).abs() < 0.5,
(size(&s, "CharacterBag1Slot") - 36.0 * 1.2).abs() < 0.5,
"swollen to 1.2x the 36px icon at the scale track's peak"
);
@@ -1629,10 +1646,10 @@ fn an_item_push_drops_its_icon_into_the_bag_that_took_it() {
s.tick(0.134);
s.resolve();
assert!(
(size(&s, "BenillaBagBarSlot2") - 36.0).abs() < 0.5,
(size(&s, "CharacterBag1Slot") - 36.0).abs() < 0.5,
"settled back to the 36px icon at the scale track's third key"
);
let (_, dy) = offset(&s, "BenillaBagBarSlot2");
let (_, dy) = offset(&s, "CharacterBag1Slot");
assert!((dy - 48.0).abs() < 0.5, "has not started falling: got {dy}");
// t=0.5: STILL PARKED — the translation track's second key is (0,0), so the whole first half
@@ -1641,26 +1658,26 @@ fn an_item_push_drops_its_icon_into_the_bag_that_took_it() {
// reading would get wrong (scale/alpha ≈ 0.686/0.682 here, the 0.267→1.000 ramps at t=0.5).
s.tick(0.233);
s.resolve();
let (_, dy) = offset(&s, "BenillaBagBarSlot2");
let (_, dy) = offset(&s, "CharacterBag1Slot");
assert!(
(dy - 48.0).abs() < 0.5,
"has not moved yet at the half second: got {dy}"
);
assert!(
(size(&s, "BenillaBagBarSlot2") - 36.0 * 0.686).abs() < 0.5,
(size(&s, "CharacterBag1Slot") - 36.0 * 0.686).abs() < 0.5,
"but is already dwindling: got {}",
size(&s, "BenillaBagBarSlot2")
size(&s, "CharacterBag1Slot")
);
assert!(
(alpha(&s, "BenillaBagBarSlot2") - 0.682).abs() < 0.02,
(alpha(&s, "CharacterBag1Slot") - 0.682).abs() < 0.02,
"and already fading: got {}",
alpha(&s, "BenillaBagBarSlot2")
alpha(&s, "CharacterBag1Slot")
);
// t=0.75: halfway down.
s.tick(0.25);
s.resolve();
let (_, dy) = offset(&s, "BenillaBagBarSlot2");
let (_, dy) = offset(&s, "CharacterBag1Slot");
assert!(
(dy - 24.0).abs() < 0.5,
"half the fall travelled by t=0.75: got {dy}"
@@ -1669,21 +1686,21 @@ fn an_item_push_drops_its_icon_into_the_bag_that_took_it() {
// Just short of the end: landed on the button, shrunk to nothing, faded out.
s.tick(0.24);
s.resolve();
let (dx, dy) = offset(&s, "BenillaBagBarSlot2");
let (dx, dy) = offset(&s, "CharacterBag1Slot");
assert!(
dx.abs() < 1.0 && dy.abs() < 1.5,
"lands centred on the button it went into: got ({dx}, {dy})"
);
assert!(
size(&s, "BenillaBagBarSlot2") < 2.0,
size(&s, "CharacterBag1Slot") < 2.0,
"collapsed to the scale track's 0.014"
);
assert!(alpha(&s, "BenillaBagBarSlot2") < 0.05, "faded out");
assert!(alpha(&s, "CharacterBag1Slot") < 0.05, "faded out");
// Past 1.000s the CLAMP sequence is over — the ref's OnAnimFinished → Hide.
s.tick(0.05);
assert!(
!shown(&s, "BenillaBagBarSlot2"),
!shown(&s, "CharacterBag1Slot"),
"one play, then gone (and no OnUpdate left running)"
);
@@ -1697,9 +1714,123 @@ fn an_item_push_drops_its_icon_into_the_bag_that_took_it() {
);
assert!(shown(&s, "KeyRingButton"), "the keyring card plays");
assert!(
!shown(&s, "BenillaBagToggle"),
!shown(&s, "MainMenuBarBackpackButton"),
"and the backpack's does not"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// **An addon that replaces `ToggleBackpack` gets the bag button's click.**
///
/// This is how a 1.12 addon customises anything — replace a global, expect the client to call your
/// replacement. `Bagnon_Core/core/Overrides.lua` is the canonical shape:
///
/// ```lua
/// local bToggleBackpack = ToggleBackpack
/// ToggleBackpack = function() … end
/// ```
///
/// It was inert here. The 'B' binding and the bag button called `BenillaBagToggle_OnClick`
/// directly, so nothing ever looked up `ToggleBackpack`; Bagnon loaded, showed up in the AddOns
/// list, replaced the globals, and the player still got the stock bags. **The director saw that and
/// no instrument here could** — the corpus survey loads addons and fires events, but never clicks.
///
/// The class is much wider than one addon: every hook-a-global addon is silently inert wherever our
/// UI calls a benilla-named equivalent. This test pins the bag path; the rule is general.
#[test]
fn an_addon_that_hooks_toggle_backpack_receives_the_click() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Fonts.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "MerchantFrame.xml");
load_xml(&s, "Cooldown.xml");
load_xml(&s, "BagFrame.xml");
s.set_money(0);
// Bagnon's exact idiom: capture the original, replace the global.
s.run(
r#"
HOOK_RAN = 0
local original = ToggleBackpack
ToggleBackpack = function() HOOK_RAN = HOOK_RAN + 1 end
"#,
)
.unwrap();
// The button's own OnClick path — what the 'B' binding and the backpack button both run.
s.run("BenillaBagToggle_OnClick()").unwrap();
assert_eq!(
s.eval::<i64>("return HOOK_RAN").unwrap(),
1,
"the click must reach the addon's replacement, not our own function"
);
// And the addon's override SUPPRESSED the stock behaviour, which is the point of hooking.
assert!(
!s.eval::<bool>("return BenillaBagFrame:IsShown()").unwrap(),
"the stock bag must not open when an addon has taken the verb over"
);
// The reference's other two names exist and are callable by an addon that wants the original.
assert!(
s.eval::<bool>("return type(OpenAllBags) == 'function' and type(ToggleBag) == 'function'")
.unwrap(),
"OpenAllBags and ToggleBag are the ref's names and addons hook them too"
);
}
/// **The equipped-bag slots carry the REFERENCE's names, and so do their icons.**
///
/// Ours were `BenillaBagBarSlot1..4`; the reference's are `CharacterBag0Slot..3` — 0-based, so
/// `CharacterBag0Slot` IS bag 1. Eight corpus addons index those names and found nil, including
/// **Bagnon**, which does `getglobal("CharacterBag0Slot"):GetScript("OnClick")`.
///
/// The same class as the `ToggleBackpack` bug the director found: our own name where the reference
/// has one, so every addon reaching for it is silently inert.
///
/// A Lua alias would not have been enough. `Bartender2/Alias.lua:352` takes
/// `CharacterBag0SlotIconTexture` — a name DERIVED from the frame's own, which only a real rename
/// produces. The icon is `$parentIconTexture` now, matching the reference's
/// `PaperDollItemSlotButtonTemplate`.
#[test]
fn the_bag_slots_carry_the_references_names_and_icon_names() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Fonts.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "MerchantFrame.xml");
load_xml(&s, "Cooldown.xml");
load_xml(&s, "BagFrame.xml");
for i in 0..4 {
assert!(
s.eval::<bool>(&format!("return CharacterBag{i}Slot ~= nil"))
.unwrap(),
"CharacterBag{i}Slot must exist — 8 corpus addons index these"
);
assert!(
s.eval::<bool>(&format!(
"return getglobal('CharacterBag{i}SlotIconTexture') ~= nil"
))
.unwrap(),
"and its icon under the derived name Bartender2 aliases"
);
}
// 0-based: CharacterBag0Slot is bag 1. Pinned because the off-by-one is the whole trap, and
// two name-building call sites had to learn it.
assert_eq!(
s.eval::<i64>("return CharacterBag0Slot.bagId").unwrap(),
1,
"CharacterBag0Slot IS bag 1 — the reference names them 0-based"
);
// Bagnon's exact reach.
assert!(
s.eval::<bool>("return getglobal('CharacterBag0Slot'):GetScript('OnClick') ~= nil")
.unwrap(),
"Bagnon captures this OnClick to replace the bag behaviour"
);
}
@@ -0,0 +1,947 @@
//! **"Only the frame header and the gold text. No bag slots at all."** — the director's Bagnon
//! report, reproduced end to end and then closed.
//!
//! ## Why this file exists
//!
//! The addon survey scored Bagnon `loaded, missing=0, session=ok, probe=ok` — every column green —
//! while the window on the director's screen was empty. Every one of those columns asks whether
//! something *raised*; none of them asks whether anything was **drawn**. Two independent faults
//! were hiding in that gap, and only one of them raises at all:
//!
//! | | what it does | does it raise? |
//! |---|---|---|
//! | `UnitName("player")` is nil at addon file scope | Bagnon reads the live bags as another character's offline cache → **0 slots**, window shown | **no** |
//! | the `SetItemButton*` family is missing | the per-slot update dies on the first slot → loop aborts, window never shown | yes, once |
//!
//! The director saw the first (their `Bagnon_Core` loaded at startup, so `currentPlayer` was
//! captured nil). Fix only that and you get the second. So the tests below pin **both**, and the
//! headline is a quad count, not an error count: [`bagnon_draws_a_slot_for_every_bag_slot`] is
//! green only when 16 item-slot quads actually reach the render list.
//!
//! Nothing from the corpus is committed, and every test here skips cleanly on a machine without
//! it — the `ui_chat::ace_gate_tests` rule.
use std::path::{Path, PathBuf};
use benilla_ui::script::{AddOnInfo, ContainerSlot, ContainerState, QuadContent, UiScript};
use benilla_ui::toc::Toc;
/// Where the vanilla addon corpus might be — `$BENILLA_ADDON_CORPUS`, else a sibling checkout
/// resolved from this crate's manifest (a pool worktree's cwd is not stable across tool calls).
fn corpus_candidates() -> Vec<PathBuf> {
let mut out = Vec::new();
if let Some(over) = std::env::var_os("BENILLA_ADDON_CORPUS") {
out.push(PathBuf::from(over));
}
let manifest = Path::new(env!("CARGO_MANIFEST_DIR"));
for up in [2usize, 3, 4] {
if let Some(root) = manifest.ancestors().nth(up) {
out.push(root.join("wow-addons-vanilla"));
}
}
out
}
/// The corpus root, or `None` — **a skip, never a failure**: it is third-party content that is
/// deliberately not in this repo.
fn corpus() -> Option<PathBuf> {
corpus_candidates().into_iter().find(|c| c.is_dir())
}
macro_rules! corpus_or_skip {
() => {
match corpus() {
Some(root) => root,
None => {
eprintln!(
"skipping: no vanilla addon corpus — looked in {:?} (set $BENILLA_ADDON_CORPUS)",
corpus_candidates()
);
return;
}
}
};
}
fn read_toc(root: &Path, name: &str) -> Toc {
let path = root.join(name).join(format!("{name}.toc"));
Toc::parse(&benilla_ui::source::decode(
&std::fs::read(&path).unwrap_or_else(|e| panic!("{}: {e}", path.display())),
))
}
/// One addon's `.toc` files through the same two arms the real loader uses — `.lua` as a chunk,
/// anything else as FrameXML — with the AddOns root as the provider's path space (decision 1186).
fn load_addon_files(script: &UiScript, root: &Path, name: &str) -> Vec<String> {
let toc = read_toc(root, name);
let provider = |req: &str| -> Option<Vec<u8>> { std::fs::read(root.join(req)).ok() };
let mut errors = Vec::new();
for file in &toc.files {
let path = benilla_ui::loader::join_ref(name, file);
let Ok(bytes) = std::fs::read(root.join(&path)) else {
errors.push(format!("{file}: not found"));
continue;
};
if file.to_ascii_lowercase().ends_with(".lua") {
// Named as the client names it — the corpus parses its own tracebacks.
if let Err(e) =
script.run_chunk_named(&bytes, &benilla_ui::script::addon_chunk_name(name, file))
{
errors.push(format!("{file}: {e}"));
}
continue;
}
match benilla_ui::framexml::parse(&benilla_ui::source::decode(&bytes)) {
Ok(doc) => {
let report = benilla_ui::loader::load_in(script, &doc, &path, &provider);
errors.extend(report.errors.into_iter().map(|e| format!("{file}: {e}")));
}
Err(e) => errors.push(format!("{file}: {e}")),
}
}
errors
}
/// The director's exact installed set. Bagnon and Bagnon_Options are `## LoadOnDemand: 1`, so the
/// startup walk skips them and `LoadAddOn` brings them in — which is why the registry below
/// carries a real root.
const INSTALLED: &[&str] = &[
"!OmniCC",
"Bagnon",
"Bagnon_Core",
"Bagnon_Forever",
"Bagnon_Options",
"MapCoords",
];
/// Whether the VM gets its `"player"` before the addons load — i.e. whether
/// [`super::seat_from_roster`] ran.
#[derive(Clone, Copy, PartialEq, Eq)]
enum Seat {
/// What the app does now: the roster row goes in ahead of `load_ingame_ui`.
BeforeAddons,
/// What it did before the fix: nothing writes `"player"` until `ui_unit::feed_units` sees the
/// self descriptor, seconds later.
AfterAddons,
}
/// The roster row the director's session would carry, and the same struct the app reads —
/// so the seat under test is built by [`super::seat_from_roster`] itself, not by a copy of it.
fn roster() -> crate::char_select::Roster {
let row = benilla_protocol::Character {
guid: 7,
name: "Harness".into(),
race: 1, // Human → Alliance
class: 1, // Warrior
gender: 0,
level: 60,
skin: 0,
face: 0,
hair_style: 0,
hair_color: 0,
facial_hair: 0,
zone: 0,
map: 0,
position: benilla_protocol::wire::Vector3d {
x: 0.0,
y: 0.0,
z: 0.0,
},
flags: 0,
equipment: [benilla_protocol::CharEnumItem::default(); 19],
};
crate::char_select::Roster::with_pending_pick(vec![row], 7)
}
/// A VM shaped like the director's session: our whole interface, their installed addons walked in
/// dependency order the way `addons::load_third_party` walks them, and a real backpack.
fn seat(root: &Path, seat: Seat) -> UiScript {
let mut s = UiScript::new().expect("VM");
s.set_screen_size(1024.0, 768.0);
let mut registry: Vec<AddOnInfo> = INSTALLED
.iter()
.map(|n| super::addons::info_from_toc(n, &read_toc(root, n)))
.collect();
s.register_addons(registry.clone(), Some(root.to_path_buf()), None, None);
s.set_realm_name("Harness");
let player = super::seat_from_roster(&roster()).expect("a pending pick seats a player");
if seat == Seat::BeforeAddons {
s.set_unit("player", Some(player.clone()));
}
let failures = super::load_default_ui(&s);
assert!(
failures.is_empty(),
"our own FrameXML failed to load: {failures:#?}"
);
// A real backpack — 16 slots, one occupied. `GetContainerNumSlots(0)` answering 16 is what
// Bagnon's LIVE path reads; the whole bug is that it took a different path and never asked.
//
// The slot carries a real `item_id` and `link`, not just an icon: the render half only ever
// needed the texture, but the INTERACTION half rides `GameTooltip:SetBagItem` (which requires
// a resolved instance) and `PickupContainerItem` (which needs something to pick up), so a
// texture-only slot would make every hover and every click a silent no-op — a fixture that
// hides the very thing the test is for.
let mut slots = std::collections::HashMap::new();
slots.insert(
1,
ContainerSlot {
texture: Some("Interface\\Icons\\INV_Misc_Bag_08".into()),
count: 1,
item_id: 4496,
quality: Some(1),
link: Some("|cffffffff|Hitem:4496:0:0:0|h[Small Brown Pouch]|h|r".into()),
..Default::default()
},
);
s.set_container(
0,
Some(ContainerState {
name: Some("Backpack".into()),
num_slots: 16,
slots,
}),
);
// The startup walk: dependencies first, LoadOnDemand skipped, one `ADDON_LOADED` each.
let mut done: Vec<String> = Vec::new();
for name in INSTALLED {
walk(&mut s, root, name, &mut done, &mut registry);
}
// Re-registered with the startup set marked loaded, so `LoadAddOn` below meets satisfied
// dependencies exactly as it does in a real session.
s.register_addons(registry, Some(root.to_path_buf()), None, None);
for event in ["VARIABLES_LOADED", "PLAYER_LOGIN"] {
s.fire_event(event, Vec::new());
}
// The self descriptor's arrival: `ui_unit::feed_units` pushes the player and fires
// PLAYER_ENTERING_WORLD off it. In the `AfterAddons` arm this is the FIRST time the VM has
// ever had a `"player"` — which is the state this file exists to reproduce.
if seat == Seat::AfterAddons {
s.set_unit("player", Some(player));
}
s.fire_event("PLAYER_ENTERING_WORLD", Vec::new());
for _ in 0..10 {
s.tick(0.1);
}
s
}
fn walk(s: &mut UiScript, root: &Path, name: &str, done: &mut Vec<String>, reg: &mut [AddOnInfo]) {
if done.iter().any(|d| d.eq_ignore_ascii_case(name)) {
return;
}
let toc = read_toc(root, name);
if toc.load_on_demand() {
return; // the reference's `0x51f600` loads only records whose LoadOnDemand byte is 0
}
done.push(name.to_string());
for dep in toc.dependencies() {
if INSTALLED.iter().any(|n| n.eq_ignore_ascii_case(dep)) {
walk(s, root, dep, done, reg);
}
}
load_addon_files(s, root, name);
if let Some(i) = reg.iter().position(|a| a.name.eq_ignore_ascii_case(name)) {
reg[i].loaded = true;
}
s.fire_event(
"ADDON_LOADED",
vec![benilla_ui::script::ScriptValue::Str(name.to_string())],
);
}
/// Every item-slot quad **Bagnon's own buttons** drew — the `UI-Quickslot2` ring, which is the one
/// texture an empty slot still paints (an occupied slot adds its icon on top).
///
/// **Attributed, not counted.** Our own `BenillaBagFrame` paints sixteen of exactly this texture,
/// so a bare total reads 16 whether Bagnon drew nothing or our window did — the "an addon replaces,
/// it does not add" trap. Every quad is charged to the nearest named frame above it
/// ([`UiScript::target_owner_name`]) and only `BagnonItem*` counts.
fn bagnon_slot_quads(s: &mut UiScript) -> usize {
s.resolve();
s.extract()
.iter()
.filter(|q| {
matches!(&q.content, QuadContent::Texture { path: Some(p), .. }
if p.contains("UI-Quickslot2"))
})
.filter(|q| {
s.target_owner_name(q.target)
.is_some_and(|n| n.starts_with("BagnonItem"))
})
.count()
}
/// Bagnon's window, open, with the fixture's backpack in it — the state every interaction test
/// below starts from. `ToggleBackpack` is Bagnon's own replacement, and its first call
/// demand-loads the `Bagnon` addon (the frame does not exist before that).
fn open_bagnon(root: &Path) -> UiScript {
let mut s = seat(root, Seat::BeforeAddons);
s.run("ToggleBackpack()").expect("ToggleBackpack");
for _ in 0..3 {
s.tick(0.1);
}
assert!(
s.eval::<bool>("return Bagnon and Bagnon:IsVisible() or false")
.unwrap(),
"the window must be on screen before anything is clicked"
);
s.resolve();
s
}
/// The centre of a named frame, in the y-up UI space [`UiScript::mouse_move`] /
/// [`UiScript::mouse_button`] take — the same space `resolve`/`extract` use, so a frame's own
/// `GetLeft`/`GetBottom` read straight back as a cursor position.
fn centre_of(s: &mut UiScript, name: &str) -> (f32, f32) {
s.resolve();
let r: Vec<f32> = s
.eval(&format!(
"local f = getglobal(\"{name}\") \
return {{ f:GetLeft(), f:GetBottom(), f:GetWidth(), f:GetHeight() }}"
))
.unwrap_or_else(|e| panic!("{name}: no resolved rect: {e}"));
assert_eq!(r.len(), 4, "{name}: unresolved rect {r:?}");
(r[0] + r[2] / 2.0, r[1] + r[3] / 2.0)
}
/// The name of the Bagnon item button showing backpack slot `slot`. Bagnon lays its buttons out in
/// its own order and `SetID`s each to the game slot, so the button holding the fixture's one item
/// is found by asking, never by assuming an index.
fn bagnon_button_for_slot(s: &UiScript, bag: i64, slot: u32) -> String {
s.eval::<String>(&format!(
"for i = 1, 36 do \
local b = getglobal(\"BagnonItem\"..i) \
if b and b:GetID() == {slot} and b:GetParent():GetID() == {bag} then \
return \"BagnonItem\"..i \
end \
end \
return \"\""
))
.inspect(|n| assert!(!n.is_empty(), "no BagnonItem* is bag {bag} slot {slot}"))
.expect("the item-button scan")
}
/// Press and release the named button over `name`, the way a player's mouse does — through
/// `mouse_button`, so the whole engine path runs (hit test, `RegisterForClicks` gate, the frame's
/// own `OnClick`), never `s.run("Handler(...)")`.
fn click(s: &mut UiScript, name: &str, button: &str) {
let (x, y) = centre_of(s, name);
s.mouse_move(x, y);
s.mouse_button(x, y, button, true);
s.mouse_button(x, y, button, false);
}
/// **The headline, and the director's own symptom.** Press B with Bagnon installed and every one
/// of the backpack's 16 slots must reach the render list.
///
/// This is the test that was red before the two fixes and is green after — and the *number* is the
/// point, not the absence of an error: before, this VM raised nothing at all on the path the
/// director was on.
#[test]
fn bagnon_draws_a_slot_for_every_bag_slot() {
let root = corpus_or_skip!();
let mut s = seat(&root, Seat::BeforeAddons);
// Bagnon's own entry point: it replaces `ToggleBackpack`, and its replacement demand-loads the
// `Bagnon` addon on the first call (the frame does not exist before that).
s.run("ToggleBackpack()").expect("ToggleBackpack");
for _ in 0..3 {
s.tick(0.1);
}
assert!(
s.eval::<bool>("return Bagnon and Bagnon:IsVisible() or false")
.unwrap(),
"the window must be on screen"
);
assert_eq!(
s.eval::<i64>("return Bagnon.size").unwrap(),
16,
"Bagnon must size itself off the LIVE backpack (GetContainerNumSlots(0) == 16), not off \
Bagnon_Forever's empty offline cache — nil UnitName('player') is what sent it there"
);
assert_eq!(
bagnon_slot_quads(&mut s),
16,
"sixteen bag slots must actually be DRAWN — the director saw a title and a gold line and \
nothing else, while every column of the addon survey called this addon fine"
);
assert!(
s.errors().is_empty(),
"and nothing may raise on the way: {:#?}",
s.errors()
);
}
/// **The control that makes the headline mean something: reproduce the director's screen.**
///
/// With the player seated only *after* the addons load — benilla's own order until
/// [`super::seat_from_roster`] landed — Bagnon shows a window whose title and money frame are
/// right and which contains **not one slot**, and raises nothing doing it. If this ever starts
/// drawing slots, the headline above has stopped testing the thing it was written for.
#[test]
fn without_a_player_at_addon_load_bagnon_draws_an_empty_window() {
let root = corpus_or_skip!();
let mut s = seat(&root, Seat::AfterAddons);
s.run("ToggleBackpack()").expect("ToggleBackpack");
for _ in 0..3 {
s.tick(0.1);
}
// The two things the director COULD see, exactly as reported.
assert!(
s.eval::<bool>("return Bagnon:IsVisible()").unwrap(),
"the window is up"
);
assert_eq!(
s.eval::<String>("return BagnonTitle:GetText()").unwrap(),
"Harness's Inventory",
"…with its header"
);
// And the thing they could not.
assert_eq!(
bagnon_slot_quads(&mut s),
0,
"no slot is drawn — this is the report, reproduced"
);
assert!(
s.eval::<String>("return tostring(BagnonItem1)").unwrap() == "nil",
"not one item button was even created"
);
}
/// The **other** fault, pinned on its own so a later regression names itself: the reference's
/// `SetItemButton*` family (`assets/ui/ItemButtonTemplate.xml`).
///
/// Bagnon ends every slot update with `SetItemButtonDesaturated` / `SetItemButtonTexture` /
/// `SetItemButtonCount`. While those were nil the raise landed inside `BagnonFrame_AddBag`'s
/// per-slot loop, so the FIRST slot aborted it, `frame.size` never advanced past 0 and the window
/// never reached its own `frame:Show()`. That is a different picture from the one above — nothing
/// on screen at all — and it is what the identity fix alone would have produced.
#[test]
fn the_item_button_helpers_paint_a_slots_icon_and_count() {
let mut s = UiScript::new().expect("VM");
s.set_screen_size(1024.0, 768.0);
let failures = super::load_default_ui(&s);
assert!(failures.is_empty(), "our own FrameXML: {failures:#?}");
// A button shaped the way the family's naming contract requires, built the way an addon does.
s.run(
r#"
local b = CreateFrame("Button", "ProbeItemButton", UIParent)
b:SetWidth(37) b:SetHeight(37)
b:SetPoint("CENTER", UIParent, "CENTER", 0, 0)
local icon = b:CreateTexture("ProbeItemButtonIconTexture", "BORDER")
icon:SetAllPoints(b)
local count = b:CreateFontString("ProbeItemButtonCount", "OVERLAY", "NumberFontNormal")
count:SetPoint("BOTTOMRIGHT", b, "BOTTOMRIGHT", -5, 2)
count:Hide()
"#,
)
.expect("probe button");
s.run(r#"SetItemButtonTexture(ProbeItemButton, "Interface\\Icons\\INV_Misc_Bag_08")"#)
.expect("SetItemButtonTexture");
s.run("SetItemButtonCount(ProbeItemButton, 5)")
.expect("SetItemButtonCount");
s.run("SetItemButtonDesaturated(ProbeItemButton, 1, 0.5, 0.5, 0.5)")
.expect("SetItemButtonDesaturated");
// Attributed to the probe button, never to the whole screen: the default UI paints its own
// "1".."0" action-bar hotkeys, so a bare "is there a quad reading 1 anywhere" would answer yes
// whatever these functions did. The `!` case below is the one that would have been fooled.
let count_text = |s: &UiScript, want: &str| {
s.extract().iter().any(|q| {
matches!(&q.content, QuadContent::Text { text, .. } if text.as_deref() == Some(want))
&& s.target_owner_name(q.target).as_deref() == Some("ProbeItemButton")
})
};
s.resolve();
assert!(
s.extract().iter().any(|q| matches!(&q.content,
QuadContent::Texture { path: Some(p), .. } if p.contains("INV_Misc_Bag_08"))),
"the icon the addon asked for must be on the region"
);
assert!(
count_text(&s, "5"),
"a stack of 5 shows its count (>1 is the reference's own gate)"
);
// The no-shader branch: benilla has no `Texture:SetDesaturated`, so a locked slot takes the
// reference's own fixed-function fallback — a flat 0.5 grey tint on the icon.
let tint = s
.eval::<Vec<f32>>("return {ProbeItemButtonIconTexture:GetVertexColor()}")
.expect("GetVertexColor");
assert_eq!(
&tint[..3],
&[0.5, 0.5, 0.5],
"a locked item greys out (ref: `elseif not r or not shaderSupported`)"
);
// A count of 1 hides the label again — the reference's gate, not a one-way switch.
s.run("SetItemButtonCount(ProbeItemButton, 1)").unwrap();
s.resolve();
assert!(
!count_text(&s, "1") && !count_text(&s, "5"),
"a single item shows no count"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The seat itself, in isolation: what the roster row actually puts in the VM, and the `None` that
/// keeps a capture (no pick, no roster) unchanged.
#[test]
fn the_roster_seat_names_the_character_the_addons_will_meet() {
let seat = super::seat_from_roster(&roster()).expect("a pending pick seats a player");
assert_eq!(seat.name.as_deref(), Some("Harness"));
assert_eq!(seat.level, 60);
assert_eq!(seat.race_file.as_deref(), Some("Human"));
assert_eq!(seat.class_file.as_deref(), Some("WARRIOR"));
assert_eq!(seat.sex, 2, "the wire's 0 is UnitSex's 2");
assert_eq!(
seat.faction_group.as_deref(),
Some("Alliance"),
"nil here is 24 corpus addons stopping on AceDB-2.0's file-scope concatenation"
);
assert!(seat.exists && seat.is_player);
// Deliberately NOT invented — the descriptor says these, within the second.
assert_eq!((seat.health, seat.max_health), (0, 0));
assert!(
super::seat_from_roster(&crate::char_select::Roster::default()).is_none(),
"no pick, no seat — a capture must load exactly as it did before"
);
}
// ─────────────────────────────────────────────────────────────────────────────────────────────
// The INTERACTION half — the layer the render half could not see
// ─────────────────────────────────────────────────────────────────────────────────────────────
//
// The tests above prove Bagnon's window is *drawn*. The director's next report was that it is
// **dead to the touch**: hover raises, click raises, and the console fills up. Every column of the
// addon survey — load, session, probe, and 1230's render column — is blind to that, for one
// structural reason: **none of them clicks**. So these do, through
// [`UiScript::mouse_move`]/[`UiScript::mouse_button`], which is the whole engine path a player's
// mouse takes (hit test → `RegisterForClicks` gate → the frame's own `OnClick`), never a direct
// `s.run("Handler(...)")` that would prove only that a function exists.
//
// `the_whole_bagnon_window_survives_being_used` below is the *enumerator*: it drives every
// gesture in one VM and asserts the error list is empty. A scan can only find what it knows to
// look for; this finds whatever is actually missing.
/// **Bug 1, hover.** `Bagnon_Core/core/Item.lua:136` ends its normal-bag branch in
/// `ContainerFrameItemButton_OnEnter(item)` — the reference's own global. It was nil, so every
/// hover over every slot raised and no tooltip ever appeared.
#[test]
fn hovering_a_bagnon_slot_shows_the_items_tooltip() {
let root = corpus_or_skip!();
let mut s = open_bagnon(&root);
let occupied = bagnon_button_for_slot(&s, 0, 1);
let (x, y) = centre_of(&mut s, &occupied);
s.mouse_move(x, y);
assert!(
s.errors().is_empty(),
"a hover must not raise: {:#?}",
s.errors()
);
assert!(
s.eval::<bool>(&format!("return GameTooltip:IsOwned({occupied})"))
.unwrap(),
"the tooltip must belong to the hovered slot ({occupied})"
);
assert!(
s.eval::<bool>("return GameTooltip:IsShown()").unwrap(),
"…and be on screen"
);
// …and it drops again on the way out, the template's own OnLeave.
let (ex, ey) = (x, y + 400.0);
s.mouse_move(ex, ey);
assert!(
!s.eval::<bool>("return GameTooltip:IsShown()").unwrap(),
"moving off the slot hides the tooltip"
);
assert!(s.errors().is_empty(), "…without raising: {:#?}", s.errors());
}
/// **Bug 1, left click.** `Item.lua:105` routes a plain click to
/// `ContainerFrameItemButton_OnClick(mouseButton, ignoreModifiers)`, whose unmodified left arm is
/// `PickupContainerItem(this:GetParent():GetID(), this:GetID())`. The observable is the cursor.
#[test]
fn left_clicking_a_bagnon_slot_picks_the_item_up() {
let root = corpus_or_skip!();
let mut s = open_bagnon(&root);
let occupied = bagnon_button_for_slot(&s, 0, 1);
assert!(
!s.eval::<bool>("return CursorHasItem()").unwrap(),
"nothing is held before the click"
);
click(&mut s, &occupied, "LeftButton");
assert!(
s.errors().is_empty(),
"a left click must not raise: {:#?}",
s.errors()
);
assert!(
s.eval::<bool>("return CursorHasItem()").unwrap(),
"the item is now on the cursor"
);
// …and clicking an empty slot puts it back down, through the same one entry point.
let empty = bagnon_button_for_slot(&s, 0, 2);
click(&mut s, &empty, "LeftButton");
assert_eq!(
s.take_container_moves(),
vec![benilla_ui::script::ContainerMove {
src_bag: 0,
src_slot: 1,
dst_bag: 0,
dst_slot: 2,
count: None,
}],
"the place half of the same gesture"
);
assert!(s.errors().is_empty(), "…nor the place: {:#?}", s.errors());
}
/// **Bug 1, right click.** The reference's right arm with no merchant open is
/// `UseContainerItem(bag, slot)` — use/equip the item.
#[test]
fn right_clicking_a_bagnon_slot_uses_the_item() {
let root = corpus_or_skip!();
let mut s = open_bagnon(&root);
let occupied = bagnon_button_for_slot(&s, 0, 1);
click(&mut s, &occupied, "RightButton");
assert!(
s.errors().is_empty(),
"a right click must not raise: {:#?}",
s.errors()
);
assert_eq!(
s.take_container_uses(),
vec![(0, 1)],
"right-click uses the slot"
);
}
/// **Bug 1, drag.** Bagnon registers `OnDragStart`/`OnReceiveDrag` on every item button and routes
/// both to `BagnonItem_OnClick("LeftButton", 1)` → `ContainerFrameItemButton_OnClick`'s
/// `ignoreModifiers` arm. Driven as a real gesture: press, move past the threshold, release.
#[test]
fn dragging_a_bagnon_slot_picks_the_item_up() {
let root = corpus_or_skip!();
let mut s = open_bagnon(&root);
let occupied = bagnon_button_for_slot(&s, 0, 1);
let empty = bagnon_button_for_slot(&s, 0, 2);
let (sx, sy) = centre_of(&mut s, &occupied);
let (dx, dy) = centre_of(&mut s, &empty);
s.mouse_move(sx, sy);
s.mouse_button(sx, sy, "LeftButton", true);
s.mouse_move(sx + 12.0, sy + 12.0); // past DRAG_START_THRESHOLD ⇒ OnDragStart
s.mouse_move(dx, dy);
s.mouse_button(dx, dy, "LeftButton", false);
assert!(
s.errors().is_empty(),
"a drag must not raise: {:#?}",
s.errors()
);
assert_eq!(
s.take_container_moves(),
vec![benilla_ui::script::ContainerMove {
src_bag: 0,
src_slot: 1,
dst_bag: 0,
dst_slot: 2,
count: None,
}],
"drag-and-drop moved the item"
);
}
/// **Bug 2 — our own handler, called the reference's way.** Bagnon takes
/// `MainMenuBarBackpackButton:GetScript("OnEnter")` and calls it back with **no arguments**
/// (`Bagnon.lua:87`), because that is the reference's contract: an XML script body takes nothing
/// and reads the frame off the `this` global. Ours compiled to `function(self, ...)` and its body
/// passed `self` straight into `GameTooltip:SetOwner`, so the hook handed it nil:
///
/// ```text
/// bad argument #2: error converting Lua nil to table
/// BagFrame.xml:789: in function 'BenillaBagToggle_OnEnter'
/// [string "MainMenuBarBackpackButton:OnEnter"]:2: in upvalue 'bMainBag_OnEnter'
/// ```
#[test]
fn the_backpack_button_still_hovers_with_bagnon_holding_its_script() {
let root = corpus_or_skip!();
let mut s = open_bagnon(&root);
// Bagnon really did take the script over — otherwise this test proves nothing.
assert!(
s.eval::<bool>(
"return MainMenuBarBackpackButton:GetScript(\"OnEnter\") == BagnonBlizMainBag_OnEnter"
)
.unwrap(),
"Bagnon_AddBagHooks must have replaced the backpack button's OnEnter"
);
let (x, y) = centre_of(&mut s, "MainMenuBarBackpackButton");
s.mouse_move(x, y);
assert!(
s.errors().is_empty(),
"the hooked hover must not raise: {:#?}",
s.errors()
);
assert!(
s.eval::<bool>("return GameTooltip:IsOwned(MainMenuBarBackpackButton)")
.unwrap(),
"our own tooltip still opens, owned by the button"
);
}
/// **Bug 3.** `Bagnon_Forever/database/ui.lua:61` sizes its character dropdown from
/// `button:GetTextWidth()` on a **CheckButton** — a real 1.12 Button method (wow-re
/// `widget-api-batch-benilla.md` Q8 lists it present, and `GetStringWidth` absent, on Button).
#[test]
fn a_button_reports_its_own_label_width() {
let root = corpus_or_skip!();
let mut s = open_bagnon(&root);
// Bagnon_Forever's exact shape: a CheckButton built from its own template, with a label.
s.run(
r#"
local b = CreateFrame("CheckButton", "ProbeWidthButton", UIParent, "BagnonDBUINameBox")
b:SetPoint("CENTER", UIParent, "CENTER", 0, 0)
b:SetText("ProbeLabelText")
"#,
)
.expect("the probe button");
s.resolve();
// Answer the host measure for the button's OWN label, and nothing else — the assertion below
// is then "the button forwarded to the right region and read the right metric", not merely
// "some number came back". A headless VM has no font atlas, so an unanswered measure is 0 and
// a `> 0` assertion would be untestable here (and unfalsifiable there).
let req = s
.fontstrings_needing_measure()
.into_iter()
.find(|r| r.text == "ProbeLabelText")
.expect("the label asks to be measured");
s.set_measured_text_unwrapped(&[(req.id, 61.0, 12.0, req.key)]);
assert_eq!(
s.eval::<f64>("return ProbeWidthButton:GetTextWidth()")
.expect("Button:GetTextWidth"),
61.0,
"the Button reports its LABEL's natural extent"
);
assert_eq!(
s.eval::<f64>("return ProbeWidthButton:GetTextHeight()")
.expect("Button:GetTextHeight"),
12.0
);
// A Button with no label at all answers 0 rather than raising — the reference dereferences a
// FontString pointer here that a bare `CreateFrame("Button")` leaves null, and what it does
// then is not byte-read, so this takes the harmless number.
s.run(r#"CreateFrame("Button", "ProbeLabellessButton", UIParent)"#)
.unwrap();
assert_eq!(
s.eval::<f64>("return ProbeLabellessButton:GetTextWidth()")
.unwrap(),
0.0
);
// The real consumer, end to end: Bagnon_Forever's own character list.
s.run("BagnonDBUI_ShowCharacterList(Bagnon)")
.expect("BagnonDBUI_ShowCharacterList");
assert!(
s.errors().is_empty(),
"the character list must not raise: {:#?}",
s.errors()
);
}
/// **Bug 4.** `Bagnon_Core/core/Item.xml:34` hangs `<Model name="$parentCooldown"
/// inherits="CooldownFrameTemplate"/>` off every item button — the reference's own template, which
/// we had never declared, so every one of the 36 buttons warned and the child came out bare.
#[test]
fn the_reference_cooldown_template_resolves() {
let root = corpus_or_skip!();
let s = open_bagnon(&root);
let occupied = bagnon_button_for_slot(&s, 0, 1);
// The child exists and answers as a frame, whatever Bagnon does to it.
assert!(
s.eval::<bool>(&format!("return getglobal(\"{occupied}Cooldown\") ~= nil"))
.unwrap(),
"the template's $parentCooldown child must exist"
);
// …and the one entry point every consumer drives it through does not raise on it.
s.run(&format!(
"CooldownFrame_SetTimer(getglobal(\"{occupied}Cooldown\"), GetTime(), 30, 1)"
))
.expect("CooldownFrame_SetTimer on an addon's cooldown child");
assert!(s.errors().is_empty(), "{:#?}", s.errors());
}
/// **The enumerator.** Every gesture a player makes in Bagnon's window, in one VM, with the error
/// list asserted empty at the end.
///
/// This is the test the arc was missing. Four landings in a row fixed the errors that happened to
/// fire and left the next layer to be found by the director playing, because every instrument we
/// had asks whether something *raised at load* and none of them touches the window. A scan finds
/// what it knows to look for; this finds what is actually missing, because it does what a player
/// does.
#[test]
fn the_whole_bagnon_window_survives_being_used() {
let root = corpus_or_skip!();
let mut s = open_bagnon(&root);
// 1 · Every slot, hovered and left again — the OnEnter/OnUpdate/OnLeave loop on both an
// occupied and an empty slot.
for i in 1..=16 {
let name = format!("BagnonItem{i}");
if s.eval::<bool>(&format!("return getglobal(\"{name}\") ~= nil"))
.unwrap_or(false)
{
let (x, y) = centre_of(&mut s, &name);
s.mouse_move(x, y);
s.tick(0.4); // Bagnon's own 0.3s OnUpdate tooltip refresh
s.mouse_move(x, y + 400.0);
}
}
// 2 · The window's own furniture: the title (a tooltip), the bag row toggle, the close button.
for name in ["BagnonTitle", "BagnonShowBags", "BagnonCloseButton"] {
if s.eval::<bool>(&format!("return getglobal(\"{name}\") ~= nil"))
.unwrap_or(false)
{
let (x, y) = centre_of(&mut s, name);
s.mouse_move(x, y);
}
}
click(&mut s, "BagnonShowBags", "LeftButton"); // show the bag row…
// 3 · Bagnon's OWN bag buttons (the row it just revealed — `BagnonBags0..4` plus the
// keyring's `BagnonBags-2`, from `Bagnon.xml`'s `$parent<id>` naming): hover, click and
// drag each. `BagnonBag_OnClick` is `PutItemInBackpack`/`PutItemInBag`, `BagnonBag_OnDrag`
// is `PickupBagFromSlot` — three engine verbs, none of which existed here.
for id in ["0", "1", "2", "3", "4", "-2"] {
let name = format!("BagnonBags{id}");
if !s
.eval::<bool>(&format!(
"local b = getglobal(\"{name}\") return b ~= nil and b:IsVisible()"
))
.unwrap_or(false)
{
continue;
}
let (x, y) = centre_of(&mut s, &name);
s.mouse_move(x, y);
s.mouse_button(x, y, "LeftButton", true);
s.mouse_button(x, y, "LeftButton", false);
s.mouse_button(x, y, "LeftButton", true);
s.mouse_move(x + 12.0, y + 12.0);
s.mouse_button(x + 12.0, y + 12.0, "LeftButton", false);
}
// 4 · The money frame's three coin buttons — `OpenCoinPickupFrame`, guarded in our own
// MoneyFrame.xml and *not* guarded in Bagnon's copy of the same handler.
for coin in ["Gold", "Silver", "Copper"] {
let name = format!("BagnonMoneyFrame{coin}Button");
if s.eval::<bool>(&format!(
"local b = getglobal(\"{name}\") return b ~= nil and b:IsVisible()"
))
.unwrap_or(false)
{
click(&mut s, &name, "LeftButton");
}
}
// 5 · The bag-bar buttons Bagnon hooked on OUR bar — hover, click, and the keyring.
for name in [
"MainMenuBarBackpackButton",
"CharacterBag0Slot",
"CharacterBag1Slot",
"KeyRingButton",
] {
if !s
.eval::<bool>(&format!(
"local b = getglobal(\"{name}\") return b ~= nil and b:IsVisible()"
))
.unwrap_or(false)
{
continue;
}
let (x, y) = centre_of(&mut s, name);
s.mouse_move(x, y);
s.mouse_button(x, y, "LeftButton", true);
s.mouse_button(x, y, "LeftButton", false);
s.mouse_move(x, y + 400.0);
}
// **A CLOSED LIST, not "is it empty".** Everything Bagnon reaches that benilla does not have
// is named here, so this test does two jobs at once: it holds the residual honest (each entry
// is a gap somebody decided, with a reason, not one nobody noticed), and it fails LOUDLY on
// any error class that is not on it. "Assert the list is empty" would have had to be deleted
// or `#[ignore]`d while a known gap stands, and then it would be catching nothing at all.
//
// To retire an entry: implement it and delete the line. To add one: you had better be able to
// say why, in the same sentence.
const KNOWN_GAPS: &[(&str, &str)] = &[
// The bag-BUTTON cursor trio (`Bag.lua:199-215` — Bagnon's own bag row, click and drag).
// Three 1.12 engine bindings benilla does not have: `PutItemInBackpack 0x4c8f70`,
// `PutItemInBag 0x4c8f00`, `PickupBagFromSlot 0x4c8fa0` (wow-re `system/ui/ledger.tsv`
// l.1962-1964 has the registrations verified; the DECOMPILATION on disk is partial —
// Ghidra removed unreachable blocks from two of the three — so what each one actually
// DOES is not read).
//
// **Deliberately not guessed.** The open question is whether `PutItemInBag(inv)`
// auto-stores the held item into that bag's CONTENTS or places it onto the equipment slot,
// and those are different opcodes. Writing a cursor transition on the wrong one moves a
// player's items to the wrong place, which is the most expensive kind of wrong here
// (the contract §4), so an RE cross-check is dispatched into wow-5875-re and these land on
// its verdict, not before.
("PutItemInBackpack", "Bagnon_Core\\core\\Bag.lua:202"),
("PutItemInBag", "Bagnon_Core\\core\\Bag.lua:204"),
("PickupBagFromSlot", "Bagnon_Core\\core\\Bag.lua:214"),
// Money on the cursor. `Frame.lua:537-543` — Bagnon's money frame calls this UNGUARDED
// where benilla's own `MoneyFrame.xml:411` guards its copy of the same reference handler.
// It is the head of a whole feature, not a missing line: the reference's
// `CoinPickupFrame.lua`/`.xml` need `GetCursorMoney`, `PickupPlayerMoney`, a MONEY cursor
// payload (wow-re `cursor-dragdrop-payload.md` row 2 — benilla's cursor has Item/Spell/
// Action only) and a keyboard-focused frame with `OnChar`/`OnKeyDown`, which this engine
// deliberately does not have (see `EnableKeyboard` in the same landing's report).
("OpenCoinPickupFrame", "Bagnon_Core\\core\\Frame.lua:543"),
];
let raised = s.errors();
let unexplained: Vec<&String> = raised
.iter()
.filter(|e| !KNOWN_GAPS.iter().any(|(name, _)| e.contains(name)))
.collect();
assert!(
unexplained.is_empty(),
"using Bagnon raised {} error(s) that are NOT on the known-gap list:\n{:#?}",
unexplained.len(),
unexplained
);
// …and the other direction, so a gap that quietly closes does not leave a stale entry behind.
for (name, site) in KNOWN_GAPS {
assert!(
raised.iter().any(|e| e.contains(name)),
"`{name}` is listed as a known gap ({site}) but nothing raised on it — if it is \
built now, delete the entry"
);
}
}
@@ -236,7 +236,7 @@ fn bag_buttons_tint_by_purchase_count_and_texture_from_the_bank_bag_feed() {
s.resolve();
let quads = s.extract();
// BagFrame.xml's own bag-bar slots (BenillaBagBarSlot1..4) fall back to this SAME empty-slot
// BagFrame.xml's own bag-bar slots (CharacterBag0Slot..4) fall back to this SAME empty-slot
// texture, so the search must be scoped to each named bank button's own rect, not the path
// alone — get each button's center via eval, then read the color off the quad sitting there.
let center = |name: &str| -> (f32, f32) {
@@ -439,3 +439,39 @@ fn managed_positions_track_the_bottom_bar_stack() {
assert_eq!(bottom(&s, "CastingBarFrame"), 100.0);
assert_eq!(bottom(&s, "ChatFrame1"), 102.0);
}
/// **`CastingBarFrameStatusBar` is published, and it IS `CastingBarFrame`.**
///
/// `CastingBarFrame.lua:16` ends `CastingBarFrame_OnLoad` with `CastingBarFrameStatusBar =
/// CastingBarFrame` — a plain alias, because in 1.12 the casting bar is itself the StatusBar and
/// there is no separate child. Our transcription copied the eight `RegisterEvent`s and both field
/// inits and dropped that one line.
///
/// It cost 11 corpus addons, the largest single name in the session-start `attempt to index global`
/// row, and implementing it moved survivors 74 -> 85.
///
/// **This is the second time this file has been caught dropping lines from the same function** (see
/// the module doc: the original transcription also lost two `CastingBarFlash:Hide()` calls). A line
/// missing from INSIDE an otherwise-verbatim function is invisible to every instrument here —
/// `framexml-transcription-diff.py` compares elements and handlers, not bodies.
///
/// Asserted as identity, not just non-nil: an alias that pointed at some other frame would satisfy
/// "exists" and still break every addon that drives the bar through it.
#[test]
fn the_casting_bar_publishes_its_status_bar_alias() {
let s = harness();
assert!(
s.eval::<bool>("return CastingBarFrameStatusBar ~= nil")
.unwrap(),
"the reference publishes this name in CastingBarFrame_OnLoad"
);
assert!(
s.eval::<bool>("return CastingBarFrameStatusBar == CastingBarFrame")
.unwrap(),
"the alias IS the bar — 1.12 has no separate child StatusBar"
);
// And it drives: the addons that index it call StatusBar methods straight through.
s.run("CastingBarFrameStatusBar:SetMinMaxValues(0, 10) CastingBarFrameStatusBar:SetValue(4)")
.unwrap();
assert!((bar_value(&s) - 4.0).abs() < 1e-9);
}
@@ -501,3 +501,150 @@ fn fired_event_names_are_all_chat_type_info_keys() {
assert!(present, r#"{name} → ChatTypeInfo["{key}"] is missing"#);
}
}
// ── The seven chat windows (NUM_CHAT_WINDOWS) ────────────────────────────────────────────────
//
// benilla shipped two windows against a constant the reference sets to seven. Everything below is
// a claim about the five that were missing, checked against the shipped `ChatFrame.xml` itself
// rather than against the loader's idea of it.
/// `NUM_CHAT_WINDOWS = 7` (ref ChatFrame.lua l.5) and every index it promises resolves to a real
/// `ScrollingMessageFrame`. _LazyPig walks exactly this loop and indexes the result with no nil
/// guard (`LazyPig.lua:1992`), so the constant without the frames is worse than neither.
#[test]
fn every_window_num_chat_windows_promises_is_a_real_frame() {
let s = chat_frame();
assert_eq!(s.eval::<i64>("return NUM_CHAT_WINDOWS").unwrap(), 7);
for i in 1..=7 {
let ok: bool = s
.eval(&format!(
"local f = getglobal('ChatFrame{i}') \
return f ~= nil and f.AddMessage ~= nil and f:GetID() == {i}"
))
.unwrap();
assert!(ok, "ChatFrame{i} is a real message frame carrying its id");
}
}
/// The corpus walk itself, verbatim from `_LazyPig/LazyPig.lua:1992` down to the unguarded
/// `getglobal(...):IsVisible()` that used to die at i=3.
#[test]
fn the_lazypig_window_walk_survives_all_seven_indices() {
let s = chat_frame();
let visible: i64 = s
.eval(
"local n = 0\n\
for i = 1, NUM_CHAT_WINDOWS do\n\
local ChatFrame = getglobal('ChatFrame'..i)\n\
if ChatFrame:IsVisible() then n = n + 1 end\n\
end\n\
return n",
)
.unwrap();
assert_eq!(visible, 1, "only the selected dock window is visible");
}
/// ChatFrame3..7 ship hidden and with no `isDocked`, the reference's own chat-cache state
/// (`DOCKED 0 / SHOWN 0`). This is not cosmetic: `Outfitter.lua:3099` reaches an UNGUARDED
/// `getglobal('ChatFrame'..i..'Tab'):GetText()` for any window that is visible *or* docked, and we
/// build no tabs past ChatFrame1Tab. The same reason keeps `isDocked` off ChatFrame2, whose
/// "Combat Log" tab is deliberately unbuilt.
#[test]
fn the_undocked_windows_are_hidden_and_carry_no_is_docked() {
let s = chat_frame();
for i in 2..=7 {
let shown: bool = s.eval(&format!("return ChatFrame{i}:IsShown()")).unwrap();
assert!(!shown, "ChatFrame{i} ships hidden");
let docked: bool = s
.eval(&format!("return ChatFrame{i}.isDocked ~= nil"))
.unwrap();
assert!(!docked, "ChatFrame{i} carries no isDocked");
}
// And therefore the Outfitter walk never reaches a tab that does not exist.
let ok: bool = s
.eval(
"for i = 1, NUM_CHAT_WINDOWS do\n\
local f = getglobal('ChatFrame'..i)\n\
if f and (f:IsVisible() or f.isDocked) then\n\
local tab = getglobal('ChatFrame'..i..'Tab')\n\
if not tab then return false end\n\
local _ = tab:GetText()\n\
end\n\
end\n\
return true",
)
.unwrap();
assert!(ok, "the Outfitter tab walk never touches a missing tab");
}
/// `GetChatWindowInfo`'s `shown` is not an independent opinion — it must agree with the frame the
/// shipped XML actually built, or an addon that trusts the getter and an addon that trusts the
/// frame will disagree about the same window. The drift guard between the Rust table and the XML.
#[test]
fn get_chat_window_info_shown_matches_the_shipped_frames() {
let s = chat_frame();
for i in 1..=7 {
let agrees: bool = s
.eval(&format!(
"local _, _, _, _, _, _, shown = GetChatWindowInfo({i})\n\
return (shown ~= nil) == ChatFrame{i}:IsShown()"
))
.unwrap();
assert!(
agrees,
"window {i}: GetChatWindowInfo disagrees with the frame"
);
}
}
/// A named debug sink is a real ring of its own: `ChatFrame3:AddMessage` (IgniteStatus does this
/// seven times, TipBuddy once, Radar guarded, and AceDebug's `debugFrame` stores the frame) lands
/// in ChatFrame3 and nowhere near the window the player is reading.
#[test]
fn a_line_added_to_chat_frame3_lands_in_chat_frame3_only() {
let mut s = chat_frame();
s.add_chat_message("ChatFrame1", "a real line", 1.0, 1.0, 1.0);
s.run("ChatFrame3:AddMessage('Radar: debug', 1, 1, 0)")
.unwrap();
assert_eq!(
s.eval::<i64>("return ChatFrame3:GetNumMessages()").unwrap(),
1
);
assert_eq!(
s.eval::<i64>("return ChatFrame1:GetNumMessages()").unwrap(),
1
);
// Hidden, so nothing of it reaches the screen.
s.resolve();
assert!(
text_color(&s.extract(), "Radar: debug").is_none(),
"a hidden window renders nothing"
);
}
/// `FCF_SelectDockFrame(frame)` — the corpus idiom (CustomNameplates.lua:69, Roid-Macros
/// Utility.lua:40) is "un-hide the default chat frame before printing into it". It takes a FRAME,
/// where ours takes a dock id; and it RAISES on a window benilla cannot dock rather than returning
/// quietly and leaving the caller's next AddMessage in a window that is still hidden.
#[test]
fn fcf_select_dock_frame_selects_by_frame_and_raises_for_an_undocked_one() {
let s = chat_frame();
s.run("FCF_SelectDockFrame(ChatFrame2)").unwrap();
let (one, two): (bool, bool) = (
s.eval("return ChatFrame1:IsShown()").unwrap(),
s.eval("return ChatFrame2:IsShown()").unwrap(),
);
assert!(!one && two, "selecting the Combat Log swaps the dock");
s.run("if not DEFAULT_CHAT_FRAME:IsVisible() then FCF_SelectDockFrame(DEFAULT_CHAT_FRAME) end")
.unwrap();
assert!(
s.eval::<bool>("return ChatFrame1:IsShown()").unwrap(),
"the corpus guard brings the default frame back"
);
assert!(
s.run("FCF_SelectDockFrame(ChatFrame5)").is_err(),
"an undocked window raises — benilla has no dock model for it (0288 §2)"
);
}
@@ -0,0 +1,184 @@
//! The two shared dropdown lists' own scripts (`UIDropDownMenu.xml`'s `DropDownList1`/`2`).
//!
//! These exist because the instance `<Scripts>` block was absent entirely and nothing said so: the
//! lists were one-line self-closing elements, the loader has no opinion about a frame with no
//! scripts, and every dropdown still opened and closed. The reference hangs three separate jobs off
//! those two elements and we had none of them.
//!
//! Each test is named after the claim it defends (1212): the submenu catch-all, the open-menu
//! registry's clear, the fact that the default text height is DERIVED rather than a literal, and —
//! found by the third while it was failing — that a button's label answers for the font object its
//! button gave it, which it did not.
use benilla_ui::script::UiScript;
fn load_xml(s: &UiScript, file: &str) {
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
);
}
/// The production prefix the lists need (ui_script/mod.rs order): the fonts (this OnLoad reads a
/// real font height off Button1's NormalText), UIParent (the lists declare `parent="UIParent"`),
/// and GameTooltip — the list's own `$parentMenuBackdrop` OnLoad indexes `TOOLTIP_DEFAULT_COLOR`,
/// which GameTooltip.xml declares.
fn load_dropdown_kit(s: &UiScript) {
for file in [
"Fonts.xml",
"UIParent.xml",
"GameTooltip.xml",
"UIDropDownMenu.xml",
] {
load_xml(s, file);
}
}
/// **Hiding level 1 closes level 2.** The bug this replaces was reachable by hand: open a menu with
/// a `hasArrow` row, hover the submenu open, then toggle level 1 shut. `ToggleDropDownMenu`'s close
/// arm is a bare `listFrame:Hide()`, so DropDownList2 stayed on screen with its parent gone.
///
/// The test hides level 1 *directly* rather than through the toggle, which is the stronger claim
/// and the reason the reference puts this on `OnHide` instead of in the toggle: it must catch every
/// path that hides level 1, including a parent Hide, the ESC ladder, and an addon calling
/// `DropDownList1:Hide()` on its own.
#[test]
fn hiding_the_parent_list_closes_an_open_submenu() {
let s = UiScript::new().unwrap();
load_dropdown_kit(&s);
s.run("DropDownList1:Show(); DropDownList2:Show();")
.unwrap();
assert!(
s.eval::<bool>("return DropDownList2:IsVisible()").unwrap(),
"fixture: the submenu must be open before level 1 hides"
);
s.run("DropDownList1:Hide();").unwrap();
assert!(
!s.eval::<bool>("return DropDownList2:IsVisible()").unwrap(),
"level 2 was left on screen after its parent list hid — the orphaned submenu"
);
}
/// **`OPEN_DROPDOWNMENUS` clears when its list hides.** `UnitPopup.xml` writes the level when a
/// unit menu opens and its every-frame driver walks the table; nothing ever removed the entry, so
/// it outlived its menu forever.
///
/// No player-visible symptom is reachable from the stale entry today — `UnitPopup_OnUpdate` returns
/// early unless `DropDownList1` is visible and a `UnitPopupFrame` owns the open menu, and
/// `UIDropDownMenu_AddButton` re-enables every button unconditionally. That is stated here rather
/// than left implied, because both of those guards belong to *other* code and either could move.
/// The divergence from the reference is in this file, so it is fixed and falsified in this file.
#[test]
fn hiding_a_list_clears_its_open_menu_registry_entry() {
let s = UiScript::new().unwrap();
load_dropdown_kit(&s);
s.run(
r#"
DropDownList1:Show();
DropDownList2:Show();
OPEN_DROPDOWNMENUS[1] = { which = "SELF", unit = "player" };
OPEN_DROPDOWNMENUS[2] = { which = "SELF", unit = "player" };
"#,
)
.unwrap();
s.run("DropDownList2:Hide();").unwrap();
assert!(
s.eval::<bool>("return OPEN_DROPDOWNMENUS[2] == nil")
.unwrap(),
"level 2's registry entry survived its list hiding"
);
s.run("DropDownList1:Hide();").unwrap();
assert!(
s.eval::<bool>("return OPEN_DROPDOWNMENUS[1] == nil")
.unwrap(),
"level 1's registry entry survived its list hiding"
);
}
/// **The default text height is read off Button1's font, never written as a literal.**
///
/// This is the trap the whole change turned on. `UIDropDownMenu.lua:16` forward-declares
/// `UIDROPDOWNMENU_DEFAULT_TEXT_HEIGHT = nil`, and reading only that line says "the reference
/// leaves it nil, so defining it would be a divergence". The assignment is in the *XML*, in this
/// OnLoad, and it derives the value from the button's own NormalText — so the honest answer is that
/// the name is a real gap whose only correct value is whatever our font object reports.
///
/// The assertion is therefore that it is non-nil and EQUALS Button1's reported height, not that it
/// equals some number: a literal would pass a number check and still be wrong.
#[test]
fn the_default_text_height_is_derived_from_button1_not_a_literal() {
let s = UiScript::new().unwrap();
load_dropdown_kit(&s);
let (height, from_button): (Option<f64>, Option<f64>) = s
.eval(
r#"
local _, buttonHeight = DropDownList1Button1NormalText:GetFont();
return UIDROPDOWNMENU_DEFAULT_TEXT_HEIGHT, buttonHeight
"#,
)
.unwrap();
let from_button = from_button.expect("fixture: Button1's NormalText must report a font height");
assert_eq!(
height,
Some(from_button),
"the constant must be the height Button1's own NormalText reports, not a literal"
);
}
/// **A button label answers for the font object its button gave it.** The gap the test above
/// uncovered: `extract` overlays the per-state font object onto a *clone* of the region's data
/// every frame, so a `<NormalFont inherits="GameFontHighlightSmall"/>` label painted correctly and
/// still answered `nil` to both `GetFont` and `GetFontObject`. Nothing errored — a FontString with
/// no font of its own is a legal state — so it took the reference's own OnLoad, which reads exactly
/// that, to surface it.
///
/// Asserted on the dropdown row because that is the site that found it, and against
/// `GameFontHighlightSmall` by name because that is what `UIDropDownMenuButtonTemplate` declares.
#[test]
fn a_button_label_reports_the_font_object_its_button_set() {
let s = UiScript::new().unwrap();
load_dropdown_kit(&s);
let (object_height, label_height, label_face): (Option<f64>, Option<f64>, Option<String>) = s
.eval(
r#"
local _, objectHeight = GameFontHighlightSmall:GetFont();
local face, height = DropDownList1Button1NormalText:GetFont();
return objectHeight, height, face
"#,
)
.unwrap();
assert_eq!(
label_height, object_height,
"the row label must report GameFontHighlightSmall's height, not nil"
);
assert!(
label_face.is_some_and(|f| f.ends_with(".TTF")),
"the row label must report a real font face through its button's font object"
);
assert!(
s.eval::<bool>(
"return DropDownList1Button1NormalText:GetFontObject() == GameFontHighlightSmall"
)
.unwrap(),
"the label must hand back the font OBJECT, which is what the corpus indexes immediately"
);
}
@@ -1,8 +1,14 @@
//! The shipped errors/info frame (`assets/ui/ErrorsFrame.xml` — the ref UIErrorsFrame) driven
//! engine-only: the yellow `UI_INFO_MESSAGE` toast (the quest objective-progress popup's surface),
//! the red `UI_ERROR_MESSAGE` line, insertMode-TOP stacking, and the hold+fade expiry.
//! The shipped errors/info frame (`assets/ui/ErrorsFrame.xml` — the ref UIErrorsFrame, a real
//! `<MessageFrame>`) driven engine-only: the yellow `UI_INFO_MESSAGE` toast (the quest
//! objective-progress popup's surface), the red `UI_ERROR_MESSAGE` line, insertMode-TOP stacking,
//! and the hold+fade expiry.
//!
//! Every assertion here reads the **drawn quads**, not Lua state. It used to read
//! `UIErrorsFrameLine1:GetText()` — the hand-rolled version's three stacked FontStrings — and those
//! are gone with it: the widget draws its own message bands now, so what a player sees is the only
//! thing left to test, which is also the thing worth testing.
use benilla_ui::script::{ScriptValue, UiScript};
use benilla_ui::script::{ExtractedQuad, QuadContent, ScriptValue, UiScript};
/// Load one shipped `assets/ui/<file>` into `s`, panicking on any loader error (the panel/quest
/// tests' loader, duplicated so this file is self-contained).
@@ -22,6 +28,28 @@ fn load_xml(s: &UiScript, file: &str) {
);
}
/// The toast lines as drawn, **top row first**, each with its colour+alpha and its band bottom.
fn toast_lines(s: &mut UiScript) -> Vec<(String, [f32; 4], f32)> {
s.resolve();
let mut v: Vec<(String, [f32; 4], f32)> = s
.extract()
.iter()
.filter_map(|q| match (&q.content, q.rect) {
(
QuadContent::Text {
text: Some(t),
color: Some(c),
..
},
Some(r),
) if !t.is_empty() => Some((t.clone(), *c, r.bottom)),
_ => None,
})
.collect();
v.sort_by(|a, b| b.2.total_cmp(&a.2));
v
}
#[test]
fn info_and_error_messages_stack_hold_and_expire() {
let mut s = UiScript::new().unwrap();
@@ -29,8 +57,9 @@ fn info_and_error_messages_stack_hold_and_expire() {
load_xml(&s, "Fonts.xml");
load_xml(&s, "ErrorsFrame.xml");
// Empty at load.
assert!(!s.eval::<bool>("return UIErrorsFrame:IsVisible()").unwrap());
// Empty at load. The frame itself is shown — a MessageFrame with nothing to say simply draws
// nothing, where the hand-rolled version had to `Hide()` itself.
assert!(toast_lines(&mut s).is_empty());
// A quest progress toast: yellow (ref UIErrorsFrame.lua:12), on the top line.
s.fire_event(
@@ -38,46 +67,46 @@ fn info_and_error_messages_stack_hold_and_expire() {
vec![ScriptValue::Str("Tough Wolf Meat: 2/8".into())],
);
assert!(s.errors().is_empty(), "info errors: {:?}", s.errors());
assert!(s.eval::<bool>("return UIErrorsFrame:IsVisible()").unwrap());
assert_eq!(
s.eval::<String>("return UIErrorsFrameLine1:GetText()")
.unwrap(),
"Tough Wolf Meat: 2/8"
);
assert!(s
.eval::<bool>(
"local m = UIErrorsFrame.messages[1] return m.r == 1.0 and m.g == 1.0 and m.b == 0.0"
)
.unwrap());
let lines = toast_lines(&mut s);
assert_eq!(lines.len(), 1);
assert_eq!(lines[0].0, "Tough Wolf Meat: 2/8");
assert_eq!(lines[0].1, [1.0, 1.0, 0.0, 1.0], "UI_INFO_MESSAGE yellow");
// A second message lands ON TOP (insertMode TOP), red for UI_ERROR_MESSAGE (lua:14),
// pushing the toast to line 2.
// A second message lands ON TOP (insertMode="TOP"), red for UI_ERROR_MESSAGE (lua:14), pushing
// the toast to the row below — the whole point of the attribute.
s.fire_event(
"UI_ERROR_MESSAGE",
vec![ScriptValue::Str("You are too far away!".into())],
);
let lines = toast_lines(&mut s);
assert_eq!(
s.eval::<String>("return UIErrorsFrameLine1:GetText()")
.unwrap(),
"You are too far away!"
lines.iter().map(|l| l.0.as_str()).collect::<Vec<_>>(),
["You are too far away!", "Tough Wolf Meat: 2/8"]
);
// 0.1 comes back as 26/255: `AddMessage` byte-quantizes every channel round-half-up
// (`ftol(v*255 + 0.5)`), so the colour a message draws with is never quite the float handed in.
assert_eq!(
s.eval::<String>("return UIErrorsFrameLine2:GetText()")
.unwrap(),
"Tough Wolf Meat: 2/8"
lines[0].1,
[1.0, 26.0 / 255.0, 26.0 / 255.0, 1.0],
"UI_ERROR_MESSAGE red, byte-quantized"
);
assert!(s
.eval::<bool>(
"local m = UIErrorsFrame.messages[1] return m.r == 1.0 and m.g == 0.1 and m.b == 0.1"
)
.unwrap());
// Hold 5 s (ref displayDuration), then the fade window, then gone — the frame hides once the
// last message expires.
// Hold 5 s (the ref's displayDuration="5"), then the class ctor's 3 s ramp, then gone. The
// phase check is per-tick, so the tick that spends the last of the hold still draws full.
s.tick(4.0);
assert!(s.eval::<bool>("return UIErrorsFrame:IsVisible()").unwrap());
s.tick(2.5); // both past 5 s hold + 1 s fade
assert!(!s.eval::<bool>("return UIErrorsFrame:IsVisible()").unwrap());
assert_eq!(toast_lines(&mut s).len(), 2);
s.tick(2.0); // the hold is spent on this tick; the ramp starts on the next
assert_eq!(toast_lines(&mut s).len(), 2);
s.tick(1.5); // half the ramp — still drawing, now dimmer
let mid = toast_lines(&mut s);
assert_eq!(mid.len(), 2);
assert!(
mid[0].1[3] < 1.0 && mid[0].1[3] > 0.0,
"mid-ramp alpha: {:?}",
mid[0].1
);
s.tick(2.0); // ramp done — retired, and this class frees the line rather than blanking it
assert!(toast_lines(&mut s).is_empty());
assert!(s.errors().is_empty(), "expiry errors: {:?}", s.errors());
}
@@ -86,10 +115,10 @@ fn info_and_error_messages_stack_hold_and_expire() {
/// this frame in HIGH (`UIErrorsFrame.xml` l.4) and benilla had dropped it, leaving it in the
/// default MEDIUM alongside the panels.
///
/// That is not a tie: the toast's content is flat FontString *regions* on the frame itself (level
/// 0), while a panel carries nested child frames (level 1+), and level outranks insertion in the
/// draw key. So the toast lost to any open panel — invisible in exactly the state you most want to
/// read it. Same defect as the party frame's in decision 0597, one stratum up.
/// That is not a tie: a panel carries nested child frames (level 1+) while the toast's content
/// belongs to the errors frame itself (level 0), and level outranks insertion in the draw key. So
/// the toast lost to any open panel — invisible in exactly the state you most want to read it. Same
/// defect as the party frame's in decision 0597, one stratum up.
#[test]
fn an_error_toast_draws_over_an_open_panel_window() {
let mut s = UiScript::new().unwrap();
@@ -109,7 +138,6 @@ fn an_error_toast_draws_over_an_open_panel_window() {
vec![ScriptValue::Str("Out of range.".into())],
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
assert!(s.eval::<bool>("return UIErrorsFrame:IsVisible()").unwrap());
assert!(s.eval::<bool>("return QuestLogFrame:IsVisible()").unwrap());
s.resolve();
@@ -118,9 +146,7 @@ fn an_error_toast_draws_over_an_open_panel_window() {
let toast = quads
.iter()
.filter_map(|q| match &q.content {
benilla_ui::script::QuadContent::Text { text: Some(t), .. } if t == "Out of range." => {
Some(q.z)
}
QuadContent::Text { text: Some(t), .. } if t == "Out of range." => Some(q.z),
_ => None,
})
.min()
@@ -131,10 +157,20 @@ fn an_error_toast_draws_over_an_open_panel_window() {
"sanity: the panel must be drawing something at all"
);
// Everything the quest log draws is below the toast, and by STRATUM — not by luck within one.
let above_toast = quads.iter().filter(|q| q.z > toast).count();
assert_eq!(
above_toast, 0,
"nothing may draw over the error toast while a panel is open"
//
// "Above" is measured against **other frames**: UIErrorsFrame's own declared `<FontString>` —
// the child that supplies the message font and the CENTER justification — is a real region of
// the frame and sorts above its message bands, exactly as a chat frame's does. It carries no
// text and draws nothing, so excluding the frame's own quads is what keeps this test about the
// thing it is named for.
let above: Vec<&ExtractedQuad> = quads
.iter()
.filter(|q| q.z > toast)
.filter(|q| s.quad_owner_name(q.target).as_deref() != Some("UIErrorsFrame"))
.collect();
assert!(
above.is_empty(),
"nothing may draw over the error toast while a panel is open: {above:?}"
);
assert_eq!(
toast >> STRATUM_SHIFT,
@@ -626,11 +626,11 @@ fn the_bag_row_greys_under_the_menu_without_any_of_it_disappearing() {
};
for owner in [
"BenillaBagToggle",
"BenillaBagBarSlot1",
"BenillaBagBarSlot2",
"BenillaBagBarSlot3",
"BenillaBagBarSlot4",
"MainMenuBarBackpackButton",
"CharacterBag0Slot",
"CharacterBag1Slot",
"CharacterBag2Slot",
"CharacterBag3Slot",
] {
assert!(
!art(&s, owner).is_empty(),
@@ -641,11 +641,11 @@ fn the_bag_row_greys_under_the_menu_without_any_of_it_disappearing() {
s.run("ToggleGameMenu(1)").unwrap();
s.resolve();
for owner in [
"BenillaBagToggle",
"BenillaBagBarSlot1",
"BenillaBagBarSlot2",
"BenillaBagBarSlot3",
"BenillaBagBarSlot4",
"MainMenuBarBackpackButton",
"CharacterBag0Slot",
"CharacterBag1Slot",
"CharacterBag2Slot",
"CharacterBag3Slot",
] {
let drawn = art(&s, owner);
assert!(
@@ -654,7 +654,7 @@ fn the_bag_row_greys_under_the_menu_without_any_of_it_disappearing() {
);
}
// The backpack icon specifically: still its own art, and tinted to SetDesaturation's grey.
let toggle = art(&s, "BenillaBagToggle");
let toggle = art(&s, "MainMenuBarBackpackButton");
assert!(
toggle
.iter()
@@ -671,7 +671,7 @@ fn the_bag_row_greys_under_the_menu_without_any_of_it_disappearing() {
s.run("ToggleGameMenu(1)").unwrap();
s.resolve();
let toggle = art(&s, "BenillaBagToggle");
let toggle = art(&s, "MainMenuBarBackpackButton");
assert!(
toggle
.iter()
@@ -680,7 +680,7 @@ fn the_bag_row_greys_under_the_menu_without_any_of_it_disappearing() {
"closing the menu restores full colour: {toggle:?}"
);
assert!(
s.eval::<bool>("return BenillaBagToggle:IsEnabled()")
s.eval::<bool>("return MainMenuBarBackpackButton:IsEnabled()")
.unwrap(),
"…and the button works again"
);
@@ -352,11 +352,19 @@ fn the_two_tabs_fit_inside_the_window() {
for (label_width, expect_tab2) in [
// A short name: under the cap, so the tab is text + PAD + SIDES.
(121.0, 121.0 + PAD + SIDES),
// A long one: the reference's cap would give CAP + PAD + SIDES = 167, but the structural
// clamp is tighter here and wins at 164 — the room left between tab 1's right edge and the
// window's DRAWN edge (decision 1002). Pinned as the smaller of the two on purpose: the
// whole point of the clamp is that it, not the hand-tuned cap, is what holds the line.
(240.0, 164.0),
// A long one: the reference's cap binds at CAP + PAD + SIDES = 167, and the structural
// clamp does NOT — it measures 184 units of room (tab 2's left 160, the parent's right 384,
// less the 40-unit drawn-plate inset), which is looser.
//
// **This pinned 164 until geometry getters settled on demand.** The clamp reads
// `tab:GetLeft()`, and before that change the read could return a rect from BEFORE tab 1
// was resized — a stale left of 180, giving room 164 and making the clamp appear to be what
// held the line. It never was, for this window. The 3-unit difference was a stale read.
//
// The clamp is still the guarantee that matters, and it is asserted directly below rather
// than inferred from a width: tab 2's right edge must stay inside the DRAWN plate (unit
// 349, decision 1002's DXT3 alpha measurement), which 160 + 167 = 327 does.
(240.0, 167.0),
] {
assert!(
expect_tab2 <= CAP + PAD + SIDES,
@@ -396,6 +404,15 @@ fn the_two_tabs_fit_inside_the_window() {
"tab 1 is text − 15 + the end slices"
);
assert_eq!(w2, expect_tab2, "tab 2, label width {label_width}");
// The property the clamp exists for, checked outright: the tab ends inside the plate the
// player can actually see, not merely inside the 384-unit frame rect.
let tab2_right: f64 = s
.eval("return BenillaMacroFrameTab2:GetLeft() + BenillaMacroFrameTab2:GetWidth()")
.unwrap();
assert!(
tab2_right <= 349.0,
"tab 2 ends at {tab2_right}, past the drawn plate's last opaque column (349)"
);
assert!(
left1 + w1 + w2 <= 384.0,
"the tab row ({left1} + {w1} + {w2}) runs off the 384-wide window"
@@ -67,6 +67,7 @@ fn bootstrap_positions(script: &UiScript) -> Vec<String> {
/// the addon harness ([`crate::addon_harness`]), which needs our entire interface under each
/// surveyed addon.
pub(crate) fn load_default_ui(script: &UiScript) -> Vec<String> {
super::reference_ui::load_sourced(script);
let builtin = Addon::builtin();
let mut failures = builtin.load_files(script, &builtin.toc.files);
failures.extend(bootstrap_positions(script));
@@ -103,6 +104,11 @@ pub(crate) fn load_ingame_ui(
script: &mut UiScript,
identity: Option<&(String, String)>,
) -> 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,
// precisely so that every global we define ourselves overwrites the reference's — its
// `ToggleBackpack` walks twelve `ContainerFrame`s we do not build; ours walks our windows.
super::reference_ui::load_sourced(script);
let builtin = Addon::builtin();
let mut failures = builtin.load_files(script, builtin.toc.files.get(1..).unwrap_or_default());
failures.extend(bootstrap_positions(script));
+71
View File
@@ -34,6 +34,10 @@ mod extract;
mod input;
mod manifest;
/// The reference FrameXML this client EXECUTES off the player's own patch chain instead of
/// transcribing it — the rule, the list, and the licensing reason are that module's header.
mod reference_ui;
// The manifest's loaders read as `ui_script::…` at every call site, including the tests' `super::`.
// `load_default_ui` is no longer test-only: the addon harness (1188 phase 6) loads the whole
// shipped interface under each surveyed addon, because roughly half of what an addon calls is
@@ -400,6 +404,14 @@ fn load_ingame_ui_on_world_entry(world: &mut World) {
.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
@@ -418,6 +430,59 @@ fn load_ingame_ui_on_world_entry(world: &mut World) {
world.resource_mut::<IngameUiLoaded>().0 = true;
}
/// 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()
})
}
/// `GetFramerate()`'s host half: push a **smoothed** frames-per-second into the VM each frame
/// (decision 1195).
///
@@ -842,6 +907,9 @@ mod unit_frame_tests;
#[cfg(test)]
mod unit_popup_tests;
#[cfg(test)]
mod dropdown_tests;
#[cfg(test)]
mod action_bar_tests;
@@ -1014,6 +1082,9 @@ mod errors_tests;
#[cfg(test)]
mod shipped_xml_tests;
#[cfg(test)]
mod bagnon_render_tests;
#[cfg(test)]
mod seam_scale_tests {
use super::seam_scale;
@@ -54,8 +54,9 @@ fn shipped_multibars_drive_end_to_end() {
report.errors
);
assert_eq!(
report.frames, 50,
"2 bar frames + 24 buttons, each with a Cooldown child"
report.frames, 100,
"4 bar frames + 48 buttons, each with a Cooldown child — the two VERTICAL bars joined \
(hidden, as the reference's VerticalMultiBar3/4 are), which doubled this count"
);
// Occupy main slot 1, BottomLeft slot 1 (action 61), BottomRight slot 1 (action 49).
@@ -468,3 +469,48 @@ fn multibar_hover_renders_the_buttons_own_action() {
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// **The two vertical multibars exist and stay hidden** — the reference's own posture, and the
/// reason three corpus addons stopped dying at session start.
///
/// `MultiActionBars.xml` l.515/523 instantiates `MultiBarRight`/`MultiBarLeft` as real
/// `parent="UIParent"` frames; their templates (VerticalMultiBar3/4, l.266/381) carry
/// `hidden="true"`. Atlas (`Atlas.lua:387`, `MultiBarLeft:SetFrameStrata`) and Bartender2
/// (`Bartender2.lua:74`, `MultiBarLeft:ClearAllPoints`) only need them to BE there — neither shows
/// one. So all three claims below are the fix: present, hidden, and on the reference's pages.
#[test]
fn the_vertical_multibars_exist_hidden_on_the_reference_pages() {
let s = UiScript::new().unwrap();
load_action_bar(&s);
load_xml(&s, "MultiBars.xml");
for bar in ["MultiBarRight", "MultiBarLeft"] {
assert!(
s.eval::<bool>(&format!("return {bar} ~= nil")).unwrap(),
"{bar} must exist — Atlas and Bartender2 index it by name at session start"
);
assert!(
!s.eval::<bool>(&format!("return {bar}:IsShown()")).unwrap(),
"{bar} must ship HIDDEN, exactly as VerticalMultiBar3/4 do"
);
// The two calls the corpus actually makes, verbatim in shape.
s.run(&format!("{bar}:SetFrameStrata(\"MEDIUM\")")).unwrap();
s.run(&format!("{bar}:ClearAllPoints()")).unwrap();
}
// ref ActionButton.lua:8-9 — RIGHT_ACTIONBAR_PAGE = 3 (actions 25..36), LEFT = 4 (37..48).
for (bar, first, last) in [("MultiBarRight", 25, 36), ("MultiBarLeft", 37, 48)] {
assert_eq!(
s.eval::<i64>(&format!("return {bar}Button1.base + {bar}Button1.index"))
.unwrap(),
first,
"{bar}'s first slot"
);
assert_eq!(
s.eval::<i64>(&format!("return {bar}Button12.base + {bar}Button12.index"))
.unwrap(),
last,
"{bar}'s last slot"
);
}
}
@@ -393,9 +393,8 @@ fn shift_click_toggles_the_watch_checkbox_and_the_tracker_hud() {
/// Shift-clicking a quest with zero objectives (ref `QUEST_WATCH_NO_OBJECTIVES`) and shift-clicking
/// past `GetNumQuestWatches() >= 5` (ref `QUEST_WATCH_TOO_MANY`) both refuse the watch and put the
/// ref's red line on the errors frame (`BenillaErrorsFrame_AddMessage` — the ref's
/// `UIErrorsFrame:AddMessage` call sites); the click still selects the row exactly like any other
/// click (pin §5).
/// ref's red line on the errors frame through its own `UIErrorsFrame:AddMessage`; the click still
/// selects the row exactly like any other click (pin §5).
#[test]
fn watch_guards_no_op_without_erroring() {
let mut s = UiScript::new().unwrap();
@@ -428,10 +427,14 @@ fn watch_guards_no_op_without_erroring() {
s.errors()
);
assert!(!s.eval::<bool>("return IsQuestWatched(2)").unwrap());
assert_eq!(
s.eval::<String>("return UIErrorsFrameLine1:GetText()")
.unwrap(),
"This quest has no objectives to track",
// Read the toast off the drawn quads: `UIErrorsFrame` is a real `<MessageFrame>` and its
// lines are the widget's own bands, not named FontStrings.
s.resolve();
assert!(
s.extract().iter().any(|q| matches!(
&q.content,
QuadContent::Text { text: Some(t), .. } if t == "This quest has no objectives to track"
)),
"the ref's QUEST_WATCH_NO_OBJECTIVES red line surfaces"
);
assert_eq!(
@@ -463,10 +466,12 @@ fn watch_guards_no_op_without_erroring() {
);
assert!(!s.eval::<bool>("return IsQuestWatched(1)").unwrap());
assert_eq!(s.eval::<i64>("return GetNumQuestWatches()").unwrap(), 5);
assert_eq!(
s.eval::<String>("return UIErrorsFrameLine1:GetText()")
.unwrap(),
"You may only watch 5 quests at a time",
s.resolve();
assert!(
s.extract().iter().any(|q| matches!(
&q.content,
QuadContent::Text { text: Some(t), .. } if t == "You may only watch 5 quests at a time"
)),
"the ref's QUEST_WATCH_TOO_MANY red line surfaces"
);
}
@@ -0,0 +1,173 @@
//! **Reference FrameXML this client EXECUTES off the patch chain, rather than transcribing.**
//!
//! ## The rule, and why it is a rule
//!
//! Some of `Interface\FrameXML` is not *our* logic to re-derive — it is a library of globals that
//! every 1.12 addon calls, or replaces, by name. `ContainerFrameItemButton_OnEnter` is one: Bagnon
//! builds its own item buttons and then hands them to the client's own handler, and four other
//! corpus addons overwrite that global outright. Those bodies do not encode anything benilla wants
//! to decide differently; they encode *the contract addons were written against*.
//!
//! Two ways to have them, and only one is available to us:
//!
//! | | |
//! |---|---|
//! | transcribe the file into `assets/ui` | **forbidden.** Blizzard's Lua, committed to a repo that mirrors public under MIT/Apache. the contract: never commit Blizzard assets. |
//! | read it out of the player's own installed patch chain at runtime | what we do. Nothing is committed; a player runs the file they already own. |
//!
//! This is not a new mechanism — it is [`super::load_global_strings`]'s, generalised. That already
//! executes the real `GlobalStrings.lua` off the chain at boot for exactly the same reason
//! (~5,000 localized globals nobody should retype), and this module is that pattern with a list.
//!
//! ## Where the reference file and our own UI collide, and which one wins
//!
//! `ContainerFrame.lua` is not only handlers. Two thirds of it drives the reference's OWN twelve
//! `ContainerFrame1..12` windows, which benilla does not build — our bag UI is `BagFrame.xml`
//! (decision 0068 T2). So sourcing the file wholesale would put reference bodies on top of working
//! benilla ones. **The rule, applied consistently and stated here once:**
//!
//! 1. **Order decides.** The sourced reference file runs **before** our own `assets/ui`, so every
//! global we define ourselves — `ToggleBag`, `ToggleBackpack`, `OpenAllBags`, `OpenBag`,
//! `CloseBag`, `IsBagOpen`, `CloseAllBags`, `OpenBackpack`, `CloseBackpack`, `ToggleKeyRing`,
//! `PutKeyInKeyRing`, `GetKeyRingSize` — **overwrites** the reference's. Ours drive our windows.
//! 2. **What only the reference defines, we keep, unmodified.** The item-button family
//! (`ContainerFrameItemButton_OnLoad`/`_OnClick`/`_OnEnter`/`_OnUpdate`, `KeyRingItemButton_OnClick`)
//! is frame-agnostic: every one of them works on whatever button the caller passes, through the
//! contract "the button's own ID is the slot, its parent's ID is the bag". Bagnon's buttons obey
//! it. So does anything else written for the real client. It also brings the file's constants
//! (`NUM_BAG_FRAMES`, read by 8 corpus addons; `NUM_CONTAINER_FRAMES` by 5) for free.
//! 3. **Where a reference body needs something benilla genuinely does not have, we ADAPT it in our
//! own file** — `assets/ui/ContainerFrameAdapters.xml`, which loads after this and wraps the
//! global. Exactly one function needs that today; the file names it and says why.
//! 4. **Nothing is stubbed silently.** The reference's frame-driven functions
//! (`ContainerFrame_GetOpenFrame`, `updateContainerFrameAnchors`, `ContainerFrame_OnLoad` and
//! kin) are left as the reference wrote them. Driven against frames that do not exist they raise
//! — naming the frame — which is loud, correct, and strictly better than a no-op that pretends
//! (1203, 1205, 1211, 1230 are four records of exactly that pretending). `ContainerFrame_Update`
//! and `ContainerFrame_GenerateFrame` are NOT in that set: both take the frame as an argument
//! and touch nothing global, so an addon that passes its own conforming frame gets the real
//! behaviour.
//!
//! ## No install, no file
//!
//! A machine with no client data (CI, a bare checkout) simply does not get these globals, and says
//! so once, loudly. Every consumer already had to survive that: it is the same condition under
//! which `GlobalStrings` is absent, and the addon survey prints which mode it ran in for that
//! reason. Tests that need the file gate on the install the way every other client-data test does.
use benilla_ui::script::UiScript;
use bevy::prelude::*;
/// The reference FrameXML files benilla sources instead of transcribing, in load order.
///
/// **Adding to this list is a decision, not a convenience.** A file earns a place when its globals
/// are a *contract addons call by name* AND benilla has no reason to compute them differently.
/// Anything we want to own — because our own windows are the consumer — belongs in `assets/ui` as
/// our own code, under the reference's names (`BagFrame.xml`'s `ToggleBackpack` is the pattern).
pub(crate) const SOURCED: &[&str] = &["Interface\\FrameXML\\ContainerFrame.lua"];
/// Execute every [`SOURCED`] file into `script`, read off the install's patch chain.
///
/// Returns whether the chain was available at all. Read **once per process** and cached: the
/// addon survey stands up 218 VMs, and this is per-VM work otherwise.
pub(crate) fn load_sourced(script: &UiScript) -> bool {
let Some(files) = sources() else {
warn!(
"ui_script: no client data — the reference FrameXML this client sources ({}) is \
absent, so its globals are nil; addons that call them will raise",
SOURCED.join(", ")
);
return false;
};
for (path, src) in files {
if let Err(e) = script.run_chunk_named(src.as_bytes(), path) {
error!("ui_script: sourced reference file {path} failed to run: {e}");
}
}
true
}
/// The [`SOURCED`] files' text, or `None` with no install to read them from.
///
/// A file listed here and missing from the chain is an `error!`, not a silent skip: the list is
/// short and deliberate, so a miss means the chain is not what we think it is.
fn sources() -> Option<&'static [(&'static str, String)]> {
use std::sync::OnceLock;
static SRC: OnceLock<Option<Vec<(&'static str, String)>>> = OnceLock::new();
SRC.get_or_init(|| {
let data = benilla_formats::wow_data()?;
let mut chain = benilla_formats::open_chain(&data).ok()?;
let mut out = Vec::new();
for &path in SOURCED {
match chain.read_file(path) {
Ok(bytes) => out.push((path, benilla_ui::source::decode(&bytes).into_owned())),
Err(e) => {
error!("ui_script: sourced reference file {path} not in the chain: {e:#}")
}
}
}
Some(out)
})
.as_deref()
}
#[cfg(test)]
mod tests {
use super::*;
/// **The item-button family arrives, and the bag verbs stay OURS** — rule 1 and rule 2 of this
/// module's header, asserted rather than described, because the order between the two is the
/// whole design and nothing else would notice if it flipped.
///
/// Skips without client data, like every other test that reads the install.
#[test]
fn sourcing_the_reference_file_adds_its_handlers_without_taking_our_bag_verbs() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().expect("VM");
s.set_screen_size(1024.0, 768.0);
assert!(load_sourced(&s), "the chain opened");
// Rule 2: what only the reference defines is now here.
for name in [
"ContainerFrameItemButton_OnEnter",
"ContainerFrameItemButton_OnClick",
"ContainerFrameItemButton_OnLoad",
"ContainerFrameItemButton_OnUpdate",
"KeyRingItemButton_OnClick",
] {
assert!(
s.eval::<bool>(&format!("return type({name}) == \"function\""))
.unwrap(),
"{name} must come from the sourced reference file"
);
}
// …including its constants, which the corpus reads directly.
assert_eq!(s.eval::<i64>("return NUM_BAG_FRAMES").unwrap(), 4);
assert_eq!(s.eval::<i64>("return NUM_CONTAINER_FRAMES").unwrap(), 12);
// Rule 1: the reference's own bag verbs are here for the moment…
assert!(s
.eval::<bool>("return type(ToggleBackpack) == \"function\"")
.unwrap());
// …and our interface, loading after, takes them over. `BENILLA_BAG_WINDOWS` is the tell:
// it is the table OUR ToggleBackpack walks, and the reference's body cannot mention it.
let failures = super::super::load_default_ui(&s);
assert!(failures.is_empty(), "our own FrameXML: {failures:#?}");
assert!(
s.eval::<bool>(
"local f = ToggleBackpack \
for _, name in ipairs(BENILLA_BAG_WINDOWS) do end \
return BenillaBagFrame ~= nil"
)
.unwrap(),
"our bag windows exist"
);
s.run("ToggleBackpack()")
.expect("ours runs — the reference's body would die on a nil ContainerFrame1");
assert!(
s.eval::<bool>("return BenillaBagFrame:IsShown()").unwrap(),
"ToggleBackpack must be OURS after our files load, not the reference's"
);
assert!(s.errors().is_empty(), "{:#?}", s.errors());
}
}
@@ -107,6 +107,14 @@ fn every_shipped_texture_path_resolves_in_the_client_archives() {
// `file=` on a texture element is an ARCHIVE path; `<Script file=>`/`<Include file=>` name our
// own source files and are top-level items, not elements, so walking elements can't see them.
fn walk(el: &Element, file: &str, out: &mut Vec<(String, String, String)>) {
// `<Model file=>` names a **model** (`.mdx`), not a texture: it never reaches
// `sprite_candidates` and it cannot draw a white quad, because this engine renders no
// FrameXML models at all. The one shipped case is the reference's `CooldownFrameTemplate`,
// transcribed with its own attributes so an addon's `inherits=` resolves, while the sweep
// it drives lives in our native `<Cooldown>` widget instead (decision 0263).
if el.tag.eq_ignore_ascii_case("Model") {
return;
}
for (key, value) in el.attrs() {
let archive_path = ["file", "bgfile", "edgefile"]
.contains(&key.to_ascii_lowercase().as_str())
@@ -446,3 +446,69 @@ fn an_instance_attribute_beats_the_templates() {
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// **`GameTooltipTextLeft1` is a real global from LOAD, hidden — before any line exists.**
///
/// Ours were engine-created purely on demand, so a cold VM had no such global. The corpus pattern
/// that breaks on is a **guard**, not a read: `Participant/Resurrection.lua:294` and
/// `CT_RaidAssist/CT_RADetectSpells.lua:47` both write
/// `... and GameTooltipTextLeft1:IsVisible() then` to ask whether the tooltip is currently showing
/// anything. On the reference that is safe and answers false; for us it raised on the check itself,
/// which is the worst shape — the addon was being careful and got punished for it.
///
/// `ref-GameTooltipTemplate.xml` declares 30 pairs, all `hidden="true"` (l.17-627). We now declare
/// the same 30, and the engine's existing adoption path takes them over when lines are added.
///
/// The second half of the assertion is what makes this safe: a declared-but-unfilled pair must not
/// count as a line. `NumLines()` reads `num_lines`, and `layout_tooltips` sizes from `num_lines`,
/// so 30 hidden pairs add no height and no width.
#[test]
fn the_tooltip_line_globals_exist_cold_and_do_not_count_as_lines() {
let mut s = harness();
for name in [
"GameTooltipTextLeft1",
"GameTooltipTextRight1",
"GameTooltipTextLeft30",
] {
assert!(
s.eval::<bool>(&format!("return {name} ~= nil")).unwrap(),
"{name} must exist before any line is added — it is declared, not grown"
);
assert!(
!s.eval::<bool>(&format!("return {name}:IsVisible()"))
.unwrap(),
"{name} must be HIDDEN cold, so the corpus guard answers false instead of raising"
);
}
// The corpus guard itself, verbatim in shape, on a tooltip that has never shown anything.
assert!(
!s.eval::<bool>("return GameTooltipTextLeft1:IsVisible()")
.unwrap(),
"Participant/Resurrection.lua:294's guard"
);
// A declared pair is not a line.
assert_eq!(
s.eval::<i64>("return GameTooltip:NumLines()").unwrap(),
0,
"30 declared pairs must not inflate NumLines"
);
// And the pairs still work as lines once filled — the adoption path, not a parallel set.
s.run("GameTooltip:SetOwner(UIParent, \"ANCHOR_NONE\") GameTooltip:AddLine(\"Corpse of Bob\") GameTooltip:Show()")
.unwrap();
answer_measures(&mut s);
assert_eq!(s.eval::<i64>("return GameTooltip:NumLines()").unwrap(), 1);
assert_eq!(
s.eval::<String>("return GameTooltipTextLeft1:GetText()")
.unwrap(),
"Corpse of Bob",
"the DECLARED region must be the one the line stack filled, not a sibling"
);
assert!(
s.eval::<bool>("return GameTooltipTextLeft1:IsVisible()")
.unwrap(),
"a filled line shows"
);
}
@@ -1644,3 +1644,61 @@ fn no_two_numeral_strings_overlap_on_any_frame() {
}
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// **`UnitFrame_OnEnter`/`OnLeave` exist under the REFERENCE's names, `this`-shaped, so an addon
/// that hooks them is reached.**
///
/// `TipBuddy.lua:2770-2773` is the exact idiom, and it is the 1.12 customisation model in general:
///
/// ```lua
/// originalUnitFrame_OnEnter = UnitFrame_OnEnter
/// function UnitFrame_OnEnter() originalUnitFrame_OnEnter() … end
/// ```
///
/// Against a missing global that captured nil and raised on the first hover. Note these are
/// ADAPTERS, not renames: the reference's contract takes no arguments and reads `this`, ours takes
/// the frame explicitly, so a rename would have reached our body with a nil frame — reachable but
/// broken, which is worse than inert.
///
/// The same class as the Bagnon bag bug, and equally invisible to the corpus survey: it loads
/// addons and fires events, but never hovers anything.
#[test]
fn the_unit_frame_hover_hooks_carry_the_references_names() {
let s = UiScript::new().unwrap();
load_unit_frames(&s);
assert!(
s.eval::<bool>(
"return type(UnitFrame_OnEnter) == 'function' \
and type(UnitFrame_OnLeave) == 'function'"
)
.unwrap(),
"the reference's names must exist for an addon to capture"
);
// TipBuddy's idiom, run for real against a live unit frame.
s.run(
r#"
HOVERS = 0
local original = UnitFrame_OnEnter
function UnitFrame_OnEnter()
HOVERS = HOVERS + 1
original()
end
this = PlayerFrame
UnitFrame_OnEnter()
"#,
)
.unwrap();
assert_eq!(
s.eval::<i64>("return HOVERS").unwrap(),
1,
"the addon's replacement must run"
);
assert!(
s.errors().is_empty(),
"and calling through to the original must not raise: {:?}",
s.errors()
);
}
+26
View File
@@ -395,6 +395,32 @@ pub(crate) fn class_names(class: u8) -> Option<(&'static str, &'static str)> {
})
}
/// A playable race id → `UnitFactionGroup`'s token (`"Alliance"`/`"Horde"`), or `None` for a race
/// id that is not one of the eight.
///
/// **A side derived from the RACE, not from the faction template** — deliberately, and only for
/// the one window in which the template does not exist yet. [`faction_group`] is the real answer
/// and reads `UNIT_FIELD_FACTIONTEMPLATE` off the descriptor; during world entry there is no
/// descriptor, and `UnitFactionGroup("player")` answering nil there is not "no faction", it is a
/// state a real player character cannot be in. AceDB-2.0 — embedded across a large slice of the
/// corpus — builds its per-realm key as `realm .. " - " .. faction` at **file scope**, so a nil
/// side is 24 corpus addons stopping on `attempt to concatenate local 'faction'`
/// (`addon_harness::seat_a_session`, decision 1195, which seats exactly this in the survey's VM).
///
/// Every playable race has a fixed side in 1.12, so this is a lookup rather than a guess — and it
/// reads the [`crate::char_create::ALLIANCE`] column the create screen already keeps, so the two
/// cannot drift apart.
pub(crate) fn race_faction_group(race: u8) -> Option<&'static str> {
if !(1..=8).contains(&race) {
return None;
}
Some(if crate::char_create::ALLIANCE.contains(&race) {
"Alliance"
} else {
"Horde"
})
}
/// Resolve a UnitPopup unit token to the **player guid** it names — `"target"` through the
/// selection iff it really is a player (the target frame's PLAYER menu), a `"partyN"` token through
/// the roster (the party frame's PARTY menu). `"player"` (yourself) and any unresolved token answer
+34 -4
View File
@@ -75,7 +75,16 @@ impl Element {
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ScriptRef {
File(String),
Inline(String),
Inline {
body: String,
/// The 1-based line **of this XML file** that `body`'s first character sits on.
///
/// Carried so the loader can pad the chunk out to that offset, making Lua's own error
/// lines the file's lines. Without it every inline block starts at line 1 and a raise
/// reports a number belonging to no file — worse than no number, because it looks like one
/// you could go and read (decision 1214).
line: u32,
},
}
/// One top-level item of a FrameXML document, in document order. Modeled as an order-preserving
@@ -187,7 +196,10 @@ pub fn parse(text: &str) -> Result<ParsedDocument, Error> {
} else if tag.eq_ignore_ascii_case("Script") {
match attr_ci(child, "file") {
Some(file) => items.push(TopLevel::Script(ScriptRef::File(file))),
None => items.push(TopLevel::Script(ScriptRef::Inline(direct_text(child)))),
None => items.push(TopLevel::Script(ScriptRef::Inline {
body: direct_text(child),
line: inline_start_line(&doc, child),
})),
}
} else if tag.eq_ignore_ascii_case("Font") {
let el = element_from_node(child);
@@ -224,6 +236,22 @@ fn attr_ci(node: roxmltree::Node, name: &str) -> Option<String> {
/// Concatenates a node's own direct text/CDATA children (not descending into child elements) —
/// the XML node struct's `body-text @ +0xc` (rf24-framexml-loader.md, header).
/// The 1-based line of the first text byte inside a `<Script>` element.
///
/// Taken from the first text child's own range rather than the element's, because a CDATA section
/// puts `<![CDATA[` between the two and every FrameXML script block in this project uses one — the
/// element's start line would be off by however much of the opening tag precedes the body.
/// Falls back to the element's line for a `<Script></Script>` with no text at all.
fn inline_start_line(doc: &roxmltree::Document, node: roxmltree::Node) -> u32 {
let at = node
.children()
.find(|n| n.is_text())
.unwrap_or(node)
.range()
.start;
doc.text_pos_at(at).row
}
fn direct_text(node: roxmltree::Node) -> String {
node.children()
.filter(|n| n.is_text())
@@ -438,7 +466,7 @@ mod tests {
.map(|item| match item {
TopLevel::Include(_) => "include",
TopLevel::Script(ScriptRef::File(_)) => "script-file",
TopLevel::Script(ScriptRef::Inline(_)) => "script-inline",
TopLevel::Script(ScriptRef::Inline { .. }) => "script-inline",
TopLevel::Font(_) => "font",
TopLevel::Template(_) => "template",
TopLevel::Instance(_) => "instance",
@@ -478,9 +506,11 @@ print(x)</Script>
TopLevel::Script(ScriptRef::File("Foo.lua".to_string()))
);
match &doc.items[1] {
TopLevel::Script(ScriptRef::Inline(body)) => {
TopLevel::Script(ScriptRef::Inline { body, line }) => {
assert!(body.contains("local x = 1"));
assert!(body.contains("print(x)"));
// Line 3 of the literal above: `<Ui>`, `<Script file=…>`, then this one.
assert_eq!(*line, 3, "the inline body's own line in the file");
}
other => panic!("expected inline script, got {other:?}"),
}
+58 -25
View File
@@ -103,18 +103,25 @@ pub fn load(
/// [`load`] for a document that does **not** sit at the provider's root.
///
/// `base` is the document's own directory, in the provider's path space (`/`-separated, no
/// trailing slash; `""` is the root). Every `<Include>`/`<Script file=>` inside resolves against
/// it, and each nested include resolves against *its* own directory in turn — so an addon whose
/// manifest lists `src\main.xml` passes `"<Addon>/src"` here and its `<Include file="templates.xml">`
/// reaches `<Addon>/src/templates.xml` (decision 1186).
/// `path` is the document's **own path** in the provider's path space (`/`-separated; `""` for a
/// document with no path, which only tests have). Its directory is what every `<Include>`/`<Script
/// file=>` inside resolves against, and each nested include resolves against *its* own directory in
/// turn — so an addon whose manifest lists `src\main.xml` passes `"<Addon>/src/main.xml"` here and
/// its `<Include file="templates.xml">` reaches `<Addon>/src/templates.xml` (decision 1186).
///
/// **The path, not the directory, because the loader has to be able to say where a raise came
/// from.** Every `<Script>` chunk is named after the file carrying it; an unnamed chunk takes
/// mlua's `#[track_caller]` default, which is *this file's* Rust source line, and 26 of the corpus
/// survey's 70 readable failures pointed at `crates/benilla-ui/src/loader/mod.rs` instead of the
/// addon (decision 1217). Three callers used to derive this directory themselves with the same
/// three lines; they pass the path now and [`dir_of`] does it once.
pub fn load_in(
script: &UiScript,
doc: &ParsedDocument,
base: &str,
path: &str,
files: &dyn Fn(&str) -> Option<Vec<u8>>,
) -> LoadReport {
load_into(script.lua(), doc, base, files)
load_into(script.lua(), doc, path, files)
}
/// [`load_in`] against the VM directly, for a caller that holds `&Lua` rather than `&UiScript` —
@@ -123,13 +130,13 @@ pub fn load_in(
pub fn load_into(
lua: &mlua::Lua,
doc: &ParsedDocument,
base: &str,
path: &str,
files: &dyn Fn(&str) -> Option<Vec<u8>>,
) -> LoadReport {
let mut loader = Loader {
lua,
files,
base: base.to_string(),
path: path.to_string(),
report: LoadReport::default(),
warned: HashSet::new(),
};
@@ -182,7 +189,7 @@ pub fn apply_template(lua: &mlua::Lua, wrapper: &Table, kind: &str, template: &s
let mut loader = Loader {
lua,
files: &no_files,
base: String::new(),
path: String::new(),
report: LoadReport::default(),
warned: HashSet::new(),
};
@@ -297,10 +304,12 @@ struct Loader<'a> {
/// `&Lua` and can never obtain the `&UiScript` that owns it.
pub(super) lua: &'a mlua::Lua,
pub(super) files: &'a dyn Fn(&str) -> Option<Vec<u8>>,
/// The current document's own directory in the provider's path space — what a relative
/// `<Include>`/`<Script file=>` inside it resolves against. Saved and restored around each
/// nested include, so depth-1 and depth-5 both resolve against their own file's folder.
pub(super) base: String,
/// The current document's own path in the provider's path space. Its **directory**
/// ([`Loader::base`]) is what a relative `<Include>`/`<Script file=>` inside it resolves
/// against; its **whole path** is what names an inline `<Script>`'s chunk. Saved and restored
/// around each nested include, so depth-1 and depth-5 both resolve — and report — against
/// their own file.
pub(super) path: String,
// The template + font-element registries live on the `Model` (`framexml_templates` /
// `framexml_fonts`), persisted ACROSS `load` calls — the client's template table is global
// (`0x6ee500`), so a file may `inherits=` a template an earlier file registered (the real
@@ -318,13 +327,30 @@ impl Loader<'_> {
self.lua
}
/// The current document's own directory — what a relative `<Include>`/`<Script file=>` inside
/// it resolves against.
fn base(&self) -> &str {
dir_of(&self.path)
}
/// Run a chunk in the one global state — `UiScript::run`'s body, reachable from `&Lua`.
///
/// Takes **bytes**, because a `<Script file=>` chunk is whatever is on disk (decision 1193) and
/// Lua 5.0 strings are byte strings. An inline `<Script>` body arrives as `&str` from the XML
/// and is passed through as its own bytes, which is the same thing.
fn run(&self, chunk: &[u8]) -> mlua::Result<()> {
self.lua.load(chunk).set_mode(mlua::ChunkMode::Text).exec()
///
/// `path` names the chunk, and **naming it is not cosmetic**: mlua's `load` is `#[track_caller]`
/// and an unnamed chunk is named after the Rust line that loaded it, so every raise from every
/// FrameXML and addon `<Script>` block in this project reported `loader/mod.rs` as its source
/// (decision 1217). Lua's leading `@` is what makes it a *file* name rather than a quoted
/// source snippet, and the separator is `\` because that is the shape an addon parsing its own
/// `debugstack()` matches against (`crate::script::addon_chunk_name`).
fn run(&self, chunk: &[u8], path: &str) -> mlua::Result<()> {
self.lua
.load(chunk)
.set_name(format!("@{}", path.replace('/', "\\")))
.set_mode(mlua::ChunkMode::Text)
.exec()
}
/// The model, for the two FrameXML registries below.
@@ -389,10 +415,10 @@ impl Loader<'_> {
match item {
TopLevel::Include(path) => self.do_include(path),
TopLevel::Script(ScriptRef::File(path)) => {
let joined = join_ref(&self.base, path);
let joined = join_ref(self.base(), path);
match (self.files)(&joined) {
Some(bytes) => {
if let Err(e) = self.run(crate::source::chunk(&bytes)) {
if let Err(e) = self.run(crate::source::chunk(&bytes), &joined) {
self.report
.errors
.push(format!("<Script file=\"{path}\">: {e}"));
@@ -404,8 +430,14 @@ impl Loader<'_> {
)),
}
}
TopLevel::Script(ScriptRef::Inline(body)) => {
if let Err(e) = self.run(body.as_bytes()) {
TopLevel::Script(ScriptRef::Inline { body, line }) => {
// Padded to the block's own offset in the XML, so Lua counts from where the
// file does. A chunk always starts at line 1; naming it after the file without
// this would make every reported line a confident, checkable lie.
let mut chunk = "\n".repeat(line.saturating_sub(1) as usize).into_bytes();
chunk.extend_from_slice(body.as_bytes());
let path = self.path.clone();
if let Err(e) = self.run(&chunk, &path) {
self.report.errors.push(format!("inline <Script>: {e}"));
}
}
@@ -430,7 +462,7 @@ impl Loader<'_> {
}
pub(super) fn do_include(&mut self, path: &str) {
let joined = join_ref(&self.base, path);
let joined = join_ref(self.base(), path);
let Some(bytes) = (self.files)(&joined) else {
self.report.errors.push(format!(
"<Include file=\"{path}\">: no provider hit for \"{joined}\"; the whole \
@@ -442,11 +474,12 @@ impl Loader<'_> {
match framexml::parse(&crate::source::decode(&bytes)) {
Ok(sub) => {
self.report.warnings.extend(sub.warnings.iter().cloned());
// The included document's OWN directory is what ITS relative references resolve
// against — restored after, so a sibling include at this level is unaffected.
let outer = std::mem::replace(&mut self.base, dir_of(&joined).to_string());
// The included document's OWN path is what ITS relative references resolve against
// and what ITS inline scripts are named after — restored after, so a sibling
// include at this level is unaffected.
let outer = std::mem::replace(&mut self.path, joined.clone());
self.load_doc(&sub);
self.base = outer;
self.path = outer;
}
Err(e) => self
.report
+33 -1
View File
@@ -64,6 +64,36 @@ impl Loader<'_> {
/// Compile a handler element into `function(self, ...) <body> end`, or resolve its
/// `function="Global"` reference. A syntax error in the body is recorded as an error (and the
/// handler dropped), never a panic.
///
/// ## `self` falls back to `this`, and that one clause is a whole class of addon bug
///
/// The 1.12 contract for an XML script body is that it takes **no arguments**: the frame
/// arrives as the `this` global, which [`crate::script::event::invoke_with_globals`] sets
/// around every dispatch. This engine also passes the modern `(self, event, …)` positionals —
/// convenient, and what Era-era addons expect — and our own FrameXML is written against
/// *that* spelling (`<OnEnter>BenillaBagToggle_OnEnter(self)</OnEnter>`).
///
/// The two spellings agree right up until **an addon captures a script and calls it back**,
/// which is the standard 1.12 hook idiom and the reason `GetScript` exists:
///
/// ```lua
/// bMainBag_OnEnter = MainMenuBarBackpackButton:GetScript("OnEnter") -- Bagnon.lua:61
/// MainMenuBarBackpackButton:SetScript("OnEnter", BagnonBlizMainBag_OnEnter)
/// function BagnonBlizMainBag_OnEnter()
/// …
/// bMainBag_OnEnter() -- l.87: no arguments. The reference's contract.
/// end
/// ```
///
/// The re-entry is perfectly legal, `this` is correctly set (Bagnon's own body reads
/// `this:GetID()` one line above), and our compiled body still took `self` from argument 1 —
/// which was nil. The director saw it as `bad argument #2: error converting Lua nil to table`
/// out of `GameTooltip:SetOwner`, from a handler that works perfectly when the engine calls it.
///
/// So the fallback lives **here**, once, rather than as `(self or this)` sprinkled through
/// ~20 handler bodies: any XML body in any file — ours or an addon's — now works under both
/// callers. It is written on line 1 of the wrapper on purpose, so `{body}` still starts at
/// line 2 and every traceback line number keeps pointing at the source the author wrote.
pub(super) fn compile_handler(
&mut self,
handler: &Element,
@@ -72,7 +102,9 @@ impl Loader<'_> {
) -> Option<Function> {
let body = handler.body.trim();
if !body.is_empty() {
let src = format!("return function(self, ...)\n{body}\nend");
let src = format!(
"return function(self, ...) if self == nil then self = this end\n{body}\nend"
);
match self
.lua()
.load(&src)
+166
View File
@@ -1237,6 +1237,172 @@ mod region_template_tests {
}
}
/// Chunk naming — which FILE and which LINE a `<Script>` raise reports (decision 1217).
mod chunk_name_tests {
use crate::framexml;
use crate::loader::*;
use crate::script::UiScript;
fn no_files(_: &str) -> Option<Vec<u8>> {
None
}
fn parse(text: &str) -> framexml::ParsedDocument {
framexml::parse(text).expect("valid FrameXML")
}
/// **A raise inside a `<Script>` block names the file it came from, and the file's own line.**
///
/// Both halves are load-bearing and both were wrong. `Loader::run` loaded every chunk with no
/// name, and mlua's `load` is `#[track_caller]` — so the chunk was named after the *Rust* line
/// that ran it, and a corpus read-back of 70 failures had 26 rows saying
/// `crates/benilla-ui/src/loader/mod.rs:327` where the addon's file belonged (decision 1217).
///
/// Naming it is only half. A Lua chunk starts at line 1, so an inline block 40 lines down a
/// file would report `File.xml:3` for its third line — a lie you can go and check, which is
/// strictly worse than the Rust path that was at least obviously not yours. The chunk is padded
/// to the block's own offset, so the number is the number you scroll to.
#[test]
fn a_script_raise_names_its_own_file_and_line() {
let s = UiScript::new().unwrap();
// `error()` with no level prefixes "chunkname:line:" — exactly what a real raise carries.
let doc = parse(
"<Ui>\n\
<Frame name=\"Pad\"/>\n\
<Script>\n\
local x\n\
error(\"boom\")\n\
</Script>\n\
</Ui>",
);
let report = load_in(&s, &doc, "Bagnon/src/main.xml", &no_files);
let err = report.errors.join("\n");
// The file, in the shape an addon's own `debugstack()` matches (backslashes, no `@`).
assert!(
err.contains("Bagnon\\src\\main.xml:"),
"the raise must name the document, got: {err}"
);
// Line 5 of the literal above is `error("boom")` — the body starts on line 4 and the
// padding carries it there. Without the pad this reads `:2:`.
assert!(
err.contains("main.xml:5:"),
"the line must be the FILE's line, not the block's, got: {err}"
);
}
/// The same, one level down: an `<Include>`d document's inline script names **its own** file,
/// not the includer's. The path is saved and restored around the recursion exactly as the
/// directory used to be, so depth-1 and depth-5 each report themselves.
#[test]
fn an_included_documents_script_names_the_included_file() {
let s = UiScript::new().unwrap();
let inner = "<Ui>\n<Script>\nerror(\"inner\")\n</Script>\n</Ui>";
let files = |req: &str| -> Option<Vec<u8>> {
(req == "Addon/sub/inner.xml").then(|| inner.as_bytes().to_vec())
};
let doc = parse("<Ui>\n<Include file=\"sub\\inner.xml\"/>\n</Ui>");
let report = load_in(&s, &doc, "Addon/outer.xml", &files);
let err = report.errors.join("\n");
assert!(
err.contains("Addon\\sub\\inner.xml:3:"),
"the INCLUDED file and its line, not the includer's: {err}"
);
assert!(
!err.contains("outer.xml"),
"the includer must not be blamed: {err}"
);
}
/// **The CDATA shape, which is the only shape our own FrameXML uses.** `<![CDATA[` sits
/// between the element's start and the body's first byte, so taking the line from the
/// *element* would be short by however much of the opening tag precedes it — and a
/// single-line-offset lie is the hardest kind to notice. The line comes from the text child's
/// own range instead, which is what this pins.
#[test]
fn a_cdata_script_block_reports_the_files_line() {
let s = UiScript::new().unwrap();
let doc = parse(
"<Ui>\n\
<Frame name=\"A\"/>\n\
<Frame name=\"B\"/>\n\
<Script><![CDATA[\n\
local ok = 1\n\
error(\"cdata boom\")\n\
]]></Script>\n\
</Ui>",
);
let report = load_in(&s, &doc, "Ours/Bag.xml", &no_files);
let err = report.errors.join("\n");
assert!(
err.contains("Ours\\Bag.xml:6:"),
"line 6 is `error(\"cdata boom\")` in the literal above, got: {err}"
);
}
/// A `<Script file=>` chunk is named after the file it loaded, not the document that named it.
#[test]
fn a_script_file_chunk_is_named_after_that_file() {
let s = UiScript::new().unwrap();
let files = |req: &str| -> Option<Vec<u8>> {
(req == "Addon/code.lua").then(|| b"\nerror(\"from lua\")\n".to_vec())
};
let doc = parse("<Ui>\n<Script file=\"code.lua\"/>\n</Ui>");
let report = load_in(&s, &doc, "Addon/host.xml", &files);
let err = report.errors.join("\n");
assert!(
err.contains("Addon\\code.lua:2:"),
"the .lua file and its own line: {err}"
);
}
/// **A captured script, called back the reference's way, still knows its frame.**
///
/// This is *the* 1.12 hook idiom and the whole reason `GetScript` exists: an addon takes the
/// client's handler, installs its own, and calls the original with **no arguments**, because
/// the reference's contract passes the frame as the `this` global and nothing else. Our
/// wrapper is `function(self, ...)` and our own FrameXML is written against `self`, so before
/// the fallback in [`super::Loader::compile_handler`] that re-entry handed every body a nil
/// frame. The director met it as `bad argument #2: error converting Lua nil to table` out of
/// `GameTooltip:SetOwner`, from a handler that works perfectly when the engine calls it.
///
/// Both spellings are asserted, because the fallback must not cost the engine's own call.
#[test]
fn a_script_captured_and_called_with_no_arguments_still_sees_its_frame() {
let s = UiScript::new().unwrap();
let doc = parse(
r#"<Ui>
<Frame name="Hooked" hidden="true">
<Scripts>
<OnShow>SEEN = self:GetName()</OnShow>
</Scripts>
</Frame>
</Ui>"#,
);
let report = load_in(&s, &doc, "Test.xml", &no_files);
assert!(report.errors.is_empty(), "{:?}", report.errors);
// The engine's own call: `self` is passed and `this` is set — the modern spelling.
s.run("SEEN = nil Hooked:Show()").unwrap();
assert_eq!(s.eval::<String>("return SEEN").unwrap(), "Hooked");
// The addon's: capture the script, then call it back bare with only `this` standing in for
// the frame — exactly `Bagnon.lua:87`'s `bMainBag_OnEnter()`.
s.run(
"SEEN = nil \
local original = Hooked:GetScript(\"OnShow\") \
this = Hooked \
original() \
this = nil",
)
.unwrap();
assert_eq!(
s.eval::<String>("return SEEN").unwrap(),
"Hooked",
"the reference's own no-argument contract must reach the same frame"
);
}
}
/// [`super::join_ref`] — the FrameXML path rule (decision 1186).
///
/// A reference is relative to the directory of the file containing it, `\` and `/` are the same
+31 -13
View File
@@ -263,22 +263,40 @@ impl Loader<'_> {
}
}
/// `<ScrollingMessageFrame>` LoadXML extras (msgframe-runtime.md `0x787b20`): `maxLines`
/// (int, `> 0` — destructive `SetMaxLines`), `displayDuration`/`fadeDuration` (float, `> 0` →
/// SetTimeVisible/SetFadeDuration), and the `fade` bool (→ SetFading). A missing attr keeps the
/// ctor default (fading on, 10s/3s, 8 lines). The `<FontString>` child renders through the
/// generic `<Layers>` path; its resolved font is what the frame's lines bake and stack at
/// (read at extract, `crate::script::UiScript::extract`).
/// The two message-frame classes' LoadXML extras — **`0x787b20` and `0x785910`, two separate
/// tables** (msgframe-runtime.md's XML-attribute section), which is why the shared attrs are
/// applied for either tag and the two divergent ones are gated on the tag that has them:
///
/// - both: `displayDuration`/`fadeDuration` (float, applied **iff `> 0`** — the client's own
/// gate) → SetTimeVisible/SetFadeDuration, and the `fade` bool → SetFading.
/// - `<ScrollingMessageFrame>` only: `maxLines` (int, `> 0`, destructive `SetMaxLines`).
/// - `<MessageFrame>` only: `insertMode` → SetInsertMode. The scrolling class has no such
/// attribute and no such binding.
///
/// A missing attr keeps the ctor default (fading on, 10s/3s; 8 lines / insertMode BOTTOM). The
/// `<FontString>` child renders through the generic `<Layers>` path; its resolved font **and its
/// `justifyH`** are what the frame's lines bake, stack and align at (read at extract,
/// `crate::script::UiScript::extract`) — that child is how `UIErrorsFrame.xml` centres its
/// toasts and how a chat frame keeps its lines flush left.
pub(super) fn apply_messageframe(&mut self, el: &Element, wrapper: &Table, dbg: &str) {
if !el.tag.eq_ignore_ascii_case("ScrollingMessageFrame") {
let scrolling = el.tag.eq_ignore_ascii_case("ScrollingMessageFrame");
let plain = el.tag.eq_ignore_ascii_case("MessageFrame");
if !scrolling && !plain {
return;
}
if let Some(n) = el
.attr("maxLines")
.and_then(|v| v.trim().parse::<i64>().ok())
{
if n > 0 {
self.call(wrapper, "SetMaxLines", n, dbg);
if scrolling {
if let Some(n) = el
.attr("maxLines")
.and_then(|v| v.trim().parse::<i64>().ok())
{
if n > 0 {
self.call(wrapper, "SetMaxLines", n, dbg);
}
}
}
if plain {
if let Some(mode) = el.attr("insertMode") {
self.call(wrapper, "SetInsertMode", mode.trim().to_string(), dbg);
}
}
if let Some(s) = el
+5
View File
@@ -398,6 +398,11 @@ pub fn traversal(arena: &WidgetArena) -> Vec<(ZTarget, ZKey)> {
let Some(region) = arena.region(rh) else {
continue;
};
// `Region:SetParent(nil)` orphaned this leaf: unlinked from every draw layer, still
// alive (see [`crate::widget::Region::detached`]). It draws nothing until re-parented.
if region.detached {
continue;
}
let key = ZKey::region(
strata,
level,
+53 -15
View File
@@ -28,11 +28,23 @@
//! ## What we deliberately do not enforce: `INTERFACE_VERSION`
//!
//! 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. **We have no such checkbox** —
//! decision 0465 hides the glue's AddOns button entirely — so enforcing the rule would silently
//! drop most of a real vanilla corpus (whose manifests say 11100, 10900, anything) with no way for
//! a player to override it. The version is reported (`GetAddOnMetadata(name, "Interface")`) and not
//! acted on. Revisit when the AddOn list lands and there is somewhere to put the toggle.
//! 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.
use std::path::PathBuf;
@@ -152,9 +164,32 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
})?,
)?;
// name, title, notes, url, loadable, reason, security, newVersion
// `newVersion` is always nil: it is the reference's addon-version-check service reporting an
// update is available, and there is no such service to ask.
// `name, title, notes, enabled, loadable, reason, security` — SEVEN values, and slot 4 is
// **`enabled`**, not `url`.
//
// **The client registers `GetAddOnInfo` TWICE, as two different functions**, and this is the
// in-game one. wow-re `system/ui/scratch/addon-version-gate.md`: glue `0x46d460` returns EIGHT
// with `url` at slot 4 and `newVersion` at 8; in-game `0x48e390` returns SEVEN with `enabled`
// at slot 4 and no `url` at all. They differ in behaviour too — the in-game one passes `dl=1`,
// so `NOT_DEMAND_LOADED` is reachable here and never from glue, and it short-circuits an
// already-loaded addon to `1, nil` before the gate.
//
// We shipped the GLUE shape in the IN-GAME VM. Slots 5/6/7 aligned by luck; slot 4 did not, and
// it is the one the ecosystem reads:
//
// local name, _, _, enabled, loadable = GetAddOnInfo(major) -- AceLibrary-1.0:400
//
// `## URL` is rare, so `enabled` came back nil for nearly every addon and Ace's
// `if enabled and loadable` refused to load its own dependency. 70 corpus folders reach it
// (AceLibrary/AceAddon replicated — one library, which is what makes it wide rather than
// narrow). Nothing errored: a nil where a flag belongs is a legal Lua value.
//
// `enabled` is the number 1 or nil, never boolean `true` — every push in these functions is
// `lua_pushnumber(1.0)` via `0x6f3810`, with no `lua_pushboolean` anywhere. Same for
// `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.
g.set(
"GetAddOnInfo",
lua.create_function(|lua, key: Value| {
@@ -168,14 +203,13 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
lua_str(lua, &a.name)?,
opt_str(lua, &a.title)?,
opt_str(lua, &a.notes)?,
opt_str(lua, &a.url)?,
flag(a.enabled),
flag(reason.is_none()),
match reason {
Some(r) => lua_str(lua, r)?,
None => Value::Nil,
},
lua_str(lua, if a.secure { "SECURE" } else { "INSECURE" })?,
Value::Nil,
]))
})?,
)?;
@@ -435,7 +469,11 @@ fn load_saved_variables(lua: &Lua, i: usize) {
let Ok(bytes) = std::fs::read(&path) else {
continue; // absent is the first-run case
};
if let Err(e) = run_chunk(lua, &bytes) {
if let Err(e) = run_chunk(
lua,
&bytes,
&crate::script::addon_chunk_name(&name.to_string(), &format!("{name}.lua")),
) {
// The reference fails this silently. We do not: a settings file that stopped parsing
// is exactly the thing a player needs told, and the file is left on disk untouched.
log_error(lua, &format!("{}: {e}", path.display()));
@@ -459,7 +497,7 @@ fn run_files(lua: &Lua, name: &str, root: &std::path::Path, files: &[String]) {
.extension()
.is_some_and(|e| e.eq_ignore_ascii_case("lua"))
{
if let Err(e) = run_chunk(lua, &bytes) {
if let Err(e) = run_chunk(lua, &bytes, &crate::script::addon_chunk_name(name, file)) {
log_error(lua, &format!("{name}/{file}: {e}"));
}
continue;
@@ -471,8 +509,7 @@ fn run_files(lua: &Lua, name: &str, root: &std::path::Path, files: &[String]) {
continue;
}
};
let base = path.rfind('/').map_or("", |i| &path[..i]);
let report = crate::loader::load_into(lua, &doc, base, &provider);
let report = crate::loader::load_into(lua, &doc, &path, &provider);
for e in report.errors {
log_error(lua, &format!("{name}/{file}: {e}"));
}
@@ -496,8 +533,9 @@ fn read_under(root: &std::path::Path, rel: &str) -> Option<Vec<u8>> {
/// Run a chunk that came off disk, from `&Lua` — [`crate::script::UiScript::run_chunk`]'s body for
/// the demand-load path, which never holds a `UiScript` (1191 §4). Bytes and a BOM strip, for the
/// reasons in [`crate::source`].
fn run_chunk(lua: &Lua, bytes: &[u8]) -> mlua::Result<()> {
fn run_chunk(lua: &Lua, bytes: &[u8], name: &str) -> mlua::Result<()> {
lua.load(crate::source::chunk(bytes))
.set_name(name)
.set_mode(mlua::ChunkMode::Text)
.exec()
}
+186 -4
View File
@@ -8,19 +8,24 @@
//! is interaction state ([`ButtonState::region_visible`], applied at extract) — faithful to the
//! documented widget model (texture array + current pointer `+0x4c4`), not byte-pinned. Two stated
//! v1 gaps: the highlight draws with normal blending (the client ADD-blends it; the quad pass has
//! no blend modes yet), and `PushedTextOffset`/per-state fonts are not modeled.
//! no blend modes yet), and `PushedTextOffset` is not modeled. Per-state label fonts *are*: the
//! `*FontObject` trio picks which object each state inherits and `extract` re-resolves it every
//! frame, while `SetFont` writes the button's own face/size/flags over all of them
//! ([`crate::widget::ButtonFont`]).
//!
//! Method resolution: CheckButton's table is consulted first, then Button's, then the shared frame
//! table — mirroring the client's class chain, and keeping duck-typing honest (`frame.SetChecked`
//! is nil on a plain Button; `frame.GetText` is nil on a plain Frame).
use mlua::{Lua, MultiValue, Table, Value};
use mlua::{Lua, MultiValue, ObjectLike, Table, Value};
use super::object::{as_f32, frame_handle_of};
use super::region::region_wrapper;
use super::{event, Model, RegionData};
use crate::order::DrawLayer;
use crate::widget::{ButtonState, FrameHandle, FrameKind, KindState, RegionKind};
use crate::widget::{
ButtonFont, ButtonState, FrameHandle, FrameKind, KindState, RegionHandle, RegionKind,
};
pub(super) const REG_BUTTON_METHODS: &str = "__benilla_button_methods";
pub(super) const REG_CHECKBUTTON_METHODS: &str = "__benilla_checkbutton_methods";
@@ -76,6 +81,48 @@ impl Slot {
}
}
/// Point the ButtonText at the button's NORMAL font object, so the label's *query* surface answers
/// what its paint already shows.
///
/// Before this, the two disagreed. `extract` resolves the per-state font object every frame and
/// overlays it onto a CLONE of the region's data (`extract.rs` l.114-122), so the label painted
/// correctly while `region_data.font_object` stayed `None` — and a dropdown row's
/// `GetFontObject()` answered **nil**, `GetFont()` answered **nil, nil**. Nothing errored: a
/// FontString with no font of its own is a legal state, so this was invisible in exactly the way
/// 1205's silent-drop class predicts.
///
/// The measured consequence is smaller than it first looks, and that is stated rather than implied.
/// The corpus's 65 `GetFontObject` sites are on FontStrings the addon created and linked itself
/// (Dewdrop-2.0 calls `text:SetFontObject(GameFontHighlightSmall)` one line before it reads the
/// object back), so none of them were reaching this. What WAS reaching it is the reference's own
/// `DropDownList1` OnLoad, which derives `UIDROPDOWNMENU_DEFAULT_TEXT_HEIGHT` from
/// `DropDownList1Button1NormalText:GetFont()` and got nil.
///
/// **Normal only, deliberately.** The highlight and disabled objects are transient states that
/// extract overlays at paint time; linking either here would make a resting button report its hover
/// font. The remaining divergence, stated: while a button is disabled or hovered, the reference's
/// label reports that state's font and ours still reports the normal one. The paint is unaffected
/// either way, and no corpus site reads a label's font mid-state.
///
/// `font::repaint` honours the severance mask, so a `<FontHeight>` or `SetTextColor` the label set
/// for itself survives the link (the rule wow-re pinned in `font-object-lua-surface.md`).
fn link_label_to_font_object(lua: &Lua, text: Option<RegionHandle>, name: Option<&str>) {
let Some(rh) = text else { return };
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
let Some(name) = name else {
model.region_data.entry(rh).or_default().font_object = None;
return;
};
// An unregistered name is not an error here: `SetTextFontObject` already accepted it, and the
// loader's own log-and-continue rule (0068) owns the reporting.
let Some(fo) = model.font_objects.get(name).cloned() else {
return;
};
let d = model.region_data.entry(rh).or_default();
d.font_object = Some(name.to_string());
super::font::repaint(d, &fo);
}
/// Run `f` over a frame's Button state under one short write borrow.
fn with_button<T>(
lua: &Lua,
@@ -239,6 +286,45 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
lua.create_function(|lua, this: Table| get_slot_texture(lua, &this, Slot::Text))?,
)?;
// GetTextWidth / GetTextHeight — the Button's OWN text-extent readers (`0x782290` / `0x782390`,
// wow-re `system/ui/scratch/item9-firing34-merge.md` l.36 and the Button method carve in
// `widget-api-batch-benilla.md` Q8, which lists both present on Button and `GetStringWidth`
// absent). Both are thin forwards onto the label FontString's own extent vtable slots
// (`0x1c` / `0x20`), which is exactly what this delegation is.
//
// **Who asks.** `Bagnon_Forever/database/ui.lua:61` sizes its character-switch dropdown from
// `button:GetTextWidth() + 40` over every saved character — with the method nil the whole
// dropdown died on the first row, so the director could not switch characters in Bagnon at all.
// The idiom generalises: it is how a 1.12 kit fits a button to its label, and the reference's
// own `MoneyFrame.lua` l.202 (`SetWidth(GetTextWidth() + iconWidth)`) is the same shape.
//
// **Which measurement**, since a FontString carries two. The label's `GetStringWidth` — the
// NATURAL, unwrapped extent — never the laid-out `GetWidth`. A button label does not wrap, so
// the two agree in the ordinary case; where they differ, serving the laid-out width would hand
// every "size the button to its text" caller its own previous output as its next input, which
// is decision 0997's measured feedback loop (the macro window's tab that changed width every
// frame). The unwrapped extent is the one that settles.
//
// `0` before the host has measured the string (a frame's latency, as everywhere else), and `0`
// for a Button with no label at all — the reference dereferences its FontString pointer `+0x338`
// here where `SetFont`/`GetFont` deliberately do not, and what a null one does is NOT byte-read,
// so this answers the harmless number rather than raising on a guess.
for (name, region_getter) in [
("GetTextWidth", "GetStringWidth"),
("GetTextHeight", "GetStringHeight"),
] {
m.set(
name,
lua.create_function(move |lua, this: Table| {
let label = get_slot_texture(lua, &this, Slot::Text)?;
match label {
Value::Table(t) => t.call_method::<f32>(region_getter, ()),
_ => Ok(0.0),
}
})?,
)?;
}
// The per-state label fonts (the 1.12 API trio; XML `<NormalFont>/<HighlightFont>/
// <DisabledFont>` route here through the loader). Stored as font-object NAMES — extract
// re-points the ButtonText to the current state's object each frame, so Enable/Disable and
@@ -254,7 +340,12 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
"SetTextFontObject",
lua.create_function(|lua, (this, font): (Table, Value)| {
let name = super::font::resolve("SetTextFontObject", &font)?;
with_button(lua, &this, |bs| bs.normal_font = name)
let text = with_button(lua, &this, |bs| {
bs.normal_font.clone_from(&name);
bs.text
})?;
link_label_to_font_object(lua, text, name.as_deref());
Ok(())
})?,
)?;
m.set(
@@ -272,6 +363,97 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
})?,
)?;
// SetFont(file, height [, flags]) / GetFont() — the Button's own font, `0x780880`/`0x79f3b0`
// (wow-re `system/ui/scratch/widget-api-batch-benilla.md` Q8, §5-verified).
// `_LazyPig/LazyPigMenu.lua:214` calls it straight on a `CreateFrame("Button", …)`, and it is
// that addon's only blocker.
//
// Three contract details, each a plausible implementation's silent divergence:
//
// · **It returns ZERO values.** The shared impl `0x79f210` pushes `1`/nil (a real font-load
// probe on a Font object — `!OmniCC` uses it), but `Button:SetFont` DISCARDS it
// (`xor eax,eax`), so a failed font load is undetectable from a Button. Returning `true`
// here would look harmless and would hand an addon a probe the client does not have.
// · **It never touches the label.** `SetFont`/`GetFont` do not dereference the FontString
// pointer `+0x338` at all, so styling a bare `CreateFrame("Button")` with no `<ButtonText>`
// is no error, no crash and **no lazy creation** — which is why the style is stored on the
// button ([`ButtonState::font`]) and `extract` applies it to whatever label exists. A later
// `SetText` (which *does* lazily create one, `0x778dc0`) then picks it up, so either call
// order works, exactly as the reference's does.
// · **It retunes all three embedded fonts** (normal `+0x33c`, disabled `+0x434`, highlight
// `+0x3b8`) and `GetFont` reads back only the normal one — see [`ButtonFont`] for why one
// record is the faithful shape for three identical writes.
//
// The argument gate is the shared impl's: arg2 `lua_isstring` and arg3 `lua_isnumber`, else
// `luaL_error("Usage: %s:SetFont(\"font\", fontHeight [, flags])")` (`0x87c69c`). Both
// predicates are the *coercing* ones in 5.0 — `lua_isstring` takes a number, `lua_isnumber`
// takes a numeric string — so the leniency is transcribed rather than tightened.
m.set(
"SetFont",
lua.create_function(
|lua, (this, file, height, flags): (Table, Value, Value, Option<String>)| {
let usage = || {
mlua::Error::runtime(
"Usage: <Button>:SetFont(\"font\", fontHeight [, flags])".to_string(),
)
};
let path = match &file {
Value::String(s) => s.to_str()?.to_string(),
Value::Number(_) | Value::Integer(_) => as_f32(&file).to_string(),
_ => return Err(usage()),
};
let height = match &height {
Value::Number(_) | Value::Integer(_) => as_f32(&height),
Value::String(s) => s.to_str()?.parse::<f32>().map_err(|_| usage())?,
_ => return Err(usage()),
};
let flags = super::Outline::flags(flags.as_deref().unwrap_or(""))
.as_str()
.to_string();
with_button(lua, &this, |bs| {
bs.font = Some(ButtonFont {
path,
height,
flags,
})
})
// …and nothing is returned: `with_button` yields `()`.
},
)?,
)?;
// GetFont() → file, height, flags — **3 values**, read off the NORMAL embedded font. Unset
// locally, that font still resolves through what the normal state inherits
// (`<NormalFont>`/`SetTextFontObject`), which is how a `GameMenuButtonTemplate` button answers
// GameFontNormal's face before anything calls `SetFont`. Nil path / nil height when neither
// exists, still 3 values.
m.set(
"GetFont",
lua.create_function(|lua, this: Table| {
let (own, inherits) =
with_button(lua, &this, |bs| (bs.font.clone(), bs.normal_font.clone()))?;
let (path, height, flags) = match own {
Some(f) => (Some(f.path), Some(f.height), f.flags),
None => {
let model = lua.app_data_ref::<Model>().expect("model app_data");
let fo = inherits.and_then(|n| model.font_objects.get(&n));
(
fo.and_then(|f| f.font.clone()),
fo.and_then(|f| f.height),
fo.map(|f| f.outline)
.unwrap_or_default()
.as_str()
.to_string(),
)
}
};
let path = match path {
Some(p) => Value::String(lua.create_string(&p)?),
None => Value::Nil,
};
Ok((path, height, flags))
})?,
)?;
// The per-state label COLORS (Button:SetTextColor + the Highlight/Disabled pair): the state's
// color, when set, repaints the ButtonText over the state font object's paint at extract —
// the dropdown kit's rows use all three (info.textR/G/B rows, isTitle's NORMAL-yellow and
+119
View File
@@ -0,0 +1,119 @@
//! `GetChannelName` — the joined-channel lookup, in both directions.
//!
//! **The state this reads already existed and was built for this verb.** `ui_chat::edit`'s
//! `ChannelState` has held `joined: Vec<String>` in join order since the chat arc, and its own doc
//! comment names `GetChannelName(n)` as the law it implements. Only the registration was missing —
//! the same silent-gap shape the loader arc kept finding, one layer up: the capability was built,
//! nothing exposed it, and nothing complained.
//!
//! Corpus demand, counted by reading every line rather than the grep total (1207): **17 sites
//! across 6 addons**, every one an unguarded call, no library replicated —
//! `ChatLog` 7, `Recap` 4, `SmartRes` 2, `Enchantrix` 2, `FuBar_AssistFu` 1, `_LazyPig` 1. Both
//! lookup directions are live in the corpus: by index (`GetChannelName(i)`) and by name
//! (`GetChannelName("world")`, `GetChannelName("Trade - City")`).
//!
//! Signature verified against wow-5875-re `system/ui/scratch/zone-chat-channel-autojoin.md`
//! l.374-380 — `GetChannelName = 0x4a05e0`, **three** returns:
//!
//! | # | the client's | ours |
//! |---|---|---|
//! | 1 | `slot[+0x00]`, the client-local **1-based joined-slot index** (= `CHAT_MSG_*` arg8) | the position in `joined` |
//! | 2 | `slot[+0x04]`, the channel name | the `joined` entry |
//! | 3 | `slot[+0x98]`, FrameXML's `instanceID` (= arg10) | **0** — see below |
//!
//! **Return 3 is 0, not nil, and that is a recorded position rather than a shrug.** It is the split
//! index from `YOU_JOINED`'s second u32, which `ui_chat::event`'s doc already records as "0 on every
//! vanilla emulator" and deliberately does not store. A client that one day meets a server which
//! splits channels would need the field; nothing in the corpus reads it.
use mlua::{Lua, MultiValue, Value};
use super::Model;
/// The 1-based slot of `name` in join order, case-insensitively — `GetChannelName`'s first return.
fn slot_of(model: &Model, name: &str) -> Option<usize> {
model
.joined_channels
.iter()
.position(|c| c.eq_ignore_ascii_case(name))
.map(|i| i + 1)
}
pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
let g = lua.globals();
// GetChannelName(indexOrName) → slot, name, instanceID.
//
// THE TRAP, and the whole risk in this verb: the first return is **always a NUMBER, never
// nil** — `0` when the channel is not joined. Verified from both sides. The reference's own
// callers compare it numerically and would raise on a nil: `ChatFrame.lua:2114`
// `if ( channelNum > 0 )` and `l.2232` `if ( channelNum <= 0 ) then return end`; so does the
// corpus, at `_LazyPig/LazyPig.lua:1996` `if id > 0 then`. Returning nil here would convert
// three working call sites into "attempt to compare nil with number".
//
// NOT a shared helper with `JoinChannelByName`, whose first return is a DIFFERENT number — the
// `ChatChannels.dbc` ChannelID, not this local slot index. wow-re calls that pair out
// explicitly (`zone-chat-channel-autojoin.md` l.379) and it is an easy, silent mistake.
g.set(
"GetChannelName",
lua.create_function(|lua, key: Value| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
let slot = match &key {
// The numeric form. The reference bounds-checks `1 <= n <= count` (`0x49bf30`) and
// answers only for a CONFIRMED-joined slot; `joined` holds exactly the confirmed
// ones (`ui_chat::feed` appends on the server's YOU_JOINED, not on the request), so
// the bound is the whole check here.
Value::Integer(_) | Value::Number(_) => {
let n = match &key {
Value::Integer(i) => *i,
Value::Number(n) => *n as i64,
_ => unreachable!(),
};
usize::try_from(n)
.ok()
.filter(|n| *n >= 1 && *n <= model.joined_channels.len())
}
// The name form. A numeric STRING arrives here and must still resolve as a number:
// `ChatFrame.lua:2113` passes the result of a `gsub` — `GetChannelName("1")` — and
// Lua's own coercion is what makes that work on the real client.
Value::String(s) => {
let name = s.to_str()?;
match name.trim().parse::<usize>() {
Ok(n) if n >= 1 && n <= model.joined_channels.len() => Some(n),
Ok(_) => None,
Err(_) => slot_of(&model, &name),
}
}
_ => None,
};
let Some(slot) = slot else {
// Not joined: the number 0, and nothing else. `channelName` is unread by every
// caller on this branch (all four sites bail on the number first).
return Ok(MultiValue::from_vec(vec![Value::Integer(0)]));
};
let name = model.joined_channels[slot - 1].clone();
Ok(MultiValue::from_vec(vec![
Value::Integer(slot as i64),
Value::String(lua.create_string(&name)?),
// instanceID — see the module doc: 0 on every vanilla emulator, and a number
// rather than nil so a caller can compare it like the client's.
Value::Integer(0),
]))
})?,
)?;
Ok(())
}
impl super::UiScript {
/// Mirror the app's confirmed-joined channel list, in join order, for [`install`]'s verb.
///
/// The `model.party` shape (`party.rs:172`), deliberately, and NOT the `open_chat_requests`
/// shape the chat-window work used: that one is a QUEUE the app drains (Lua → app), and this is
/// app state READ BY Lua, which is the opposite direction. `ui_chat::feed` owns both edges that
/// change it — the server's YOU_JOINED and YOU_LEFT notices — so it pushes here from one place.
pub fn set_joined_channels(&mut self, joined: Vec<String>) {
self.model_mut().joined_channels = joined;
}
}
+295
View File
@@ -0,0 +1,295 @@
//! The chat **window** surface: `GetChatWindowInfo` (the engine's per-window settings getter) and
//! `ChatFrame_OpenChat` (the FrameXML verb that opens the edit box prefilled).
//!
//! The windows themselves are frames, declared in the app's shipped `ChatFrame.xml` —
//! `ChatFrame1`..`ChatFrame7`, of which 1 and 2 are docked and 3..7 ship hidden and undocked, the
//! reference's own arrangement. This module is the *settings* half: what an addon learns when it
//! asks about window `i` without touching the frame.
//!
//! ## `GetChatWindowInfo(id)` → `name, fontSize, r, g, b, a, shown, locked, docked`
//!
//! A registered Lua binding in the real client (`0x4a0ba0`, wow-re `system/ui/ledger.tsv`), reading
//! the per-window struct array at `0xb4fe50` — stride 0x98, **10 windows**, colour bytes at
//! +0xd8..+0xdb renormalised by the f32 1/255 (`system/ui/scratch/chat-color-table.md`). Those
//! structs are loaded from the character's `chat-cache.txt`, and a real stock one is on disk
//! (`wow-5875-re/WoW/WTF/Account/ONE/VMaNGOS/Onepaladin/chat-cache.txt`) — every value below is
//! quoted from it:
//!
//! ```text
//! WINDOW 1 SIZE 0 COLOR 0 0 0 0 LOCKED 1 DOCKED 1 SHOWN 1
//! WINDOW 2 SIZE 0 COLOR 0 0 0 0 LOCKED 1 DOCKED 2 SHOWN 0
//! WINDOW 3…10 SIZE 0 COLOR 0 0 0 0 LOCKED 1 DOCKED 0 SHOWN 0
//! ```
//!
//! **Three traps live in that tuple, and all three are load-bearing:**
//!
//! 1. **`name` is the empty string, not "General".** Nothing writes a window name until the user
//! renames a tab, so a stock client answers `""` for every window including the first two. The
//! familiar labels are a *FrameXML fallback*, not data: `FCF_SetWindowName` (FloatingChatFrame.lua
//! l.680-684) reads `if ( not name or name == "" )` and substitutes `GENERAL` / `COMBAT_LOG` /
//! `format(CHAT_NAME_TEMPLATE, id)`. Our own tab labels are that fallback, hardcoded. An addon
//! proves the contract from the other side: `Enchantrix/EnxConfig.lua:113` does
//! `local name = GetChatWindowInfo(i); if ( name == "" ) then if (i == 1) then name =
//! _ENCH('TextGeneral') …` — it would not have been written if the getter answered "General".
//! Answering "General" here would look friendlier and would be a divergence.
//! 2. **`shown`/`locked`/`docked` are numbers or `nil` — never `0`.** FrameXML branches on them
//! bare (`if ( shown ) then chatFrame:Show()`, `if ( docked ) then FCF_DockFrame(…)` —
//! FloatingChatFrame.lua l.59/69), and `0` is TRUE in Lua. A getter that returned the cache's
//! literal `0` would show every hidden window.
//! 3. **`docked` is a dock POSITION, not a flag.** Window 1 answers `1`, window 2 answers `2` —
//! the order tabs sit in the dock, which is why `FCF_DockFrame(frame, docked)` takes it as an
//! index. Reading it as a boolean happens to work; reading it as "is docked" and writing back
//! `1` would silently reorder the dock.
//!
//! `fontSize` is the cache's `SIZE`, and `0` is what a stock client stores — "use the font's own
//! height". No FrameXML path applies it (`FloatingChatFrame_Update` destructures it and never
//! reads it; the options dropdown checks `FCF_GetCurrentChatFrame():GetFont()` instead), and no
//! corpus addon reads it.
//!
//! Measured demand: **3 of the 5 corpus addons that iterate `NUM_CHAT_WINDOWS` call this on the
//! very next line** — `EnhTooltip/Tooltip.lua:1302`, `MikScrollingBattleText.lua:1951` and
//! `Enchantrix/EnxConfig.lua:110`. The first two are the same idiom (look for a window the user
//! named "debug"/"ettdebug"); the third builds a name→index map for its `/enx print-in` config.
//! Without this getter, declaring `NUM_CHAT_WINDOWS` hands those three a loop that raises on its
//! first iteration, so the constant and the getter are one change, not two.
//!
//! ## `ChatFrame_OpenChat(text, chatFrame)`
//!
//! FrameXML in the reference (ChatFrame.lua l.1545), and the same seam as its neighbour
//! `ChatFrame_SendTell` ([`super::party`]): benilla's chat edit machine is app-side
//! (`benilla_app::ui_chat::edit`), so the verb queues the request and the app opens the box.
use mlua::{Lua, MultiValue, Value};
use super::Model;
/// How many chat windows the client's own settings array holds — **10**, both in the engine
/// (`0xb4fe50`, stride 0x98) and in `chat-cache.txt`, which writes `WINDOW 1` … `WINDOW 10`.
/// FrameXML declares `ChatFrame1TabDockRegion`..`ChatFrame10TabDockRegion` to match, and then
/// builds only seven `ChatFrame`s: `NUM_CHAT_WINDOWS = 7` is the *UI's* count, not the engine's.
/// Recorded so the 7 below reads as a deliberate choice rather than a miscount.
const ENGINE_CHAT_WINDOW_SLOTS: usize = 10;
/// The windows benilla actually builds — `ChatFrame1`..`ChatFrame7` in the app's `ChatFrame.xml`,
/// which is also `NUM_CHAT_WINDOWS`. `benilla_app`'s `chat_tests` cross-checks every entry against
/// the shipped XML so the two cannot drift.
const NUM_CHAT_WINDOWS: usize = 7;
/// `(shown, docked)` per window, 1-based, as the tuple's *Lua* values: `None` where the reference
/// answers `nil`. Everything else in the tuple is identical across all seven windows on a stock
/// client — name `""`, size `0`, colour `0,0,0`, alpha `0` — so only the two that differ are
/// tabulated. `locked` is `1` for every window (the cache's `LOCKED 1`), and benilla has no unlock
/// path at all, so it is a constant below rather than a column here.
const WINDOW_STATE: [(Option<i64>, Option<i64>); NUM_CHAT_WINDOWS] = [
(Some(1), Some(1)), // 1 "General" — shown, dock position 1
(None, Some(2)), // 2 "Combat Log" — docked at position 2, not shown (the dock shows one)
(None, None), // 3 — hidden, undocked
(None, None), // 4
(None, None), // 5
(None, None), // 6
(None, None), // 7
];
impl super::UiScript {
/// Drain the `ChatFrame_OpenChat` requests queued since the last call — each is the text the
/// caller wants the chat edit box to open prefilled with. The app opens and focuses the box,
/// applies its own sticky-type law, and lets the live parse take the text from there.
pub fn take_open_chat_requests(&mut self) -> Vec<String> {
std::mem::take(&mut self.model_mut().open_chat_requests)
}
}
pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
// GetChatWindowInfo(id) → name, fontSize, r, g, b, a, shown, locked, docked.
//
// Out of range RAISES rather than answering. The reference's array has 10 slots and benilla
// builds 7 windows; a question about window 8 is a question about a window that does not exist
// here, and the two honest answers are "raise" or "invent a row". Every corpus caller loops
// `1, NUM_CHAT_WINDOWS`, so the raise is unreachable from real code and exists to keep the
// next caller from reading a fabricated window as a real one.
lua.globals().set(
"GetChatWindowInfo",
lua.create_function(|lua, id: i64| {
if id < 1 || id as usize > NUM_CHAT_WINDOWS {
return Err(mlua::Error::runtime(format!(
"GetChatWindowInfo: window {id} out of range — benilla builds \
ChatFrame1..ChatFrame{NUM_CHAT_WINDOWS} (the client's settings array holds \
{ENGINE_CHAT_WINDOW_SLOTS} slots, but only {NUM_CHAT_WINDOWS} have frames)"
)));
}
let (shown, docked) = WINDOW_STATE[id as usize - 1];
let num = |v: Option<i64>| match v {
Some(n) => Value::Integer(n),
None => Value::Nil,
};
Ok(MultiValue::from_vec(vec![
// name: "" — never a label. See the module docs' trap 1.
Value::String(lua.create_string("")?),
Value::Integer(0), // fontSize — the cache's SIZE 0
Value::Number(0.0), // r ┐ COLOR 0 0 0 0, renormalised by the engine's 1/255,
Value::Number(0.0), // g │ and the value ChatFrame.xml's load-time
Value::Number(0.0), // b ┘ SetVertexColor(0, 0, 0) already applies
Value::Number(0.0), // a — window alpha 0: the box only exists on hover
num(shown), // 1 or nil, never 0
Value::Integer(1), // locked — LOCKED 1, and benilla has no unlock path
num(docked), // the dock POSITION (1, 2) or nil
]))
})?,
)?;
// ChatFrame_OpenChat(text, chatFrame) — open the chat edit box prefilled with `text`.
//
// **The second argument is inert, and that is the reference's doing, not a shortcut.** The ref
// reaches the box through `chatFrame.editBox`, and in 1.12 every chat frame's `.editBox` is
// the *same* global `ChatFrameEditBox`: `FloatingChatFrame_OnLoad` sets
// `this.editBox = ChatFrameEditBox` for each frame (FloatingChatFrame.lua l.30), and
// ChatFrame1 — which loads before the edit box exists and so skips that `if` — is patched by
// the box's own OnLoad, `DEFAULT_CHAT_FRAME.editBox = this` (FloatingChatFrame.xml l.742).
// Per-frame edit boxes are a 2.x feature. So `ChatFrame_OpenChat(text, ChatFrame5)` opens the
// one and only edit box on a real 1.12 client too, and accepting-and-ignoring the frame here
// is exact rather than approximate. `benilla_app`'s `chat_tests` states that as a claim.
//
// Measured demand: 3 distinct addons, and all three are the same shape — check the box is not
// already up, then open it prefilled, else fall back to SetText/Insert:
// FuBar_HeyFu/Core.lua:281,292 ChatFrame_OpenChat(reply, DEFAULT_CHAT_FRAME)
// FuBar_FriendsFu/FriendsFu.lua:434 ChatFrame_OpenChat(format("/w %s ", name)) -- 1 arg
// TipBuddy/TipBuddy.xml:2715,2717 ChatFrame_OpenChat("", chatFrame) / ("/", chatFrame)
// Two of the three prefill `/w <name> `, which the app's live parse then turns into whisper
// mode with the target extracted — the same path a human typing those characters takes.
//
// The ref's tail — the PARTY/RAID/BATTLEGROUND sticky downgrade (l.1554-1565) — is not skipped,
// it is the app's: `ui_chat::edit`'s open path already applies exactly that law (a sticky PARTY
// with nobody in the party opens as SAY), so running it here would be running it twice.
lua.globals().set(
"ChatFrame_OpenChat",
lua.create_function(|lua, (text, _chat_frame): (Option<String>, Value)| {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
model.open_chat_requests.push(text.unwrap_or_default());
Ok(())
})?,
)?;
Ok(())
}
#[cfg(test)]
mod tests {
use crate::script::UiScript;
/// The tuple is nine values wide and in the reference's own order.
#[test]
fn get_chat_window_info_answers_the_nine_value_tuple() {
let s = UiScript::new().unwrap();
assert_eq!(
s.eval::<i64>("return select('#', GetChatWindowInfo(1))")
.unwrap(),
9
);
}
/// Trap 1: a stock client has never been told a window's name, so the getter answers `""` and
/// FrameXML supplies "General"/"Combat Log". Enchantrix's `if ( name == "" )` fallback is only
/// correct because of this.
#[test]
fn every_window_name_is_the_empty_string_not_a_label() {
let s = UiScript::new().unwrap();
for id in 1..=7 {
let name: String = s
.eval(&format!("return (GetChatWindowInfo({id}))"))
.unwrap();
assert_eq!(name, "", "window {id} name");
}
}
/// Trap 2: `shown`/`docked` are `nil` where the cache stores 0, because FrameXML branches on
/// them bare and `0` is true in Lua. Window 1 is shown at dock position 1; window 2 is docked
/// at position 2 but not shown; 3..7 are neither.
#[test]
fn hidden_and_undocked_windows_answer_nil_never_zero() {
let s = UiScript::new().unwrap();
let probe = |id: i32| -> (String, String) {
let shown = s
.eval::<String>(&format!(
"local _,_,_,_,_,_,shown = GetChatWindowInfo({id}) return type(shown)"
))
.unwrap();
let docked = s
.eval::<String>(&format!(
"local _,_,_,_,_,_,_,_,docked = GetChatWindowInfo({id}) return type(docked)"
))
.unwrap();
(shown, docked)
};
assert_eq!(probe(1), ("number".into(), "number".into()));
assert_eq!(probe(2), ("nil".into(), "number".into()));
for id in 3..=7 {
assert_eq!(probe(id), ("nil".into(), "nil".into()), "window {id}");
}
// And the truthiness FrameXML actually branches on.
assert!(s
.eval::<bool>(
"for i = 3, 7 do local _,_,_,_,_,_,shown = GetChatWindowInfo(i) \
if shown then return false end end return true"
)
.unwrap());
}
/// Trap 3: `docked` is the dock POSITION — window 2 answers 2, not 1.
#[test]
fn docked_is_a_dock_position_not_a_flag() {
let s = UiScript::new().unwrap();
assert_eq!(
s.eval::<i64>("local _,_,_,_,_,_,_,_,d = GetChatWindowInfo(1) return d")
.unwrap(),
1
);
assert_eq!(
s.eval::<i64>("local _,_,_,_,_,_,_,_,d = GetChatWindowInfo(2) return d")
.unwrap(),
2
);
}
/// The corpus's own debug-window walk (MikScrollingBattleText, EnhTooltip) runs to completion
/// and finds nothing — `string.lower(name)` needs a string, and no window is named "debug".
#[test]
fn the_corpus_debug_window_walk_completes_and_finds_none() {
let s = UiScript::new().unwrap();
let found: i64 = s
.eval(
"local debugWin = 0\n\
for i = 1, 7 do\n\
local name, _, _, _, _, _, shown = GetChatWindowInfo(i)\n\
if string.lower(name) == 'debug' then debugWin = i break end\n\
end\n\
return debugWin",
)
.unwrap();
assert_eq!(found, 0);
}
/// A window benilla has no frame for raises rather than answering with an invented row.
#[test]
fn a_window_past_the_last_frame_raises() {
let s = UiScript::new().unwrap();
assert!(s.eval::<i64>("return (GetChatWindowInfo(8))").is_err());
assert!(s.eval::<i64>("return (GetChatWindowInfo(0))").is_err());
}
/// `ChatFrame_OpenChat` queues the text for the app; the optional frame argument is accepted
/// and dropped (in 1.12 every chat frame shares one edit box — see the install comment).
#[test]
fn chat_frame_open_chat_queues_its_text_and_ignores_the_frame() {
let mut s = UiScript::new().unwrap();
s.run("ChatFrame_OpenChat('/w Bob ')").unwrap();
s.run("ChatFrame_OpenChat('', 'not even a frame')").unwrap();
assert_eq!(
s.take_open_chat_requests(),
vec!["/w Bob ".to_string(), String::new()]
);
assert!(
s.take_open_chat_requests().is_empty(),
"the drain is a take"
);
}
}
+18 -3
View File
@@ -110,9 +110,24 @@ impl super::UiScript {
/// Set at world entry from the realm the session actually connected to. Idempotent, and the
/// empty string is a legitimate value (no realm yet), not a "clear".
pub fn set_realm_name(&mut self, realm: &str) {
let mut model = self.model_mut();
if model.realm_name != realm {
model.realm_name = realm.to_string();
{
let mut model = self.model_mut();
if model.realm_name != realm {
model.realm_name = realm.to_string();
}
// The `realmName` CVar is the SAME fact, and it is set here so it cannot drift from
// `GetRealmName()`. It is a real 1.12 CVar (`0x83f2d0`, persisted — the client builds
// its SavedVariables path from it, wow-re `savedvariables-protocol.md`), and it had no
// value at all here: `Ace/AceState.lua:27` is
// `ace.trim(GetCVar("realmName"))` inside `SetGameState`, which EVERY Ace addon runs at
// PLAYER_ENTERING_WORLD, so the nil became `gsub(nil, ...)` and took the family down.
//
// Written straight into the slot rather than through `set_cvar_host` because that
// borrows the model again, and warns on an unknown name — this is the host declaring
// the value, not looking it up.
if let Some(slot) = model.cvars.get_mut("realmname") {
slot.value = realm.to_string();
}
}
}
+33
View File
@@ -349,4 +349,37 @@ mod tests {
s.register_cvars([("MusicVolume", "0.4")]);
assert_eq!(s.cvar("MusicVolume").as_deref(), Some("0.9"));
}
/// **`GetCVar("realmName")` answers the session's realm, set by the one seam that owns it.**
///
/// It is a real 1.12 CVar (`0x83f2d0`, persisted — the client builds its SavedVariables path
/// from it) and it had no value here at all. `Ace/AceState.lua:27` is
/// `ace.trim(GetCVar("realmName"))` inside `SetGameState`, which EVERY Ace addon runs at
/// PLAYER_ENTERING_WORLD, so nil became `gsub(nil, ...)` and took the whole family down.
///
/// Asserted through `set_realm_name` rather than by writing the CVar directly, because that is
/// the point: the CVar and `GetRealmName()` are the SAME fact and must not be settable apart.
#[test]
fn the_realm_name_cvar_and_get_realm_name_are_one_fact() {
let mut s = UiScript::new().unwrap();
s.register_cvars([("realmName", "")]);
// Before a session: empty, never nil — `ace.trim` must have a string to gsub.
assert_eq!(
s.eval::<String>(r#"return GetCVar("realmName")"#).unwrap(),
""
);
s.set_realm_name("Archimonde");
assert_eq!(
s.eval::<String>(r#"return GetCVar("realmName")"#).unwrap(),
"Archimonde",
"the realm seam must write the CVar too, or the two facts drift"
);
// Ace's own line, run for real.
let trimmed: String = s
.eval(r#"return string.gsub(GetCVar("realmName"), "^%s*", "")"#)
.unwrap();
assert_eq!(trimmed, "Archimonde");
}
}
+86 -21
View File
@@ -144,27 +144,6 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
"GetBlinkSpeed",
lua.create_function(|lua, this: Table| with_editbox(lua, &this, |eb| eb.blink_period))?,
)?;
// SetTextColor(r, g, b [, a]) — the FontInstance half an EditBox inherits in the client
// (`ChatEdit_UpdateHeader` colors the typed text this way): tint the box's text region,
// creating it lazily like every text-touching path.
m.set(
"SetTextColor",
lua.create_function(
|lua, (this, r, g, b, a): (Table, f32, f32, f32, Option<f32>)| {
let h = frame_handle_of(lua, &this)?;
let Some(rh) = super::ensure_text_region(lua, h) else {
return Err(mlua::Error::runtime("not an EditBox"));
};
lua.app_data_mut::<Model>()
.expect("model app_data")
.region_data
.entry(rh)
.or_default()
.vertex_color = Some([r, g, b, a.unwrap_or(1.0)]);
Ok(())
},
)?,
)?;
m.set(
"SetTextInsets",
lua.create_function(
@@ -229,10 +208,96 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
)?;
}
install_font_block(lua, &m)?;
lua.set_named_registry_value(REG_EDITBOX_METHODS, m)?;
Ok(())
}
/// **The font block — entries #0–#15 of the EditBox method table**, and the largest single gap the
/// per-kind widget-method census found in the 218-addon corpus (decision 1229).
///
/// `EditBox` is one of the six text-bearing types, so it re-declares the whole font block in its own
/// flat table (`.data 0x87bb68`, 48 entries, count read from `mov edx,0x30` at `0x799ab5`; there is
/// no `FontInstance` class in the 1.12 Lua chain). We had written every one of these verbs already —
/// on `FontString` and on the `<Font>` object — and never wired them to the kind that wanted them.
/// The census row is `63 EditBox:SetFontObject (on Texture, FontString)`, and its `(on …)` tail is
/// exactly that: *a verb we have, on the wrong kinds*.
///
/// **The 63 is one library, not 63 independent addons.** Every one of those call sites is the same
/// three lines of an embedded `Dewdrop-2.0.lua` —
/// ```lua
/// local editBox = CreateFrame("EditBox", nil, editBoxFrame)
/// editBoxFrame.editBox = editBox
/// editBox:SetFontObject(ChatFontNormal)
/// ```
/// — vendored into 63 addon folders (65 copies of the file: `FuBar` and its ~50 plugins, `BigWigs`,
/// `AtlasLoot`, `oRA2`, …). One library replicated, so 63 chances to hit the *same* next wall
/// (decision 1207).
///
/// Ten of the sixteen are the shared block and come from [`super::super::font_block`], which carries
/// the per-verb byte evidence and the return-shape traps. Two are deliberately absent and four are
/// installed here:
///
/// - **`SetSpacing`/`GetSpacing` (#10–#11) are NOT installed.** Nothing in this client models line
/// spacing, so they could only store a number no renderer reads — the silently-ignored-setter
/// failure of 1203/1205/1211 — while their absence raises a nil-value call that names itself.
/// Corpus demand is zero: `:SetSpacing(`/`:GetSpacing(` appear in **0** of 218 addons.
/// `script::font` withholds the same pair for the same reason.
/// - **The justify four (#12–#15) are installed here**, against the box's own
/// [`EditBoxState::justify`] word rather than its text region — the field's doc has the why (our
/// editbox draw law seats that region LEFT unconditionally) and states the divergence.
fn install_font_block(lua: &Lua, m: &Table) -> mlua::Result<()> {
// Every font method acts on the box's implicit FontString: each binding's shim loads
// `[this+0x324]` and hands it to the shared implementation, and that offset is
// `EditBoxState::text_region`. Created on demand, exactly like every other text-touching path.
crate::script::font_block::install(lua, m, |lua, this| {
let h = frame_handle_of(lua, this)?;
super::ensure_text_region(lua, h).ok_or_else(|| mlua::Error::runtime("not an EditBox"))
})?;
// SetJustifyH("LEFT"|"CENTER"|"RIGHT") / SetJustifyV("TOP"|"MIDDLE"|"BOTTOM") → 0 values.
//
// Two verified traps, both of which a plausible implementation gets wrong, and both of which
// our older FontString/Font copies of these verbs do get wrong (they coerce anything unknown to
// CENTER/MIDDLE — noted, not fixed here):
// · an **unrecognised token RAISES** `Usage: %s:SetJustifyH("justify")` (`0x87c77c`), rather
// than falling back to a default;
// · a token from the **other axis** parses fine and then masks to nothing — `SetJustifyH("TOP")`
// yields 0x08, `0x08 & 0x07 == 0`, so it CLEARS justifyH and `GetJustifyH()` answers
// `"UNKNOWN"`. No error either way.
//
// `AceGUIWidget-Slider.lua:210` (`editbox:SetJustifyH("CENTER")`, three addons) is the demand.
for (name, mask) in [
("SetJustifyH", EditBoxState::JUSTIFY_H_MASK),
("SetJustifyV", EditBoxState::JUSTIFY_V_MASK),
] {
m.set(
name,
lua.create_function(move |lua, (this, token): (Table, String)| {
let bits = EditBoxState::justify_bit(&token).ok_or_else(|| {
mlua::Error::runtime(format!("Usage: <EditBox>:{name}(\"justify\")"))
})?;
with_editbox(lua, &this, |eb| eb.set_justify_axis(mask, bits))
})?,
)?;
}
// GetJustifyH()/GetJustifyV() → exactly 1 string, the first set bit's token in the reference's
// own table order, or the literal "UNKNOWN".
for (name, mask) in [
("GetJustifyH", EditBoxState::JUSTIFY_H_MASK),
("GetJustifyV", EditBoxState::JUSTIFY_V_MASK),
] {
m.set(
name,
lua.create_function(move |lua, this: Table| {
with_editbox(lua, &this, |eb| eb.justify_token(mask))
})?,
)?;
}
Ok(())
}
/// A `Set<Flag>` name paired with the EditBoxState mutator it drives (Lua truthiness).
type FlagSetter = (&'static str, fn(&mut EditBoxState, bool));
+125 -5
View File
@@ -5,11 +5,88 @@
use crate::layout::Rect;
use crate::order::{self, ZTarget};
use crate::widget::FrameHandle;
use super::clip::{effective_clip, scroll_clip_sources};
use super::{slider, ExtractedQuad, FontObject, QuadContent, TexCoords, UiScript};
impl UiScript {
/// **Every live draw target in the VM**, whether or not it is currently visible — each frame's
/// own slot followed by its region leaves, in arena order.
///
/// [`Self::extract`] answers *what is being drawn*; this answers *what exists*, and the two
/// together are what let an instrument say **who drew a quad**. Diff this across a load and the
/// new targets are exactly the ones that load created: no name prefixes, no heuristics, and no
/// dependence on an addon naming anything at all. That is the oracle behind the addon
/// harness's render column, and it is written this way because the obvious alternatives are
/// both wrong — a with/without **quad-count diff** reads zero for an addon that *replaces* a
/// window rather than adding one (Bagnon takes the bags over), and a **name-prefix** match
/// cannot see an anonymous frame or a region an addon hangs off one of ours.
///
/// Handles are generational, so a destroyed-and-reused slot can never be mistaken for a
/// survivor of the baseline.
pub fn live_targets(&self) -> Vec<ZTarget> {
let model = self.model_ref();
let mut out = Vec::new();
for (fh, frame) in model.arena.iter_frames() {
out.push(ZTarget::Frame(fh));
out.extend(frame.regions.iter().copied().map(ZTarget::Region));
}
out
}
/// The frame a draw target belongs to — itself for a frame slot, the owner for a region.
///
/// The half of the attribution that separates *created a window of its own* from *painted onto
/// one of ours*: a new `Region` whose owner frame is **not** new is an addon hooking an
/// existing frame, which is precisely what `!OmniCC` does to a cooldown and what a check built
/// only on new frames would score as "drew nothing".
pub fn target_frame(&self, target: ZTarget) -> Option<FrameHandle> {
match target {
ZTarget::Frame(fh) => self.model_ref().arena.frame(fh).map(|_| fh),
ZTarget::Region(rh) => self.model_ref().arena.region(rh).map(|r| r.owner),
}
}
/// A frame's parent, or `None` for a top-level frame (or a stale handle).
///
/// The other half of the attribution walk: a frame being **new** does not make it the addon's
/// own window — `!OmniCC` creates a brand-new anonymous frame *parented to one of our action
/// buttons*, and only the chain tells an overlay from a window.
pub fn frame_parent(&self, frame: FrameHandle) -> Option<FrameHandle> {
self.model_ref().arena.frame(frame)?.parent
}
/// A frame's name, or `None` — it is anonymous, or the handle is stale.
///
/// For reporting only. Nothing above depends on a frame being named; this is what turns an
/// attributed handle into a row a human can act on.
pub fn frame_name(&self, frame: FrameHandle) -> Option<String> {
self.model_ref().arena.frame(frame)?.name.clone()
}
/// The nearest **named** frame at or above `target` — its own frame's name, else the closest
/// named ancestor.
///
/// An addon's slot buttons are usually named and its inner art usually is not, so a bare
/// [`Self::frame_name`] would report a hole exactly where the interesting quads are. Walking up
/// answers "which window is this part of", which is the question a report is asking.
pub fn target_owner_name(&self, target: ZTarget) -> Option<String> {
let model = self.model_ref();
let mut frame = match target {
ZTarget::Frame(fh) => Some(fh),
ZTarget::Region(rh) => Some(model.arena.region(rh)?.owner),
};
while let Some(fh) = frame {
let f = model.arena.frame(fh)?;
if let Some(name) = &f.name {
return Some(name.clone());
}
frame = f.parent;
}
None
}
/// The render list in the client's painter order (decision 0068): the visible-tree
/// [`crate::order::traversal`] zipped with the resolved rects and region visuals. Already sorted
/// ascending by `ZKey`. Call [`UiScript::resolve`] first for populated rects.
@@ -100,6 +177,9 @@ impl UiScript {
// per-state label font swap (UIPanelButtonTemplate's gold/white/gray trio).
let mut state_font: Option<&FontObject> = None;
let mut state_color: Option<[f32; 4]> = None;
// `Button:SetFont` — the face/size/flags written on the button's own embedded
// fonts rather than on any object they inherit.
let mut button_font: Option<&crate::widget::ButtonFont> = None;
if let Some(crate::widget::KindState::Button(bs)) =
owner_frame.map(|f| &f.kind_state)
{
@@ -120,6 +200,7 @@ impl UiScript {
bs.normal_font.as_ref()
};
state_font = name.and_then(|n| model.font_objects.get(n));
button_font = bs.font.as_ref();
// The per-state color override (Button:Set*TextColor) — hover falls
// back to the normal color, mirroring the font fallback above.
state_color = if !bs.enabled {
@@ -147,10 +228,24 @@ impl UiScript {
// FONTINSTANCE+0x38, color bit 0x404).
data.font_path = fo.font.clone().or(data.font_path);
data.font_height = fo.height.or(data.font_height);
data.font_shadow = fo.shadow.or(data.font_shadow);
// Same severance as the colour below: a region that called
// `SetShadowColor`/`SetShadowOffset` keeps its own, or the font object it
// inherits would silently overwrite the value the addon just set.
if !data.font_explicit.shadow {
data.font_shadow = fo.shadow.or(data.font_shadow);
}
data.outline = fo.outline;
if data.vertex_color.is_none() {
data.vertex_color = fo.color;
// Test the severance MASK, which is what the sentence above claims and what
// wow-re pinned, not `vertex_color.is_none()`. The nil-check was an
// equivalent proxy for exactly as long as an explicit `SetTextColor` was the
// only way a button label's colour could be populated at all; the moment
// `SetTextFontObject` began linking the label to its font object (so
// `GetFont`/`GetFontObject` stop answering nil), a label carried the NORMAL
// object's colour and the disabled state's gray silently stopped applying.
// Caught by `button_label_repaints_by_state_font_object`, which is the test
// that exists for this.
if !data.font_explicit.color {
data.vertex_color = fo.color.or(data.vertex_color);
}
// The object's own justify (`<NormalFont inherits=… justifyH="LEFT"/>` —
// how the ref left-aligns a ButtonText).
@@ -161,6 +256,23 @@ impl UiScript {
data.justify_v = j;
}
}
// `Button:SetFont` sits BETWEEN the two: it is a local set on the button's own
// embedded font, so it outranks the font object that font inherits (a locally
// set axis severs inheritance and is never restored — wow-re
// `font-object-lua-surface.md`), but it loses to a face the label FontString
// set for *itself*, which severs one level further down. That is what
// `font_explicit` is, so it is the gate here too.
if let Some(bf) = button_font {
if !data.font_explicit.face {
data.font_path = Some(bf.path.clone());
}
if !data.font_explicit.height {
data.font_height = Some(bf.height);
}
if !data.font_explicit.outline {
data.outline = super::Outline::flags(&bf.flags);
}
}
// The button-level state color wins over the font object AND the region's own
// explicit color — the client's Button color slots repaint the label outright.
if let Some(c) = state_color {
@@ -216,11 +328,19 @@ impl UiScript {
// outlives the art it was tinting") and used to leak out of here as a
// solid plate the moment the art was cleared (the white action buttons on
// a character switch; the 2026-07-10 grey wells were the same class).
let has_texture = data.texture.is_some() || data.fill.is_some();
// A GRADIENT is drawable content in its own right — the client generates it
// into the same texture slot the colour form of `SetTexture` fills, so a
// region carrying only a gradient still paints. Folded to its midpoint here
// because a quad carries ONE tint; the whole gradient stays on the region
// (`RegionData::gradient`) so a renderer that grows a second stop needs no
// API change. The approximation is visible, and it is stated there and here
// rather than discovered later.
let fill = data.fill.or_else(|| data.gradient.map(|g| g.midpoint()));
let has_texture = data.texture.is_some() || fill.is_some();
QuadContent::Texture {
path: data.texture,
color: has_texture
.then(|| texture_color(data.fill, data.vertex_color))
.then(|| texture_color(fill, data.vertex_color))
.flatten(),
additive: data.additive,
tex_coords: data.tex_coords,
+4 -1
View File
@@ -345,7 +345,10 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
fo.height = Some(h);
}
if let Some(f) = flags {
fo.outline = Outline::parse(&f);
// The LUA flags spelling ("OUTLINE"/"THICKOUTLINE"), not the XML
// attribute's ("NORMAL"/"THICK") — this read the XML one, so every
// `GameFontNormal:SetFont(f, h, "OUTLINE")` silently cleared the outline.
fo.outline = Outline::flags(&f);
}
})?;
Ok(if ok { Value::Number(1.0) } else { Value::Nil })
+351
View File
@@ -0,0 +1,351 @@
//! **The font block** — the ten font methods a text-bearing widget re-declares in its own method
//! table, implemented once over the [`RegionData`](super::RegionData) the glyphs actually paint
//! from, and installed onto any widget's table by [`install`].
//!
//! ## Why this is a module and not a base class
//!
//! **There is no `FontInstance` class in the 1.12.1 Lua chain.** wow-re's registrar map
//! (`system/ui/scratch/widget-api-batch-benilla.md`, and the per-table carve in
//! `font-object-lua-surface.md` §2) shows 23 flat `{const char* name, void* fn}` `.data` tables and
//! a per-class `vtable[+0x8]` lookup that tail-calls **exactly one** base class's lookup on a miss.
//! The six text-bearing types each *re-declare* the same font names in their own flat table; the
//! sharing happens one level **down**, in C++, where every binding is a thin type-guard shim that
//! tail-calls one shared implementation (`0x79f210` SetFont · `0x79f3b0` GetFont · `0x79f4d0` /
//! `0x79f680` Set/GetTextColor · `0x79f730` / `0x79f910` Set/GetShadowColor · `0x79f9c0` /
//! `0x79fad0` Set/GetShadowOffset · `0x79ef10` / `0x79f090` Set/GetFontObject).
//!
//! So: **which font methods a type has is a per-table fact, never derivable from "it draws text"** —
//! and this module is that shared C++ layer, with each widget's own table naming the subset it
//! really carries. Adding a name a table does not carry is exactly as wrong as missing one.
//!
//! ## The membership, read off the binary rather than assumed
//!
//! `EditBox`'s table is `.data 0x87bb68`, **48 entries** — the count read from the `mov edx,0x30` at
//! the registering site `0x799ab5`, never from a run-length scan (adjacent tables are contiguous and
//! a scan merges them into a bogus blob). Its **first sixteen entries are the font block**, in this
//! order, and each one is a shim that `call`s the shared implementation and returns **without
//! touching `eax`** — so every return arity below is the shared implementation's, verbatim:
//!
//! | # | EditBox entry | binding | tail-calls | returns |
//! |---|---|---|---|---|
//! | 0 | `SetFontObject` | `0x797090` | `0x79ef10` | **0 values** |
//! | 1 | `GetFontObject` | `0x797150` | `0x79f090` | **1 value** (the handle, or nil) |
//! | 2 | `SetFont` | `0x797210` | `0x79f210` | **1 value: the NUMBER 1, or nil** |
//! | 3 | `GetFont` | `0x7972d0` | `0x79f3b0` | **3 values** (path, height, flags) |
//! | 4 | `SetTextColor` | `0x797390` | `0x79f4d0` | **0 values** |
//! | 5 | `GetTextColor` | `0x797450` | `0x79f680` | **4 values** |
//! | 6 | `SetShadowColor` | `0x797510` | `0x79f730` | **0 values** |
//! | 7 | `GetShadowColor` | `0x7975d0` | `0x79f910` | **4 values** (`0x79f9b3 mov eax,0x4`) |
//! | 8 | `SetShadowOffset` | `0x797690` | `0x79f9c0` | **0 values** |
//! | 9 | `GetShadowOffset` | `0x797750` | `0x79fad0` | **2 values** |
//! | 10–11 | `Set/GetSpacing` | `0x797810` / `0x7978d0` | `0x79fb40` / `0x79fbe0` | *not installed — below* |
//! | 12–15 | `Set/GetJustifyH`, `Set/GetJustifyV` | `0x797990`… | `0x79fc20`… | *EditBox-side — below* |
//!
//! **`SetFont` is the trap here, and it points the opposite way to the one this codebase already
//! pinned.** `Button:SetFont` (`0x780880`) ends `xor eax,eax` and returns **nothing** — that
//! divergence has its own test. `EditBox:SetFont` does **not**: `0x7972b2 call 0x79f210` is followed
//! by `pop edi; pop esi; pop ebx; ret` with no `eax` write, so the shared implementation's single
//! value (`lua_pushnumber(1.0)` at `0x79f345`, or `lua_pushnil` at `0x79f361`) passes straight
//! through. It is the number `1`, not `true`, and not zero values.
//!
//! The table above was read off the PE `.data` section directly (with Font's 22-entry table as a
//! positive control) and **independently re-derived by a wow-re §5 trio**, which agrees on all 48
//! names, all 48 binding addresses and all 16 arities —
//! `system/ui/scratch/editbox-font-surface.md`. Its own control for the "nothing is discarded"
//! claim is `SetMultiLine`'s binding, which *does* emit `xor eax,eax` (`0x797e9d`) and returns 0.
//! Entry 47 ends at `0x87bce8`, the first byte of the string `"GetAltArrowKeyMode"` — the table
//! abuts its own string pool, which pins the count at 48 a second way.
//!
//! **A stated divergence, verified and deliberately left consistent rather than fixed on one table:**
//! the reference's `SetTextColor` and `SetShadowColor` read r, g and b with a **bare `lua_tonumber`**
//! (a non-number silently becomes `0.0`) and carry **no usage string at all**, so they *cannot
//! raise* — `SetTextColor()` sets opaque black. Ours take `f32`, so a missing or non-numeric channel
//! errors instead. Alpha matches (`lua_isnumber`-gated, default 1.0). `script::region`'s and
//! `script::font`'s copies are strict in the same way, so correcting only this one would put two
//! answers in the codebase for one contract; it belongs with the collapse below. No corpus site is
//! known to depend on it.
//!
//! ## Where it lands, and why that is the mechanism rather than an approximation
//!
//! Each shim loads **`[this+0x324]`** and hands it to the shared implementation as the target. On a
//! `CSimpleEditBox` that offset is the box's implicit FontString — the same field
//! [`EditBoxState::text_region`](crate::widget::EditBoxState::text_region) models (RF-0082, and it is
//! the EditBox's analogue of `ButtonText`). So `editBox:SetFontObject(ChatFontNormal)` really does
//! paint *the box's font string*, which is exactly what this module writes: resolve the widget to
//! the region its glyphs come from, then set the same [`RegionData`](super::RegionData) fields a
//! `FontString` sets for itself.
//!
//! ## Not installed here, and the reason is not laziness
//!
//! - **`SetSpacing` / `GetSpacing`.** We model no line spacing anywhere — neither `RegionData` nor
//! the text layout carries the field, and the line pitch is the font height — so a setter here
//! could only store a number nobody draws. That is the silently-ignored-setter failure this
//! codebase keeps being bitten by (1203, 1205, 1211), and the alternative failure is the good one:
//! a missing method raises `attempt to call method 'SetSpacing' (a nil value)`, which names itself.
//! Demand is **zero** — `grep -r ':SetSpacing(\|:GetSpacing('` over the 218-addon corpus finds
//! nothing at all. `script::font`'s own table withholds the pair for the same reason; when spacing
//! becomes something the renderer honours, both land together.
//! - **The justify four.** They are real EditBox entries and they *are* wired — but on the EditBox's
//! own state rather than on its text region, because our EditBox draw law forces the region's
//! justification (see `script::editbox::methods`). They live there for that reason, not here.
//!
//! ## The one duplicate this leaves, named rather than left to be found
//!
//! `script::region`'s FontString table still carries its own hand-written copies of these ten. They
//! are the same contract over the same fields and should collapse onto this module — one
//! `font_block::install(lua, &m, region_handle_of)` — when `region.rs` is split (it is over the
//! file-size budget, which is why that half is not done here). The collapse carries one correction
//! with it: that copy's `SetFont` answers the **boolean `true`**, where the bytes above say the
//! number `1` or nil.
use mlua::{Lua, Table, Value};
use super::object::as_f32;
use super::{FontShadow, Model, Outline};
use crate::widget::RegionHandle;
/// How a widget's method table resolves `this` to the region its glyphs are painted from.
///
/// A `FontString` *is* that region; an `EditBox` owns one and creates it on demand. A resolver
/// returning an error is how a method called on the wrong receiver reports it.
pub(super) type ResolveRegion = fn(&Lua, &Table) -> mlua::Result<RegionHandle>;
/// Install the ten shared font-block methods onto `m`, reading `this` through `resolve`.
///
/// The caller names the subset: this installs exactly the ten the module doc tabulates, and a
/// widget whose real table lacks one of them must not use this installer for it.
pub(super) fn install(lua: &Lua, m: &Table, resolve: ResolveRegion) -> mlua::Result<()> {
// ── the font object, and the live link to it ────────────────────────────────────────────
// SetFontObject(font | "font" | nil) → 0 values. All three argument forms the reference's own
// usage string names (`.rdata 0x87c5cc`: `Usage: %s:SetFontObject(font or "font" or nil)`).
// `Dewdrop-2.0.lua:1675` — `editBox:SetFontObject(ChatFontNormal)`, two lines after
// `CreateFrame("EditBox", nil, editBoxFrame)` — passes the OBJECT, and that one line is
// replicated into 63 of the corpus's 218 addons.
//
// A frame, a number, or an unknown name is an ERROR, never a silent no-op: every rejection in
// the reference is `luaL_error 0x6f4940`, which longjmps and aborts the call (1203/1205/1211's
// silent-drop class).
m.set(
"SetFontObject",
lua.create_function(move |lua, (this, font): (Table, Value)| {
let name = super::font::resolve("SetFontObject", &font)?;
let rh = resolve(lua, &this)?;
let mut model = lua.app_data_mut::<Model>().expect("model");
// The nil form severs the link and leaves the paint standing — the reference stores a
// null parent, and nothing re-reads or clears the resolved values.
let Some(name) = name else {
model.region_data.entry(rh).or_default().font_object = None;
return Ok(());
};
let Some(fo) = model.font_objects.get(&name).cloned() else {
return Err(mlua::Error::runtime(format!(
"SetFontObject: no font object named '{name}' is registered"
)));
};
let d = model.region_data.entry(rh).or_default();
d.font_object = Some(name);
// The severance mask is deliberately NOT reset. §5-verified: the real "stop inheriting
// this property" signal is a CLEARED bit in the inheritMask (`FONTINSTANCE+0x2c`),
// cleared by each local setter and never restored — so a property the widget set for
// itself stays severed across a later `SetFontObject`.
super::font::repaint(d, &fo);
Ok(())
})?,
)?;
// GetFontObject() → exactly 1 value: the font OBJECT last resolved, or nil. The object, never
// its name — `Dewdrop-2.0.lua:2181` indexes the result immediately.
m.set(
"GetFontObject",
lua.create_function(move |lua, this: Table| {
let rh = resolve(lua, &this)?;
let name = {
let model = lua.app_data_ref::<Model>().expect("model");
model
.region_data
.get(&rh)
.and_then(|d| d.font_object.clone())
.filter(|n| model.font_objects.contains_key(n))
};
match name {
Some(n) => Ok(Value::Table(super::font::wrapper(lua, &n)?)),
None => Ok(Value::Nil),
}
})?,
)?;
// ── the face ────────────────────────────────────────────────────────────────────────────
// SetFont(path, height [, flags]) → **the NUMBER 1, or nil** — one value either way, never a
// boolean and never zero values (the module doc's table; `0x7972b2` does not clobber `eax` the
// way `Button:SetFont` does). The shared impl gates arg2 on `lua_isstring` and arg3 on
// `lua_isnumber`, raising `Usage: %s:SetFont("font", fontHeight [, flags])` (`0x87c69c`)
// otherwise — both accept a numeric string, since `lua_isstring`/`lua_isnumber` coerce.
//
// nil is the *load-failure* answer, not an argument error: `!OmniCC/main.lua:41`'s
// `if not Font:SetFont(saved, size) then revert end` is a font-file validity probe, so an empty
// path must come back falsey rather than raising.
m.set(
"SetFont",
lua.create_function(
move |lua, (this, file, height, flags): (Table, Value, Value, Option<String>)| {
let usage = || {
mlua::Error::runtime(
"Usage: <EditBox>:SetFont(\"font\", fontHeight [, flags])".to_string(),
)
};
let path = match &file {
Value::String(s) => s.to_str()?.to_string(),
Value::Number(_) | Value::Integer(_) => as_f32(&file).to_string(),
_ => return Err(usage()),
};
let height = match &height {
Value::Number(_) | Value::Integer(_) => as_f32(&height),
Value::String(s) => s.to_str()?.parse::<f32>().map_err(|_| usage())?,
_ => return Err(usage()),
};
let rh = resolve(lua, &this)?;
let mut model = lua.app_data_mut::<Model>().expect("model");
let d = model.region_data.entry(rh).or_default();
// Every argument supplied is an EXPLICIT set: it must survive a later mutation of
// the font object this region inherits.
let ok = !path.is_empty();
if ok {
d.font_path = Some(path);
d.font_explicit.face = true;
}
d.font_height = Some(height);
d.font_explicit.height = true;
if let Some(f) = flags {
// The LUA flags spelling ("OUTLINE"/"THICKOUTLINE"), not the XML attribute's
// ("NORMAL"/"THICK") — the shared parse `0x6f1a90` is a case-insensitive
// SUBSTRING scan, which is why "THICKOUTLINE" yields both bits.
d.outline = Outline::flags(&f);
d.font_explicit.outline = true;
}
Ok(if ok { Value::Number(1.0) } else { Value::Nil })
},
)?,
)?;
// GetFont() → 3 values: path, height, flagsString (`mov eax,3` at `0x79f407`). The flags string
// is `""` when there are none — built into a zeroed static buffer at `0xceea60` — never nil.
m.set(
"GetFont",
lua.create_function(move |lua, this: Table| {
let rh = resolve(lua, &this)?;
let model = lua.app_data_ref::<Model>().expect("model");
let d = model.region_data.get(&rh);
let path = match d.and_then(|d| d.font_path.clone()) {
Some(p) => Value::String(lua.create_string(&p)?),
None => Value::Nil,
};
let height = d.and_then(|d| d.font_height);
let flags = d.map(|d| d.outline).unwrap_or_default().as_str();
Ok((path, height, flags))
})?,
)?;
// ── the text colour ─────────────────────────────────────────────────────────────────────
// SetTextColor(r, g, b [, a]) → 0 values; alpha defaults to 1.0 (`lua_isnumber(L,5)`-gated,
// `0x3f800000`). A FontString has no texel of its own, so its vertex colour IS the colour it
// draws — the same `+0xb8` slot `SetVertexColor` writes.
m.set(
"SetTextColor",
lua.create_function(
move |lua, (this, r, g, b, a): (Table, f32, f32, f32, Option<f32>)| {
let rh = resolve(lua, &this)?;
let mut model = lua.app_data_mut::<Model>().expect("model");
let d = model.region_data.entry(rh).or_default();
d.vertex_color = Some([r, g, b, a.unwrap_or(1.0)]);
// The shared impl clears the inheritMask's colour bit on whatever instance it ran
// on (`0x79dbd0`'s `and [edi+0xd4],0xfffffffb`), so an explicit colour survives a
// later repaint from the font object.
d.font_explicit.color = true;
Ok(())
},
)?,
)?;
// GetTextColor() → 4 values, r, g, b, a. Never set = the white every region draws at.
m.set(
"GetTextColor",
lua.create_function(move |lua, this: Table| {
let rh = resolve(lua, &this)?;
let model = lua.app_data_ref::<Model>().expect("model");
let c = model
.region_data
.get(&rh)
.and_then(|d| d.vertex_color)
.unwrap_or([1.0, 1.0, 1.0, 1.0]);
Ok((c[0], c[1], c[2], c[3]))
})?,
)?;
// ── the shadow ──────────────────────────────────────────────────────────────────────────
// **`GetShadowColor` returns FOUR values, not three** (`0x79f9b3`, `mov eax,0x4`). Three is the
// plausible wrong answer and it silently drops the alpha that
// `FuBar_NavigatorFu/NavigatorFu.lua:31` round-trips. `GetShadowOffset` returns two, in UI
// units (the same units as `SetWidth`/`SetPoint`), not device pixels.
//
// Either half may be set before the other, so each starts from whatever is already there —
// shadow colour and offset share one inherit slot in the reference too.
m.set(
"SetShadowColor",
lua.create_function(
move |lua, (this, r, g, b, a): (Table, f32, f32, f32, Option<f32>)| {
let rh = resolve(lua, &this)?;
let mut model = lua.app_data_mut::<Model>().expect("model");
let d = model.region_data.entry(rh).or_default();
let offset = d.font_shadow.map_or([0.0, 0.0], |s| s.offset);
d.font_shadow = Some(FontShadow {
offset,
color: [r, g, b, a.unwrap_or(1.0)],
});
d.font_explicit.shadow = true;
Ok(())
},
)?,
)?;
m.set(
"GetShadowColor",
lua.create_function(move |lua, this: Table| {
let rh = resolve(lua, &this)?;
let model = lua.app_data_ref::<Model>().expect("model");
let c = model
.region_data
.get(&rh)
.and_then(|d| d.font_shadow)
.map_or([0.0, 0.0, 0.0, 1.0], |s| s.color);
Ok((c[0], c[1], c[2], c[3]))
})?,
)?;
// SetShadowOffset(x, y) → 0 values, and **both arguments are required** — the shared impl
// raises `Usage: %s:SetShadowOffset(x, y)` (`0x87c6e8`) rather than defaulting the missing one.
m.set(
"SetShadowOffset",
lua.create_function(move |lua, (this, x, y): (Table, f32, f32)| {
let rh = resolve(lua, &this)?;
let mut model = lua.app_data_mut::<Model>().expect("model");
let d = model.region_data.entry(rh).or_default();
let color = d.font_shadow.map_or([0.0, 0.0, 0.0, 1.0], |s| s.color);
d.font_shadow = Some(FontShadow {
offset: [x, y],
color,
});
d.font_explicit.shadow = true;
Ok(())
})?,
)?;
m.set(
"GetShadowOffset",
lua.create_function(move |lua, this: Table| {
let rh = resolve(lua, &this)?;
let model = lua.app_data_ref::<Model>().expect("model");
let o = model
.region_data
.get(&rh)
.and_then(|d| d.font_shadow)
.map_or([0.0, 0.0], |s| s.offset);
Ok((o[0], o[1]))
})?,
)?;
Ok(())
}
+19 -216
View File
@@ -5,10 +5,7 @@ use crate::order::ZTarget;
use crate::widget::{FrameHandle, FrameKind, KindState, RegionHandle};
use super::backdrop;
use super::{
ExtractedQuad, FontShadow, JustifyH, JustifyV, LineMeasureRequest, MeasureRequest, Model,
Outline, QuadContent, UiScript, SCREEN,
};
use super::{ExtractedQuad, MeasureRequest, Model, QuadContent, UiScript, SCREEN};
/// The paint a frame slot's derived quads inherit — its `effective_alpha` and `effective_scale`
/// travelling together (backdrop pieces and message-frame ring lines are emitted BY the frame,
@@ -521,9 +518,24 @@ impl UiScript {
}) else {
continue;
};
let Some(owner_rect) = resolved.get(&owner).copied() else {
continue;
};
// An owner with NO resolved rect does not disqualify its regions. `owner_rect`
// is only the fallback for the axes this region's own anchors do not pin (see the
// two `axis(..)` calls below) — a region anchored fully to some OTHER frame needs
// nothing from its owner, and the reference resolves it.
//
// Skipping here made a whole shape silently invisible: a bare container frame
// (`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,
});
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).
@@ -702,93 +714,6 @@ impl UiScript {
out
}
/// Ring lines whose wrapped **row count** needs a host measurement (new line, or the frame's
/// width/font changed under it) — the message-frame half of the measure round-trip. Call after
/// [`UiScript::resolve`] (widths must be resolved); answer with
/// [`UiScript::set_message_line_rows`] before extract. Cache keys keep this empty on quiet
/// frames.
pub fn message_lines_needing_measure(&mut self) -> Vec<LineMeasureRequest> {
use std::hash::{Hash, Hasher};
let mut model = self.model_mut();
let mut out = Vec::new();
let frames: Vec<(FrameHandle, Rect)> = model
.resolved
.iter()
.filter(|(&fh, _)| {
model.arena.frame(fh).is_some_and(|f| {
matches!(f.kind_state, KindState::ScrollingMessage(_)) && f.effective_visible
})
})
.map(|(&fh, &fr)| (fh, fr))
.collect();
for (fh, fr) in frames {
let wrap_width = fr.right - fr.left;
if wrap_width <= 1.0 {
continue; // unresolved/degenerate width — nothing meaningful to wrap against
}
let scale = model
.arena
.frame(fh)
.map(|f| f.effective_scale)
.unwrap_or(1.0);
let (font, height, _, outline) = Self::message_frame_font(&model, fh);
let frame_id = model.frame_id(fh);
let Some(frame) = model.arena.frame(fh) else {
continue;
};
let KindState::ScrollingMessage(smf) = &frame.kind_state else {
continue;
};
for (index, line) in smf.lines.iter().enumerate() {
let mut hasher = std::collections::hash_map::DefaultHasher::new();
line.text.hash(&mut hasher);
font.hash(&mut hasher);
height.map(f32::to_bits).hash(&mut hasher);
wrap_width.to_bits().hash(&mut hasher);
(outline as u8).hash(&mut hasher);
scale.to_bits().hash(&mut hasher);
let key = hasher.finish();
if line.rows_key == key {
continue;
}
out.push(LineMeasureRequest {
frame: frame_id,
index: index as u32,
font: font.clone(),
height,
wrap_width,
outline,
scale,
text: line.text.clone(),
key,
});
}
}
out
}
/// Store host row-count answers for [`LineMeasureRequest`]s (`(frame, index, rows, key)` —
/// frame/index/key verbatim from the request). The key is stored beside the rows, so a line
/// whose width/font changed again since the request simply re-requests next frame.
pub fn set_message_line_rows(&mut self, rows: &[(u32, u32, u16, u64)]) {
let mut model = self.model_mut();
for &(frame_id, index, n, key) in rows {
let Some(&fh) = model.id_to_frame.get(&frame_id) else {
continue;
};
let Some(frame) = model.arena.frame_mut(fh) else {
continue;
};
let KindState::ScrollingMessage(smf) = &mut frame.kind_state else {
continue;
};
if let Some(line) = smf.lines.get_mut(index as usize) {
line.rows = n.max(1);
line.rows_key = key;
}
}
}
/// Push one [`QuadContent::Backdrop`] per piece of frame `fh`'s installed backdrop (no-op if the
/// frame has none). Pieces carry the frame slot's `z` — behind the frame's regions (which sort
/// after it) and, among themselves, bg-then-border in paint order (the stable z-sort keeps the
@@ -846,126 +771,4 @@ impl UiScript {
});
}
}
/// Push one [`QuadContent::Text`] per visible message of a ScrollingMessageFrame `fh` (no-op for
/// any other kind). Messages stack bottom-up from `fr`'s bottom edge; each occupies
/// `rows × pitch` (its host-measured wrapped row count — the message-line measure round-trip),
/// so a long line pushes everything above it up by its real height. The pitch is the **font
/// height itself** — the client's own line-step law (`LayoutLines` 0x5cdc20: step =
/// px(size) + spacing, spacing 0), the same law the app's text renderer lays wrapped rows at,
/// so band grid and glyph rows coincide by construction. (The msgframe's own relayout
/// `0x788750/0x788c00` is only partially read in wow-re — if the look pass ever shows the ref
/// spacing chat lines wider than the font height, that residual is the place to pin.)
///
/// `scroll_offset` picks which message sits at the bottom. A message that only partially fits
/// at the frame's top still draws — clipped to the frame rect, so its lower wrapped rows show
/// (a reader mid-scrollback), never ink outside the frame. A fully-faded line draws nothing but
/// its rows still hold their place: the reference's chat never re-packs as old lines fade.
pub(super) fn emit_message_lines(
model: &Model,
fh: FrameHandle,
fr: Rect,
z: u64,
paint: FramePaint,
clip: Option<Rect>,
out: &mut Vec<ExtractedQuad>,
) {
let Some(frame) = model.arena.frame(fh) else {
return;
};
let crate::widget::KindState::ScrollingMessage(smf) = &frame.kind_state else {
return;
};
if smf.lines.is_empty() {
return;
}
let (font, font_height, font_shadow, outline) = Self::message_frame_font(model, fh);
// The band grid lives in the frame's RESOLVED (scale-multiplied) rect, so the row pitch
// rides the frame scale exactly like the glyphs it must coincide with (the font height
// itself is frame-local).
let pitch = font_height.unwrap_or(14.0) * paint.scale;
if pitch <= 0.0 || fr.top <= fr.bottom {
return;
}
// Message text never inks outside its frame: a partially-fitting top message is scissored
// at the frame edge (intersected with any ScrollFrame ancestor clip).
let line_clip = Some(match clip {
Some(c) => super::clip::intersect_rect(c, fr),
None => fr,
});
// The newest message the view shows sits `scroll_offset` up from the ring's back.
let top_index = smf.lines.len().saturating_sub(1 + smf.scroll_offset);
let mut used = 0.0f32; // rows already consumed, in px up from fr.bottom
for idx in (0..=top_index).rev() {
if used >= fr.top - fr.bottom {
break; // the next band would start above the frame — nothing more can show
}
let line = &smf.lines[idx];
let band_h = f32::from(line.rows.max(1)) * pitch;
let bottom = fr.bottom + used;
used += band_h;
if line.alpha <= 0.0 {
continue; // fully faded — holds its place, draws nothing
}
let band = Rect::new(bottom, fr.left, bottom + band_h, fr.right);
out.push(ExtractedQuad {
target: ZTarget::Frame(fh),
z,
rect: Some(band),
alpha: paint.alpha,
clip: line_clip,
content: QuadContent::Text {
text: Some(line.text.clone()),
color: Some([
f32::from(line.color[0]) / 255.0,
f32::from(line.color[1]) / 255.0,
f32::from(line.color[2]) / 255.0,
line.alpha,
]),
justify_h: JustifyH::Left,
// The band is our own per-message construct (rows × pitch tall, not a
// FontString rect) — keep the block at its top so the band math, not MIDDLE
// centering, owns the vertical rhythm; the band height equals the wrapped
// block height exactly, so Top means "fills the band".
justify_v: JustifyV::Top,
font: font.clone(),
// The chat font object's own drop shadow (ChatFontNormal's `(1,-1)` black) — what
// makes the lines read against the world behind the transparent frame.
shadow: font_shadow,
font_height,
text_height: None, // message lines are never SetTextHeight'd
outline,
alpha_gradient: None,
},
scale: paint.scale,
});
}
}
/// The font a ScrollingMessageFrame's ring lines draw with: its declared `<FontString>` child
/// (ChatFontNormal for the chat window). Shared by [`Self::emit_message_lines`] and the
/// message-line measure round-trip so bands, measures, and glyphs agree. Missing ⇒ the
/// renderer's default face at the ~14px chat default.
pub(super) fn message_frame_font(
model: &Model,
fh: FrameHandle,
) -> (Option<String>, Option<f32>, Option<FontShadow>, Outline) {
model
.arena
.frame(fh)
.and_then(|frame| {
frame
.regions
.iter()
.find(|&&rh| {
matches!(
model.arena.region(rh).map(|r| r.kind),
Some(crate::widget::RegionKind::FontString)
)
})
.and_then(|rh| model.region_data.get(rh))
.map(|d| (d.font_path.clone(), d.font_height, d.font_shadow, d.outline))
})
.unwrap_or((None, None, None, Outline::default()))
}
}
@@ -0,0 +1,317 @@
//! The two message-frame widget classes, and the display machinery they share.
//!
//! 1.12 ships **two** of them, and msgframe-runtime.md is emphatic that they are *siblings, not
//! parent and child* (different ctors, different vtables, different Lua type tags, offsets that do
//! not transfer):
//!
//! - [`scrolling`] — `CSimpleMessageScrollFrame` (ctor `0x787670`), the chat window's class: a true
//! ring of `maxLines`, a scrollback cursor, `AddMessage(text[,r,g,b[,id]])` with alpha forced
//! opaque.
//! - [`plain`] — `CSimpleMessageFrame` (ctor `0x785640`), `UIErrorsFrame`'s class: no ring, no
//! scrollback, `insertMode`, and `AddMessage(text[,r,g,b[,a]])` whose fourth numeric is a real
//! **alpha**.
//!
//! Each gets its **own** Lua method table, consulted by the frame `__index` dispatcher only for its
//! own [`FrameKind`](crate::widget::FrameKind) — so a duck-typing addon (`if f.SetInsertMode then`)
//! sees `nil` everywhere else, exactly as against the client's per-class registrar tables.
//!
//! What *is* shared sits here: both classes display a stack of [`MessageLine`] records, so the
//! wrapped-row measure round-trip, the band emit, and the font/justification read are one code path
//! over [`KindState::message_lines`]. Everything that actually differs stays on the two states.
use crate::layout::Rect;
use crate::order::ZTarget;
use crate::widget::{FrameHandle, InsertMode, KindState, MessageLine};
use super::layout::FramePaint;
use super::{
ExtractedQuad, FontShadow, JustifyH, JustifyV, LineMeasureRequest, Model, Outline, QuadContent,
UiScript,
};
mod plain;
mod scrolling;
pub(super) use plain::REG_MESSAGEFRAME_METHODS;
pub(super) use scrolling::REG_SCROLLINGMESSAGEFRAME_METHODS;
/// Install both classes' method tables (and the chat input globals the scrolling one carries).
pub(super) fn install(lua: &mlua::Lua) -> mlua::Result<()> {
scrolling::install(lua)?;
plain::install(lua)
}
/// The font a message frame's lines draw with — its declared `<FontString>` child, which is exactly
/// how both reference frames spell it (`<FontString inherits="ChatFontNormal" justifyH="LEFT"/>` on
/// a chat frame, `<FontString inherits="ErrorFont" justifyH="CENTER"/>` on `UIErrorsFrame.xml`).
/// Shared by the emit and the measure round-trip so bands, measures and glyphs agree. Missing ⇒ the
/// renderer's default face at the ~14px chat default.
#[derive(Clone)]
pub(super) struct MessageFont {
pub(super) path: Option<String>,
pub(super) height: Option<f32>,
pub(super) shadow: Option<FontShadow>,
pub(super) outline: Outline,
/// The child's `justifyH`. **Load-bearing, not decoration**: it is the only reason the
/// reference's error toasts sit centred in their 512-wide frame while chat lines run flush
/// left, and the class draws its lines through that font instance. A frame with no declared
/// FontString falls back to LEFT (the chat shape), never to the FontString default of CENTER —
/// there is no region here whose own default could apply.
pub(super) justify_h: JustifyH,
}
impl UiScript {
pub(super) fn message_frame_font(model: &Model, fh: FrameHandle) -> MessageFont {
model
.arena
.frame(fh)
.and_then(|frame| {
frame
.regions
.iter()
.find(|&&rh| {
matches!(
model.arena.region(rh).map(|r| r.kind),
Some(crate::widget::RegionKind::FontString)
)
})
.and_then(|rh| model.region_data.get(rh))
.map(|d| MessageFont {
path: d.font_path.clone(),
height: d.font_height,
shadow: d.font_shadow,
outline: d.outline,
justify_h: d.justify_h,
})
})
.unwrap_or(MessageFont {
path: None,
height: None,
shadow: None,
outline: Outline::default(),
justify_h: JustifyH::Left,
})
}
/// How many display **rows** frame `fh`'s resolved rect holds at its line font's pitch — the
/// client's layout-derived `numLinesDisplayed`. `0` when the frame has no resolved rect.
///
/// Both classes need it and neither can compute it from its own state: it is the page size for
/// the scrolling class's `PageUp`/`PageDown` and the whole capacity law for the plain one.
pub(super) fn message_viewport_rows(model: &Model, fh: FrameHandle) -> usize {
let pitch = Self::message_frame_font(model, fh)
.height
.unwrap_or(14.0)
.max(1.0);
model.resolved.get(&fh).map_or(0, |r| {
((r.top - r.bottom) / pitch).floor().max(0.0) as usize
})
}
/// Message lines whose wrapped **row count** needs a host measurement (a new line, or the
/// frame's width/font changed under it) — the message-frame half of the measure round-trip.
/// Call after [`UiScript::resolve`] (widths must be resolved); answer with
/// [`UiScript::set_message_line_rows`] before extract. Cache keys keep this empty on quiet
/// frames.
pub fn message_lines_needing_measure(&mut self) -> Vec<LineMeasureRequest> {
use std::hash::{Hash, Hasher};
let mut model = self.model_mut();
let mut out = Vec::new();
let frames: Vec<(FrameHandle, Rect)> = model
.resolved
.iter()
.filter(|(&fh, _)| {
model
.arena
.frame(fh)
.is_some_and(|f| f.kind_state.message_lines().is_some() && f.effective_visible)
})
.map(|(&fh, &fr)| (fh, fr))
.collect();
for (fh, fr) in frames {
let wrap_width = fr.right - fr.left;
if wrap_width <= 1.0 {
continue; // unresolved/degenerate width — nothing meaningful to wrap against
}
let scale = model
.arena
.frame(fh)
.map(|f| f.effective_scale)
.unwrap_or(1.0);
let font = Self::message_frame_font(&model, fh);
let frame_id = model.frame_id(fh);
let Some(frame) = model.arena.frame(fh) else {
continue;
};
let Some(lines) = frame.kind_state.message_lines() else {
continue;
};
for (index, line) in lines.iter().enumerate() {
let mut hasher = std::collections::hash_map::DefaultHasher::new();
line.text.hash(&mut hasher);
font.path.hash(&mut hasher);
font.height.map(f32::to_bits).hash(&mut hasher);
wrap_width.to_bits().hash(&mut hasher);
(font.outline as u8).hash(&mut hasher);
scale.to_bits().hash(&mut hasher);
let key = hasher.finish();
if line.rows_key == key {
continue;
}
out.push(LineMeasureRequest {
frame: frame_id,
index: index as u32,
font: font.path.clone(),
height: font.height,
wrap_width,
outline: font.outline,
scale,
text: line.text.clone(),
key,
});
}
}
out
}
/// Store host row-count answers for [`LineMeasureRequest`]s (`(frame, index, rows, key)` —
/// frame/index/key verbatim from the request). The key is stored beside the rows, so a line
/// whose width/font changed again since the request simply re-requests next frame.
pub fn set_message_line_rows(&mut self, rows: &[(u32, u32, u16, u64)]) {
let mut model = self.model_mut();
for &(frame_id, index, n, key) in rows {
let Some(&fh) = model.id_to_frame.get(&frame_id) else {
continue;
};
let Some(frame) = model.arena.frame_mut(fh) else {
continue;
};
let Some(lines) = frame.kind_state.message_lines_mut() else {
continue;
};
if let Some(line) = lines.get_mut(index as usize) {
line.rows = n.max(1);
line.rows_key = key;
}
}
}
/// Push one [`QuadContent::Text`] per visible message of either message-frame class (no-op for
/// any other kind). Each message occupies `rows × pitch` (its host-measured wrapped row count —
/// the message-line measure round-trip), so a long line pushes its neighbours by its real
/// height. The pitch is the **font height itself** — the client's own line-step law
/// (`LayoutLines` 0x5cdc20: step = px(size) + spacing, spacing 0), the same law the app's text
/// renderer lays wrapped rows at, so band grid and glyph rows coincide by construction. (The
/// msgframe's own relayout `0x788750/0x788c00` is only partially read in wow-re — if the look
/// pass ever shows the ref spacing chat lines wider than the font height, that residual is the
/// place to pin.)
///
/// **Which edge the stack grows from is the one place the two classes diverge visibly.** A
/// ScrollingMessageFrame always stacks bottom-up with `scroll_offset` picking which message
/// sits on the bottom row. A MessageFrame has no scrollback and instead honours `insertMode`:
/// BOTTOM (the ctor default) is the same bottom-up stack, TOP hangs the newest message off the
/// frame's top edge with older ones stepping *down* — the shape `UIErrorsFrame.xml:4` asks for,
/// and the one every corpus `SetInsertMode` caller asks for. The growth *anchor* per mode is
/// wow-re's own named residual (it lives in the unwalked wrap/layout pass), so this is the
/// INFERRED half: it is the reading that reproduces both shipped usages, and the place to
/// re-pin if a reference A/B ever disagrees.
///
/// A message that only partially fits at the far edge still draws — clipped to the frame rect,
/// so its inner wrapped rows show, never inking outside the frame. A fully-faded scrolling line
/// draws nothing but still holds its rows (the reference's chat never re-packs as old lines
/// fade); a faded MessageFrame line is gone from the state entirely by then, because that class
/// frees its lines instead of holding slots.
pub(super) fn emit_message_lines(
model: &Model,
fh: FrameHandle,
fr: Rect,
z: u64,
paint: FramePaint,
clip: Option<Rect>,
out: &mut Vec<ExtractedQuad>,
) {
let Some(frame) = model.arena.frame(fh) else {
return;
};
// (lines, index of the message on the anchored row, which edge it hangs off).
let (lines, top_index, from_top): (&std::collections::VecDeque<MessageLine>, usize, bool) =
match &frame.kind_state {
KindState::ScrollingMessage(smf) => (
&smf.lines,
smf.lines.len().saturating_sub(1 + smf.scroll_offset),
false,
),
KindState::Message(mf) => (
&mf.lines,
mf.lines.len().saturating_sub(1),
matches!(mf.insert_mode, InsertMode::Top),
),
_ => return,
};
if lines.is_empty() {
return;
}
let font = Self::message_frame_font(model, fh);
// The band grid lives in the frame's RESOLVED (scale-multiplied) rect, so the row pitch
// rides the frame scale exactly like the glyphs it must coincide with (the font height
// itself is frame-local).
let pitch = font.height.unwrap_or(14.0) * paint.scale;
if pitch <= 0.0 || fr.top <= fr.bottom {
return;
}
// Message text never inks outside its frame: a partially-fitting outermost message is
// scissored at the frame edge (intersected with any ScrollFrame ancestor clip).
let line_clip = Some(match clip {
Some(c) => super::clip::intersect_rect(c, fr),
None => fr,
});
let mut used = 0.0f32; // rows already consumed, in px in from the anchored edge
for idx in (0..=top_index).rev() {
if used >= fr.top - fr.bottom {
break; // the next band would start outside the frame — nothing more can show
}
let line = &lines[idx];
let band_h = f32::from(line.rows.max(1)) * pitch;
let (bottom, top) = if from_top {
(fr.top - used - band_h, fr.top - used)
} else {
(fr.bottom + used, fr.bottom + used + band_h)
};
used += band_h;
if line.alpha <= 0.0 {
continue; // fully faded — holds its place, draws nothing
}
out.push(ExtractedQuad {
target: ZTarget::Frame(fh),
z,
rect: Some(Rect::new(bottom, fr.left, top, fr.right)),
alpha: paint.alpha,
clip: line_clip,
content: QuadContent::Text {
text: Some(line.text.clone()),
color: Some([
f32::from(line.color[0]) / 255.0,
f32::from(line.color[1]) / 255.0,
f32::from(line.color[2]) / 255.0,
line.alpha,
]),
justify_h: font.justify_h,
// The band is our own per-message construct (rows × pitch tall, not a
// FontString rect) — keep the block at its top so the band math, not MIDDLE
// centering, owns the vertical rhythm; the band height equals the wrapped
// block height exactly, so Top means "fills the band".
justify_v: JustifyV::Top,
font: font.path.clone(),
// The font object's own drop shadow (ChatFontNormal's `(1,-1)` black) — what
// makes the lines read against the world behind the transparent frame.
shadow: font.shadow,
font_height: font.height,
text_height: None, // message lines are never SetTextHeight'd
outline: font.outline,
alpha_gradient: None,
},
scale: paint.scale,
});
}
}
}
@@ -0,0 +1,422 @@
//! The `MessageFrame` method surface — `CSimpleMessageFrame` (ctor `0x785640`), the class
//! `UIErrorsFrame` is and the one `CreateFrame("MessageFrame")` makes.
//!
//! Grounded in wow-re's byte-verified reads (msgframe-runtime.md's §5 pair, re-confirmed at the
//! registrar tables in `widget-api-batch-benilla.md` Q4). Three facts decide this whole file, and
//! each is one an implementation would otherwise get wrong in a way nothing would catch:
//!
//! 1. **`AddMessage(text [, r, g, b [, a]])` — the fifth argument is ALPHA and there is no sixth.**
//! Three corpus addons pass six (`EasyCopy.lua:12`, `QuestHistory.lua:1678`, `QuestItem.lua:300`,
//! all `UIErrorsFrame:AddMessage(msg, r, g, b, a, holdTime)`), and the real 1.12.1 binding
//! `0x795590` reads five and stops. Honouring that trailing number as a per-message hold time
//! would be **unfaithful, not generous** — the client shows the message for the *frame's*
//! `displayDuration` and nothing else. It is ignored here, deliberately.
//! 2. **This `AddMessage` is not the scrolling class's.** `0x792900` takes an **id** in the same
//! slot and forces alpha `0xFF` (`792add: or edi,0xffffff00`); `0x795590` packs a real
//! `0xAARRGGBB` with a default of 1.0. Two bindings, two tables, no sharing.
//! 3. **`SetInsertMode`/`GetInsertMode` (`0x794ed0`/`0x794ff0`) live on this class only** — the
//! scrolling class has neither, and no `insertMode` XML attribute either.
//!
//! The state (display lines, fade phases, the vertical cap) is
//! [`MessageFrameState`](crate::widget::MessageFrameState) in `crate::widget`; this is the thin Lua
//! binding over it. The band emit and the wrapped-row measure round-trip are shared with the
//! scrolling class and live in [`super`].
use mlua::{Lua, Table, Value};
use crate::script::object::frame_handle_of;
use crate::script::Model;
use crate::widget::{InsertMode, KindState, MessageFrameState};
/// Registry key of the MessageFrame method table (the MAXCSTACK discipline: Lua-side root, named
/// key).
pub(crate) const REG_MESSAGEFRAME_METHODS: &str = "__benilla_plain_messageframe_methods";
/// Run `f` over a frame's MessageFrame state under one short write borrow. Errors if `this` is not
/// a live MessageFrame (unreachable through the kind dispatcher, but the method table is a plain
/// Lua value — a caller could fish it out and misapply it).
fn with_mf<T>(
lua: &Lua,
this: &Table,
f: impl FnOnce(&mut MessageFrameState) -> T,
) -> mlua::Result<T> {
let h = frame_handle_of(lua, this)?;
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
let frame = model
.arena
.frame_mut(h)
.ok_or_else(|| mlua::Error::runtime("stale frame handle"))?;
match &mut frame.kind_state {
KindState::Message(mf) => Ok(f(mf)),
_ => Err(mlua::Error::runtime("not a MessageFrame")),
}
}
/// A Lua number-ish → f32 (nil/other → 0.0), for the colour args.
fn num_f32(v: &Value) -> f32 {
match v {
Value::Number(n) => *n as f32,
Value::Integer(i) => *i as f32,
_ => 0.0,
}
}
pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
let m = lua.create_table()?;
// AddMessage(text [, r, g, b [, a]]) — binding 0x795590.
//
// r/g/b are required **as a trio** (three presence checks ANDed at 0x7956xx); absent ⇒ hard
// 0xFFFFFFFF white. The fourth numeric is a real alpha, `lua_isnumber`-gated with a default of
// 1.0 (`795752: mov [ebp-0x18],0x3f800000`).
//
// **The signature stops there, and the closure's arity is the enforcement.** The corpus's three
// blocked callers all pass a sixth argument they believe is a hold time; mlua hands us the
// first five and drops the rest, exactly as the real binding does by reading five stack slots.
// Adding a `holdTime` parameter here would make benilla show those messages for a duration no
// 1.12 client ever showed them for.
m.set(
"AddMessage",
lua.create_function(
|lua, (this, text, r, g, b, a): (Table, String, Value, Value, Value, Value)| {
let has_rgb = !matches!(
(&r, &g, &b),
(Value::Nil, _, _) | (_, Value::Nil, _) | (_, _, Value::Nil)
);
let (r, g, b) = if has_rgb {
(num_f32(&r), num_f32(&g), num_f32(&b))
} else {
(1.0, 1.0, 1.0)
};
// Alpha only counts when rgb came with it — the client reads arg 5 off the same
// parse that required the trio, and a lone `AddMessage(text, nil, nil, nil, 0.5)`
// has no colour to apply it to.
let a = match (&a, has_rgb) {
(Value::Number(_) | Value::Integer(_), true) => num_f32(&a),
_ => 1.0,
};
with_mf(lua, &this, |mf| mf.add(text, r, g, b, a))
},
)?,
)?;
m.set(
"Clear",
lua.create_function(|lua, this: Table| with_mf(lua, &this, MessageFrameState::clear))?,
)?;
// SetInsertMode("TOP"|"BOTTOM") / GetInsertMode — 0x794ed0 / 0x794ff0, THIS CLASS ONLY. The
// client compares the string against "BOTTOM" (0x871404) and stores `streq-BOTTOM`, so anything
// that is not literally BOTTOM lands on TOP; the ctor default is 1 = BOTTOM.
m.set(
"SetInsertMode",
lua.create_function(|lua, (this, mode): (Table, Value)| {
let mode = match &mode {
Value::String(s) => {
if s.to_str()?.trim().eq_ignore_ascii_case("BOTTOM") {
InsertMode::Bottom
} else {
InsertMode::Top
}
}
// A non-string argument fails the streq and therefore reads as TOP, like the
// client's own comparison against a string it never got.
_ => InsertMode::Top,
};
with_mf(lua, &this, |mf| mf.insert_mode = mode)
})?,
)?;
m.set(
"GetInsertMode",
lua.create_function(|lua, this: Table| {
with_mf(lua, &this, |mf| match mf.insert_mode {
InsertMode::Top => "TOP",
InsertMode::Bottom => "BOTTOM",
})
})?,
)?;
// The fade trio — the same names and the same field the scrolling class exposes (the XML attr
// is `displayDuration`, the Lua accessors call it `TimeVisible`), over this class's own state.
m.set(
"SetFading",
lua.create_function(|lua, (this, on): (Table, Value)| {
let on = !matches!(on, Value::Nil | Value::Boolean(false));
with_mf(lua, &this, |mf| mf.fading_enabled = on)
})?,
)?;
m.set(
"GetFading",
lua.create_function(|lua, this: Table| with_mf(lua, &this, |mf| mf.fading_enabled))?,
)?;
m.set(
"SetTimeVisible",
lua.create_function(|lua, (this, s): (Table, f32)| {
with_mf(lua, &this, |mf| mf.time_visible = s.max(0.0))
})?,
)?;
m.set(
"GetTimeVisible",
lua.create_function(|lua, this: Table| with_mf(lua, &this, |mf| mf.time_visible))?,
)?;
m.set(
"SetFadeDuration",
lua.create_function(|lua, (this, s): (Table, f32)| {
with_mf(lua, &this, |mf| mf.fade_duration = s.max(0.0))
})?,
)?;
m.set(
"GetFadeDuration",
lua.create_function(|lua, this: Table| with_mf(lua, &this, |mf| mf.fade_duration))?,
)?;
lua.set_named_registry_value(REG_MESSAGEFRAME_METHODS, m)?;
Ok(())
}
#[cfg(test)]
mod tests {
use crate::script::UiScript;
/// How many display lines a named message frame holds. Read off the state rather than through
/// Lua on purpose: `GetNumMessages` is a **ScrollingMessageFrame** binding, and inventing one
/// on this class to make its tests convenient is exactly the drift these tests are pinning
/// against.
fn num_messages(s: &UiScript, name: &str) -> usize {
let m = s.model_ref();
let h = m.arena.lookup(name).expect("named frame");
m.arena
.frame(h)
.and_then(|f| f.kind_state.message_lines())
.map_or(0, |l| l.len())
}
/// The whole point of the arity, pinned: the sixth argument three corpus addons pass is a hold
/// time the real client never had, and it must change **nothing**. The frame's own
/// `displayDuration` is what governs, so the message with a "hold time" of 999 fades on exactly
/// the same tick as the one without.
#[test]
fn the_sixth_addmessage_argument_is_ignored_not_a_holdtime() {
let mut s = UiScript::new().unwrap();
s.run("CreateFrame('MessageFrame', 'MF')").unwrap();
s.run("MF:SetTimeVisible(1)").unwrap();
s.run("MF:SetFadeDuration(0)").unwrap();
// The exact shape of EasyCopy.lua:12 / QuestItem.lua:300 / QuestHistory.lua:1678.
s.run("MF:AddMessage('held', 1.0, 1.0, 1.0, 1.0, 999)")
.unwrap();
s.run("MF:AddMessage('plain', 1.0, 1.0, 1.0, 1.0)").unwrap();
assert_eq!(num_messages(&s, "MF"), 2);
// Past timeVisible with no ramp: both retire together. A honoured holdTime would keep the
// first alive here, which is exactly the divergence this test exists to catch.
s.tick(1.1);
s.tick(0.1);
assert_eq!(
num_messages(&s, "MF"),
0,
"the 6th arg must not extend a message's life"
);
}
/// `SetInsertMode` is a MessageFrame method and **only** a MessageFrame method — a plain Frame
/// and a ScrollingMessageFrame both resolve it to nil, which is what a duck-typing addon reads.
#[test]
fn set_insert_mode_is_messageframe_only() {
let s = UiScript::new().unwrap();
s.run(
"CreateFrame('MessageFrame', 'MF')\n\
CreateFrame('Frame', 'PlainF')\n\
CreateFrame('ScrollingMessageFrame', 'SMF')",
)
.unwrap();
assert_eq!(
s.eval::<String>("return type(MF.SetInsertMode)").unwrap(),
"function"
);
assert_eq!(
s.eval::<String>("return type(PlainF.SetInsertMode)")
.unwrap(),
"nil",
"a plain Frame must not quack like a MessageFrame"
);
assert_eq!(
s.eval::<String>("return type(SMF.SetInsertMode)").unwrap(),
"nil",
"the scrolling class has no SetInsertMode binding at all"
);
// Default BOTTOM (ctor 1), and the round trip.
assert_eq!(
s.eval::<String>("return MF:GetInsertMode()").unwrap(),
"BOTTOM"
);
s.run("MF:SetInsertMode('TOP')").unwrap();
assert_eq!(
s.eval::<String>("return MF:GetInsertMode()").unwrap(),
"TOP"
);
}
/// The two `AddMessage`s are different functions, and the observable tell is the fifth argument:
/// on a MessageFrame it is alpha (0.5 ⇒ a half-transparent line), on a ScrollingMessageFrame it
/// is an id and the line stays opaque.
#[test]
fn messageframe_addmessage_is_not_the_scrolling_ones() {
let s = UiScript::new().unwrap();
s.run(
"CreateFrame('MessageFrame', 'MF')\n\
CreateFrame('ScrollingMessageFrame', 'SMF')",
)
.unwrap();
assert!(
!s.eval::<bool>("return MF.AddMessage == SMF.AddMessage")
.unwrap(),
"one shared AddMessage would give the wrong meaning to the 5th arg on one of them"
);
s.run("MF:AddMessage('half', 1, 1, 1, 0.5)").unwrap();
s.run("SMF:AddMessage('ident', 1, 1, 1, 42)").unwrap();
let alphas = {
let m = s.model_ref();
let pick = |name: &str| {
let h = m.arena.lookup(name).unwrap();
m.arena
.frame(h)
.unwrap()
.kind_state
.message_lines()
.unwrap()[0]
.alpha
};
(pick("MF"), pick("SMF"))
};
assert!(
(alphas.0 - 128.0 / 255.0).abs() < 1e-6,
"MessageFrame's 5th arg is alpha, quantized round-half-up: {alphas:?}"
);
assert_eq!(
alphas.1, 1.0,
"ScrollingMessageFrame's 5th arg is an id; its alpha is forced opaque"
);
}
/// `insertMode` decides which edge the stack grows from: TOP hangs the newest message off the
/// frame's top, BOTTOM (the default) stacks up from its bottom. Same three messages, mirrored.
#[test]
fn insert_mode_picks_the_growth_edge() {
let bands = |mode: &str| {
let mut s = UiScript::new().unwrap();
s.set_screen_size(800.0, 600.0);
s.run(
"local f = CreateFrame('MessageFrame', 'MF')\n\
f:SetPoint('BOTTOMLEFT', 0, 100)\n\
f:SetWidth(400)\n\
f:SetHeight(48)",
)
.unwrap();
s.run(&format!("MF:SetInsertMode('{mode}')")).unwrap();
for t in ["one", "two", "three"] {
s.run(&format!("MF:AddMessage('{t}', 1, 1, 1)")).unwrap();
}
s.resolve();
let mut v: Vec<(String, f32)> = s
.extract()
.iter()
.filter_map(|q| match (&q.content, q.rect) {
(crate::script::QuadContent::Text { text: Some(t), .. }, Some(r)) => {
Some((t.clone(), r.bottom))
}
_ => None,
})
.collect();
v.sort_by(|a, b| b.1.total_cmp(&a.1)); // top row first
v
};
// Default pitch 14, frame [100, 148).
let top = bands("TOP");
assert_eq!(
top.iter().map(|(t, _)| t.as_str()).collect::<Vec<_>>(),
["three", "two", "one"],
"TOP: newest on the frame's top row, older stepping down"
);
assert!((top[0].1 - 134.0).abs() < 0.01, "newest hangs off fr.top");
let bottom = bands("BOTTOM");
assert_eq!(
bottom.iter().map(|(t, _)| t.as_str()).collect::<Vec<_>>(),
["one", "two", "three"],
"BOTTOM: newest on the frame's bottom row, older stepping up"
);
assert!(
(bottom[2].1 - 100.0).abs() < 0.01,
"newest sits on fr.bottom"
);
}
/// The class has no `maxLines` — its cap is what fits vertically, applied at the tick. A frame
/// 3 rows tall holds 3 messages however many arrive.
#[test]
fn the_cap_is_what_fits_vertically() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(800.0, 600.0);
s.run(
"local f = CreateFrame('MessageFrame', 'MF')\n\
f:SetPoint('BOTTOMLEFT', 0, 0)\n\
f:SetWidth(400)\n\
f:SetHeight(42)\n\
f:SetFading(false)",
)
.unwrap();
for n in 0..9 {
s.run(&format!("MF:AddMessage('m{n}', 1, 1, 1)")).unwrap();
}
s.resolve();
s.tick(0.1);
assert_eq!(num_messages(&s, "MF"), 3, "42px / 14px pitch = 3 rows");
// ...and it is the OLDEST that went.
s.resolve();
let texts: Vec<String> = s
.extract()
.iter()
.filter_map(|q| match &q.content {
crate::script::QuadContent::Text { text: Some(t), .. } => Some(t.clone()),
_ => None,
})
.collect();
assert!(texts.contains(&"m8".to_string()) && !texts.contains(&"m0".to_string()));
}
/// A faded line on this class is **freed**, not left blank holding its rows — the difference
/// from the scrolling class, whose ring slots persist. `Clear` is the immediate form.
#[test]
fn a_finished_line_is_retired_and_clear_empties_now() {
let mut s = UiScript::new().unwrap();
s.run("CreateFrame('MessageFrame', 'MF')").unwrap();
s.run("MF:SetTimeVisible(0.5)").unwrap();
s.run("MF:SetFadeDuration(2)").unwrap();
s.run("MF:AddMessage('x', 1, 1, 1)").unwrap();
s.tick(0.6); // spends phase 1
s.tick(1.0); // mid-ramp: still alive, alpha down
assert_eq!(num_messages(&s, "MF"), 1);
s.tick(1.2); // ramp done → retired
assert_eq!(num_messages(&s, "MF"), 0);
s.run("MF:AddMessage('a', 1, 1, 1)").unwrap();
s.run("MF:AddMessage('b', 1, 1, 1)").unwrap();
assert_eq!(num_messages(&s, "MF"), 2);
s.run("MF:Clear()").unwrap();
assert_eq!(num_messages(&s, "MF"), 0);
}
/// The ctor defaults, straight off msgframe-runtime.md's shared-defaults section, plus the
/// setters' round trip.
#[test]
fn ctor_defaults_and_the_fade_accessors() {
let s = UiScript::new().unwrap();
s.run("CreateFrame('MessageFrame', 'MF')").unwrap();
assert!(s.eval::<bool>("return MF:GetFading()").unwrap());
assert_eq!(s.eval::<f32>("return MF:GetTimeVisible()").unwrap(), 10.0);
assert_eq!(s.eval::<f32>("return MF:GetFadeDuration()").unwrap(), 3.0);
s.run("MF:SetTimeVisible(5) MF:SetFadeDuration(1) MF:SetFading(false)")
.unwrap();
assert_eq!(s.eval::<f32>("return MF:GetTimeVisible()").unwrap(), 5.0);
assert_eq!(s.eval::<f32>("return MF:GetFadeDuration()").unwrap(), 1.0);
assert!(!s.eval::<bool>("return MF:GetFading()").unwrap());
}
}
@@ -7,21 +7,25 @@
//! while **AtBottom** (scrolled up freezes every alpha), and 1-slot scrollback. The heavy lifting
//! (the ring, the fade phases, the scroll clamps) lives on [`ScrollingMessageState`]
//! (`crate::widget`), unit-tested there; this module is the thin Lua binding over it, plus the
//! host-facing [`super::UiScript::add_chat_message`]/fade advance the app drives.
//! host-facing [`UiScript::add_chat_message`]/fade advance the app drives.
//!
//! The methods live in their own registry table, consulted by the frame `__index` dispatcher only
//! for ScrollingMessageFrame frames — so duck-typing addons (`if frame.AddMessage then …`) see `nil`
//! on every other kind, exactly as against the client's per-class method sets.
//! on every other kind, exactly as against the client's per-class method sets. **Its sibling class
//! `MessageFrame` has its own table next door** ([`super::plain`]) carrying a *different*
//! `AddMessage`: merging the two would hand a chat frame a `SetInsertMode` it does not have, and a
//! MessageFrame an id argument where its alpha belongs.
use mlua::{Lua, Table, Value};
use super::object::frame_handle_of;
use super::Model;
use crate::script::object::frame_handle_of;
use crate::script::{Model, UiScript};
use crate::widget::{KindState, ScrollingMessageState};
/// Registry key of the ScrollingMessageFrame method table (the MAXCSTACK discipline: Lua-side root,
/// named key).
pub(super) const REG_MESSAGEFRAME_METHODS: &str = "__benilla_messageframe_methods";
pub(crate) const REG_SCROLLINGMESSAGEFRAME_METHODS: &str =
"__benilla_scrollingmessageframe_methods";
/// Run `f` over a frame's message-frame state under one short write borrow. Errors if `this` is not a
/// live ScrollingMessageFrame (unreachable through the kind dispatcher, but the method table is a
@@ -59,11 +63,7 @@ fn num_f32(v: &Value) -> f32 {
fn page(lua: &Lua, this: &Table, up: bool) -> mlua::Result<()> {
let h = frame_handle_of(lua, this)?;
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
let (_, height, _, _) = super::UiScript::message_frame_font(&model, h);
let pitch = height.unwrap_or(14.0).max(1.0);
let viewport_rows = model.resolved.get(&h).map_or(0, |r| {
((r.top - r.bottom) / pitch).floor().max(0.0) as usize
});
let viewport_rows = UiScript::message_viewport_rows(&model, h);
let frame = model
.arena
.frame_mut(h)
@@ -200,7 +200,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
lua.create_function(|lua, this: Table| with_smf(lua, &this, |smf| smf.fade_duration))?,
)?;
lua.set_named_registry_value(REG_MESSAGEFRAME_METHODS, m)?;
lua.set_named_registry_value(REG_SCROLLINGMESSAGEFRAME_METHODS, m)?;
// SubmitChatInput(text) — the chat input EditBox's OnEnterPressed hands the typed line here; it
// queues into the model for the app to parse into a chat command (the outbound Lua→app seam, the
+88 -2
View File
@@ -42,8 +42,10 @@ mod backdrop;
mod bank;
mod binding_abi;
mod button;
mod channel;
mod char_stats;
mod chat_send;
mod chat_window;
mod clip;
mod colorselect;
mod container;
@@ -62,6 +64,7 @@ pub(crate) mod event;
mod extract;
mod follow;
pub(crate) mod font;
mod font_block;
mod gossip;
mod inspect;
mod item_stats;
@@ -167,8 +170,8 @@ pub use trainer::{
};
pub use types::{
EditAction, EditBoxTextUi, EditOutcome, EditUnit, ExtractedQuad, FontObject, FontShadow,
JustifyH, JustifyV, LineMeasureRequest, MeasureRequest, Outline, QuadContent, ScriptValue,
TexCoords,
Gradient, JustifyH, JustifyV, LineMeasureRequest, MeasureRequest, Outline, QuadContent,
ScriptValue, TexCoords,
};
pub(crate) use types::{MeasuredText, RegionData};
pub use unit::{grey_band, level_reads_unknown, power_token, unit_is_grey, UnitState};
@@ -298,6 +301,23 @@ pub struct UiScript {
lua: Lua,
}
/// The chunk name the CLIENT gives an addon's file: `Interface\AddOns\<Folder>\<File>`.
///
/// Backslashes, and the `Interface\AddOns\` prefix, because addons PARSE this. `FuBarPlugin-2.0`
/// derives each plugin's own folder from a stack trace —
/// `string.find(debugstack(6, 1, 0), "\\AddOns\\(.*)\\")` (`FuBarPlugin-2.0.lua:752`) — and
/// feeds the capture straight into `format("Interface\\AddOns\\%s\\icon", self.folderName)`.
/// With no name set, mlua defaults the chunk to the Rust caller location, the pattern misses,
/// `folderName` is nil, and every FuBar plugin dies formatting it. That was 20 addons.
///
/// The greedy `(.*)` in their pattern is why the FILE has to be in the name too: it captures up to
/// the LAST backslash, so `…\AddOns\FuBar_BagFu\FuBar_BagFu.lua` yields `FuBar_BagFu`. A name
/// stopping at the folder would capture the empty string.
pub fn addon_chunk_name(folder: &str, file: &str) -> String {
// `@` is Lua's "this chunk is a file" marker; the traceback then prints the path plainly.
format!("@Interface\\AddOns\\{folder}\\{}", file.replace('/', "\\"))
}
impl UiScript {
/// Build a fully sandboxed, stdlib- and object-model-equipped host.
pub fn new() -> mlua::Result<UiScript> {
@@ -306,6 +326,8 @@ impl UiScript {
addon::install(&lua)?;
chat_send::install(&lua)?;
channel::install(&lua)?;
chat_window::install(&lua)?;
client::install(&lua)?;
stdlib::sandbox(&lua)?;
// Before the stdlib layer, so its aliases bind the 5.0-shaped functions (decision 1194).
@@ -456,8 +478,21 @@ impl UiScript {
/// odd glyph. The two front-door transforms the reference's own compiler applies — the UTF-8
/// BOM strip and the `#`-line skip — are applied here ([`crate::source::chunk`]).
pub fn run_chunk(&self, chunk: &[u8]) -> mlua::Result<()> {
self.run_chunk_named(chunk, "(chunk)")
}
/// Run a chunk under the name the CLIENT would give it — `Interface\AddOns\<Folder>\<File>`
/// for an addon file (build it with [`addon_chunk_name`]).
///
/// The name is not cosmetic. Without `set_name`, mlua defaults a chunk to the **Rust** caller
/// location, so every error an addon raised was reported against `crates/benilla-ui/src/...`
/// — wrong in any error an addon shows a player, and load-bearing for the addons that PARSE a
/// traceback. `FuBarPlugin-2.0.lua:752` derives a plugin's own folder with
/// `string.find(debugstack(6, 1, 0), "\\AddOns\\(.*)\\")`, which cannot match a Rust path.
pub fn run_chunk_named(&self, chunk: &[u8], name: &str) -> mlua::Result<()> {
self.lua
.load(crate::source::chunk(chunk))
.set_name(name)
.set_mode(mlua::ChunkMode::Text)
.exec()
}
@@ -788,6 +823,57 @@ impl UiScript {
self.model_ref().font_objects.contains_key(name)
}
/// The **widget kind of a published name** — `"MessageFrame"`, `"Button"`, `"Texture"`, … — or
/// `None` if nothing by that name is a live widget.
///
/// [`Self::has_framexml_template`]'s sibling, and the same kind of thing: a pure Rust-side query
/// on the live model, for the corpus harness. The harness needs to know what `UIErrorsFrame` in
/// `UIErrorsFrame:AddMessage(…)` actually *is* in our object graph, because a widget-method
/// census that asks "does ANY kind answer this name" cannot see a verb wired to one class and
/// forgotten on its sibling (decision 1228). Attributing the call site to a kind is what makes
/// that question askable, and this is the only honest answer to "what kind is that global": the
/// arena's own record, not a name list.
///
/// **Deliberately NOT a Lua binding.** The reference publishes this as `GetObjectType`, which we
/// do not implement outside font objects; adding it here would put a new verb in front of every
/// addon and move the very numbers the census is measured by. An instrument must be able to
/// claim it perturbed nothing, so it stays on the Rust side where no addon can observe it.
///
/// The spellings are `CreateFrame`'s own (`SimpleHTML`, not `SimpleHtml`), so a caller can
/// compare a name here against a kind it passed to `CreateFrame`.
pub fn widget_kind(&self, name: &str) -> Option<&'static str> {
let model = self.model_ref();
if let Some(h) = model.arena.lookup(name) {
return model.arena.frame(h).map(|f| match f.kind {
crate::widget::FrameKind::Frame => "Frame",
crate::widget::FrameKind::Button => "Button",
crate::widget::FrameKind::CheckButton => "CheckButton",
crate::widget::FrameKind::EditBox => "EditBox",
crate::widget::FrameKind::StatusBar => "StatusBar",
crate::widget::FrameKind::Slider => "Slider",
crate::widget::FrameKind::ScrollFrame => "ScrollFrame",
crate::widget::FrameKind::Model => "Model",
crate::widget::FrameKind::MessageFrame => "MessageFrame",
crate::widget::FrameKind::ScrollingMessageFrame => "ScrollingMessageFrame",
crate::widget::FrameKind::ColorSelect => "ColorSelect",
crate::widget::FrameKind::SimpleHtml => "SimpleHTML",
crate::widget::FrameKind::MovieFrame => "MovieFrame",
crate::widget::FrameKind::GameTooltip => "GameTooltip",
crate::widget::FrameKind::Minimap => "Minimap",
crate::widget::FrameKind::Cooldown => "Cooldown",
});
}
// The region leaves publish into their own name table (`region_names`), not the arena's —
// and they matter here: `GameTooltipTextLeft1:GetText()` is a FontString call, and the
// corpus scrapes those constantly.
let id = *model.region_names.get(name)?;
let h = *model.id_to_region.get(&id)?;
model.arena.region(h).map(|r| match r.kind {
crate::widget::RegionKind::Texture => "Texture",
crate::widget::RegionKind::FontString => "FontString",
})
}
/// A snapshot of the script errors collected so far (from `pcall`'d handlers).
pub fn errors(&self) -> Vec<String> {
self.model_ref().errors.clone()
+10
View File
@@ -221,6 +221,10 @@ pub(crate) struct Model {
/// §2) — `GetNumPartyMembers`/`GetPartyLeaderIndex`/`GetLootMethod`/… read it ([`party`]).
/// `PartyState::default()` = not in a group (every getter reports the solo-player shape).
/// Per-member game state rides the `units` map above, under `"party1"`..`"party4"` tokens.
/// The channels this session has CONFIRMED joining, in join order — the client-side number
/// law `GetChannelName` answers with ([`super::channel`]). Mirrored from `ui_chat`'s
/// `ChannelState` by `set_joined_channels`; empty until the server's first YOU_JOINED.
pub(crate) joined_channels: Vec<String>,
pub(crate) party: party::PartyState,
/// Party/loot intents (`AcceptGroup`/`InviteToParty`/`SetLootMethod`/…) queued since the
/// app's last [`super::UiScript::take_party_requests`] drain — the outbound seam ([`party`]).
@@ -237,6 +241,10 @@ pub(crate) struct Model {
/// [`super::UiScript::take_tell_requests`] drain — the app opens its chat edit box prefilled
/// `/w <name> ` (the unit popup's WHISPER action; [`party`] registers the global).
pub(crate) tell_requests: Vec<String>,
/// Prefill texts `ChatFrame_OpenChat` queued since the app's last
/// [`super::UiScript::take_open_chat_requests`] drain — `tell_requests`' sibling, one step
/// less resolved: a name there, a whole draft line here ([`chat_window`] registers the global).
pub(crate) open_chat_requests: Vec<String>,
/// The player's default chat language name, app-resolved from `ChrRaces.BaseLanguage` ×
/// `Languages.dbc` ([`super::UiScript::set_default_language`]). `None` = the reference's
/// no-player-object state, where `GetDefaultLanguage()` returns **zero Lua values**
@@ -960,11 +968,13 @@ impl Model {
target_by_name_requests: Vec::new(),
drop_item_on_unit: Vec::new(),
target_clear: false,
joined_channels: Vec::new(),
party: party::PartyState::default(),
party_requests: Vec::new(),
social: social::SocialState::default(),
social_requests: Vec::new(),
tell_requests: Vec::new(),
open_chat_requests: Vec::new(),
default_language: None,
duel_requests: Vec::new(),
follow_requests: Vec::new(),
+8 -2
View File
@@ -221,7 +221,6 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
super::statusbar::install(lua)?;
super::button::install(lua)?;
super::editbox::install(lua)?;
super::messageframe::install(lua)?;
// Shared frame metatable: __index is a Rust dispatcher over the frame method table (RF-0023),
// checking the frame's *kind-specific* method table first. Per-kind resolution matters beyond
@@ -315,7 +314,14 @@ fn kind_method_registries(lua: &Lua, this: &Table) -> &'static [&'static str] {
match model.arena.frame(*h).map(|f| f.kind) {
Some(FrameKind::StatusBar) => &[super::statusbar::REG_STATUSBAR_METHODS],
Some(FrameKind::EditBox) => &[super::editbox::REG_EDITBOX_METHODS],
Some(FrameKind::ScrollingMessageFrame) => &[super::messageframe::REG_MESSAGEFRAME_METHODS],
Some(FrameKind::ScrollingMessageFrame) => {
&[super::messageframe::REG_SCROLLINGMESSAGEFRAME_METHODS]
}
// Its SIBLING, with its own table — not a fallthrough to the scrolling one. Until this arm
// existed a `<MessageFrame>` resolved no kind-specific method at all, which is what blocked
// `EasyCopy`, `QuestHistory` and `QuestItem` on `UIErrorsFrame:AddMessage` (three of the
// corpus's six method-class blockers) and made `SetInsertMode` unreachable.
Some(FrameKind::MessageFrame) => &[super::messageframe::REG_MESSAGEFRAME_METHODS],
Some(FrameKind::ScrollFrame) => &[super::scrollframe::REG_SCROLLFRAME_METHODS],
Some(FrameKind::Slider) => &[super::slider::REG_SLIDER_METHODS],
Some(FrameKind::ColorSelect) => &[super::colorselect::REG_COLORSELECT_METHODS],
@@ -81,6 +81,36 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
"GetScript",
lua.create_function(|lua, (this, name): (Table, String)| get_script(lua, &this, &name))?,
)?;
// HasScript(name) — "can this widget carry that script kind at all", NOT "does it have one
// set". The distinction is the whole point of the verb: every caller in the corpus uses it to
// decide whether it is safe to hook, then reads the current handler separately.
//
// if parent:HasScript("OnMouseDown") then -- Tablet-2.0.lua:2409
// local script = parent:GetScript("OnMouseDown") -- FuBar-compat:2422
// parent:SetScript("OnMouseDown", function() … end)
// end
//
// It was the top session-start blocker: 32 of the 39 `attempt to call method` failures were
// this one name, across the Tablet/FuBar/oRA2 stack.
//
// ANSWERED FROM THE FLAT `SCRIPT_KINDS`, and that is a deliberate, stated approximation rather
// than the full answer. The reference's table is PER WIDGET TYPE — a plain Frame really does
// answer false for `OnClick` — so ours is over-permissive for the type-specific kinds. It is
// exactly right for the base kinds, which is what every corpus caller asks about
// (`OnMouseDown` here; a Frame genuinely has it). The cost of the over-permission is small and
// worth naming: an addon that asks about a type-specific kind installs a handler that never
// fires, where the reference would have had it skip. That is the same latitude `SetScript`
// already takes — this verb does not widen it, it just stops lying about its existence.
//
// Making it exact means per-type SCRIPT_KINDS, which newly RAISES on 30 sites that work today
// (9 in our own FrameXML). That is behaviour removal and belongs in its own change, with its
// own measurement; it is not a tail on this one.
m.set(
"HasScript",
lua.create_function(|_, (_this, name): (Table, String)| {
Ok(SCRIPT_KINDS.iter().any(|k| k.eq_ignore_ascii_case(&name)))
})?,
)?;
// RegisterForDrag(...varargs of button names) — the drag-gesture twin of `RegisterForClicks`
// (decision 0216 §3), but on the SHARED table: any Frame can be a drag source, not just a
// Button. Replace-the-set semantics (empty varargs clears); `crate::script::cursor`'s
@@ -140,16 +170,35 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
/// `system/ui/ui.md` l.544-556 summarises it), so what is missing here is never a mystery — it is a
/// deliberate not-yet. What the corpus actually asks for, and why each answer is what it is:
///
/// * **`OnKeyDown` / `OnKeyUp` / `OnChar`** (14 + 1 + 4 corpus sites over 13 addons) — **raising.**
/// They are in the base map, so *every* frame type has them, and the delivery mechanism is
/// `EnableKeyboard` + the hit-test root's **kind-0/kind-1 index** (wow-re
/// `scratch/scripts-auto-enable.md` §1-2: `0x76af00(kind, -1)`, `OnChar` = kind 0,
/// `OnKeyDown`/`OnKeyUp` = kind 1) with the frame-script pre-gate `0x76bba0` consuming the key
/// ahead of the whole binding dispatch (`scratch/keybinding-dispatch-law.md` §1). benilla has
/// none of that: no `EnableKeyboard`, no keyboard index, and its keys go straight to the focused
/// EditBox (whose C++ override replaces these slots anyway — RF-0082 §2). Accepting the names
/// before the index exists is the exact silent-no-op trap above; the ordering/consumption law is
/// dispatched to wow-re.
/// * **`OnKeyDown` / `OnKeyUp` / `OnChar`** (14 + 1 + 4 corpus sites over 13 addons) — **raising,
/// and now unblocked**: the delivery law was the second §5 this work dispatched, and it landed
/// (wow-re `scratch/frame-key-script-delivery.md`). benilla still has none of the machinery it
/// describes — no `EnableKeyboard`, no keyboard index, keys routed straight to the focused
/// EditBox — so the names stay out until it exists, which is the whole rule above. What has to
/// get built, so the next pass does not have to re-derive it:
///
/// The frame must be in the hit-test root's **kind-0 / kind-1 bucket**
/// (`scratch/scripts-auto-enable.md` §1-2: `0x76af00(kind, …)`, `OnChar` = kind 0,
/// `OnKeyDown`/`OnKeyUp` = kind 1; XML `enableKeyboard` enables both, a `<Scripts>` block
/// auto-enables per handler, and Lua `SetScript` auto-enables **nothing** — so a Lua-only frame
/// is not in the bucket at all and its bound handler can never fire). The dispatcher then walks
/// **strata 8 → 0, level high → low, ties oldest-registration-first**, calling each frame's base
/// input virtual (`0x765f10` key-down → vtable `+0x60`; `0x765df0` char → `+0x5c`) and
/// **stopping at the first nonzero return**. `arg1` is a **key-name string** with no modifier
/// prefix, decoded from the *same* table the keybinding chord names use — verified byte-identical
/// over 273 codes, which is the opposite of the mouse case's two-table shape. `OnChar` gets the
/// literal character, UTF-8.
///
/// Two findings that will bite whoever implements it. The consumption gate is **existence, not
/// handling** — a 1.12 handler cannot signal "handled" (the fire's return is discarded at all
/// three sites), so merely having a key script bound suppresses the key's binding; and
/// asymmetrically, a frame with **only** an `OnKeyUp` script consumes every key-down and runs
/// nothing. And `CGWorldFrame` sits at **strata 0 / level 0** — last in the walk — which is
/// precisely why any keyboard-enabled frame pre-empts the entire binding system.
///
/// (The old gloss here called `0x76bba0` a "frame-script pre-gate" walking the index, after
/// `scratch/keybinding-dispatch-law.md` §1. That is refuted: `0x76bba0` is `CSimpleFrame::OnKeyUp`,
/// one frame's base virtual. The walk is one level up, in the dispatcher.)
/// * **`OnCursorChanged`** (4 sites over 3 addons — all of them the Era `ScrollingEdit_OnCursorChanged`
/// auto-scroll idiom) — **raising.** It is the EditBox's own slot (RF-28 `+0x428`), fired by the
/// caret flush `0x77da80` with **four float caret-POSITION args**, and caret geometry is the one
@@ -255,6 +255,82 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
Ok(())
})?,
)?;
// `GetBackdrop()` (`0x777370`; wow-re `ui/scratch/widget-api-batch-benilla.md` Q5) — five things
// a plausible implementation gets wrong, so each is spelled out with its reason:
//
// 1. It is a **reconstruction from the struct**, never the caller's table. The reference stores
// no Lua reference anywhere: `SetBackdrop` reads six keys into a fresh 0x68-byte struct at
// `frame+0x1ac` and drops the table, so `0x777426`–`0x7776b9` re-push every key from those
// fields. Handing back a stored clone would leak keys the reader never accepted and would
// keep stale values a later `SetBackdropColor` changed.
// 2. **No backdrop ⇒ ZERO Lua values, not `nil`** — the early bail is `xor eax,eax; ret`, which
// for a *return* path really is "no values" (contrast `binding_abi`'s note: the same two
// bytes after a `luaL_error` are unreachable boilerplate). Observable through `select('#')`,
// and it is the shape our `GetTitleRegion` will *not* have when it lands — that one pushes
// nil, i.e. one value. The client cannot distinguish "never set" from `SetBackdrop(nil)`.
// 3. **A partial `SetBackdrop` omits nothing on the way out.** Every `SetBackdrop` allocates a
// fresh struct (`0x777801`, ctor `0x77e5f0`), so a key the caller left out is a *ctor
// default* here, not an absent key and not the previous backdrop's value: `bgFile`/`edgeFile`
// `""`, `tileSize` 0, `edgeSize` **32**. Our `backdrop_from_table` already builds on
// `Backdrop::default()`, so this falls out — but only because `None` maps to `""` below
// rather than to nil.
// 4. **`tile` is the NUMBER `1`, or the key is ABSENT — never `true`/`false`.** The push is
// `0x3ff00000` (the double 1.0) on true and `lua_pushnil` on false, and `lua_settable` with a
// nil value creates no key (and *erases* one from a recycled table, which is why the nil is
// written rather than skipped). An addon reading `if backdrop.tile then` sees the same truth
// either way; one that round-trips the table into `SetBackdrop` is why the number matters,
// since `tile` there goes through a coercer that takes numbers.
// 5. The undocumented **in-place form** (`0x77740e`): `lua_type(L,2) == LUA_TTABLE` skips
// `lua_newtable` and fills arg 2, reusing an existing `insets` subtable rather than replacing
// it. Implemented — it is four lines, and an addon caching one table across frames would
// otherwise silently get a new one each call. A non-table arg 2 is ignored, not an error.
//
// No `bgColor`/`edgeColor`/`alpha` key exists: the two colors live in the struct but the reader
// never pushes them (`GetBackdropColor`/`GetBackdropBorderColor` are their only accessors).
m.set(
"GetBackdrop",
lua.create_function(|lua, (this, target): (Table, Value)| {
let h = frame_handle_of(lua, &this)?;
// Copied out before a single Lua write: filling a *caller-supplied* table can run a
// `__newindex` metamethod, which can re-enter us and would panic on the app-data borrow.
let bd = {
let model = lua.app_data_ref::<Model>().expect("model");
model.backdrops.get(&h).cloned()
};
let Some(bd) = bd else {
return Ok(mlua::MultiValue::new()); // trap 2 — zero values, not nil
};
let t = match target {
Value::Table(t) => t,
_ => lua.create_table()?,
};
t.set("bgFile", bd.bg_file.as_deref().unwrap_or(""))?;
t.set("edgeFile", bd.edge_file.as_deref().unwrap_or(""))?;
t.set(
"tile",
if bd.tile {
Value::Number(1.0) // trap 4
} else {
Value::Nil // trap 4 — erases the key from a recycled table
},
)?;
t.set("tileSize", f64::from(bd.tile_size))?;
t.set("edgeSize", f64::from(bd.edge_size))?;
let insets = match t.get::<Value>("insets") {
Ok(Value::Table(existing)) => existing, // trap 5 — reuse, don't replace
_ => {
let fresh = lua.create_table()?;
t.set("insets", &fresh)?;
fresh
}
};
insets.set("left", f64::from(bd.insets.left))?;
insets.set("right", f64::from(bd.insets.right))?;
insets.set("top", f64::from(bd.insets.top))?;
insets.set("bottom", f64::from(bd.insets.bottom))?;
Ok(mlua::MultiValue::from_vec(vec![Value::Table(t)]))
})?,
)?;
m.set(
"SetBackdropColor",
lua.create_function(
@@ -176,6 +176,7 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
"GetWidth",
lua.create_function(|lua, this: Table| {
let h = frame_handle_of(lua, &this)?;
settle(lua);
let model = lua.app_data_ref::<Model>().expect("model");
Ok(size_read(&model, h, true))
})?,
@@ -184,6 +185,7 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
"GetHeight",
lua.create_function(|lua, this: Table| {
let h = frame_handle_of(lua, &this)?;
settle(lua);
let model = lua.app_data_ref::<Model>().expect("model");
Ok(size_read(&model, h, false))
})?,
@@ -198,6 +200,7 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
"GetCenter",
lua.create_function(|lua, this: Table| {
let h = frame_handle_of(lua, &this)?;
settle(lua);
let model = lua.app_data_ref::<Model>().expect("model");
let inv = 1.0 / eff_scale(&model, h);
Ok(match model.resolved.get(&h) {
@@ -238,6 +241,7 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
name,
lua.create_function(move |lua, this: Table| {
let h = frame_handle_of(lua, &this)?;
settle(lua);
let model = lua.app_data_mut::<Model>().expect("model");
let inv = 1.0 / eff_scale(&model, h);
Ok(model.resolved.get(&h).map(|r| {
@@ -255,6 +259,33 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
}
/// The frame's effective scale, ε-guarded (a zero would poison the local-unit division).
/// **Resolve the layout graph NOW if anything has moved since the last pass.**
///
/// The client answers a geometry query against current layout; ours cached rects in a per-frame
/// `resolve()` pass, so a query made EARLIER IN THE SAME CALL STACK than that pass read nil. That
/// is not an edge case — it is what every menu does. `Dewdrop-2.0.lua` (embedded in ~65 corpus
/// addons) creates its menu frame, anchors it, shows it and then measures it inside one `OnClick`:
///
/// ```lua
/// local left = frame:GetLeft() -- l.1942, nil for us
/// frame:SetPoint(point, parent, relativePoint, curX - left - width / 2, 0) -- l.1960, dies
/// ```
///
/// **97 of the 108 addons that drew and then raised on being touched died on that one line.** No
/// missing verb: `GetLeft` was always there, and always answered nil.
///
/// Cheap: `resolve_layout`'s tier-1 gate is one epoch comparison that returns immediately when
/// nothing has been touched, so a settled tree pays a compare per getter.
///
/// **It deliberately does NOT fire `OnSizeChanged`.** That drain runs Lua handlers, and re-entering
/// Lua from inside a binding is how a borrow panic or an unbounded recursion happens. The
/// size-change queue is drained by the next real `UiScript::resolve`, one tick later than the
/// reference's per-rect-application fire (`ApplyRect 0x76b580`). Stated rather than hidden.
fn settle(lua: &Lua) {
let mut model = lua.app_data_mut::<Model>().expect("model");
crate::script::UiScript::resolve_layout(&mut model);
}
pub(super) fn eff_scale(model: &Model, h: FrameHandle) -> f32 {
let s = model
.arena
+26 -10
View File
@@ -47,6 +47,7 @@ use mlua::Value;
use crate::layout::Rect;
use crate::order;
use crate::widget::FrameHandle;
use super::clip::{effective_clip, scroll_clip_sources};
use super::{button, cursor, editbox, event, UiScript};
@@ -127,25 +128,40 @@ pub(super) fn install(lua: &mlua::Lua) -> mlua::Result<()> {
}
impl UiScript {
/// Hit-test the cursor at `(x, y)` and return the **id** of the captured frame (the topmost-drawn
/// mouse-enabled, effective-visible frame whose rect contains the point **and** whose effective
/// ScrollFrame clip, if any, also contains it — decision 0112 §5: a button scrolled out of its
/// ScrollFrame's rect must not hit), or `None`. Pure query — fires nothing, mutates nothing. The
/// id is the same one used across this host (e.g. the layout [`crate::layout::Handle`]); the app
/// can hand it back or map it as it likes.
pub fn hit_test(&self, x: f32, y: f32) -> Option<u32> {
/// Hit-test the cursor at `(x, y)` and return the **handle** of the captured frame (the
/// topmost-drawn mouse-enabled, effective-visible frame whose rect contains the point **and**
/// whose effective ScrollFrame clip, if any, also contains it — decision 0112 §5: a button
/// scrolled out of its ScrollFrame's rect must not hit), or `None`. Pure query — fires nothing,
/// mutates nothing. Call [`Self::resolve`] first; a frame with no resolved rect never captures.
///
/// **The handle form exists because a name is not always available and an id is not always
/// comparable.** [`Self::hit_test`] answers in ids (what the app and the Lua bindings speak) and
/// [`Self::hit_test_name`] answers in names (what a human reads). An instrument that has to ask
/// *whose* frame ate a click needs neither: the frame may be **anonymous**, and the only set it
/// can be checked against is a handle set snapshotted earlier ([`Self::live_targets`]'s). The
/// addon harness's use probe asks exactly that — a pointer event is only an addon's to be
/// judged by when the frame under the cursor is one the addon itself created.
pub fn hit_test_frame(&self, x: f32, y: f32) -> Option<FrameHandle> {
let model = self.model_ref();
let sorted = order::traversal(&model.arena);
let scroll_sources = scroll_clip_sources(&model);
let hit = order::hit_test(&sorted, |fh| {
order::hit_test(&sorted, |fh| {
model.arena.is_mouse_enabled(fh)
&& model.resolved.get(&fh).is_some_and(|r| {
point_in_rect(inset_rect(*r, model.arena.hit_rect_insets(fh)), x, y)
})
&& effective_clip(&model, &scroll_sources, fh)
.is_none_or(|c| point_in_rect(c, x, y))
});
hit.and_then(|h| model.frame_to_id.get(&h).copied())
})
}
/// Hit-test the cursor at `(x, y)` and return the **id** of the captured frame, or `None` —
/// [`Self::hit_test_frame`] with the id lookup every caller in the input path wants. The id is
/// the same one used across this host (e.g. the layout [`crate::layout::Handle`]); the app can
/// hand it back or map it as it likes.
pub fn hit_test(&self, x: f32, y: f32) -> Option<u32> {
let hit = self.hit_test_frame(x, y)?;
self.model_ref().frame_to_id.get(&hit).copied()
}
/// The NAME of the frame [`Self::hit_test`] captures at `(x, y)` — the pointer twin of
+326 -12
View File
@@ -127,6 +127,79 @@ fn install_region_methods(lua: &Lua) -> mlua::Result<()> {
})?,
)?;
// SetParent(frame) — **a Texture/FontString really does have this**, and we were the ones
// missing it. `SetParent` lives in the REGION method table (`0x7a1550`); Texture's class lookup
// falls back to Region's at `0x79c650` and FontString's at `0x79ee50`, so both reach it (wow-re
// `system/ui/scratch/widget-api-batch-benilla.md` Q7, §5-verified). `FuBar_FuXPFu.lua:210`'s
// `self.Spark:SetParent(self.XPBar)` is not a broken addon.
//
// Four contract traps, each spelled out because each is a plausible implementation's silent
// divergence:
//
// · **The argument must be a FRAME.** `0x7a16ea` runs `IsA(FrameTag)` on it, and a Texture or
// Region argument raises `"…Wrong parent object type, expected frame"` (`0x87cb78`). Ours
// raises too — a re-parent that quietly did nothing is the silent-drop class of 1203/1205.
// · **A missing argument is NOT the nil form.** TNONE fails the `== LUA_TNIL` test and falls
// through to `"…Couldn't find region named '%s'"` (`0x87cb48`), so `tex:SetParent()` raises
// while `tex:SetParent(nil)` detaches. That is why this reads a `MultiValue`: mlua cannot
// tell an absent argument from an explicit nil any other way.
// · **Anchors are untouched.** The re-link moves draw-layer/region-list membership only; a
// re-parented Texture still anchors to whatever `SetPoint` named — which is why FuXPFu
// re-points its spark afterwards, and why our anchors (which store the resolved target id)
// need no fixing up at all.
// · **Zero return values**, on every path.
//
// The mechanism half — full re-link, layer and sub-level preserved, `nil` = orphaned but not
// destroyed — is [`crate::widget::WidgetArena::set_region_owner`]'s doc.
m.set(
"SetParent",
lua.create_function(|lua, args: mlua::MultiValue| {
let mut it = args.into_iter();
let Some(Value::Table(this)) = it.next() else {
return Err(mlua::Error::runtime("SetParent: expected a region"));
};
let rh = region_handle_of(lua, &this)?;
let Some(parent) = it.next() else {
return Err(mlua::Error::runtime(
"SetParent(): Couldn't find region named '' (no argument)",
));
};
let wrong_type =
|| mlua::Error::runtime("SetParent(): Wrong parent object type, expected frame");
let mut model = lua.app_data_mut::<Model>().expect("model");
let new_owner = match &parent {
Value::Nil => None,
// A region/font-object wrapper decodes to an id that is not a frame's (or to no id
// at all) — both land on the same "expected frame" the reference raises.
Value::Table(t) => Some(
decode_id(t)
.ok()
.and_then(|id| model.id_to_frame.get(&id).copied())
.ok_or_else(wrong_type)?,
),
// A name resolves through the frame registry, as every other frame-target argument
// does (`SetPoint`'s relativeTo, `SetParent` on the frame side).
Value::String(s) => {
let name = s.to_str()?;
Some(model.arena.lookup(name.as_ref()).ok_or_else(|| {
mlua::Error::runtime(format!(
"SetParent(): Couldn't find region named '{}'",
name.as_ref()
))
})?)
}
_ => return Err(wrong_type()),
};
if model.arena.set_region_owner(rh, new_owner) {
// An un-anchored region draws relative to its owner, and an anchored one resolves
// against the owner's rect and effective scale (`layout.rs`'s region sweep) — the
// owner is a layout input, so a re-link is a layout change.
model.touch_layout();
}
Ok(())
})?,
)?;
// Region-level visibility — the real VisibleRegion Show/Hide on Textures/FontStrings (the
// ref kit hides tab slices, cooldown swipes, money coins…). A hidden region draws nothing;
// IsVisible additionally requires the owner frame's effective visibility, mirroring the
@@ -234,10 +307,72 @@ fn install_region_methods(lua: &Lua) -> mlua::Result<()> {
Ok((c[0], c[1], c[2], c[3]))
})?,
)?;
// SetGradientAlpha(orientation, r1,g1,b1,a1, r2,g2,b2,a2) and its alpha-less twin
// SetGradient(orientation, r1,g1,b1, r2,g2,b2) — the two-stop linear gradient the client
// generates into the same `+0xcc` texture slot the colour form of SetTexture fills.
//
// These were missing, and they were the single wall in front of the corpus's largest family:
// `FuBar\FuBar_Panel.lua:144` calls SetGradientAlpha while building the bar, so all 20 FuBar
// plugins died there — after the debugstack/chunk-name fix got them that far.
//
// The orientation token is matched case-insensitively and anything that is not "VERTICAL" is
// horizontal, which is the client's own leniency and matters because addons spell it both ways.
// The gradient is stored WHOLE (see `RegionData::gradient`); the paint folds it to its midpoint
// because a quad carries one tint today. That approximation is visible and is recorded at the
// field, not hidden here.
for (name, with_alpha) in [("SetGradientAlpha", true), ("SetGradient", false)] {
m.set(
name,
lua.create_function(move |lua, args: mlua::MultiValue| {
let mut it = args.into_iter();
let this: Table = match it.next() {
Some(Value::Table(t)) => t,
_ => return Err(mlua::Error::runtime("expected a region")),
};
let orientation = match it.next() {
Some(Value::String(s)) => s.to_str()?.to_string(),
// A missing/!string orientation is horizontal, like any non-"VERTICAL" token.
_ => String::new(),
};
let n = if with_alpha { 8 } else { 6 };
let mut c = [0.0f32; 8];
for slot in c.iter_mut().take(n) {
*slot = it.next().as_ref().map(as_f32).unwrap_or(0.0);
}
let (start, end) = if with_alpha {
([c[0], c[1], c[2], c[3]], [c[4], c[5], c[6], c[7]])
} else {
// SetGradient has no alpha stops: both ends are opaque.
([c[0], c[1], c[2], 1.0], [c[3], c[4], c[5], 1.0])
};
let rh = region_handle_of(lua, &this)?;
let mut model = lua.app_data_mut::<Model>().expect("model");
let d = model.region_data.entry(rh).or_default();
d.gradient = Some(super::Gradient {
vertical: orientation.eq_ignore_ascii_case("VERTICAL"),
start,
end,
});
Ok(())
})?,
)?;
}
m.set(
"SetTexture",
// The trailing three are `Value`, not `Option<f32>`, and that is a fidelity fix rather than
// laxity. The path form reads ONE argument (`0x770200`); only the colour form
// (`0x770360`) reads up to four. A C function takes what it wants off the Lua stack and
// ignores the rest, so `SetTexture(path, true)` is fine on the real client — and typing
// these as `Option<f32>` made us raise on it, `bad argument #3: error converting Lua
// boolean to f32`, in a call the client accepts silently.
//
// Found by `_LazyPig/LazyPigMenu.lua:182`
// (`texture_title:SetTexture("Interface\DialogFrame\UI-DialogBox-Header", true)`), which
// reached us only once the survey started seating the addon registry — the whole point of
// that instrument fix. The stray `true` is meaningless in 1.12 and the addon author
// presumably meant a later client's second parameter; either way the client shrugs.
lua.create_function(
|lua, (this, arg, g, b, a): (Table, Value, Option<f32>, Option<f32>, Option<f32>)| {
|lua, (this, arg, g, b, a): (Table, Value, Value, Value, Value)| {
let rh = region_handle_of(lua, &this)?;
let mut model = lua.app_data_mut::<Model>().expect("model");
let data = model.region_data.entry(rh).or_default();
@@ -260,13 +395,21 @@ fn install_region_methods(lua: &Lua) -> mlua::Result<()> {
data.texture = Some(s.to_str()?.to_string());
data.fill = None;
}
// The colour form, and the ONLY branch that looks at the trailing three. A
// non-numeric there takes the same default a missing one does, which is what
// reading off a C stack does: `lua_tonumber` on a non-number yields 0.
Value::Number(_) | Value::Integer(_) => {
data.fill = Some([
as_f32(&arg),
g.unwrap_or(0.0),
b.unwrap_or(0.0),
a.unwrap_or(1.0),
]);
let chan = |v: &Value, dflt: f32| match v {
Value::Number(_) | Value::Integer(_) => as_f32(v),
Value::String(s) => s
.to_str()
.ok()
.and_then(|s| s.parse::<f32>().ok())
.unwrap_or(dflt),
_ => dflt,
};
data.fill =
Some([as_f32(&arg), chan(&g, 0.0), chan(&b, 0.0), chan(&a, 1.0)]);
data.texture = None;
}
// SetTexture(nil) clears (the live API's blank-the-region form); a cleared
@@ -281,6 +424,73 @@ fn install_region_methods(lua: &Lua) -> mlua::Result<()> {
},
)?,
)?;
// SetDesaturated(flag) -> shaderSupported — Texture only (`0x79c1e0`, wow-re ledger; the
// reference's own `ItemButtonTemplate.lua:69` is `local shaderSupported =
// icon:SetDesaturated(desaturated)`).
//
// **The RETURN is the whole design, and it is the half a plausible implementation drops.**
// 1.12 ran on cards that could not do the shader, so the verb reports whether it took effect
// and FrameXML falls back by hand:
//
// if ( not desaturated ) then r,g,b = 1,1,1
// elseif ( not r or not shaderSupported ) then r,g,b = 0.5,0.5,0.5 end
// icon:SetVertexColor(r, g, b)
//
// We have no desaturation in the renderer, so we answer **nil — unsupported**, which is a real
// 1.12 machine's answer and not a lie. Claiming support would suppress that grey fallback and
// leave disabled icons drawn at full colour: strictly worse-looking than saying no. When a
// desaturating shader lands, flip the return and this comment with it.
//
// Why it matters far past one verb: **98 of the 109 addons that draw and then raise on being
// used, raise here** — `FuBar_Panel.lua:43`'s right-click reaches Dewdrop's `AddLine`
// (`Dewdrop-2.0.lua:2172`), which calls `button.arrow:SetDesaturated(true)` unguarded. A
// static scan costed this at 61 addons and it was declined; the use-probe costed it at 98 the
// moment anyone right-clicks.
m.set(
"SetDesaturated",
lua.create_function(|lua, (this, flag): (Table, Value)| {
let on = !matches!(flag, Value::Nil | Value::Boolean(false));
let rh = region_handle_of(lua, &this)?;
let mut model = lua.app_data_mut::<Model>().expect("model");
model.region_data.entry(rh).or_default().desaturated = on;
// nil, not false: the reference's `shaderSupported` is a 1|nil C answer and callers
// write `not shaderSupported`.
Ok(Value::Nil)
})?,
)?;
// GetTexture() — Texture only (`0x79ba70`), verified in wow-re's widget-method batch
// (`system/ui/scratch/widget-api-batch-benilla.md`). Three contract details are each the kind a
// plausible implementation gets silently wrong, so each is spelled out:
//
// · **Exactly ONE return value**, never a multi-return.
// · **The colour form returns the literal string `"Solid Texture"`** (`0x835708`), NOT nil.
// `SetTexture(r,g,b)` synthesizes an 8x8 solid, and the getter reports that name — so an
// addon's `if not tex then` passes straight through on a colour-filled region. Returning nil
// here would look tidier and would be wrong in the one direction callers test for.
// · **The path is stripped at the LAST `.`** (`0x79baf0`): the loader appends `.blp`/`.tga` to
// what was set and the getter strips an extension back off. Taken verbatim rather than
// "strip only a real extension" — a directory containing a dot is mangled by the real client
// too, and this surface is a transcription, not an improvement.
//
// Four corpus addons: `AtlasQuest.lua:228` (`AQATLASMAP = AtlasMap:GetTexture()`) and
// `FuBarPlugin-2.0.lua:343` (`return self.iconFrame:GetTexture()`), each reached by two addons.
m.set(
"GetTexture",
lua.create_function(|lua, this: Table| {
let rh = region_handle_of(lua, &this)?;
let model = lua.app_data_mut::<Model>().expect("model");
let Some(data) = model.region_data.get(&rh) else {
return Ok(None);
};
if data.fill.is_some() {
return Ok(Some("Solid Texture".to_string()));
}
Ok(data.texture.as_ref().map(|t| match t.rfind('.') {
Some(i) => t[..i].to_string(),
None => t.clone(),
}))
})?,
)?;
// SetPortraitToTexture(path) — set the texture AND mark the region a portrait (drawn masked to
// its inscribed circle). The client's portraits are circular (model or icon fallback); the frame
// ring is a thin band whose transparent corners would otherwise show the square texture's edges.
@@ -745,6 +955,114 @@ fn install_region_methods(lua: &Lua) -> mlua::Result<()> {
}
})?,
)?;
// SetNonSpaceWrap(enable) / CanNonSpaceWrap() — FontString only (`0x79e9f0` / `0x79ead0`).
//
// Two contract details from wow-re's batch, both easy to get wrong:
// · the getter is **`CanNonSpaceWrap`**, not `GetNonSpaceWrap`, and it answers **`1` or nil**,
// not a boolean — 1.12 predates that convention and an addon may compare against 1.
// · a **no-argument call ENABLES** it (the default is on), rather than being a query.
//
// `oRA2/Leader/Item.lua:561` is `f.textname:SetNonSpaceWrap(false)`, reached by two addons.
m.set(
"SetNonSpaceWrap",
lua.create_function(|lua, (this, enable): (Table, Value)| {
let on = match &enable {
Value::Nil => true,
Value::Boolean(b) => *b,
_ => true,
};
let rh = region_handle_of(lua, &this)?;
let mut model = lua.app_data_mut::<Model>().expect("model");
model.region_data.entry(rh).or_default().non_space_wrap = Some(on);
Ok(())
})?,
)?;
m.set(
"CanNonSpaceWrap",
lua.create_function(|lua, this: Table| {
let rh = region_handle_of(lua, &this)?;
let model = lua.app_data_mut::<Model>().expect("model");
let on = model
.region_data
.get(&rh)
.and_then(|d| d.non_space_wrap)
.unwrap_or(true);
Ok(if on { Some(1i64) } else { None })
})?,
)?;
// ── shadow, on the REGION ────────────────────────────────────────────────────────────────
// These already existed on the font-object table; a FontString made by `CreateFontString` had
// none of them, and that is where the corpus calls them:
// `FuBar_NavigatorFu/NavigatorFu.lua:31` does
// `coordText:SetShadowColor(GameFontNormal:GetShadowColor())` — the GETTER on a font object,
// the SETTER on a fresh region — and `KLHThreatMeter/.../KTM_Gui.lua:404` is
// `fontstring:SetShadowColor(0,0,0,0.3)`.
//
// **`GetShadowColor` returns FOUR values, not three** (`0x79dd2f`, `mov eax,0x4` — wow-re's
// widget-method batch). Three is the plausible wrong answer and it silently drops the alpha
// that NavigatorFu is round-tripping. `GetShadowOffset` returns two, in UI units.
//
// Either half may be set before the other, so each starts from whatever is there — the same
// rule the font-object versions already follow.
m.set(
"SetShadowColor",
lua.create_function(
|lua, (this, r, g, b, a): (Table, f32, f32, f32, Option<f32>)| {
let rh = region_handle_of(lua, &this)?;
let mut model = lua.app_data_mut::<Model>().expect("model");
let d = model.region_data.entry(rh).or_default();
let offset = d.font_shadow.map_or([0.0, 0.0], |s| s.offset);
d.font_shadow = Some(crate::script::FontShadow {
offset,
color: [r, g, b, a.unwrap_or(1.0)],
});
d.font_explicit.shadow = true;
Ok(())
},
)?,
)?;
m.set(
"GetShadowColor",
lua.create_function(|lua, this: Table| {
let rh = region_handle_of(lua, &this)?;
let model = lua.app_data_mut::<Model>().expect("model");
let c = model
.region_data
.get(&rh)
.and_then(|d| d.font_shadow)
.map_or([0.0, 0.0, 0.0, 1.0], |s| s.color);
Ok((c[0], c[1], c[2], c[3]))
})?,
)?;
m.set(
"SetShadowOffset",
lua.create_function(|lua, (this, x, y): (Table, f32, f32)| {
let rh = region_handle_of(lua, &this)?;
let mut model = lua.app_data_mut::<Model>().expect("model");
let d = model.region_data.entry(rh).or_default();
let color = d.font_shadow.map_or([0.0, 0.0, 0.0, 1.0], |s| s.color);
d.font_shadow = Some(crate::script::FontShadow {
offset: [x, y],
color,
});
d.font_explicit.shadow = true;
Ok(())
})?,
)?;
m.set(
"GetShadowOffset",
lua.create_function(|lua, this: Table| {
let rh = region_handle_of(lua, &this)?;
let model = lua.app_data_mut::<Model>().expect("model");
let o = model
.region_data
.get(&rh)
.and_then(|d| d.font_shadow)
.map_or([0.0, 0.0], |s| s.offset);
Ok((o[0], o[1]))
})?,
)?;
// SetFont(path, height [, flags]) — the direct face/size/outline setter (the real region API and
// the XML `font=`/`<FontHeight>`/`outline=` join). `flags` is an OUTLINETYPE-ish string
// ("OUTLINE"/"THICKOUTLINE"/…"); anything else clears the outline. A nil/empty `path` keeps the
@@ -769,11 +1087,7 @@ fn install_region_methods(lua: &Lua) -> mlua::Result<()> {
d.font_explicit.height = true;
}
if let Some(f) = flags {
d.outline = match f.to_ascii_uppercase() {
f if f.contains("THICK") => Outline::Thick,
f if f.contains("OUTLINE") => Outline::Normal,
_ => Outline::None,
};
d.outline = Outline::flags(&f);
d.font_explicit.outline = true;
}
Ok(true)
+203 -2
View File
@@ -38,10 +38,40 @@ pub(super) fn sandbox(lua: &Lua) -> mlua::Result<()> {
g.set(name, Value::Nil)?;
}
// A `debugstack` stub: real addons call it in error paths; returning "" keeps them running.
// `debugstack([start [, count1 [, count2]]])` — a REAL traceback, not the `""` stub this used
// to be.
//
// The stub was justified as "real addons call it in error paths; returning \"\" keeps them
// running", and that was true of the addons that only ever DISPLAY it. It is false of the ones
// that PARSE it, and the biggest family in the corpus is one of those: `FuBarPlugin-2.0.lua:752`
// derives every plugin's own folder with
// `string.find(debugstack(6, 1, 0), "\\AddOns\\(.*)\\")`, then formats the capture into an
// icon path. Against `""` the find returns nil, `folderName` stays nil, and the plugin dies in
// `format` — 20 corpus addons, every one of them a FuBar plugin, and the error surfaced as a
// `format` complaint three frames away from the cause.
//
// `start` is the stack level to begin at and defaults to 1, matching the client's own callers
// (`debugstack(6, 1, 0)` walks past FuBar's own wrapper frames to the plugin file). The two
// count arguments bound how many frames are shown at the top and bottom; mlua's traceback does
// not take them, so they are accepted and ignored — stated rather than silently dropped, and
// harmless because every corpus caller passes a shape whose whole purpose is "give me the
// frames", never an exact line count.
g.set(
"debugstack",
lua.create_function(|_, _: Variadic<Value>| Ok(String::new()))?,
lua.create_function(|lua, args: Variadic<Value>| {
let start = match args.first() {
Some(Value::Integer(i)) => *i as usize,
Some(Value::Number(n)) => *n as usize,
_ => 1,
};
// A level past the top of the stack is not an error here, it is an empty trace — the
// same shape the old stub returned, so a caller that guessed too deep still gets a
// string rather than a raise.
Ok(lua
.traceback(None, start)
.and_then(|t| t.to_str().map(|s| s.to_owned()))
.unwrap_or_default())
})?,
)?;
// Text-only `loadstring`: compile a *source* string (never bytecode). Returns `function` on
@@ -93,6 +123,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
})?,
)?;
install_time(lua)?;
lua.load(WOW_STDLIB)
.set_name("=[benilla wow stdlib]")
.set_mode(mlua::ChunkMode::Text)
@@ -388,3 +419,173 @@ do
format = reformat
end
"#;
/// `time()` and `date([format [, when]])` — engine globals in the 1.12 client's own `_G`, slots 34
/// and 33 of its base registry (`0x7035a0` / `0x7033a0`, wow-re `lua-dialect.md`).
///
/// They are Lua 5.0's `os.time`/`os.date` hoisted to globals, and we lacked both because the
/// sandbox strips `os` wholesale. `time` was the top name in the session-start
/// `attempt to call global` row (6 addons); `date` is a handful more. Every corpus `time()` site is
/// the same shape — an epoch stamp persisted into SavedVariables and compared across sessions
/// (`FTC_Save[k].LastCheck = time()`) — so it has to be real wall-clock seconds, not a
/// session-relative clock like `GetTime`.
///
/// **UTC, and that is a stated divergence.** `os.date` uses LOCAL time and this uses UTC, because
/// resolving a local offset needs a timezone database and this tree has no date dependency at all
/// (deliberately — the format crates here are all in-repo). The corpus's uses are a debug
/// timestamp, a "last scanned" string, and `%M:%S` over a DURATION; only the first two shift, and
/// they shift by a constant. Fixing it means a tz source, not a different algorithm.
fn install_time(lua: &Lua) -> mlua::Result<()> {
let g = lua.globals();
g.set("time", lua.create_function(|_, ()| Ok(unix_seconds()))?)?;
g.set(
"date",
lua.create_function(|_, (fmt, when): (Option<String>, Option<i64>)| {
// Bare `date()` is `%c`, as in Lua — `Recap.lua:2690` calls it with no arguments.
let fmt = fmt.unwrap_or_else(|| "%c".to_string());
Ok(format_epoch(when.unwrap_or_else(unix_seconds), &fmt))
})?,
)?;
Ok(())
}
/// Wall-clock seconds since the Unix epoch. Before the epoch is not a state a game client is in;
/// a clock that somehow reports it clamps to 0 rather than raising inside an addon's file scope.
fn unix_seconds() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0)
}
const DAYS: [&str; 7] = [
"Sunday",
"Monday",
"Tuesday",
"Wednesday",
"Thursday",
"Friday",
"Saturday",
];
const MONTHS: [&str; 12] = [
"January",
"February",
"March",
"April",
"May",
"June",
"July",
"August",
"September",
"October",
"November",
"December",
];
/// Broken-down UTC time from an epoch second: (year, month 1-12, day 1-31, hour, min, sec,
/// weekday 0=Sunday, yearday 1-366).
///
/// The civil-from-days conversion is Howard Hinnant's, shifted to a March-based year so the leap
/// day lands at the end and no month-length table is needed. Chosen over a hand-rolled loop because
/// it is a known-correct closed form with no accumulation error, which matters for the "persisted
/// last session, compared this session" use every corpus caller has.
fn civil_from_epoch(secs: i64) -> (i64, u32, u32, u32, u32, u32, u32, u32) {
let days = secs.div_euclid(86_400);
let rem = secs.rem_euclid(86_400);
let (hour, min, sec) = (
(rem / 3600) as u32,
((rem % 3600) / 60) as u32,
(rem % 60) as u32,
);
// 1970-01-01 was a Thursday (4).
let weekday = (days + 4).rem_euclid(7) as u32;
let z = days + 719_468;
let era = z.div_euclid(146_097);
let doe = z.rem_euclid(146_097);
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let y = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let day = (doy - (153 * mp + 2) / 5 + 1) as u32;
let month = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
let year = if month <= 2 { y + 1 } else { y };
// Day of the year, from the same conversion applied to Jan 1.
let jan1 = days_from_civil(year, 1, 1);
let yearday = (days - jan1 + 1) as u32;
(year, month, day, hour, min, sec, weekday, yearday)
}
/// Days since the epoch for a civil date — the inverse of the above, used only for `%j`.
fn days_from_civil(y: i64, m: u32, d: u32) -> i64 {
let y = if m <= 2 { y - 1 } else { y };
let era = y.div_euclid(400);
let yoe = y.rem_euclid(400);
let mp = if m > 2 { m - 3 } else { m + 9 } as i64;
let doy = (153 * mp + 2) / 5 + d as i64 - 1;
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
era * 146_097 + doe - 719_468
}
/// The `strftime` subset the corpus actually uses, plus the obvious neighbours.
///
/// Bounded on purpose: the specifiers here are the ones read off real call sites —
/// `%H:%M:%S` (AceDebug), `%A, %B %d, %Y - %H:%M` (FuBar_PotHerbFu), `%M:%S` (FuBar_AnkhTimerFu),
/// `%c` (bare `date()`). An unknown specifier is emitted VERBATIM rather than swallowed, so a
/// format this does not know shows up in the output instead of silently vanishing.
fn format_epoch(secs: i64, fmt: &str) -> String {
let (year, month, day, hour, min, sec, wday, yday) = civil_from_epoch(secs);
let mut out = String::with_capacity(fmt.len() + 16);
let mut chars = fmt.chars().peekable();
while let Some(c) = chars.next() {
if c != '%' {
out.push(c);
continue;
}
let Some(spec) = chars.next() else {
out.push('%');
break;
};
let hour12 = match hour % 12 {
0 => 12,
h => h,
};
match spec {
'a' => out.push_str(&DAYS[wday as usize][..3]),
'A' => out.push_str(DAYS[wday as usize]),
'b' | 'h' => out.push_str(&MONTHS[(month - 1) as usize][..3]),
'B' => out.push_str(MONTHS[(month - 1) as usize]),
'c' => out.push_str(&format!(
"{} {} {:2} {:02}:{:02}:{:02} {}",
&DAYS[wday as usize][..3],
&MONTHS[(month - 1) as usize][..3],
day,
hour,
min,
sec,
year
)),
'd' => out.push_str(&format!("{day:02}")),
'H' => out.push_str(&format!("{hour:02}")),
'I' => out.push_str(&format!("{hour12:02}")),
'j' => out.push_str(&format!("{yday:03}")),
'm' => out.push_str(&format!("{month:02}")),
'M' => out.push_str(&format!("{min:02}")),
'p' => out.push_str(if hour < 12 { "AM" } else { "PM" }),
'S' => out.push_str(&format!("{sec:02}")),
'w' => out.push_str(&format!("{wday}")),
'x' => out.push_str(&format!("{month:02}/{day:02}/{:02}", year.rem_euclid(100))),
'X' => out.push_str(&format!("{hour:02}:{min:02}:{sec:02}")),
'y' => out.push_str(&format!("{:02}", year.rem_euclid(100))),
'Y' => out.push_str(&format!("{year}")),
'%' => out.push('%'),
// Unknown: verbatim, so it is visible rather than swallowed.
other => {
out.push('%');
out.push(other);
}
}
}
out
}
@@ -107,3 +107,131 @@ fn setpoint_unresolved_name_warns() {
.unwrap();
assert!(s.take_warnings().is_empty());
}
/// **A region whose OWNER has no rect still resolves from its own anchors.**
///
/// `layout.rs`'s region pass used to `continue` when `resolved` held nothing for the owner frame.
/// But the owner's rect is only the *fallback* for axes the region's own anchors do not pin — a
/// region anchored fully to some other frame needs nothing from its owner, and the reference
/// resolves it. Skipping made an ordinary addon shape silently invisible: a bare container frame
/// (`CreateFrame("Frame", n, parent)` with no size and no `SetPoint`) holding a region anchored
/// elsewhere.
///
/// Found by reproducing the director's MapCoords report. Its world-map readout computed the right
/// string every single frame — `Cursor Coords: … Player Coords: …` — and was never given a
/// position, with no error raised anywhere. Three of its four frames are this shape.
///
/// The control matters as much as the subject: a SIZED owner must be unaffected, or the fix has
/// traded one wrong layout for another.
#[test]
fn a_region_resolves_even_when_its_owner_frame_has_no_rect() {
let mut s = crate::script::UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
s.run(
r#"
host = CreateFrame("Frame", "AnchorHost")
host:SetWidth(400) host:SetHeight(200)
host:SetPoint("BOTTOMLEFT", 100, 50)
-- the shape that was invisible: owner with NO size and NO SetPoint
bare = CreateFrame("Frame", "BareOwner")
mark = bare:CreateTexture("BareMark", "ARTWORK")
mark:SetWidth(20) mark:SetHeight(10)
mark:SetPoint("BOTTOMLEFT", host, "BOTTOMLEFT", 5, 7)
-- control: identical region, owner that DOES resolve
sized = CreateFrame("Frame", "SizedOwner")
sized:SetWidth(10) sized:SetHeight(10) sized:SetPoint("CENTER")
ctl = sized:CreateTexture("SizedMark", "ARTWORK")
ctl:SetWidth(20) ctl:SetHeight(10)
ctl:SetPoint("BOTTOMLEFT", host, "BOTTOMLEFT", 5, 7)
"#,
)
.unwrap();
s.resolve();
// The owner genuinely has no rect — that part is correct and must stay true.
assert_eq!(
s.eval::<Option<f32>>("return BareOwner:GetLeft()").unwrap(),
None,
"an unpositioned frame has no rect; the fix must not invent one for it"
);
// ...and its region resolves anyway, from its own anchor: host's BOTTOMLEFT (100,50) + (5,7).
assert_eq!(
s.eval::<Option<f32>>("return BareMark:GetLeft()").unwrap(),
Some(105.0),
"a fully-anchored region needs nothing from its owner"
);
assert_eq!(
s.eval::<Option<f32>>("return BareMark:GetBottom()")
.unwrap(),
Some(57.0)
);
// The control is unmoved.
assert_eq!(
s.eval::<Option<f32>>("return SizedMark:GetLeft()").unwrap(),
Some(105.0),
"a sized owner's region must be unaffected by the fix"
);
}
/// **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
/// pass can run. `Dewdrop-2.0.lua` — embedded in ~65 corpus addons — does exactly this in its menu
/// `Open`: `local left = frame:GetLeft()` (l.1942) then `curX - left - width/2` (l.1960). Our
/// getters read a cache filled by the per-frame `resolve()`, so `left` was nil and the arithmetic
/// died. **97 of the 108 addons that drew and then raised on being touched died on that one line**,
/// with no missing verb anywhere.
///
/// The readers settle the graph on demand now. The second half of the test is the one that keeps
/// it honest: a *further* move inside the same stack must also be visible, or the fix is just a
/// one-shot warm-up.
#[test]
fn geometry_answers_within_the_call_stack_that_moved_it() {
let mut s = crate::script::UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
// Dewdrop's shape, in one eval — no resolve() between the writes and the read.
let (left, bottom): (Option<f32>, Option<f32>) = s
.eval(
r#"
local f = CreateFrame("Frame", "MenuLike", UIParent)
f:SetWidth(100) f:SetHeight(50)
f:SetPoint("BOTTOMLEFT", UIParent, "BOTTOMLEFT", 40, 60)
f:Show()
return f:GetLeft(), f:GetBottom()
"#,
)
.unwrap();
assert_eq!(
(left, bottom),
(Some(40.0), Some(60.0)),
"a frame must report its geometry in the stack that anchored it"
);
// ...and a LATER move in the same stack is visible too — not a stale first answer.
let moved: Option<f32> = s
.eval(
r#"
MenuLike:ClearAllPoints()
MenuLike:SetPoint("BOTTOMLEFT", UIParent, "BOTTOMLEFT", 200, 60)
return MenuLike:GetLeft()
"#,
)
.unwrap();
assert_eq!(moved, Some(200.0), "the settle must re-run after each move");
// Width/height and the centre reader take the same path.
assert_eq!(
s.eval::<(f32, f32)>("return MenuLike:GetWidth(), MenuLike:GetHeight()")
.unwrap(),
(100.0, 50.0)
);
assert_eq!(
s.eval::<(Option<f32>, Option<f32>)>("return MenuLike:GetCenter()")
.unwrap(),
(Some(250.0), Some(85.0))
);
}
@@ -44,6 +44,114 @@ fn backdrop_installs_and_extracts_pieces_with_colors() {
.all(|(p, c)| p == "edge" && *c == [1.0, 1.0, 1.0, 1.0]));
}
// `GetBackdrop()` — the four traps of `0x777370` (wow-re `widget-api-batch-benilla.md` Q5), pinned
// where an addon can observe them. The corpus caller is `BuffCheck2.lua:448`, which reads the table
// back, edits `insets`, and feeds it straight to `SetBackdrop` — so a wrong `tile` type or a missing
// ctor default does not just read wrong, it round-trips wrong.
#[test]
fn get_backdrop_reconstructs_from_the_struct() {
let s = script();
s.run(
r#"
local f = CreateFrame("Frame", "GB")
-- TRAP 2: no backdrop => ZERO values, not nil. `select('#')` is the only way to see it.
assert(select('#', f:GetBackdrop()) == 0, "unset backdrop must return no values at all")
-- TRAP 1: the result is rebuilt from the struct, not the caller's table. The alien key is
-- the proof: SetBackdrop never accepted it, so it cannot come back out.
local passed = { bgFile = "bg", edgeFile = "edge", tile = true,
tileSize = 16, edgeSize = 24,
insets = { left = 1, right = 2, top = 3, bottom = 4 },
alien = "must not survive" }
f:SetBackdrop(passed)
local b = f:GetBackdrop()
assert(select('#', f:GetBackdrop()) == 1, "a set backdrop is exactly one value")
assert(b ~= passed, "must not hand back the caller's own table")
assert(b.alien == nil, "keys SetBackdrop never read cannot reappear")
assert(b.insets ~= passed.insets, "the insets subtable is rebuilt too")
assert(b.bgFile == "bg" and b.edgeFile == "edge", "files round-trip")
assert(b.tileSize == 16 and b.edgeSize == 24, "sizes round-trip")
assert(b.insets.left == 1 and b.insets.right == 2
and b.insets.top == 3 and b.insets.bottom == 4, "insets round-trip")
-- TRAP 4: `tile` is the NUMBER 1, never the boolean the caller passed.
assert(type(b.tile) == "number", "tile must be a number, got " .. type(b.tile))
assert(b.tile == 1, "tile must be 1")
-- Mutating the caller's table afterwards cannot reach the frame (no reference is kept).
passed.bgFile = "clobbered"
assert(f:GetBackdrop().bgFile == "bg", "no live reference to the caller's table")
-- TRAP 3: a partial SetBackdrop omits NOTHING — a fresh struct is allocated every call, so
-- the keys left out come back as CTOR DEFAULTS, and none of the old backdrop survives.
f:SetBackdrop({ edgeFile = "onlyedge" })
local p = f:GetBackdrop()
assert(p.bgFile == "", "an omitted bgFile is the empty string, not nil")
assert(p.edgeSize == 32, "an omitted edgeSize is the ctor's 32, not the previous 24")
assert(p.tileSize == 0, "an omitted tileSize is 0, not the previous 16")
assert(p.insets.left == 0 and p.insets.right == 0
and p.insets.top == 0 and p.insets.bottom == 0, "omitted insets are 0, not 1/2/3/4")
-- TRAP 4, the other half: tile false pushes nil, so the key is ABSENT — never `false`.
assert(p.tile == nil, "tile false means the key is absent, not `false`")
-- The undocumented in-place form: fills and returns YOUR table, recycling `insets`, and
-- erases a stale `tile` on the way (the nil push is a real `lua_settable`).
local ins = {}
local mine = { insets = ins, tile = true, stale = "kept" }
local got = f:GetBackdrop(mine)
assert(got == mine, "the in-place form returns the table it was given")
assert(mine.insets == ins, "an existing insets subtable is reused, not replaced")
assert(mine.tile == nil, "a stale tile key is erased from a recycled table")
assert(mine.stale == "kept", "keys it does not write are left alone")
assert(mine.edgeSize == 32 and ins.bottom == 0, "the recycled table is filled")
-- SetBackdrop(nil) is indistinguishable from never having set one: back to zero values.
f:SetBackdrop(nil)
assert(select('#', f:GetBackdrop()) == 0, "SetBackdrop(nil) returns to the zero-value shape")
"#,
)
.unwrap();
}
// BuffCheck2.lua:448's exact shape: read the plate back, edit `insets`, push it straight back in.
// It is the whole reason this method exists in our client, so it is the test that must not rot.
#[test]
fn get_backdrop_round_trips_through_set_backdrop() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.run(
r#"
local f = CreateFrame("Frame", "BC2Plate")
f:SetPoint("CENTER")
f:SetSize(54, 54)
f:SetBackdrop({ bgFile = "bg", edgeFile = "edge", tile = true,
tileSize = 32, edgeSize = 32,
insets = { left = 11, right = 12, top = 12, bottom = 11 } })
local backdrop = f:GetBackdrop()
backdrop["insets"] = { top = 12, bottom = 11, right = 12, left = 11 }
backdrop["tile"] = true
backdrop["tileSize"] = 32
backdrop["edgeSize"] = 32
f:SetBackdrop(backdrop)
local b = f:GetBackdrop()
assert(b.bgFile == "bg" and b.edgeFile == "edge", "files survive the round trip")
assert(b.tile == 1 and b.tileSize == 32 and b.edgeSize == 32, "tiling survives")
assert(b.insets.left == 11 and b.insets.bottom == 11, "insets survive")
"#,
)
.unwrap();
s.resolve();
// The plate still draws: bg + 8 border pieces, unchanged by the round trip.
assert_eq!(
s.extract()
.iter()
.filter(|q| matches!(q.content, QuadContent::Backdrop { .. }))
.count(),
9
);
}
// SetBackdrop(nil) tears the plate down (no pieces after).
#[test]
fn set_backdrop_nil_tears_down() {
@@ -458,3 +458,66 @@ fn button_label_repaints_by_state_font_object() {
let c = label_color(&mut s).expect("colored");
assert!((c[0] - 0.1).abs() < 1e-6 && (c[1] - 0.2).abs() < 1e-6);
}
/// **`Button:GetTextWidth` / `GetTextHeight`** — the reference's own Button text-extent readers
/// (`0x782290` / `0x782390`; wow-re `widget-api-batch-benilla.md` Q8 carves them present on Button
/// and `GetStringWidth` **absent**). Both forward to the label FontString's own extent slots, which
/// is what this asserts: answer a host measure for the label, and the BUTTON must report it.
///
/// `Bagnon_Forever/database/ui.lua:61` sizes its character-switch dropdown from
/// `button:GetTextWidth() + 40`, so a nil method took the whole dropdown down — the director could
/// not switch characters in Bagnon at all.
#[test]
fn a_button_reports_its_labels_extent() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.run(
r#"
b = CreateFrame("Button", "WidthBtn", UIParent)
b:SetPoint("CENTER", UIParent, "CENTER", 0, 0)
b:SetWidth(120) b:SetHeight(24)
b:SetText("A Label")
plain = CreateFrame("Button", "LabellessBtn", UIParent)
"#,
)
.unwrap();
s.resolve();
// Unmeasured is 0 — the same "converges next frame" contract every other metric read has.
assert_eq!(
s.eval::<f64>("return WidthBtn:GetTextWidth()").unwrap(),
0.0
);
let req = s
.fontstrings_needing_measure()
.into_iter()
.find(|r| r.text == "A Label")
.expect("the label asks the host for its extent");
s.set_measured_text_unwrapped(&[(req.id, 47.0, 14.0, req.key)]);
assert_eq!(
s.eval::<f64>("return WidthBtn:GetTextWidth()").unwrap(),
47.0,
"the Button forwards to its own label"
);
assert_eq!(
s.eval::<f64>("return WidthBtn:GetTextHeight()").unwrap(),
14.0
);
// A Button with no label at all answers 0 rather than raising: the reference dereferences a
// FontString pointer (`+0x338`) here that a bare CreateFrame("Button") leaves null, and what it
// does then is not byte-read — so this takes the harmless number instead of guessing a crash.
assert_eq!(
s.eval::<f64>("return LabellessBtn:GetTextWidth()").unwrap(),
0.0
);
// …and it is a BUTTON method: a plain Frame must not have grown one.
assert!(
s.eval::<bool>(r#"return CreateFrame("Frame").GetTextWidth == nil"#)
.unwrap(),
"GetTextWidth is Button's, not Region's (wow-re Q8's own split)"
);
}
@@ -0,0 +1,110 @@
//! `GetChannelName` — the joined-channel lookup ([`crate::script::channel`]).
//!
//! The verb is small; the contract around it is not, and every test here is named after one claim.
//! The load-bearing one is [`a_channel_that_is_not_joined_answers_the_number_zero_never_nil`]: the
//! reference's own callers and the corpus both compare the first return numerically, so a nil there
//! does not degrade — it raises, at four call sites that work today.
use super::common::script;
/// Two channels joined in order, the way `ui_chat::feed` mirrors them after the server's
/// YOU_JOINED. Join ORDER is the whole numbering law, so the fixture is deliberately not sorted.
fn joined() -> crate::script::UiScript {
let mut s = script();
s.set_joined_channels(vec!["World".into(), "Trade - City".into()]);
s
}
/// **A joined channel answers its 1-based slot, its name, and an instanceID.** The slot is the
/// position in JOIN order — not a DBC id, not an alphabetical rank.
#[test]
fn a_joined_channel_answers_its_slot_name_and_instance() {
let s = joined();
let (id, name, instance): (i64, String, i64) = s.eval("return GetChannelName(2)").unwrap();
assert_eq!(id, 2, "the 1-based slot in join order");
assert_eq!(name, "Trade - City");
assert_eq!(
instance, 0,
"instanceID is 0 on every vanilla emulator, and a NUMBER so a caller can compare it"
);
}
/// **The name form resolves, case-insensitively, and yields the same slot as the index form.**
/// Half the corpus sites look up by name (`GetChannelName("world")`, `GetChannelName("Trade -
/// City")`), and `_LazyPig` passes `"world"` for a channel the server names `"World"`.
#[test]
fn the_name_form_resolves_case_insensitively_to_the_same_slot() {
let s = joined();
let by_name: i64 = s.eval("return GetChannelName('world')").unwrap();
let by_index: i64 = s.eval("return GetChannelName(1)").unwrap();
assert_eq!(by_name, 1);
assert_eq!(by_name, by_index, "both directions must agree on the slot");
let name: String = s
.eval("local _, n = GetChannelName('TRADE - CITY') return n")
.unwrap();
assert_eq!(
name, "Trade - City",
"the answer is the JOINED spelling, not the caller's"
);
}
/// **Not joined ⇒ the NUMBER 0. Never nil.** This is the trap, and it is the only way this verb can
/// break working code rather than merely fail to help it.
///
/// Verified from both sides. The reference compares it numerically at `ChatFrame.lua:2114`
/// (`if ( channelNum > 0 )`) and `l.2232` (`if ( channelNum <= 0 ) then return end`); so does the
/// corpus at `_LazyPig/LazyPig.lua:1996` (`if id > 0 then`). A nil first return turns every one of
/// those into "attempt to compare nil with number" — a loud failure in code that works today.
///
/// Asserted by DOING the comparison, not by inspecting the type, because the comparison is the
/// thing that must not raise.
#[test]
fn a_channel_that_is_not_joined_answers_the_number_zero_never_nil() {
let s = joined();
let id: i64 = s.eval("return GetChannelName('NoSuchChannel')").unwrap();
assert_eq!(id, 0);
// The reference's own guard shape, run for real: it must evaluate, not raise.
let guard: bool = s
.eval("local id = GetChannelName('NoSuchChannel') return id > 0")
.unwrap();
assert!(
!guard,
"the reference's `if ( channelNum > 0 )` must run and be false"
);
let out_of_range: bool = s
.eval("local id = GetChannelName(99) return id > 0")
.unwrap();
assert!(!out_of_range, "an out-of-range index takes the same branch");
let zero: bool = s
.eval("local id = GetChannelName(0) return id > 0")
.unwrap();
assert!(
!zero,
"the client bounds-checks 1 <= n <= count, so 0 is not a slot"
);
}
/// **A numeric STRING resolves as a number**, because `ChatFrame.lua:2113` hands one straight in:
/// it `gsub`s `/1` down to `"1"` and calls `GetChannelName(channel)`. On the real client Lua's own
/// coercion makes that work; ours must not treat it as a channel literally named "1".
#[test]
fn a_numeric_string_resolves_as_an_index_not_as_a_name() {
let s = joined();
let id: i64 = s.eval("return GetChannelName('2')").unwrap();
assert_eq!(id, 2, "the `/2` slash-command path depends on this");
}
/// **Empty until the server confirms a join.** `ui_chat::feed` appends on the server's YOU_JOINED
/// notice, never on the request, so a session that has asked and not been answered has no slots —
/// and the verb must still answer 0 rather than raise on an empty list.
#[test]
fn nothing_is_joined_before_the_server_confirms_it() {
let s = script();
let id: i64 = s.eval("return GetChannelName('World')").unwrap();
assert_eq!(id, 0);
}
@@ -112,3 +112,63 @@ fn events_fire_in_registration_order_fifo() {
s.fire_event("E", vec![]);
assert_eq!(s.eval::<String>("return order").unwrap(), "ACB");
}
/// **`HasScript` answers "can this widget CARRY that kind", not "does it have one set".**
///
/// That distinction is the verb's whole purpose, and every corpus caller depends on it: they ask
/// before hooking, precisely when nothing is set yet.
///
/// ```lua
/// if parent:HasScript("OnMouseDown") then -- Tablet-2.0.lua:2409
/// local script = parent:GetScript("OnMouseDown")
/// parent:SetScript("OnMouseDown", function() … end)
/// end
/// ```
///
/// It was the top session-start blocker — 32 of 39 `attempt to call method` failures were this one
/// name — and implementing it took survivors from 41 to 69.
///
/// The known over-permission is asserted too, so it is a recorded divergence rather than a
/// discovery: our table is flat where the reference's is per widget type, so a plain Frame answers
/// true for a Button-only kind. Exact for the base kinds, which is what the corpus asks about.
#[test]
fn has_script_reports_the_kind_is_supported_not_that_one_is_set() {
let s = script();
s.run(r#"f = CreateFrame("Frame", "HasScriptProbe")"#)
.unwrap();
// True with NOTHING set — the case every caller is actually in.
assert!(
s.eval::<bool>(r#"return f:HasScript("OnMouseDown")"#)
.unwrap(),
"a frame must report it can carry OnMouseDown before one is set"
);
assert!(
!s.eval::<bool>(r#"return f:HasScript("OnNotARealScript")"#)
.unwrap(),
"an unknown kind is false, not true"
);
// Tablet's exact idiom, run end to end: guard, read the (absent) handler, install one, fire it.
let fired: bool = s
.eval(
r#"
RAN = false
if f:HasScript("OnMouseDown") then
local prev = f:GetScript("OnMouseDown")
f:SetScript("OnMouseDown", function() RAN = true end)
end
f:GetScript("OnMouseDown")()
return RAN
"#,
)
.unwrap();
assert!(fired, "the guarded hook must install and run");
// The recorded divergence: flat table, so a Frame says true for a Button-only kind. The
// reference says false. Pinned so making SCRIPT_KINDS per-type has to come here and decide.
assert!(
s.eval::<bool>(r#"return f:HasScript("OnClick")"#).unwrap(),
"over-permissive by design today — see the comment at the binding"
);
}
@@ -0,0 +1,223 @@
//! Lua 5.0's iterator-less generic-for, restored in the vendored VM (decision 1215).
//!
//! `for k, v in someTable do` is Lua 5.0 syntax that 5.1 removed at the OPCODE level. 183 of 218
//! corpus addons are reached by it and it is the first session-start error for 60 of them — three
//! quarters of everything that breaks in a running session. The fix is 5.0's `OP_TFORPREP` folded
//! into `OP_TFORLOOP` in `third_party/lua-src/lua-5.1.5/lvm.c`.
//!
//! Each test below pins one detail that was byte-verified in the real 1.12.1 client (wow-5875-re
//! `system/ui/scratch/lua-generic-for.md`), because each is a place where a *reasonable* guess
//! diverges from what the client does. The `__call` case is the sharpest: two of the three
//! conditions somebody would naturally write for this behave differently there.
use super::common::script;
/// **The construct works at all** — the whole point, in the shape the corpus uses.
///
/// `Ace.lua`'s is the canonical one and Ace is embedded across half the ecosystem:
/// `for _, lang in self.langs do … end`, with no `pairs` and no iterator function.
#[test]
fn a_table_generator_iterates_like_lua_50() {
let s = script();
let seen: i64 = s
.eval(
r#"
local t = { a = 1, b = 2, c = 3 }
local sum = 0
for k, v in t do sum = sum + v end
return sum
"#,
)
.unwrap();
assert_eq!(seen, 6, "every pair must be visited exactly once");
// The array shape too, and the KEY must be the real key rather than a counter.
let keys: String = s
.eval(
r#"
local t = { "x", "y" }
local out = ""
for i, v in t do out = out .. i .. v end
return out
"#,
)
.unwrap();
assert_eq!(keys, "1x2y");
}
/// **The condition is a bare type-tag test, so a table carrying `__call` STILL gets `next`.**
///
/// This is the discriminating case, and the reason the patch does not test "is not callable" or
/// "has no `__call`" — both of those are reasonable readings and both diverge from the client here.
/// The 1.12 handler makes **no** metatable access whatsoever (no `luaT_gettmbyobj`, no
/// `tryfuncTM`), so the `__call` is never consulted and the table is iterated.
#[test]
fn a_table_with_a_call_metamethod_is_still_iterated_not_called() {
let s = script();
let out: String = s
.eval(
r#"
local called = false
local t = { only = "pair" }
setmetatable(t, { __call = function() called = true return nil end })
local keys = ""
for k, v in t do keys = keys .. k end
return keys .. "|" .. tostring(called)
"#,
)
.unwrap();
assert_eq!(
out, "only|false",
"the table must be ITERATED and its __call never invoked"
);
}
/// **The callee is the global `next`, read fresh at each loop entry — so an addon can replace it.**
///
/// Observable behaviour of the real client, not an accident: the lookup is a raw read of the
/// thread's globals table, performed once per loop entry. An addon that assigns `next = myfn`
/// changes every later generic-for in the session. Reproduced rather than improved on.
#[test]
fn the_substituted_generator_is_the_live_global_next() {
let s = script();
let out: String = s
.eval(
r#"
local realnext = next
local calls = 0
next = function(t, k) calls = calls + 1 return realnext(t, k) end
local t = { a = 1, b = 2 }
local n = 0
for k, v in t do n = n + 1 end
next = realnext
return n .. "|" .. (calls > 0 and "hooked" or "not hooked")
"#,
)
.unwrap();
assert_eq!(
out, "2|hooked",
"a replaced global `next` must drive the loop, as it does on the client"
);
}
/// **Only a table is substituted.** A function generator is untouched (the ordinary 5.1 path), and
/// a non-table, non-function generator still raises on the FIRST ITERATION rather than at entry —
/// the client's tag mismatch falls through to the normal call path.
#[test]
fn only_a_table_is_substituted_and_other_types_still_raise() {
let s = script();
// The ordinary iterator form is completely unaffected.
let via_pairs: i64 = s
.eval(
r#"
local t = { a = 1, b = 2, c = 3 }
local n = 0
for k, v in pairs(t) do n = n + 1 end
return n
"#,
)
.unwrap();
assert_eq!(via_pairs, 3);
// A custom iterator function still drives its own loop.
let custom: i64 = s
.eval(
r#"
local function upto(state, i)
i = i + 1
if i <= state then return i end
end
local sum = 0
for i in upto, 3, 0 do sum = sum + i end
return sum
"#,
)
.unwrap();
assert_eq!(
custom, 6,
"an explicit generator/state/control triple is untouched"
);
// A number generator is not a table: it must still raise, and say so.
let err = s
.eval::<i64>("local n = 0 for k, v in 42 do n = n + 1 end return n")
.unwrap_err()
.to_string();
assert!(
err.contains("attempt to call a number value"),
"a non-table, non-function generator must still raise: {err}"
);
}
/// **An empty table terminates immediately** rather than looping — `next(t, nil)` on an empty table
/// returns nil, and the loop's own continue test is unchanged by the substitution.
#[test]
fn an_empty_table_generator_terminates() {
let s = script();
let n: i64 = s
.eval("local n = 0 for k, v in {} do n = n + 1 end return n")
.unwrap();
assert_eq!(n, 0);
}
/// **Nested and repeated loops each get their own substitution.** The patch rewrites the loop's own
/// registers, so an inner loop must not disturb an outer one, and a loop entered twice must
/// substitute twice (the registers are reloaded from the expression each entry).
#[test]
fn nested_and_repeated_loops_each_substitute_independently() {
let s = script();
let out: i64 = s
.eval(
r#"
local outer = { a = 1, b = 2 }
local inner = { x = 10, y = 20 }
local total = 0
for k, v in outer do
for k2, v2 in inner do total = total + v2 end
total = total + v
end
return total
"#,
)
.unwrap();
// Each of the 2 outer pairs runs the full inner loop (30) plus its own value.
assert_eq!(out, 30 + 30 + 1 + 2);
// Entering the same loop a second time must work identically.
let twice: i64 = s
.eval(
r#"
local t = { a = 1, b = 2 }
local function count() local n = 0 for k, v in t do n = n + 1 end return n end
return count() + count()
"#,
)
.unwrap();
assert_eq!(twice, 4);
}
/// **5.0's long-string NESTING, restored** — the second dialect divergence in this file's family.
///
/// `[[ ... [[ ... ]] ... ]]` nests in Lua 5.0, which is what 1.12.1 ships. 5.1 kept the machinery
/// (`llex.c` `read_long_string`'s `cont` counter, guarded by `LUA_COMPAT_LSTR == 2`) and put an
/// advisory error in front of it — *"nesting of [[...]] is deprecated"* — which is a 5.1 opinion
/// about a dialect we are not targeting. Two corpus addons stopped LOADING on it.
///
/// The fix is Lua's own documented switch, `LUA_COMPAT_LSTR` 1 -> 2 in our vendored `luaconf.h`,
/// not a patch to the lexer: the code path was already there and already correct.
#[test]
fn a_nested_long_string_parses_as_lua_50_does() {
let s = crate::script::UiScript::new().unwrap();
// The shape an addon writes: a long comment containing a long string.
s.run("--[[ outer [[ inner ]] still comment ]]\nNESTED_OK = 1")
.unwrap();
assert_eq!(s.eval::<i64>("return NESTED_OK").unwrap(), 1);
// And a nested long STRING keeps its inner brackets, 5.0's own semantics: the outer runs to
// the matching close, so the inner `]]` does not terminate it.
let v = s
.eval::<String>("return [[a [[b]] c]]")
.expect("nested long string must parse");
assert_eq!(v, "a [[b]] c");
}
@@ -7,6 +7,7 @@
mod anchors;
mod backdrop;
mod button;
mod channel;
mod common;
mod cooldown;
mod create_frame_template;
@@ -14,6 +15,7 @@ mod end_to_end;
mod events;
mod font_object;
mod frame_api;
mod generic_for;
mod input;
mod layout_gate;
mod measure;
@@ -177,3 +177,794 @@ fn the_exception_list_has_no_dead_entries() {
wrong.join(" ")
);
}
/// **`Texture:GetTexture()` — the three contract details that a plausible implementation gets
/// silently wrong.** Verified in wow-re's widget-method batch (`0x79ba70`/`0x79baf0`/`0x835708`,
/// `system/ui/scratch/widget-api-batch-benilla.md`).
///
/// Four corpus addons reach it: `AtlasQuest.lua:228` is `AQATLASMAP = AtlasMap:GetTexture()` and
/// `FuBarPlugin-2.0.lua:343` is `return self.iconFrame:GetTexture()`, each behind two addons.
///
/// The colour case is the one worth a test of its own. `SetTexture(r,g,b)` synthesizes an 8x8 solid
/// and the getter reports the literal name `"Solid Texture"` — so an addon's `if not tex then`
/// guard passes straight through. Returning nil there would read as tidier and would be wrong in
/// exactly the direction callers test for.
#[test]
fn get_texture_returns_the_stripped_path_and_solid_texture_for_a_fill() {
let s = crate::script::UiScript::new().unwrap();
s.run(
r#"
f = CreateFrame("Frame", "GTF")
tex = f:CreateTexture("GTFTex", "ARTWORK")
fill = f:CreateTexture("GTFFill", "ARTWORK")
"#,
)
.unwrap();
// Never set: nil, and exactly one value.
assert_eq!(
s.eval::<Option<String>>("return GTFTex:GetTexture()")
.unwrap(),
None
);
assert_eq!(
s.eval::<i64>("local a,b = GTFTex:GetTexture() return select('#', GTFTex:GetTexture())")
.unwrap_or(1),
1,
"exactly one return value"
);
// A path with no extension comes back unchanged.
s.run(r#"GTFTex:SetTexture("Interface\\Icons\\Spell_Fire_FlameBolt")"#)
.unwrap();
assert_eq!(
s.eval::<String>("return GTFTex:GetTexture()").unwrap(),
"Interface\\Icons\\Spell_Fire_FlameBolt"
);
// ...and one WITH an extension is stripped at the last `.` — the loader appends it, the getter
// takes it back off.
s.run(r#"GTFTex:SetTexture("Interface\\Icons\\Foo.blp")"#)
.unwrap();
assert_eq!(
s.eval::<String>("return GTFTex:GetTexture()").unwrap(),
"Interface\\Icons\\Foo"
);
// The colour form: the literal name, NOT nil.
s.run("GTFFill:SetTexture(1, 0, 0, 1)").unwrap();
assert_eq!(
s.eval::<String>("return GTFFill:GetTexture()").unwrap(),
"Solid Texture",
"a colour-filled region reports a NAME — `if not tex then` must not fire"
);
// And a plain SetTexture makes it ordinary again.
s.run(r#"GTFFill:SetTexture("Interface\\Buttons\\UI-Quickslot2")"#)
.unwrap();
assert_eq!(
s.eval::<String>("return GTFFill:GetTexture()").unwrap(),
"Interface\\Buttons\\UI-Quickslot2"
);
}
/// **The shadow accessors on a REGION, and `GetShadowColor`'s four values.**
///
/// They existed on the font-object table; a FontString from `CreateFontString` had none, which is
/// exactly where the corpus calls them. `FuBar_NavigatorFu/NavigatorFu.lua:31` is
/// `coordText:SetShadowColor(GameFontNormal:GetShadowColor())` — getter on a font object, setter on
/// a fresh region, in one line — and `KLHThreatMeter/.../KTM_Gui.lua:404` is
/// `fontstring:SetShadowColor(0,0,0,0.3)`.
///
/// **Four values, not three** (`0x79dd2f`, `mov eax,0x4` — wow-re's widget-method batch). Three is
/// the plausible wrong answer and it silently drops the alpha NavigatorFu round-trips: the whole
/// point of its line is that whatever `GameFontNormal` carries arrives intact.
#[test]
fn region_shadow_accessors_round_trip_four_values() {
let s = crate::script::UiScript::new().unwrap();
s.run(r#"f = CreateFrame("Frame", "SHF") fs = f:CreateFontString("SHFText", "ARTWORK")"#)
.unwrap();
assert_eq!(
s.eval::<i64>("return select('#', SHFText:GetShadowColor())")
.unwrap(),
4,
"GetShadowColor returns FOUR values — three drops the alpha"
);
assert_eq!(
s.eval::<i64>("return select('#', SHFText:GetShadowOffset())")
.unwrap(),
2
);
// NavigatorFu's line, in shape: a font object's shadow piped straight into a region's.
s.run("SHFText:SetShadowColor(0, 0, 0, 0.3) SHFText:SetShadowOffset(1, -1)")
.unwrap();
let (r, g, b, a) = s
.eval::<(f32, f32, f32, f32)>("return SHFText:GetShadowColor()")
.unwrap();
assert_eq!((r, g, b, a), (0.0, 0.0, 0.0, 0.3), "the alpha survives");
assert_eq!(
s.eval::<(f32, f32)>("return SHFText:GetShadowOffset()")
.unwrap(),
(1.0, -1.0)
);
// Either half may be set first: setting only the offset must not blank the colour.
s.run("SHFText:SetShadowOffset(2, -2)").unwrap();
assert_eq!(
s.eval::<f32>("local _,_,_,a = SHFText:GetShadowColor() return a")
.unwrap(),
0.3,
"setting the offset alone keeps the colour"
);
}
/// **`Region:SetParent(frame)` — it exists on a Texture/FontString, and we were the ones missing
/// it.** `SetParent` is in the Region method table (`0x7a1550`) and both leaf lookups fall back to
/// Region's (`0x79c650` / `0x79ee50`), so `FuBar_FuXPFu.lua:210`'s
/// `self.Spark:SetParent(self.XPBar)` — a texture from `XPBar:CreateTexture` — is a working line on
/// the real client (wow-re `system/ui/scratch/widget-api-batch-benilla.md` Q7).
///
/// The two traps pinned here are the ones a plausible implementation gets wrong in opposite
/// directions. **A non-Frame argument RAISES** (`IsA(FrameTag)` at `0x7a16ea`, message
/// `"…Wrong parent object type, expected frame"`) rather than no-opping; and **anchors are NOT
/// touched** by the re-link, which moves draw-layer/region-list membership only — so a re-parented
/// texture keeps resolving against whatever `SetPoint` named, and FuXPFu's re-point on the next
/// line is the correct pattern rather than a workaround.
#[test]
fn region_set_parent_relinks_the_draw_owner_and_leaves_anchors_alone() {
/// The resolved rect of the one texture quad whose path contains `needle`, if it draws at all.
fn tex_rect(s: &crate::script::UiScript, needle: &str) -> Option<crate::layout::Rect> {
s.extract().iter().find_map(|q| match &q.content {
crate::script::QuadContent::Texture { path: Some(p), .. } if p.contains(needle) => {
Some(q.rect.expect("resolved rect"))
}
_ => None,
})
}
let mut s = crate::script::UiScript::new().unwrap();
s.set_screen_size(800.0, 600.0);
s.run(
r#"
A = CreateFrame("Frame", "SPOwnerA")
A:SetPoint("BOTTOMLEFT", 0, 0) A:SetSize(100, 50)
B = CreateFrame("Frame", "SPOwnerB")
B:SetPoint("BOTTOMLEFT", 300, 0) B:SetSize(100, 50)
Spark = A:CreateTexture("SPSpark", "ARTWORK")
Spark:SetTexture("Interface\\SPGlow")
Spark:SetSize(24, 24)
Spark:SetPoint("TOPLEFT", 4, -4)
"#,
)
.unwrap();
s.resolve();
// A is [bottom 0, left 0, top 50, right 100]: TOPLEFT +(4,-4), 24x24 ⇒ 4..28 x 22..46.
let before = tex_rect(&s, "SPGlow").expect("the spark draws under A");
assert_eq!(before, crate::layout::Rect::new(22.0, 4.0, 46.0, 28.0));
// FuXPFu's line, and it returns nothing at all.
assert_eq!(
s.eval::<i64>("return select('#', Spark:SetParent(B))")
.unwrap(),
0,
"SetParent returns zero values on every path"
);
s.resolve();
assert_eq!(
tex_rect(&s, "SPGlow"),
Some(before),
"the anchors are untouched — the spark still resolves against A, 300px from B"
);
// …and the re-link is real, not merely recorded: the spark now draws with B. Hiding B takes it
// off the screen; hiding A (which it is still anchored to) does not.
s.run("B:Hide()").unwrap();
assert_eq!(
tex_rect(&s, "SPGlow"),
None,
"the spark hides with its NEW owner"
);
s.run("B:Show() A:Hide()").unwrap();
s.resolve();
assert_eq!(
tex_rect(&s, "SPGlow"),
Some(before),
"and not with the frame it is merely anchored to"
);
// A non-Frame argument raises. A texture is the case the reference names, and it is the one an
// addon actually hits (a mixed table of frames and textures walked in a loop).
for bad in ["Spark:SetParent(Spark)", "Spark:SetParent(5)"] {
let e = s
.run(bad)
.expect_err("a non-frame parent must raise")
.to_string();
assert!(
e.contains("expected frame"),
"wanted the reference's 'expected frame' rejection, got: {e}"
);
}
// A MISSING argument is not the nil form: TNONE never reaches the nil branch.
assert!(s.run("Spark:SetParent()").is_err(), "no argument raises");
// The nil form detaches without error: orphaned and unrendered, not destroyed — a later
// re-parent brings the same region back.
s.run("A:Show() Spark:SetParent(nil)").unwrap();
s.resolve();
assert_eq!(
tex_rect(&s, "SPGlow"),
None,
"a detached region draws nothing"
);
s.run("Spark:SetParent(A)").unwrap();
s.resolve();
assert_eq!(
tex_rect(&s, "SPGlow"),
Some(before),
"and re-parenting restores it"
);
}
/// **`Button:SetFont(file, height [, flags])` — it exists, it returns NOTHING, and it never
/// touches the label.** `0x780880`, wow-re's widget-method batch Q8 (§5-verified).
/// `_LazyPig/LazyPigMenu.lua:214` calls it straight on a `CreateFrame("Button", …)` and is blocked
/// on it today.
///
/// Three traps, each pinned here because each is a plausible implementation's silent divergence.
/// **Zero return values**: the shared impl pushes `1`/nil (a real font-load probe on a Font object)
/// and Button *discards* it (`xor eax,eax`), so answering `true` would hand an addon a probe the
/// client does not have. **`GetFont` returns three values**, off the normal embedded font — which
/// resolves through what that state inherits when nothing set it locally. And **a bare Button with
/// no `<ButtonText>` is a silent no-op**: `SetFont`/`GetFont` never dereference the FontString
/// pointer `+0x338`, so there is no error and — the observable half — **no lazy label creation**,
/// which `GetFontString()` would report.
#[test]
fn button_set_font_returns_nothing_and_is_a_no_op_without_a_label() {
let s = crate::script::UiScript::new().unwrap();
s.run(
r#"
Bare = CreateFrame("Button", "SFBare")
Labelled = CreateFrame("Button", "SFLabelled")
Labelled:SetText("Show Keybinds")
"#,
)
.unwrap();
// _LazyPig's line, on a Button that has no label at all.
assert_eq!(
s.eval::<i64>(r#"return select('#', SFBare:SetFont("Fonts\\FRIZQT__.TTF", 8))"#)
.unwrap(),
0,
"SetFont returns ZERO values — the delegate's 1/nil is discarded by a Button"
);
assert!(
s.eval::<bool>("return SFBare:GetFontString() == nil")
.unwrap(),
"no label existed and none was lazily created"
);
assert_eq!(
s.eval::<i64>("return select('#', SFBare:GetFont())")
.unwrap(),
3,
"GetFont returns THREE values"
);
assert_eq!(
s.eval::<(String, f32, String)>("return SFBare:GetFont()")
.unwrap(),
("Fonts\\FRIZQT__.TTF".to_string(), 8.0, String::new())
);
// Still three when nothing has ever set a font — path and height are nil, not absent.
assert_eq!(
s.eval::<i64>("return select('#', SFLabelled:GetFont())")
.unwrap(),
3
);
assert!(s
.eval::<bool>("local f = SFLabelled:GetFont() return f == nil")
.unwrap());
// The flags argument is normalized to its OUTLINETYPE token, like every other GetFont.
s.run(r#"SFBare:SetFont("Fonts\\SKURRI.TTF", 12, "THICKOUTLINE")"#)
.unwrap();
assert_eq!(
s.eval::<String>("local _, _, flags = SFBare:GetFont() return flags")
.unwrap(),
"THICKOUTLINE"
);
// Both arguments are required (`lua_isstring` + `lua_isnumber`, else the usage error).
assert!(s.run(r#"SFBare:SetFont("Fonts\\SKURRI.TTF")"#).is_err());
// Unset locally, GetFont reads through what the NORMAL state inherits — how a
// GameMenuButtonTemplate button answers its template's face before anything calls SetFont.
s.run(
r#"
CreateFont("SFInherited")
SFInherited:SetFont("Fonts\\MORPHEUS.TTF", 14)
SFLabelled:SetTextFontObject(SFInherited)
"#,
)
.unwrap();
assert_eq!(
s.eval::<(String, f32)>("local p, h = SFLabelled:GetFont() return p, h")
.unwrap(),
("Fonts\\MORPHEUS.TTF".to_string(), 14.0)
);
// …and a local SetFont outranks that inherited object on the label's PAINT, not only in the
// getter — the whole point of the call `_LazyPig` makes.
s.run(r#"SFLabelled:SetFont("Fonts\\SKURRI.TTF", 9)"#)
.unwrap();
let (font, height) = s
.extract()
.iter()
.find_map(|q| match &q.content {
crate::script::QuadContent::Text {
text: Some(t),
font,
font_height,
..
} if t == "Show Keybinds" => Some((font.clone(), *font_height)),
_ => None,
})
.expect("the labelled button draws its text");
assert_eq!(
(font.as_deref(), height),
(Some("Fonts\\SKURRI.TTF"), Some(9.0)),
"the button's own font repaints the label over the inherited font object"
);
}
/// **`SetNonSpaceWrap` / `CanNonSpaceWrap`** — FontString only (`0x79e9f0`/`0x79ead0`, wow-re's
/// widget-method batch). `oRA2/Leader/Item.lua:561` is `f.textname:SetNonSpaceWrap(false)`, reached
/// by two addons.
///
/// Two contract details, both easy to get wrong and both pinned here: the getter is
/// **`CanNonSpaceWrap`**, not `GetNonSpaceWrap`, and it answers **`1` or nil** rather than a
/// boolean — 1.12 predates that convention and an addon may compare against 1. And a **no-argument
/// call ENABLES** it; the default is on, so it is not a query.
#[test]
fn non_space_wrap_defaults_on_and_answers_one_or_nil() {
let s = crate::script::UiScript::new().unwrap();
s.run(r#"f = CreateFrame("Frame", "NSF") fs = f:CreateFontString("NSFText", "ARTWORK")"#)
.unwrap();
// Default is ON, and the answer is the NUMBER 1 — not `true`.
assert_eq!(
s.eval::<Option<i64>>("return NSFText:CanNonSpaceWrap()")
.unwrap(),
Some(1),
"default on, answered as 1"
);
assert!(
!s.eval::<bool>("return NSFText:CanNonSpaceWrap() == true")
.unwrap(),
"it must NOT be a boolean — an addon comparing against 1 would break"
);
// oRA2's line.
s.run("NSFText:SetNonSpaceWrap(false)").unwrap();
assert_eq!(
s.eval::<Option<i64>>("return NSFText:CanNonSpaceWrap()")
.unwrap(),
None,
"off answers nil, not 0 and not false"
);
// A no-argument call ENABLES rather than querying.
s.run("NSFText:SetNonSpaceWrap()").unwrap();
assert_eq!(
s.eval::<Option<i64>>("return NSFText:CanNonSpaceWrap()")
.unwrap(),
Some(1),
"a bare SetNonSpaceWrap() turns it ON"
);
}
/// **The EditBox font block — the first sixteen entries of its own registrar table, and the largest
/// single gap the per-kind widget-method census found** (decision 1229, whose ranking opens
/// `63 EditBox:SetFontObject (on Texture, FontString)`).
///
/// `EditBox`'s table is `.data 0x87bb68`, **48 entries** — the count read from the `mov edx,0x30` at
/// the registering site `0x799ab5`, never from a run-length scan. There is no `FontInstance` class
/// in the 1.12 Lua chain (wow-re `widget-api-batch-benilla.md`): each of the six text-bearing types
/// re-declares the block in its own flat table, so membership is a per-table fact and **a name we
/// add that the table does not carry is exactly as wrong as one we miss**.
///
/// The two halves pinned here are therefore both directions. Present: the fourteen we wire.
/// Absent: `SetSpacing`/`GetSpacing`, which the table *does* carry (#10–#11) and we deliberately
/// withhold because nothing here models line spacing — a stored-but-undrawn setter is the silent
/// divergence of 1203/1205/1211, while a nil-value call names itself, and corpus demand is zero.
/// Absent too: the names that belong to *other* tables and must not leak onto an EditBox through
/// ours — `CopyFontObject` (Font object only), `SetNonSpaceWrap`/`CanNonSpaceWrap` and
/// `SetTextHeight`/`GetStringWidth` (FontString only).
#[test]
fn editbox_carries_the_font_block_its_registrar_table_declares() {
let s = crate::script::UiScript::new().unwrap();
s.run(r#"EB = CreateFrame("EditBox", "EBFont")"#).unwrap();
for name in [
"SetFontObject",
"GetFontObject",
"SetFont",
"GetFont",
"SetTextColor",
"GetTextColor",
"SetShadowColor",
"GetShadowColor",
"SetShadowOffset",
"GetShadowOffset",
"SetJustifyH",
"GetJustifyH",
"SetJustifyV",
"GetJustifyV",
] {
assert_eq!(
s.eval::<String>(&format!("return type(EBFont.{name})"))
.unwrap(),
"function",
"EditBox table entry '{name}' is missing"
);
}
for name in [
// On EditBox's real table, withheld on purpose (nothing models spacing).
"SetSpacing",
"GetSpacing",
// Not on EditBox's table at all — adding one is the same class of error as missing one.
"CopyFontObject",
"SetNonSpaceWrap",
"CanNonSpaceWrap",
"SetTextHeight",
"GetStringWidth",
] {
assert!(
s.eval::<bool>(&format!("return EBFont.{name} == nil"))
.unwrap(),
"'{name}' must NOT answer on an EditBox"
);
}
}
/// **Every EditBox font binding is a type-guard shim that returns whatever the shared implementation
/// returned**, so the arities below are the shared ones verbatim — verified at the bytes: each
/// binding ends `call <shared>` then `pop edi; pop esi; pop ebx; ret`, with **no `mov eax,N` and no
/// `xor eax,eax`** after the call.
///
/// The trap is `SetFont`, and it points the opposite way to the one already pinned for Button.
/// `Button:SetFont` (`0x780880`) ends `xor eax,eax` and returns **nothing** —
/// [`button_set_font_returns_nothing_and_is_a_no_op_without_a_label`]. `EditBox:SetFont`
/// (`0x797210` → `0x79f210`) does not discard, so the shared `lua_pushnumber(1.0)` (`0x79f345`) /
/// `lua_pushnil` (`0x79f361`) passes straight through: **one value, the number `1`, not `true` and
/// not zero values**. A probe written `if not eb:SetFont(f, s) then` needs the nil half; a probe
/// written `== 1` needs the number half.
///
/// `GetShadowColor` returns **four** values (`0x79f9b3 mov eax,0x4`); three is the plausible wrong
/// answer and silently drops the alpha.
#[test]
fn editbox_font_block_return_shapes_are_the_shared_implementations() {
let s = crate::script::UiScript::new().unwrap();
s.run(
r#"
EB = CreateFrame("EditBox", "EBShape")
CreateFont("EBProbeFont")
EBProbeFont:SetFont("Fonts\\FRIZQT__.TTF", 14)
"#,
)
.unwrap();
// The mutators push nothing at all.
for call in [
"SetFontObject(EBProbeFont)",
"SetTextColor(1, 0, 0)",
"SetShadowColor(0, 0, 0, 0.3)",
"SetShadowOffset(1, -1)",
r#"SetJustifyH("CENTER")"#,
r#"SetJustifyV("TOP")"#,
] {
assert_eq!(
s.eval::<i64>(&format!("return select('#', EBShape:{call})"))
.unwrap(),
0,
"EditBox:{call} must return ZERO values"
);
}
// The getters' exact widths.
for (call, want) in [
("GetFontObject()", 1),
("GetFont()", 3),
("GetTextColor()", 4),
("GetShadowColor()", 4),
("GetShadowOffset()", 2),
("GetJustifyH()", 1),
("GetJustifyV()", 1),
] {
assert_eq!(
s.eval::<i64>(&format!("return select('#', EBShape:{call})"))
.unwrap(),
want,
"EditBox:{call} must return {want} value(s)"
);
}
// SetFont: ONE value, and it is the number 1 — not `true`, and not nothing (the Button shape).
assert_eq!(
s.eval::<i64>(r#"return select('#', EBShape:SetFont("Fonts\\SKURRI.TTF", 12))"#)
.unwrap(),
1,
"EditBox:SetFont returns one value, unlike Button:SetFont"
);
assert_eq!(
s.eval::<String>(r#"return type(EBShape:SetFont("Fonts\\SKURRI.TTF", 12))"#)
.unwrap(),
"number",
"the number 1, never the boolean true"
);
assert!(s
.eval::<bool>(r#"return EBShape:SetFont("Fonts\\SKURRI.TTF", 12) == 1"#)
.unwrap());
// An unloadable (empty) path is the FALSEY answer, not an error — `!OmniCC`'s probe idiom.
assert!(s
.eval::<bool>(r#"return EBShape:SetFont("", 12) == nil"#)
.unwrap());
// Both arguments are gated (`lua_isstring` + `lua_isnumber`, else the usage error).
assert!(s.run(r#"EBShape:SetFont("Fonts\\SKURRI.TTF")"#).is_err());
// The shadow round-trips all four channels — three would drop the alpha.
assert_eq!(
s.eval::<(f32, f32, f32, f32)>("return EBShape:GetShadowColor()")
.unwrap(),
(0.0, 0.0, 0.0, 0.3)
);
assert_eq!(
s.eval::<(f32, f32)>("return EBShape:GetShadowOffset()")
.unwrap(),
(1.0, -1.0)
);
// GetFontObject answers the OBJECT, never its name — the corpus indexes the result immediately.
assert_eq!(
s.eval::<String>("return type(EBShape:GetFontObject())")
.unwrap(),
"table"
);
}
/// **The real `Dewdrop-2.0` shape, which is the whole reason this landed.**
///
/// `Dewdrop-2.0.lua:1673-1675` is verbatim:
/// ```lua
/// local editBox = CreateFrame("EditBox", nil, editBoxFrame)
/// editBoxFrame.editBox = editBox
/// editBox:SetFontObject(ChatFontNormal)
/// ```
/// — an **anonymous** EditBox parented to a frame, then `SetFontObject` on it two lines later. That
/// file is vendored into **63 addon folders** of the 218-addon corpus (65 copies: `FuBar` plus ~50
/// FuBar plugins, `BigWigs`, `AtlasLoot`, `oRA2`, `SnaFu`, …), so the census's `63` is **one library
/// replicated**, not 63 independent addons (decision 1207).
///
/// `AceGUIWidget-Slider.lua:204-210` is the same shape with `SetJustifyH("CENTER")` on the end.
#[test]
fn the_dewdrop_editbox_font_shape_resolves_and_paints() {
let s = crate::script::UiScript::new().unwrap();
s.run(
r#"
CreateFont("ChatFontNormal")
ChatFontNormal:SetFont("Fonts\\ARIALN.TTF", 11)
ChatFontNormal:SetTextColor(0.9, 0.9, 0.9)
editBoxFrame = CreateFrame("Frame", "DewdropEBFrame")
local editBox = CreateFrame("EditBox", nil, editBoxFrame)
editBoxFrame.editBox = editBox
editBox:SetFontObject(ChatFontNormal)
editBox:SetWidth(160)
editBox:SetHeight(13)
"#,
)
.unwrap();
// The link is live and readable back as the object.
assert!(s
.eval::<bool>("return DewdropEBFrame.editBox:GetFontObject() == ChatFontNormal")
.unwrap());
// …and the object's paint really reached the box's implicit FontString (the `[this+0x324]` the
// reference's shim hands the shared implementation).
assert_eq!(
s.eval::<(String, f32)>("local p, h = DewdropEBFrame.editBox:GetFont() return p, h")
.unwrap(),
("Fonts\\ARIALN.TTF".to_string(), 11.0)
);
let (r, g, b, _) = s
.eval::<(f32, f32, f32, f32)>("return DewdropEBFrame.editBox:GetTextColor()")
.unwrap();
assert_eq!((r, g, b), (0.9, 0.9, 0.9));
// The AceGUI slider's tail, on the same box.
s.run(r#"DewdropEBFrame.editBox:SetJustifyH("CENTER")"#)
.unwrap();
}
/// **`SetJustifyH`/`SetJustifyV` mask to their own axis, and a token from the other axis silently
/// clears rather than erroring.** The enum is `.rdata 0x811ad0`, `{bits, token}`: `LEFT` 0x01,
/// `CENTER` 0x02, `RIGHT` 0x04, `TOP` 0x08, `MIDDLE` 0x10, `BOTTOM` 0x20, stored in one dword with
/// bits 0–2 horizontal and 3–5 vertical.
///
/// Two traps, both of which our older FontString/Font copies of these verbs get wrong by coercing
/// anything unrecognised to CENTER/MIDDLE:
/// · **an unknown token RAISES** `Usage: %s:SetJustifyH("justify")` (`0x87c77c`);
/// · **a valid token from the wrong axis parses and then masks to nothing** — `SetJustifyH("TOP")`
/// yields 0x08 and `0x08 & 0x07 == 0`, so justifyH is CLEARED and `GetJustifyH()` answers the
/// literal `"UNKNOWN"` (`0x6f1a00`, `.data 0x838044`). No error is raised either way.
#[test]
fn editbox_justify_masks_to_its_axis_and_answers_unknown() {
let s = crate::script::UiScript::new().unwrap();
s.run(r#"EB = CreateFrame("EditBox", "EBJustify")"#)
.unwrap();
// **LEFT, not the generic font default CENTER.** The `CSimpleFont` ctor really does default to
// `0x212` (CENTER | MIDDLE | 0x200), but the EditBox ctor overrides the horizontal axis right
// after linking its font instance (`0x779bcd … and eax,~6; or eax,1;` stored at `0x779be4`), so
// a fresh box starts at `0x211`. Taking the generic default is the plausible wrong answer, and
// it is exactly what this test asserted until wow-re's §5 trio read that ctor
// (`system/ui/scratch/editbox-font-surface.md` §6.2).
assert_eq!(
s.eval::<String>("return EBJustify:GetJustifyH()").unwrap(),
"LEFT"
);
assert_eq!(
s.eval::<String>("return EBJustify:GetJustifyV()").unwrap(),
"MIDDLE"
);
// Each axis is replaced independently — setting V leaves H alone.
s.run(r#"EBJustify:SetJustifyH("LEFT") EBJustify:SetJustifyV("BOTTOM")"#)
.unwrap();
assert_eq!(
s.eval::<(String, String)>("return EBJustify:GetJustifyH(), EBJustify:GetJustifyV()")
.unwrap(),
("LEFT".to_string(), "BOTTOM".to_string())
);
// The cross-axis trap: "TOP" parses, masks to 0, and CLEARS justifyH.
s.run(r#"EBJustify:SetJustifyH("TOP")"#).unwrap();
assert_eq!(
s.eval::<String>("return EBJustify:GetJustifyH()").unwrap(),
"UNKNOWN",
"a vertical token on SetJustifyH clears the axis rather than centering it"
);
assert_eq!(
s.eval::<String>("return EBJustify:GetJustifyV()").unwrap(),
"BOTTOM",
"and it leaves the vertical axis untouched"
);
// An unrecognised token is the usage error, never a silent fallback to CENTER.
assert!(s.run(r#"EBJustify:SetJustifyH("SIDEWAYS")"#).is_err());
}
/// **`EditBox:SetJustifyV` echoes through its getter and never reaches the pixels — on the real
/// client too, permanently and by construction.** wow-re `editbox-font-surface.md` §6 (§5 trio +
/// byte arbitration): `CSimpleFontString+0x124` is a per-bit *inherit* mask over the rendered
/// justify `+0x120`, and `SetMultiLine 0x77a4a0` clears the whole vertical group `0x38` from it on
/// **both** legs while writing the V bits locally — multi-line → TOP, single-line → MIDDLE. The
/// EditBox ctor calls `SetMultiLine` unconditionally at birth (`0x779c2f`, with the ctor's zero
/// register), and a census of all 256 `+0x124` operands image-wide found every `CSimpleFontString`
/// writer to be an AND: **nothing ever ORs an inherit bit back in.** So the value written by
/// `SetJustifyV` is masked out at `0x77086e`, while `GetJustifyV` reads the font *instance*
/// (`0x79fd73`) and answers it faithfully.
///
/// This pins the shape rather than the accident: a future change that "fixes" the getter by wiring
/// V justify through to the region would match neither the client nor our own draw law.
#[test]
fn editbox_justify_v_echoes_but_multiline_alone_decides_the_pixels() {
let mut s = crate::script::UiScript::new().unwrap();
s.set_screen_size(800.0, 600.0);
s.run(
r#"
EB = CreateFrame("EditBox", "EBJV")
EBJV:SetPoint("CENTER", 0, 0) EBJV:SetWidth(120) EBJV:SetHeight(40)
EBJV:SetText("typed")
"#,
)
.unwrap();
/// The vertical justification the box's text actually draws with.
fn drawn_v(s: &crate::script::UiScript) -> crate::script::JustifyV {
s.extract()
.iter()
.find_map(|q| match &q.content {
crate::script::QuadContent::Text {
text: Some(t),
justify_v,
..
} if t == "typed" => Some(*justify_v),
_ => None,
})
.expect("the box draws its text")
}
// A single-line box renders MIDDLE, the `0x77a599` leg.
assert_eq!(drawn_v(&s), crate::script::JustifyV::Middle);
// SetJustifyV("TOP") is echoed by the getter…
s.run(r#"EBJV:SetJustifyV("TOP")"#).unwrap();
assert_eq!(
s.eval::<String>("return EBJV:GetJustifyV()").unwrap(),
"TOP",
"the getter reads the font instance and echoes faithfully"
);
// …and does NOT move the pixels. This is the disagreement, and it is faithful.
assert_eq!(
drawn_v(&s),
crate::script::JustifyV::Middle,
"SetJustifyV is masked out of the rendered justify — multiLine alone decides it"
);
// multiLine is what actually moves it, on the `0x77a509` leg.
s.run("EBJV:SetMultiLine(true)").unwrap();
assert_eq!(drawn_v(&s), crate::script::JustifyV::Top);
// …and the getter still echoes whatever was last set, unaffected.
assert_eq!(
s.eval::<String>("return EBJV:GetJustifyV()").unwrap(),
"TOP"
);
}
/// **`Texture:SetDesaturated(flag)` answers `shaderSupported`, and ours says nil.**
///
/// `0x79c1e0` (wow-re ledger). The reference's own `ItemButtonTemplate.lua:69` is
/// `local shaderSupported = icon:SetDesaturated(desaturated)`, and lines 70-78 fall back to a 0.5
/// grey vertex tint when that answer is falsy — 1.12 shipped on cards without the shader, so the
/// verb reporting "no" is a real machine's answer, not a stub.
///
/// We have no desaturating shader, so nil is the honest reply: claiming support would suppress
/// FrameXML's grey fallback and leave disabled icons at full colour, which looks *more* wrong.
/// The test pins the return shape rather than the state, because the return is what callers branch
/// on — and `nil`, not `false`, because the C answer is `1|nil` and the reference writes
/// `not shaderSupported`.
///
/// 98 of the 109 draw-then-raise addons in the corpus die on this one verb, via
/// `FuBar_Panel.lua:43` → Dewdrop `AddLine` → `button.arrow:SetDesaturated(true)`, unguarded.
#[test]
fn set_desaturated_reports_no_shader_support_and_does_not_raise() {
let s = crate::script::UiScript::new().unwrap();
s.run(r#"f = CreateFrame("Frame", "DsF") tex = f:CreateTexture("DsTex", "ARTWORK")"#)
.unwrap();
// Dewdrop's exact call — the one 98 addons reach. It must not raise.
s.run("DsTex:SetDesaturated(true)").unwrap();
// ...and it answers nil, so the reference's `not shaderSupported` branch is taken.
assert!(
s.eval::<bool>("return DsTex:SetDesaturated(true) == nil")
.unwrap(),
"shaderSupported must be nil (1|nil C shape), not false"
);
assert_eq!(
s.eval::<i64>("return select('#', DsTex:SetDesaturated(true))")
.unwrap(),
1,
"one return value"
);
// The reference's own consumer, transcribed: a falsy answer greys via vertex colour.
s.run(
r#"
local shaderSupported = DsTex:SetDesaturated(true)
local r, g, b = 1, 1, 1
if not shaderSupported then r, g, b = 0.5, 0.5, 0.5 end
DsTex:SetVertexColor(r, g, b)
"#,
)
.unwrap();
let (r, g, b) = s
.eval::<(f32, f32, f32)>("return DsTex:GetVertexColor()")
.unwrap();
assert_eq!((r, g, b), (0.5, 0.5, 0.5), "the no-shader fallback greys");
// Both spellings of "off" are off — nil and false.
s.run("DsTex:SetDesaturated(nil) DsTex:SetDesaturated(false)")
.unwrap();
}
@@ -574,3 +574,91 @@ fn a_vertex_colour_without_a_texture_draws_nothing() {
"no texture at +0xcc -> emit NOTHING, never a solid plate of the surviving tint"
);
}
/// **`SetTexture` ignores arguments past the path**, because the client's does.
///
/// The path form reads ONE argument (`0x770200`); only the colour form (`0x770360`) reads up to
/// four, and a C function takes what it wants off the Lua stack and ignores the rest. We typed the
/// trailing three as `Option<f32>` and so RAISED on a stray extra — `bad argument #3: error
/// converting Lua boolean to f32` — where the client silently accepts.
///
/// The live case is `_LazyPig/LazyPigMenu.lua:182`:
/// `texture_title:SetTexture("Interface\DialogFrame\UI-DialogBox-Header", true)`. The `true` is
/// meaningless in 1.12; the addon reached us only once the survey began seating the addon registry.
///
/// Asserted as "does not raise", which is the whole claim — there is no `GetTexture` binding to read
/// the path back through, and inventing one to satisfy a test would be the tail wagging the dog.
/// The colour form is exercised alongside so the fix cannot have made it lax: a non-numeric channel
/// must take the same default a missing one does, exactly as `lua_tonumber` of a non-number is 0.
#[test]
fn set_texture_ignores_arguments_past_the_path_like_the_client_does() {
let s = script();
s.run(
r#"
f = CreateFrame("Frame", "SetTexArgs")
t = f:CreateTexture(nil, "ARTWORK")
t:SetTexture("Interface\\DialogFrame\\UI-DialogBox-Header", true)
"#,
)
.expect("a stray extra argument must not raise — the client ignores it");
// The colour form, with a boolean and a numeric string among the channels.
s.run("t:SetTexture(0.25, '0.5', true)")
.expect("the colour form must tolerate what lua_tonumber tolerates");
// And the ordinary shapes still work.
s.run("t:SetTexture(nil) t:SetTexture('') t:SetTexture(1, 0, 0, 1)")
.expect("clear, blank and the plain colour form are unaffected");
}
/// **`SetGradientAlpha` / `SetGradient` exist, store both stops, and paint.**
///
/// These were the single wall in front of the corpus's largest family: `FuBar\FuBar_Panel.lua:144`
/// calls `SetGradientAlpha` while building the bar, so all 20 FuBar plugins died there — right after
/// the chunk-name/`debugstack` fix got them that far.
///
/// Asserted on the region PAINTING (a gradient-only region must emit a quad, because the client
/// generates the gradient into the same texture slot the colour form fills) and on the midpoint
/// being what a one-tint quad shows. The full gradient stays on the region for a renderer that
/// grows a second stop.
#[test]
fn a_gradient_is_stored_whole_and_painted_as_its_midpoint() {
let mut s = script();
s.run(
r#"
f = CreateFrame("Frame", "GradProbe")
f:SetWidth(100) f:SetHeight(20)
f:SetPoint("TOPLEFT", 0, 0)
t = f:CreateTexture(nil, "ARTWORK")
t:SetAllPoints(f)
-- FuBar's own call: white at both stops, ALPHA only, vertical.
t:SetGradientAlpha("VERTICAL", 1, 1, 1, 0, 1, 1, 1, 0.5)
"#,
)
.expect("SetGradientAlpha must exist and accept the client's argument shape");
s.resolve();
let painted = s.extract().iter().any(|q| {
matches!(&q.content, crate::script::QuadContent::Texture { color: Some(c), .. }
// midpoint of alpha 0.0 and 0.5
if (c[3] - 0.25).abs() < 1e-6 && c[0] == 1.0)
});
assert!(
painted,
"a region carrying only a gradient must paint, at the midpoint of its two stops"
);
// The alpha-less twin: both stops opaque, and a non-"VERTICAL" token is horizontal.
s.run("t:SetGradient('HORIZONTAL', 1, 0, 0, 0, 0, 1)")
.expect("SetGradient takes six colour arguments and no alpha");
s.resolve();
let mid = s.extract().iter().find_map(|q| match &q.content {
crate::script::QuadContent::Texture { color: Some(c), .. } => Some(*c),
_ => None,
});
assert_eq!(
mid.map(|c| [c[0], c[1], c[2], c[3]]),
Some([0.5, 0.0, 0.5, 1.0]),
"SetGradient's stops are opaque, so the midpoint alpha is 1"
);
}
+111 -2
View File
@@ -92,8 +92,60 @@ fn sandbox_removes_dangerous_globals() {
)
.unwrap();
assert!(all_nil);
// debugstack stub survives and returns "".
assert_eq!(s.eval::<String>("return debugstack()").unwrap(), "");
// `debugstack` survives the sandbox — and now returns a REAL traceback, not the `""` this
// used to assert. The stub was fine for addons that only DISPLAY it and wrong for the ones
// that PARSE it: `FuBarPlugin-2.0.lua:752` finds each plugin's own folder in
// `debugstack(6, 1, 0)`, and against `""` that returned nil and killed 20 corpus addons.
let trace = s.eval::<String>("return debugstack()").unwrap();
assert!(
trace.contains("traceback"),
"debugstack must return a real traceback: {trace:?}"
);
// A level far past the top of the stack yields the bare header and never raises — a caller
// that guesses too deep still gets a string it can `string.find` against, which is exactly how
// every corpus caller uses it.
assert_eq!(
s.eval::<String>("return debugstack(99)").unwrap().trim(),
"stack traceback:"
);
}
/// **An addon's chunk is named the way the CLIENT names it**, because addons parse that name.
///
/// `FuBarPlugin-2.0.lua:752` is `string.find(debugstack(6, 1, 0), "\\AddOns\\(.*)\\")`, and it
/// feeds the capture into `format("Interface\\AddOns\\%s\\icon", folderName)`. Without a name
/// mlua defaults the chunk to the RUST caller location, that pattern misses, and every FuBar plugin
/// dies formatting a nil three frames from the cause.
///
/// The greedy `(.*)` is why the file has to be in the name too: it captures to the LAST backslash.
#[test]
fn an_addon_chunk_is_named_the_way_the_client_names_it() {
let name = crate::script::addon_chunk_name("FuBar_BagFu", "FuBar_BagFu.lua");
assert_eq!(name, "@Interface\\AddOns\\FuBar_BagFu\\FuBar_BagFu.lua");
// FuBar's own pattern, run for real against a traceback from a chunk loaded under that name.
let s = script();
s.run_chunk_named(
b"function BenillaProbeFolder() return debugstack(1, 1, 0) end",
&name,
)
.unwrap();
let folder: String = s
.eval(
"local _, _, f = string.find(BenillaProbeFolder(), \"\\\\AddOns\\\\(.*)\\\\\") \
return f or '<no match>'",
)
.unwrap();
assert_eq!(
folder, "FuBar_BagFu",
"FuBarPlugin's own capture must yield the folder name"
);
// A nested path in the manifest keeps its separators as backslashes.
assert_eq!(
crate::script::addon_chunk_name("Big", "libs/Thing/Thing.lua"),
"@Interface\\AddOns\\Big\\libs\\Thing\\Thing.lua"
);
}
#[test]
@@ -211,3 +263,60 @@ fn the_lua_5_0_dialect_vanilla_addons_are_written_in_runs_here() {
)
.unwrap();
}
/// **`time()` is real epoch seconds and `date()` formats them** — engine globals in the 1.12
/// client's own `_G` (slots 34/33 of its base registry) that we lacked entirely, because the
/// sandbox strips `os`.
///
/// `time` was the top name in the session-start `attempt to call global` row. Every corpus site
/// persists it into SavedVariables and compares across sessions
/// (`FTC_Save[k].LastCheck = time()`), so a session-relative clock like `GetTime` would be wrong in
/// a way that only shows up on the SECOND login.
///
/// The formats asserted are the ones read off real call sites, against a FIXED epoch so the
/// conversion is checked rather than the machine's clock: 2001-09-09 01:46:40 UTC, a Sunday.
#[test]
fn time_is_epoch_seconds_and_date_formats_them() {
let s = script();
// A plausible wall clock: after 2020, before 2100. Pins that this is not GetTime's 0-based one.
let now: i64 = s.eval("return time()").unwrap();
assert!(
(1_577_836_800..4_102_444_800).contains(&now),
"time() must be wall-clock epoch seconds, got {now}"
);
// 1_000_000_000 = 2001-09-09 01:46:40 UTC, a Sunday, day 252 of the year.
for (fmt, want) in [
("%Y-%m-%d", "2001-09-09"),
("%H:%M:%S", "01:46:40"),
(
"%A, %B %d, %Y - %H:%M",
"Sunday, September 09, 2001 - 01:46",
),
("%a %b %y", "Sun Sep 01"),
("%I:%M %p", "01:46 AM"),
("%j", "252"),
("%w", "0"),
("%c", "Sun Sep 9 01:46:40 2001"),
("100%%", "100%"),
// An unknown specifier is emitted verbatim rather than swallowed.
("%Q", "%Q"),
] {
let got: String = s
.eval(&format!(r#"return date("{fmt}", 1000000000)"#))
.unwrap();
assert_eq!(got, want, "date({fmt:?})");
}
// Bare `date()` is `%c` (Recap.lua:2690 calls it with no arguments) and must not raise.
let bare: String = s.eval("return date()").unwrap();
assert!(
bare.len() > 10,
"bare date() must format something: {bare:?}"
);
// A leap day, because the civil conversion is where a date implementation goes wrong.
let leap: String = s.eval(r#"return date("%Y-%m-%d %A", 951782400)"#).unwrap();
assert_eq!(leap, "2000-02-29 Tuesday");
}
+36 -1
View File
@@ -78,7 +78,21 @@ impl UiScript {
.map(|(&h, _)| h)
.filter(|&h| model.arena.frame(h).is_some_and(|f| f.effective_visible))
.collect();
frames.into_iter().map(|h| model.frame_id(h)).collect()
let mut ids: Vec<u32> = frames.into_iter().map(|h| model.frame_id(h)).collect();
// **Deterministic order, by frame id — i.e. creation order.**
//
// `model.scripts` is a hash map, so this sweep used to fire OnUpdate in an order that
// varied per process. That was invisible for as long as every geometry getter answered
// from the PREVIOUS frame's resolve: each handler saw the same stale world regardless
// of when it ran. The moment the getters began settling on demand (so a handler can
// observe work an earlier handler did this same sweep), the order became load-bearing
// and the macro window's tab width started flipping 164/167 run to run.
//
// Random order was always wrong — the reference sweeps a defined order — the staleness
// was merely hiding it. Creation order is stable, matches FrameXML declaration order,
// and is what a handler reading a sibling's geometry should see.
ids.sort_unstable();
ids
};
for id in ids {
if let Err(e) = event::fire_update_handler(&self.lua, id, elapsed) {
@@ -90,6 +104,27 @@ impl UiScript {
// AtBottom freeze gate lives inside `ScrollingMessageState::tick`.
let now = self.now();
let mut model = self.model_mut();
// The sibling class's OnUpdate (`0x786200`): the same two-phase fade with no scroll gate,
// plus the capacity law that is this class's stand-in for `maxLines` — the cap is what fits
// vertically, so it needs the frame's resolved rect and is collected first (the arena walk
// below holds a mutable borrow that cannot also read `model.resolved`).
let message_frames: Vec<(FrameHandle, usize)> = model
.arena
.iter_frames()
.filter(|(_, f)| matches!(f.kind_state, crate::widget::KindState::Message(_)))
.map(|(h, _)| h)
.collect::<Vec<_>>()
.into_iter()
.map(|h| (h, Self::message_viewport_rows(&model, h)))
.collect();
for (h, viewport_rows) in message_frames {
if let Some(crate::widget::KindState::Message(mf)) =
model.arena.frame_mut(h).map(|f| &mut f.kind_state)
{
mf.tick(elapsed);
mf.trim_to_viewport(viewport_rows);
}
}
let mut finished_cooldowns: Vec<FrameHandle> = Vec::new();
for (h, frame) in model.arena.iter_frames_mut() {
if let crate::widget::KindState::ScrollingMessage(smf) = &mut frame.kind_state {
+79 -1
View File
@@ -210,7 +210,8 @@ pub enum Outline {
}
impl Outline {
/// Parse an OUTLINETYPE token (`"NONE"`/`"NORMAL"`/`"THICK"`, case-insensitive); unknown ⇒ `None`.
/// Parse the **XML** OUTLINETYPE attribute (`outline="NONE"/"NORMAL"/"THICK"`,
/// case-insensitive); unknown ⇒ `None`. This is *not* the Lua spelling — see [`Outline::flags`].
pub fn parse(s: &str) -> Outline {
match s.to_ascii_uppercase().as_str() {
"NORMAL" => Outline::Normal,
@@ -219,6 +220,33 @@ impl Outline {
}
}
/// Parse a **Lua `SetFont` flags string** — a different vocabulary from the XML attribute
/// above, and mixing the two is a silent no-op: `SetFont(path, h, "OUTLINE")` through
/// [`Outline::parse`] matches nothing and lands on `NONE`.
///
/// The reference reads the argument as a *set* of substrings against
/// `{0x1 OUTLINE, 0x4 THICKOUTLINE, 0x2 MONOCHROME}` (`0x811b10`, wow-re
/// `system/ui/scratch/widget-api-batch-benilla.md` Q8), which is why this is `contains` and not
/// equality — `"OUTLINE, MONOCHROME"` is a real thing addons write. MONOCHROME has no field
/// here (we model no glyph AA mode) and is ignored; `THICK` is checked first because
/// `"THICKOUTLINE"` contains `"OUTLINE"`.
///
/// One function, three callers: `Button:SetFont`, `FontString:SetFont` and `Font:SetFont` all
/// delegate to the *same* impl in the reference (`0x79f210`), so they cannot be allowed to
/// disagree here. They did: the Font-object binding parsed its Lua flags with the XML reader,
/// so `GameFontNormal:SetFont(f, h, "OUTLINE")` silently cleared the outline instead of setting
/// it, while the FontString binding carried its own inline copy of this match.
pub fn flags(s: &str) -> Outline {
let s = s.to_ascii_uppercase();
if s.contains("THICK") {
Outline::Thick
} else if s.contains("OUTLINE") {
Outline::Normal
} else {
Outline::None
}
}
/// The OUTLINETYPE token (for `GetFont`'s flags return).
pub fn as_str(self) -> &'static str {
match self {
@@ -299,6 +327,28 @@ pub struct ExtractedQuad {
/// The visual state of a region leaf (`Texture`/`FontString`), stored beside the arena because
/// [`crate::widget::Region`] models only structure (kind/owner/layer), not paint. See [`object`]'s
/// `SetTexture`/`SetVertexColor`/`SetText`.
/// A two-stop linear gradient (`SetGradientAlpha`/`SetGradient`).
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Gradient {
/// `true` for `"VERTICAL"`, `false` for `"HORIZONTAL"` — the client matches the token
/// case-insensitively and treats anything else as horizontal.
pub vertical: bool,
/// The gradient's two stops, RGBA. `SetGradient` (no alpha) sets both alphas to 1.
pub start: [f32; 4],
pub end: [f32; 4],
}
impl Gradient {
/// The single colour a one-tint quad can show for this gradient — the midpoint.
pub fn midpoint(&self) -> [f32; 4] {
let mut out = [0.0; 4];
for (i, chan) in out.iter_mut().enumerate() {
*chan = (self.start[i] + self.end[i]) / 2.0;
}
out
}
}
#[derive(Clone, Debug, Default, PartialEq)]
pub(crate) struct RegionData {
/// Region-level visibility (`Show`/`Hide` on a Texture/FontString, XML `hidden=` — the real
@@ -333,6 +383,21 @@ pub(crate) struct RegionData {
/// ARGB), which is why the XML alpha rides in the **texel** and multiplies with
/// [`Self::vertex_color`] rather than being replaced by it.
pub(crate) fill: Option<[f32; 4]>,
/// `SetGradientAlpha(orientation, r1,g1,b1,a1, r2,g2,b2,a2)` / `SetGradient(...)` — the two-stop
/// linear gradient the client generates into the same texture slot the colour form fills.
///
/// **Stored in full, painted as its midpoint, and that gap is stated rather than implied.** The
/// UI renderer has one tint per quad (a colour region is the shared 1x1 white image tinted), so
/// there is nowhere yet to put a second stop; folding to the average is a *visible*
/// approximation and belongs to the director to judge (method §7). It is kept whole HERE so the
/// renderer can honour it later without another API change — the data is already right, only
/// the paint is coarse.
///
/// Measured cost of the approximation on the corpus's biggest consumer: `FuBar_Panel.lua:144`
/// is `SetGradientAlpha("VERTICAL", 1,1,1,0, 1,1,1,0.5*t)` — **white at both stops, alpha
/// only**, i.e. a soft fade, and both textures are `Hide()`n on the next two lines. A uniform
/// half-alpha band instead of a fade is the whole difference there.
pub(crate) gradient: Option<Gradient>,
/// The **vertex colour** (`SetVertexColor 0x79abd0` → `0x77f750`, writing `+0xb8`; a StatusBar's
/// `SetStatusBarColor`; a FontString's `SetTextColor` and its font object's colour) — storage
/// distinct from [`Self::fill`]/[`Self::texture`]'s `+0xcc`.
@@ -379,6 +444,19 @@ pub(crate) struct RegionData {
/// from the owner frame's rect.
pub(crate) anchors: Vec<Anchor>,
/// FontString horizontal justification (`SetJustifyH`/XML `justifyH`); default CENTER.
/// `SetNonSpaceWrap` / `CanNonSpaceWrap` — FontString only (`0x79e9f0`/`0x79ead0`, wow-re's
/// widget-method batch). **State only here.** The real client's gx flag `0x40` feeds a
/// mid-word wrap carry and the fit-count terminator whose one consumer is the ellipsis
/// truncate at `0x771ec0`; our text layout has no ellipsis path, so honouring the value in
/// layout would mean inventing one. Stored and answered faithfully, unread by the renderer —
/// which is the honest position and is stated rather than left to be discovered.
///
/// Default is ON (a no-arg `SetNonSpaceWrap()` also enables), so `None` reads as enabled.
/// `Texture:SetDesaturated(flag)` — the shader desaturation state (`0x79c1e0`, verified in
/// wow-re's ledger). **Stored; the renderer does not yet honour it**, which is exactly why the
/// binding answers "shader unsupported" — see `region.rs`'s `SetDesaturated`.
pub(crate) desaturated: bool,
pub(crate) non_space_wrap: Option<bool>,
pub(crate) justify_h: JustifyH,
/// FontString vertical justification (`SetJustifyV`/XML `justifyV`); default MIDDLE.
pub(crate) justify_v: JustifyV,
@@ -145,6 +145,100 @@ pub struct EditBoxState {
/// The selection highlight tint (`SetHighlightColor`, the `E+0x350` texture trio's color;
/// ctor default **0xFF606060** — opaque medium gray). RGBA 0..1.
pub highlight_color: [f32; 4],
/// The justification bit word behind `Set/GetJustifyH` and `Set/GetJustifyV` — entries #12–#15
/// of the EditBox method table (`0x797990`/`0x797a50`/`0x797b10`/`0x797bd0`, tail-calling the
/// shared `0x79fc20`/`0x79fcb0`/`0x79fce0`/`0x79fd70`).
///
/// **One dword, two axes** (`CSimpleFont+0x54` / `CSimpleFontString+0x120`), from the 6-entry
/// enum at `.rdata 0x811ad0`: bits 0–2 horizontal (`LEFT` 0x01, `CENTER` 0x02, `RIGHT` 0x04),
/// bits 3–5 vertical (`TOP` 0x08, `MIDDLE` 0x10, `BOTTOM` 0x20).
///
/// **An EditBox's default is `0x211` — LEFT, not the generic CENTER.** The `CSimpleFont` ctor
/// default really is `0x212` (`CENTER | MIDDLE | 0x200`), but the EditBox constructor
/// *overrides* the horizontal axis immediately after linking its font instance —
/// `0x779bcd mov ecx,[edi+0x54]; and eax,~6; or eax,1; 0x779be4 mov [edi+0x54],eax` — so
/// `GetJustifyH()` on a fresh box answers **`"LEFT"`**. Bit `0x200` falls outside both axis
/// masks and neither accessor reads it, so only `0x11` is modelled. (wow-re
/// `system/ui/scratch/editbox-font-surface.md` §6.2; this **corrected** our first cut, which
/// took the generic `0x212` and answered `"CENTER"`.)
///
/// **Kept on the box, not on its text region.** Each font binding's shim hands the shared
/// implementation `[this+0x324]` — the box's implicit FontString, i.e. [`Self::text_region`] —
/// so on the reference these do write that string's justify field. But our EditBox draw law
/// seats that region's vertical justification by `multiLine` (TOP / MIDDLE) and its horizontal
/// one LEFT, and writing the region here would fight that law *and* be clobbered by the next
/// `SetMultiLine`.
///
/// **The vertical half of that is now VERIFIED to be exactly right, and for a reason worth
/// keeping** (§6 of the note above). `CSimpleFontString+0x124` is a per-bit *inherit* mask over
/// `+0x120`, and `SetMultiLine 0x77a4a0` clears the whole vertical group `0x38` from it on
/// **both** legs while writing the V bits locally (multi-line → TOP, single-line → MIDDLE) —
/// and the EditBox ctor calls `SetMultiLine` unconditionally at birth (`0x779c2f`, with the
/// ctor's zero register). A census of all 256 `+0x124` operands image-wide found every
/// `CSimpleFontString` writer to be an AND: **nothing ever ORs an inherit bit back.** So
/// `SetJustifyV` writes the instance and is masked out at `0x77086e`, never reaching the
/// rendered text, while `GetJustifyV` reads the instance (`0x79fd73`) and echoes it back:
/// **the getter and the pixels disagree permanently, by construction, on the real client too.**
/// Our rendered V justify is decided solely by `multiLine`, which is the same rule.
///
/// **The horizontal half stays open, and is deliberately not guessed.** `SetMultiLine` never
/// clears the `0x7` bits, so `SetJustifyH`'s value *does* reach the FontString's `+0x120` —
/// but whether the editbox's own draw `0x77da80` reads it for placement, rather than
/// left-anchoring at the insets rect the way rf82's windowed draw describes, is untraced.
/// Verifying that the value propagates is not verifying that the draw honours it, so the
/// reading that changes no pixels is the one taken and the question is named. It is the whole
/// visible difference for `AceGUIWidget-Slider.lua:210`'s `editbox:SetJustifyH("CENTER")`
/// (3 corpus addons), whose call now round-trips either way.
pub justify: u32,
}
/// The justify enum, `.rdata 0x811ad0` — `{bits, token}`, in the reference's own table order, which
/// is the order [`EditBoxState::justify_token`] scans (the getter returns the **first** set bit's
/// token).
const JUSTIFY_TOKENS: [(u32, &str); 6] = [
(0x01, "LEFT"),
(0x02, "CENTER"),
(0x04, "RIGHT"),
(0x08, "TOP"),
(0x10, "MIDDLE"),
(0x20, "BOTTOM"),
];
impl EditBoxState {
/// The horizontal axis mask (bits 0–2) — `SetJustifyH`'s `(cur & ~7) | (parsed & 7)`.
pub const JUSTIFY_H_MASK: u32 = 0x07;
/// The vertical axis mask (bits 3–5) — `SetJustifyV`'s.
pub const JUSTIFY_V_MASK: u32 = 0x38;
/// Parse a justify token to its bit, case-insensitively and whole-string (`0x6f1990`, a linear
/// `SStrCmpI` scan of all six). `None` = no match, which is what makes the caller raise
/// `Usage: %s:SetJustifyH("justify")` — the reference checks this one, unlike `SetTextColor`.
pub fn justify_bit(token: &str) -> Option<u32> {
JUSTIFY_TOKENS
.iter()
.find(|(_, name)| token.eq_ignore_ascii_case(name))
.map(|(bits, _)| *bits)
}
/// Replace one axis's bits with `parsed`'s, masked to that axis (`0x79fc5d`:
/// `(cur ^ parsed) & mask ^ cur`, i.e. `(cur & !mask) | (parsed & mask)`).
///
/// **The verified trap this reproduces:** `SetJustifyH("TOP")` *parses* (0x08) but
/// `0x08 & 0x07 == 0`, so it **clears** justifyH and a later `GetJustifyH()` answers
/// `"UNKNOWN"` — no error is raised. A plausible implementation that maps unknown-to-CENTER
/// silently answers something the client never would.
pub fn set_justify_axis(&mut self, mask: u32, parsed: u32) {
self.justify = (self.justify & !mask) | (parsed & mask);
}
/// The token for one axis: the **first** entry in table order whose bit is set, else the
/// literal `"UNKNOWN"` (`0x6f1a00`, string `.data 0x838044`).
pub fn justify_token(&self, mask: u32) -> &'static str {
JUSTIFY_TOKENS
.iter()
.find(|(bits, _)| self.justify & mask & bits != 0)
.map_or("UNKNOWN", |(_, name)| *name)
}
}
impl Default for EditBoxState {
@@ -177,6 +271,9 @@ impl Default for EditBoxState {
blink_accum: 0.0,
caret_shown: true,
highlight_color: [96.0 / 255.0, 96.0 / 255.0, 96.0 / 255.0, 1.0],
// The EditBox ctor's `0x211` minus the unread bit 0x200 — `LEFT | MIDDLE`. LEFT, not
// the generic font default CENTER: `0x779be4` overrides the H axis at construction.
justify: 0x01 | 0x10,
}
}
}
@@ -18,10 +18,14 @@ pub struct MessageLine {
/// Remaining phase-2 countdown (the `fadeDuration` snapshot). Once `time_left` hits `0` this
/// counts down and drives [`Self::alpha`].
pub fade_left: f32,
/// The current display alpha in `[0, 1]` (byte-quantized in the fade tick,
/// `trunc(remaining/fadeDuration*255)`). `0` once fully faded — the line stays in the ring
/// (a ring slot is freed only by drop-oldest / `SetMaxLines`), it just draws nothing (its rows
/// still hold their place — the reference's chat never re-packs when old lines fade).
/// The current display alpha in `[0, 1]`. Phase 1 leaves it at whatever `AddMessage` set —
/// forced opaque on a ScrollingMessageFrame (`792add: or edi,0xffffff00`), the caller's real
/// alpha arg on a MessageFrame (`795752`, default 1.0) — and phase 2 **overwrites** it with the
/// byte-quantized ramp `trunc(remaining/fadeDuration*255)`, the client's own store into the
/// line's alpha byte (`0x788547` / `786364`→`[edi+0xb]`). `0` once fully faded — in a
/// ScrollingMessageFrame the line stays in the ring (a slot is freed only by drop-oldest /
/// `SetMaxLines`) and merely draws nothing, its rows still holding their place; a MessageFrame
/// has no ring, so its retire helper (`0x786570`) frees the line outright and the rest re-pack.
pub alpha: f32,
/// How many display rows the line wraps into at the frame's current width/font — host-measured
/// through the message-line measure round-trip
@@ -231,3 +235,162 @@ impl ScrollingMessageState {
fn quantize_fade(x: f32) -> u8 {
(x.clamp(0.0, 1.0) * 255.0).trunc() as u8
}
/// Where a [`MessageFrameState`]'s newest message enters the display stack — the `insertMode` XML
/// attribute (`0x87a618`) and the `SetInsertMode`/`GetInsertMode` pair (`0x794ed0`/`0x794ff0`).
///
/// **MessageFrame only**, which is why it lives here and not on [`ScrollingMessageState`]: the
/// scrolling class has neither binding and no such XML attribute (msgframe-runtime.md's
/// binding-family table; wow-re `widget-api-batch-benilla.md` Q4, byte-verified).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum InsertMode {
/// `"TOP"` (the client's `0`): the newest message takes the frame's **top** line and older ones
/// step down. The reference's own `UIErrorsFrame.xml:4` asks for this, and so does every corpus
/// caller of `SetInsertMode` (`BigWigs/Plugins/Messages.lua:209`).
Top,
/// `"BOTTOM"` (the client's `1`, and the **ctor default**): newest at the bottom, older stepping
/// up — the chat shape.
#[default]
Bottom,
}
/// A `CSimpleMessageFrame`'s runtime state (ctor `0x785640`; msgframe-runtime.md, byte-verified §5
/// pair) — the class `UIErrorsFrame` is, and [`ScrollingMessageState`]'s **sibling, never its
/// base**. The two come from different ctors and their `AddMessage` bindings are different
/// functions with different tails, so nothing is shared here beyond the line record itself:
///
/// | | MessageFrame (this) | ScrollingMessageFrame |
/// |---|---|---|
/// | store | display lines only — no ring, **no `maxLines`**; the cap is what fits vertically | a true ring of `maxLines`, drop-oldest |
/// | `AddMessage` tail | a real **alpha**, default 1.0 (`0x795590`) | an **id**; alpha forced `0xFF` (`0x792900`) |
/// | scrollback | none at all | cursor + scroll/page set |
/// | `SetInsertMode` | yes ([`InsertMode`]) | no binding, no attribute |
/// | a fully-faded line | retired and the rest re-pack (`0x786570`) | keeps its ring slot and its rows |
///
/// The one recorded shape this does **not** model is the pending queue: the real class only
/// *enqueues* in `AddMessage` and drains at OnUpdate, dropping the message outright if
/// `numLinesDisplayed` is 0 at drain time (`786265`). Here a message lands in [`Self::lines`]
/// immediately and [`Self::trim_to_viewport`] applies the vertical cap at the next tick — the same
/// observable result one tick earlier, without a second buffer whose only visible consequence is
/// that a message added to a zero-height frame vanishes.
#[derive(Clone, Debug, PartialEq)]
pub struct MessageFrameState {
/// The display lines, **newest at the back always** — [`InsertMode`] is a *display direction*
/// resolved at emit, not a storage order, so "evict the oldest" is one `pop_front` in both
/// modes.
pub lines: VecDeque<MessageLine>,
/// `insertMode` — which end of the frame new messages enter from (ctor default BOTTOM).
pub insert_mode: InsertMode,
/// `timeVisible`/`displayDuration` — phase-1 duration a new line holds its insert alpha (ctor
/// 10.0s; `UIErrorsFrame.xml` sets 5).
pub time_visible: f32,
/// `fadeDuration` — phase-2 ramp length (ctor 3.0s). `0` ⇒ the line vanishes at phase-1 expiry
/// with no ramp.
pub fade_duration: f32,
/// `fadingEnabled` (ctor 1). While false nothing fades — and, because retirement is what bounds
/// this list, nothing retires either; the vertical cap is then the only bound (which is exactly
/// the client's own arrangement).
pub fading_enabled: bool,
}
impl Default for MessageFrameState {
fn default() -> MessageFrameState {
// The CSimpleMessageFrame ctor defaults (msgframe-runtime.md §"Shared ctor defaults":
// fadingEnabled=1, timeVisible=10.0, fadeDuration=3.0; insertMode 1 = BOTTOM). Note the
// fade pair is shared with the scrolling class but `maxLines` has no counterpart here.
MessageFrameState {
lines: VecDeque::new(),
insert_mode: InsertMode::default(),
time_visible: 10.0,
fade_duration: 3.0,
fading_enabled: true,
}
}
}
impl MessageFrameState {
/// `AddMessage(text, r, g, b, a)` (`0x795590` → `0x785d00`): quantize the colour the same
/// round-half-up way the scrolling class does, take the **alpha argument** as the line's
/// starting alpha (this class's fourth numeric really is alpha — the scrolling one's is an id
/// and forces `0xFF`), snapshot the frame's fade config, and append.
pub fn add(&mut self, text: String, r: f32, g: f32, b: f32, a: f32) {
self.lines.push_back(MessageLine {
text,
color: [quantize_u8(r), quantize_u8(g), quantize_u8(b)],
time_left: self.time_visible,
fade_left: self.fade_duration,
// Quantized like the colour: the client packs all four channels into one 0xAARRGGBB
// dword through the same `ftol(v*255 + 0.5)`.
alpha: f32::from(quantize_u8(a)) / 255.0,
rows: 1,
rows_key: 0,
});
}
/// `Clear` — retire every line immediately, no fade. (`_LazyPig` calls `UIErrorsFrame:Clear()`
/// eleven times; it is the one non-`AddMessage` verb the corpus actually uses on this class.)
pub fn clear(&mut self) {
self.lines.clear();
}
/// Advance the fade by `dt` — the OnUpdate virtual `0x786200`, whose gate is
/// `activeCount != 0 && fadingEnabled != 0 && numLinesDisplayed > 0`. **No scroll gate**: this
/// class has no scrollback, so unlike [`ScrollingMessageState::tick`] nothing can freeze it.
///
/// Two phases per line, identical formula to the scrolling class: `time_left` counts down at the
/// insert alpha, then `fade_left` counts down and *overwrites* the alpha with
/// `trunc(fade_left/fade_duration*255)` against the **live** frame `fade_duration`. A finished
/// line is then **freed** rather than left in place — this class has no ring to hold a slot, so
/// the survivors re-pack (`0x786570`, the retire helper that decrements the active count).
pub fn tick(&mut self, dt: f32) {
if !self.fading_enabled {
return;
}
for line in &mut self.lines {
if line.time_left > 0.0 {
line.time_left -= dt;
continue;
}
if self.fade_duration <= 0.0 {
line.alpha = 0.0;
continue;
}
line.fade_left -= dt;
if line.fade_left <= 0.0 {
line.fade_left = 0.0;
line.alpha = 0.0;
} else {
line.alpha = f32::from(quantize_fade(line.fade_left / self.fade_duration)) / 255.0;
}
}
// Retired lines are gone, not blank: `alpha == 0` past phase 1 is the finished state.
self.lines.retain(|l| l.time_left > 0.0 || l.alpha > 0.0);
}
/// Evict the oldest lines until only what fits in `viewport_rows` display rows remains — this
/// class's whole capacity law ("no `maxLines` anywhere on this class — the cap is what fits
/// vertically"), with each line costing its host-measured wrapped [`MessageLine::rows`].
///
/// `viewport_rows == 0` means the frame has no resolved rect yet (or a degenerate one) and is
/// left alone rather than emptied: dropping there would lose a message posted from an `OnLoad`
/// before the first solve, which is a v1 ordering artefact and not the client's zero-height
/// case. The fade is what bounds the list in that window — and with `fading_enabled` false it
/// is unbounded until the frame resolves, which is stated rather than papered over.
pub fn trim_to_viewport(&mut self, viewport_rows: usize) {
if viewport_rows == 0 {
return;
}
let mut used = 0usize;
let mut keep = 0usize;
for line in self.lines.iter().rev() {
if used >= viewport_rows {
break;
}
used += usize::from(line.rows.max(1));
keep += 1;
}
while self.lines.len() > keep.max(1) {
self.lines.pop_front();
}
}
}
+78 -1
View File
@@ -3,7 +3,7 @@
//! modeled per-kind behavior ([`KindState`]) that a `CSimple*` subtype adds over `CSimpleFrame`
//! (RF-28 tables; decision 0068). Split from the arena so each grows independently.
use std::collections::HashSet;
use std::collections::{HashSet, VecDeque};
use super::{FrameHandle, RegionHandle};
@@ -27,6 +27,10 @@ pub enum FrameKind {
Slider,
ScrollFrame,
Model,
/// `CSimpleMessageFrame` — the non-scrolling message frame (`UIErrorsFrame`'s class, and the
/// one `CreateFrame("MessageFrame")` makes). Its behaviour (the display lines, the per-line
/// fade, `insertMode`) is modeled in [`KindState::Message`]. Sibling of
/// [`FrameKind::ScrollingMessageFrame`], not a base or a subset of it.
MessageFrame,
/// `CSimpleMessageScrollFrame` — the scrolling, ring-buffered message frame (the chat window's
/// class). Its behavior (the line ring, per-line fade, wheel scrollback) is modeled in
@@ -88,6 +92,11 @@ pub enum KindState {
/// `CSimpleMessageScrollFrame` (ctor `0x787670`; runtime model msgframe-runtime.md): the line
/// ring, the per-line fade snapshots + phases, and the scrollback cursor.
ScrollingMessage(ScrollingMessageState),
/// `CSimpleMessageFrame` (ctor `0x785640`; same runtime model) — the `UIErrorsFrame` class:
/// display lines with no ring and no scrollback, plus `insertMode`. A **sibling** of
/// [`KindState::ScrollingMessage`], not a subset of it ([`MessageFrameState`]'s doc has the
/// contract table).
Message(MessageFrameState),
/// `CSimpleScrollFrame` (decision 0112 — the ScrollFrame mechanism, the engine's last structural
/// gap: the quest log's detail pane, chat history, and every long-content window need it): the
/// scroll child + the vertical scroll offset. The mechanism is spec-faithful (the documented
@@ -116,6 +125,34 @@ pub enum KindState {
Tooltip(TooltipState),
}
impl KindState {
/// The display lines of **either** message-frame class, or `None` for every other kind.
///
/// The two classes are siblings with different stores, different `AddMessage` tails and
/// different scrollback (see [`MessageFrameState`]), but the *line record* they display is the
/// same [`MessageLine`] — text, quantized colour, fade phases, wrapped row count. This pair of
/// accessors is the only place that likeness is spent: the wrapped-row measure round-trip and
/// the band emit are one code path for both, while every behaviour that actually differs stays
/// on its own state. Matching on the two variants at those sites instead would have been two
/// near-copies of the round-trip, which is how the second one silently rots.
pub fn message_lines(&self) -> Option<&VecDeque<MessageLine>> {
match self {
KindState::ScrollingMessage(smf) => Some(&smf.lines),
KindState::Message(mf) => Some(&mf.lines),
_ => None,
}
}
/// [`Self::message_lines`], mutably — the measure round-trip's write-back half.
pub fn message_lines_mut(&mut self) -> Option<&mut VecDeque<MessageLine>> {
match self {
KindState::ScrollingMessage(smf) => Some(&mut smf.lines),
KindState::Message(mf) => Some(&mut mf.lines),
_ => None,
}
}
}
/// A `GameTooltip`'s runtime state (decision 0274). The line *text/color/wrap* is not duplicated
/// here — each line pair is a real named FontString region (`<name>TextLeftN`/`TextRightN`,
/// engine-created on demand, published as Lua globals exactly like the real template's 30
@@ -304,6 +341,34 @@ pub struct ScrollFrameState {
pub vertical: f32,
}
/// The face/size/flags one `Button:SetFont(file, height [, flags])` call writes.
///
/// **One record, not three, and that is the faithful shape here rather than a shortcut.** The
/// client's Button embeds three `CSimpleFont` sub-objects — normal `+0x33c`, disabled `+0x434`,
/// highlight `+0x3b8` — and `SetFont` (`0x780880`) retunes **all three with the same values**
/// (`GetFont` reads only the normal one). Nothing in the 1.12 Lua surface can set them apart: a
/// Button has `SetFont` and the `*FontObject` triple, and the latter changes which object each
/// state *inherits*, never its local face. So three identical copies could only ever diverge by
/// our own bug (wow-re `system/ui/scratch/widget-api-batch-benilla.md` Q8).
#[derive(Clone, Debug, PartialEq)]
pub struct ButtonFont {
/// The TTF path (`"Fonts\\FRIZQT__.TTF"`).
pub path: String,
/// The font height in logical px.
pub height: f32,
/// The **normalized** OUTLINETYPE token — `""`, `"OUTLINE"` or `"THICKOUTLINE"` — so
/// `GetFont`'s third return reads like the FontString/Font-object one rather than echoing
/// whatever the addon spelled. Kept as a string, not `script::Outline`, because this module is
/// the arena's vocabulary and deliberately names no script type.
///
/// **An omitted `flags` argument clears the outline** (this is `""`), which is the reading the
/// batch does not pin: it records arg4 as parsed against `{OUTLINE, THICKOUTLINE, MONOCHROME}`
/// and says nothing about its absence. A `lua_tostring` on a missing argument yields no flags,
/// so "absent means none" is what the shared impl most plausibly does, and it is the reading
/// an addon setting a plain face expects.
pub flags: String,
}
/// A Button's state model: which of the state textures draws is a *function of interaction state*
/// (the client's texture array `+0x4b8` with a current-shown pointer `+0x4c4`), so the regions all
/// exist in the arena and [`Self::region_visible`] picks at extract time.
@@ -351,6 +416,17 @@ pub struct ButtonState {
pub highlight_font: Option<String>,
/// See [`ButtonState::normal_font`] — the disabled state.
pub disabled_font: Option<String>,
/// `Button:SetFont(file, height [, flags])` — the button's own face/size/flags, set on the
/// embedded font objects themselves rather than on any font object they inherit. See
/// [`ButtonFont`] for why one record covers the client's three.
///
/// It lives here, not on the ButtonText's [`crate::script::RegionData`], because the reference
/// **never dereferences the label pointer** (`+0x338`) in `SetFont`/`GetFont`: styling a
/// `CreateFrame("Button")` with no `<ButtonText>` is a silent no-op there, and writing to a
/// label would have meant lazily creating one — observable through `GetFontString()`, which
/// must stay nil. `extract` applies it to the label whenever there is one, which also makes a
/// later `SetText` (which *does* create the FontString) pick the style up for free.
pub font: Option<ButtonFont>,
/// Per-state label COLOR overrides (`Button:SetTextColor` and the Highlight/Disabled pair):
/// when the matching state is current, extract repaints the ButtonText with this color over
/// the state font object's own paint — the dropdown kit's rows lean on all three
@@ -384,6 +460,7 @@ impl Default for ButtonState {
normal_font: None,
highlight_font: None,
disabled_font: None,
font: None,
normal_color: None,
highlight_color: None,
disabled_color: None,

Some files were not shown because too many files have changed in this diff Show More