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:
Generated
-2
@@ -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
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
///
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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(""));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:?}"),
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
+11
-11
@@ -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
|
||||
@@ -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()
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user