the inspect and talent windows are the reference's own

- the inspect and talent windows are the reference's own: `Blizzard_InspectUI` and `Blizzard_TalentUI` are LoadOnDemand addons whose real `.xml`/`.lua` live inside `patch.MPQ` — the loose `Interface\AddOns` folder holds only a `.pub` signature, which three notes in this tree had read as "packed, no source to migrate from", one wrong inference that kept five windows on hand-written transcriptions; 2300 lines of ours out, no engine work needed, and three things our talent transcription had quietly invented fell out (there was no level-10 gate, every tree scrolled where a shallow one does not, and a nil `TalentMicroButton` threw out of OnShow before the window painted)
- a nil unit token RAISES in 53 of 83 bindings: wow-re censused the registrar table at `0x850438` — `UnitName(nil)` raises and `UnitIsVisible(nil)` is quiet, one line apart in stock files, so there is no rule to derive, the table is the rule; ours answered a default and our own test had pinned it; both token positions of the seven two-token predicates are gated, `GetRaidTargetIndex` too, and `UnitIsAFK`/`UnitIsDND` are confirmed not to exist in 5875 at all (zero occurrences under a positive control) — we invented them, and they stay deliberately ungated
- `EditBox:SetNumber` did not exist (the only chain file calling it is `MoneyInputFrame.lua`, on the chain since the money kit): it is byte-identical to `SetText`, formats through `%.14g` in MSVC's CRT (`1e+020`, `1.#INF`), and `OnTextChanged` never fires from an edit — it fires from the dirty-word drain inside the box's own OnUpdate, coalesced to one per box per frame, where ours fired synchronously and `MoneyInputFrame_SetCopper` raised on it; `PanelTemplates_TabResize`'s `tab` argument is optional as the reference's is (stock macro tabs call it with `0`)
- the skull or star on your CURRENT target never appeared or cleared until you re-targeted — `RAID_TARGET_UPDATE` was never fired, and stock `TargetFrame.lua` hangs its repaint off it; a raid-to-party conversion now empties the mark board as `0x4ba550` does; and the group-loot roll popups were already on the chain inside stock `LootFrame.xml` — our 546-line `GroupLootFrame.xml` had been shadowing all four of them

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-09-02 17:06:18 +07:00
co-authored by Claude Fable 5.1
parent 7308046d60
commit 2bebb94471
41 changed files with 1400 additions and 3134 deletions
@@ -1,546 +0,0 @@
<Ui>
<!--
benilla's group-loot roll window (decision 0591) — the Need/Greed/Pass dialog: four stacked
`GroupLootFrame`s, re-expressed from the real 1.12 client (`LootFrame.xml` l.243-533,
`LootFrame.lua` l.246-296, `UIParent.lua` l.513-515/1577 — all extracted, scratchpad
`WoW/_extracted_framexml/`). Geometry, art paths, and behavior below are QUOTED from those
(line citations inline); the Lua is re-expressed logic turned onto our own `loot_roll` engine
seam (`benilla-ui/src/script/loot_roll.rs`, decision 0591) and its app feed
(`crates/benilla/src/ui_loot_roll.rs`).
FOUR STACKED POPUPS, NOT A UIPANEL. Unlike LootFrame.xml, this window is a real 1.12
`frameStrata="DIALOG"`/`toplevel="true"` popup family — never registered in UiPanels.xml, no
pushable slot. `GroupLootFrame1` sits at the ref's own hardcoded anchor (BOTTOM,
+0,60 — ref l.498-506); frames 2-4 each stack on the PREVIOUS frame's TOP +15 (ref l.507-533).
`GroupLootFrame_OpenNewFrame` (ref LootFrame.lua l.246-258) claims the first currently-hidden
of the four on every `START_LOOT_ROLL`, so up to 4 concurrent rolls each get their own frame,
stacking upward; a 5th simultaneous roll finds none free and is silently dropped — the
reference's own ceiling (`NUM_GROUP_LOOT_FRAMES = 4`, LootFrame.lua l.2), not a benilla gap.
THE FOUR INSTANCES KEEP THE REFERENCE'S BARE NAMES (`GroupLootFrame1`..`4`), deliberately —
the one place the house `Benilla`-prefix convention is NOT applied, for the same reason
`CastingBarFrame` keeps its bare name (CastingBar.xml's POSITION note). `UIParent.xml` (l.70)
already carries the ref's `UIPARENT_MANAGED_FRAME_POSITIONS["GroupLootFrame1"]` row (baseY 60,
+42 for a shown bottom multibar, +42 for the pet/stance bar, +9 for the rep watch bar), and
`UIParent_ManageFramePositions` finds its frames by a literal `getglobal(name)` — no prefix
awareness. UIParent.xml's own comment lists `GroupLootFrame1` among the rows that "no-op
through the ref's own `if ( frame )` guard and self-activate if the frames later land": this
file is that landing, so the bare name is what makes the row engage.
It matters concretely, not theoretically: with benilla's always-on bottom multibars the
managed pass puts frame 1 at y=102 (and y=144 while the stance bar shows). Under a prefixed
name the row would never fire and the roll dialogs would sit permanently at y=60 — squarely on
top of the action bars. Bare names are also what a real 1.12 addon expects to find (0068's
Era-addon target), so this costs nothing elsewhere. The template, the driver, and this file's
own Lua functions stay `Benilla`-prefixed: nothing in the reference keys on those.
NO CENTRAL UIParent EVENT ROUTER. The reference's `START_LOOT_ROLL` dispatch (`UIParent.lua`
l.513-515: `GroupLootFrame_OpenNewFrame(arg1, arg2)`) rides the monolithic `UIParent_OnEvent`
this engine doesn't ship (`UIParent.xml`'s own note: "not ours to ship - no consumer yet").
Rather than claim UIParent's single OnEvent slot (which a later, more general router would then
have to fight over) or special-case ONE of the four peer frames, a small dedicated hidden
driver frame (`BenillaGroupLootFrameDriver`, below — the same shape as `DeathFrame.xml`'s
`BenillaDeathDriver`) owns exactly this one registration, keeping all four
`BenillaGroupLootFrame`s byte-identical instances of the same template (each registers only
`CANCEL_LOOT_ROLL` on itself, ref l.487-489/493-495, verbatim).
THE DRESSING-ROOM / CHAT-LINK CLICK LANDED (decisions 1059/1060 — this note used to read "no
dressing room exists", which was true until the room did). The ref icon button's Ctrl-click
`DressUpItemLink`/Shift-click `ChatFrameEditBox:Insert` (ref l.353-361) are re-expressed in
`BenillaGroupLootFrame_Icon_OnClick` below, over the `GetLootRollItemLink(rollID)` binding the
same arc added to `loot_roll.rs`. Still dropped: the ref's `this.hasItem = 1` OnLoad flag
(ref l.338-340) and its `CursorUpdate()`/`ResetCursor()`/`CursorOnUpdate()` hover-cursor calls
(ref l.344,348,351) — the flag exists only to feed those, and they are the Ctrl-hover
MAGNIFIER, a separate affordance (`ShowInspectCursor`, which the bags and the vendor rows do
wire) that this button has never had. LootFrame.xml's own row template
(`BenillaLootButtonTemplate`) carries the same hover-cursor omission.
THE ITEM TOOLTIP uses `GameTooltip:SetLootRollItem(rollID)` — the reference's own binding
(decision 1547; it replaced a `SetItemById` that could not see the drop's random-suffix
roll and so printed `<Random enchantment>` on every rolled item). The `itemId` guard stays:
it is the benilla-extension 6th return of `GetLootRollItemInfo` (see `loot_roll.rs`),
always populated once a roll is known — unlike name/texture/quality, which may be `nil`
while the item template is still in flight. `GetLootRollItemLink` (decision 1059) is the
click's, not the hover's: the tooltip wants the roll, not the markup.
`toplevel="true"` (ref l.243) IS CARRIED, since decision 1739. Two earlier reasons for
omitting it have both expired: `SetToplevel` landed (the loader calls the real method, and
the raise law — occlusion-gated, confined to this frame's own stratum — is
`script::object::toplevel`, wow-re `ui/scratch/toplevel-raise.md`), and the one blocker
left after that was a test fixture, not a mechanism.
What it does, measured rather than guessed: `GroupLootFrame1`'s rect (390.5,60)-(633.5,144)
overlaps `ExhaustionTick` — `ActionBar.xml` l.1111, `frameStrata="DIALOG"` transcribed
verbatim from ref-MainMenuBar.xml l.415-442 — in a ~2.5 px band, so showing a roll frame
raises its whole subtree within DIALOG. The only pixels that move are the sliver where the
roll frame meets the tick, and it is exactly what the reference does. It did cost
`group_loot_tests::start_loot_roll_claims_frames_in_order_and_paints_the_roll` its
`quad_center("UI-GroupLoot-Dice-Up")` stand-in for "frame 1's Need button" — an unstated
assumption that declaration order is draw order among the four, which the raise
legitimately ends; that test now takes the button by NAME and asserts the hit before
clicking.
NAMED FONT OBJECTS ARE SUPPORTED on this branch (the loader's top-level `<Font>` handling,
`loader/mod.rs`'s `do_font`, plus the full `Fonts.xml` transcription, both landed since
LootFrame.xml's own "not implemented yet" caveat was written) — `$parentName`'s
`inherits="GameFontNormalSmall"` below resolves for real; no stale caveat needed here.
IN-FLIGHT ITEMS: `GetLootRollItemInfo`'s `name` and `texture` are `nil` while the item
template query hasn't landed yet (`loot_roll.rs`); its `count` and `quality` are the
reference's own miss tail, `1` and `1` (decision 1805 — 0x4c31a3), never nil. So the
`or ITEM_QUALITY_COLORS[1]` below is dead weight rather than load-bearing, and the stock
`GroupLootFrame_OnShow` this file shadows survives the same state without it. The
`BENILLA_FALLBACK_ICON` / empty-name fallbacks are still doing work: the reference leaves
both of those nil.
The Era API this drives (`benilla-ui/src/script/loot_roll.rs`, decision 0591):
`GetLootRollItemInfo(rollID)` -> texture,name,count,quality,bindOnPickUp,itemID;
`GetLootRollItemLink(rollID)` -> the item's link (the icon click's two arms, decision 1059);
`GetLootRollTimeLeft(rollID)` -> ms remaining; `RollOnLoot(rollID, rollType)` (0 Pass / 1 Need /
2 Greed). Events: `START_LOOT_ROLL(rollID, rollTime)` claims a frame; `CANCEL_LOOT_ROLL(rollID)`
hides the one that owns it (`crates/benilla/src/ui_loot_roll.rs` fires both).
-->
<Script><![CDATA[
-- LootFrame.lua l.2's NUM_GROUP_LOOT_FRAMES, under the REFERENCE's own name.
--
-- It carried the house `BENILLA_` prefix until an addon died on the bare one:
-- `Bongos_RollBar/bar.lua:85` is `for i=1, NUM_GROUP_LOOT_FRAMES do` and got a nil limit.
-- That is 1222's shadowed-global class again — the fifth instance — and the rule it
-- settled applies to a CONSTANT an addon reads exactly as it does to a frame: our name for
-- a global is as load-bearing as our name for a function. The prefix is right only where
-- the reference has no counterpart; here it has one, with the same value (4) counting the
-- same four frames, which already carry the reference's bare names two paragraphs down.
NUM_GROUP_LOOT_FRAMES = 4
-- The bind-on-pickup confirm (ref StaticPopup.lua l.1371-1382, verbatim bar the TEXT()
-- indirection this house dialect drops). Need/Greed on a BoP roll never reaches the wire
-- directly: the seam diverts it to CONFIRM_LOOT_ROLL, the driver below raises this popup,
-- and only OnAccept's ConfirmLootRoll re-enters past the gate (decision 0594, VERIFIED in
-- the 5875 binary — the gate lives in the C RollOnLoot, not in the Lua). Keyed BARE, like
-- every other StaticPopupDialogs entry: the key is the StaticPopup_Show argument, not a
-- frame name. `id`/`rollType` arrive as the dialog's data/data2.
-- The three strings quoted verbatim from the real 1.12 GlobalStrings.lua (patch-2.MPQ,
-- Interface\FrameXML\GlobalStrings.lua l.2612/2818/465), following UiPanels.xml's
-- DELETE_ITEM idiom rather than reading the globals: GlobalStrings is loaded from the
-- PATCH CHAIN at runtime, so a bare global is nil under test and any format() over it
-- errors — and an error here aborts the whole chunk, taking every function in this file
-- with it.
local LOOT_NO_DROP_TEXT = "Looting this item will bind it to you." -- LOOT_NO_DROP
local OKAY_TEXT = "Okay" -- OKAY
local CANCEL_TEXT = "Cancel" -- CANCEL
StaticPopupDialogs["CONFIRM_LOOT_ROLL"] = {
text = LOOT_NO_DROP_TEXT,
button1 = OKAY_TEXT,
button2 = CANCEL_TEXT,
OnAccept = function(id, rollType)
ConfirmLootRoll(id, rollType)
end,
timeout = 0,
whileDead = 1,
exclusive = 1,
hideOnEscape = 1
}
-- GroupLootFrame_OpenNewFrame (ref LootFrame.lua l.246-258): claim the first currently-hidden
-- of the four frames for a freshly opened roll, seed its rollID/rollTime, and prime the
-- Timer StatusBar's max before showing it (fields set BEFORE Show(), so OnShow below can
-- already read frame.rollID).
function GroupLootFrame_OpenNewFrame(rollId, rollTime)
for i = 1, NUM_GROUP_LOOT_FRAMES do
local frame = getglobal("GroupLootFrame" .. i)
if frame and not frame:IsVisible() then
frame.rollID = rollId
frame.rollTime = rollTime
getglobal(frame:GetName() .. "Timer"):SetMinMaxValues(0, rollTime)
frame:Show()
return
end
end
-- All 4 already claimed: the reference has no overflow handling either (a 5th
-- simultaneous roll simply finds no free frame) — its own ceiling, not a benilla gap.
end
-- GroupLootFrame_OnShow's body (ref l.260-277): resolve the roll's display fields and paint
-- the icon/name/backdrop. name/texture/quality may be nil while the item template query is
-- still in flight (loot_roll.rs) — fall back to the fallback icon / blank name / common (1)
-- quality rather than erroring, the same fallback shape LootFrame_Update uses for an
-- in-flight loot row (LootFrame.xml).
--
-- SPLIT OUT OF OnShow, unlike the reference, because it must be re-runnable: the ref's
-- `GetLootRollItemInfo` reads live C state that is already populated when START_LOOT_ROLL
-- fires, whereas ours reads a *pushed snapshot* that by construction does not yet contain a
-- roll opening this frame (loot_roll.rs `feed_loot_rolls`, and the late item template on top
-- of that). `UPDATE_LOOT_ROLL` re-enters here; the paint is idempotent.
function BenillaGroupLootFrame_Paint(frame)
local texture, name, count, quality, bindOnPickUp, itemId = GetLootRollItemInfo(frame.rollID)
frame.itemId = itemId
if bindOnPickUp then
frame:SetBackdrop({
bgFile = "Interface\\DialogFrame\\UI-DialogBox-Gold-Background",
edgeFile = "Interface\\DialogFrame\\UI-DialogBox-Gold-Border",
tile = true, tileSize = 32, edgeSize = 32,
insets = { left = 11, right = 12, top = 12, bottom = 11 },
})
getglobal(frame:GetName() .. "Corner"):SetTexture("Interface\\DialogFrame\\UI-DialogBox-Gold-Corner")
getglobal(frame:GetName() .. "Decoration"):Show()
else
frame:SetBackdrop({
bgFile = "Interface\\DialogFrame\\UI-DialogBox-Background",
edgeFile = "Interface\\DialogFrame\\UI-DialogBox-Border",
tile = true, tileSize = 32, edgeSize = 32,
insets = { left = 11, right = 12, top = 12, bottom = 11 },
})
getglobal(frame:GetName() .. "Corner"):SetTexture("Interface\\DialogFrame\\UI-DialogBox-Corner")
getglobal(frame:GetName() .. "Decoration"):Hide()
end
getglobal(frame:GetName() .. "IconFrameIcon"):SetTexture(texture or BENILLA_FALLBACK_ICON)
local nameText = getglobal(frame:GetName() .. "Name")
nameText:SetText(name or "")
local color = ITEM_QUALITY_COLORS[quality] or ITEM_QUALITY_COLORS[1]
nameText:SetVertexColor(color.r, color.g, color.b)
end
function GroupLootFrame_OnShow()
local frame = this
BenillaGroupLootFrame_Paint(frame)
end
-- Per-instance CANCEL_LOOT_ROLL (ref l.487-489 OnLoad, l.279-285/493-495 OnEvent): each of the
-- four frames registers and checks its OWN rollID, hiding only itself. UPDATE_LOOT_ROLL is
-- the benilla extension alongside it (see BenillaGroupLootFrame_Paint) and takes the same
-- per-instance shape: arg1 is the rollID whose display identity changed.
function BenillaGroupLootFrame_OnLoad(frame)
frame:RegisterEvent("CANCEL_LOOT_ROLL")
frame:RegisterEvent("UPDATE_LOOT_ROLL")
end
function GroupLootFrame_OnEvent()
local frame = this
if arg1 ~= frame.rollID then
return
end
if event == "CANCEL_LOOT_ROLL" then
frame:Hide()
elseif event == "UPDATE_LOOT_ROLL" then
BenillaGroupLootFrame_Paint(frame)
end
end
-- GroupLootFrame_OnUpdate (ref l.287-296): poll the remaining time each tick, clamped to the
-- bar's own [min,max] exactly as the reference does (an out-of-range read — e.g. an unknown
-- rollID's 0 — clamps to min rather than erroring). Bound to the Timer bar itself so
-- `bar:IsVisible()` follows its (hidden) parent frame, matching the ref's `this:IsVisible()`.
function BenillaGroupLootFrame_Timer_OnUpdate(bar)
if bar:IsVisible() then
local left = GetLootRollTimeLeft(bar:GetParent().rollID)
local min, max = bar:GetMinMaxValues()
if left < min or left > max then
left = min
end
bar:SetValue(left)
end
end
-- Pass/Need/Greed (ref $parentPassButton/$parentRollButton/$parentGreedButton OnClick,
-- l.374/398/425): one shared handler, rollType 0 Pass / 1 Need / 2 Greed.
function BenillaGroupLootFrame_Roll_OnClick(button, rollType)
RollOnLoot(button:GetParent().rollID, rollType)
end
function BenillaGroupLootFrame_Roll_OnEnter(button, text)
GameTooltip:SetOwner(button, "ANCHOR_RIGHT")
GameTooltip:SetText(text)
end
function BenillaGroupLootFrame_Roll_OnLeave()
GameTooltip:Hide()
end
-- The item icon's hover tooltip (ref $parentIconFrame OnEnter/OnLeave, l.341-349, minus the
-- CursorUpdate/ResetCursor calls this engine has no counterpart for — see the header).
-- SetLootRollItem, the reference's own binding: the ROLL is the source, so the hover sees
-- the drop's random-suffix roll (through SetItemById it showed `<Random enchantment>` —
-- decision 1547).
function BenillaGroupLootFrame_Icon_OnEnter(button)
local frame = button:GetParent()
GameTooltip:SetOwner(button, "ANCHOR_RIGHT")
local _, name, _, quality, _, itemId = GetLootRollItemInfo(frame.rollID)
if itemId then
GameTooltip:SetLootRollItem(frame.rollID)
end
end
function BenillaGroupLootFrame_Icon_OnLeave()
GameTooltip:Hide()
end
-- The item icon's click (ref $parentIconFrame OnClick, LootFrame.xml l.353-361, re-expressed
-- whole): CTRL previews the rolled item in the dressing room (decision 1060), SHIFT posts
-- its link into an open chat edit box (decision 1059) — "link me that" being most of what a
-- roll popup gets asked. Both read `GetLootRollItemLink(rollID)`, the binding this arc added
-- (loot_roll.rs); it answers nil while the item template is still in flight, and both arms
-- drop that nil — the ctrl one inside DressUpItemLink (DressUpFrame.lua l.10-16), the shift
-- one inside BenillaChatEdit_InsertLink.
--
-- Nothing else happens on this button, exactly as in the reference: the vote lives on the
-- Pass/Need/Greed buttons and the icon is a preview/link affordance only. The ref's
-- `this.hasItem = 1` OnLoad flag (l.338-340) stays dropped — it exists solely for the
-- CursorUpdate/CursorOnUpdate ctrl-hover magnifier (l.344/351), which this button still does
-- not wire (see the header). No RegisterForClicks either, also as in the reference: a Button
-- is registered for LeftButtonUp by default (widget/kinds/mod.rs), which is the whole set
-- this handler wants.
function BenillaGroupLootFrame_Icon_OnClick(button)
local rollId = button:GetParent().rollID
if IsControlKeyDown() then
DressUpItemLink(GetLootRollItemLink(rollId)) -- ref l.354-355
elseif IsShiftKeyDown() then
-- ref l.356-359, through the shared helper (UIParent.xml): it carries the ref's own
-- edit-box-visible guard AND our nil-link one, which this popup needs routinely —
-- the frame is up the instant START_LOOT_ROLL fires, well before the item template
-- answers, and until it does there is no link to post.
BenillaChatEdit_InsertLink(GetLootRollItemLink(rollId))
end
end
]]></Script>
<!--
The template (ref GroupLootFrameTemplate, l.243-497): 243x84, DIALOG strata, toplevel, hidden
by default. Geometry/art below is QUOTED verbatim, INCLUDING dropping the stray `d` character
inside ref l.292's `<Anchor point="TOPLEFT">d` (a typo in Blizzard's own shipped file — not
transcribed).
-->
<!--
THE REFERENCE'S OWN NAME, deliberately un-prefixed (decision 1254).
This was `BenillaGroupLootFrameTemplate`. The `Benilla` prefix marks things WE invented, so
that nothing we made up can collide with a name an addon expects; this template is the
opposite of that — a line-by-line transcription of 1.12's `GroupLootFrameTemplate`, and
addons inherit it BY THAT NAME. Bongos_RollBar builds its four roll frames with
`CreateFrame("Frame", "BRollBarFrame"..i, bar, "GroupLootFrameTemplate")`, which resolves on
the real client and missed here for no reason but our prefix.
-->
<Frame name="GroupLootFrameTemplate" toplevel="true" frameStrata="DIALOG" hidden="true" virtual="true">
<Size><AbsDimension x="243" y="84"/></Size>
<Layers>
<Layer level="ARTWORK">
<!-- The item slot plate (ref l.249-260). -->
<Texture name="$parentSlotTexture" file="Interface\Buttons\UI-EmptySlot">
<Size><AbsDimension x="64" y="64"/></Size>
<Anchors>
<Anchor point="TOPLEFT"><Offset><AbsDimension x="3" y="-3"/></Offset></Anchor>
</Anchors>
</Texture>
<!-- The parchment name plate (ref l.261-272). -->
<Texture name="$parentNameFrame" file="Interface\MerchantFrame\UI-Merchant-LabelSlots">
<Size><AbsDimension x="128" y="64"/></Size>
<Anchors>
<Anchor point="LEFT" relativeTo="$parentSlotTexture" relativePoint="RIGHT">
<Offset><AbsDimension x="-9" y="-10"/></Offset>
</Anchor>
</Anchors>
</Texture>
<!-- The item name (ref l.273-284): GameFontNormalSmall, LEFT. -->
<FontString name="$parentName" inherits="GameFontNormalSmall" text="Item Name" justifyH="LEFT">
<Size><AbsDimension x="90" y="30"/></Size>
<Anchors>
<Anchor point="LEFT" relativeTo="$parentSlotTexture" relativePoint="RIGHT">
<Offset><AbsDimension x="-5" y="5"/></Offset>
</Anchor>
</Anchors>
</FontString>
</Layer>
<Layer level="OVERLAY">
<!-- The gold BoP dragon flourish (ref l.287-298): hidden by default, shown only for a
bind-on-pickup roll (GroupLootFrame_OnShow). -->
<Texture name="$parentDecoration" file="Interface\DialogFrame\UI-DialogBox-Gold-Dragon" hidden="true">
<Size><AbsDimension x="120" y="120"/></Size>
<Anchors>
<Anchor point="TOPLEFT"><Offset><AbsDimension x="-30" y="15"/></Offset></Anchor>
</Anchors>
</Texture>
<!-- The dialog-box corner (ref l.299-310): swapped plain/gold in OnShow. -->
<Texture name="$parentCorner" file="Interface\DialogFrame\UI-DialogBox-Corner">
<Size><AbsDimension x="32" y="32"/></Size>
<Anchors>
<Anchor point="TOPRIGHT"><Offset><AbsDimension x="-6" y="-7"/></Offset></Anchor>
</Anchors>
</Texture>
</Layer>
</Layers>
<Frames>
<!-- The item icon (ref $parentIconFrame, l.314-363): hover tooltip + the ref's own
Ctrl/Shift dressing-room/chat-link click (l.353-361, landed — decision 1059/1060);
only the hasItem OnLoad flag stays dropped (header). -->
<Button name="$parentIconFrame" enableMouse="true">
<Size><AbsDimension x="34" y="34"/></Size>
<Anchors>
<Anchor point="TOPLEFT" relativeTo="$parentSlotTexture">
<Offset><AbsDimension x="15" y="-15"/></Offset>
</Anchor>
</Anchors>
<Layers>
<Layer level="OVERLAY">
<Texture name="$parentIcon">
<Size><AbsDimension x="34" y="34"/></Size>
<Anchors><Anchor point="TOPLEFT"/></Anchors>
</Texture>
</Layer>
</Layers>
<Scripts>
<OnEnter>BenillaGroupLootFrame_Icon_OnEnter(self)</OnEnter>
<OnLeave>BenillaGroupLootFrame_Icon_OnLeave()</OnLeave>
<OnClick>BenillaGroupLootFrame_Icon_OnClick(self)</OnClick>
</Scripts>
</Button>
<!-- Pass (ref $parentPassButton, l.364-384): the ref inherits UIPanelCloseButton, which
this engine ships no template for — reconstructed on the same minimize-button art
LootFrame's own close button uses (LootFrame.xml's identical "no
UIPanelCloseButton template ships" precedent), at the ref's own 32x32/position. -->
<Button name="$parentPassButton">
<Size><AbsDimension x="32" y="32"/></Size>
<Anchors>
<Anchor point="TOPRIGHT" relativeTo="$parentCorner" relativePoint="TOPRIGHT">
<Offset><AbsDimension x="5" y="5"/></Offset>
</Anchor>
</Anchors>
<NormalTexture file="Interface\Buttons\UI-Panel-MinimizeButton-Up"/>
<PushedTexture file="Interface\Buttons\UI-Panel-MinimizeButton-Down"/>
<HighlightTexture file="Interface\Buttons\UI-Panel-MinimizeButton-Highlight" alphaMode="ADD"/>
<Scripts>
<OnClick>BenillaGroupLootFrame_Roll_OnClick(self, 0)</OnClick>
<OnEnter>BenillaGroupLootFrame_Roll_OnEnter(self, PASS)</OnEnter>
<OnLeave>BenillaGroupLootFrame_Roll_OnLeave()</OnLeave>
</Scripts>
</Button>
<!-- Need (the dice, ref $parentRollButton, l.385-411). -->
<Button name="$parentRollButton">
<Size><AbsDimension x="32" y="32"/></Size>
<Anchors>
<Anchor point="TOPRIGHT"><Offset><AbsDimension x="-37" y="-14"/></Offset></Anchor>
</Anchors>
<NormalTexture file="Interface\Buttons\UI-GroupLoot-Dice-Up"/>
<HighlightTexture file="Interface\Buttons\UI-GroupLoot-Dice-Highlight" alphaMode="ADD"/>
<PushedTexture file="Interface\Buttons\UI-GroupLoot-Dice-Down"/>
<Scripts>
<OnClick>BenillaGroupLootFrame_Roll_OnClick(self, 1)</OnClick>
<OnEnter>BenillaGroupLootFrame_Roll_OnEnter(self, NEED)</OnEnter>
<OnLeave>BenillaGroupLootFrame_Roll_OnLeave()</OnLeave>
</Scripts>
</Button>
<!-- Greed (the coin, ref $parentGreedButton, l.412-438). -->
<Button name="$parentGreedButton">
<Size><AbsDimension x="32" y="32"/></Size>
<Anchors>
<Anchor point="TOP" relativeTo="$parentRollButton" relativePoint="BOTTOM">
<Offset><AbsDimension x="-2" y="2"/></Offset>
</Anchor>
</Anchors>
<NormalTexture file="Interface\Buttons\UI-GroupLoot-Coin-Up"/>
<HighlightTexture file="Interface\Buttons\UI-GroupLoot-Coin-Highlight" alphaMode="ADD"/>
<PushedTexture file="Interface\Buttons\UI-GroupLoot-Coin-Down"/>
<Scripts>
<OnClick>BenillaGroupLootFrame_Roll_OnClick(self, 2)</OnClick>
<OnEnter>BenillaGroupLootFrame_Roll_OnEnter(self, GREED)</OnEnter>
<OnLeave>BenillaGroupLootFrame_Roll_OnLeave()</OnLeave>
</Scripts>
</Button>
<!-- The 1-minute countdown bar (ref $parentTimer, l.439-473): 152x10, [0,60000] ms,
yellow UI-Character-Skills-Bar fill + its border overlay. -->
<StatusBar name="$parentTimer" drawLayer="BACKGROUND" minValue="0" maxValue="60000" defaultValue="0">
<Size><AbsDimension x="152" y="10"/></Size>
<Anchors>
<Anchor point="TOPLEFT" relativeTo="$parentSlotTexture" relativePoint="BOTTOMLEFT">
<Offset><AbsDimension x="13" y="10"/></Offset>
</Anchor>
</Anchors>
<Layers>
<Layer level="OVERLAY">
<Texture file="Interface\PaperDollInfoFrame\UI-Character-Skills-BarBorder">
<Size><AbsDimension x="156" y="20"/></Size>
<Anchors>
<Anchor point="TOP"><Offset><AbsDimension x="0" y="5"/></Offset></Anchor>
</Anchors>
</Texture>
</Layer>
</Layers>
<Scripts>
<OnUpdate>BenillaGroupLootFrame_Timer_OnUpdate(self)</OnUpdate>
</Scripts>
<BarTexture name="$parentBar" file="Interface\PaperDollInfoFrame\UI-Character-Skills-Bar"/>
<BarColor r="1.0" g="1.0" b="0"/>
</StatusBar>
</Frames>
<!-- The plain dialog plate (ref l.475-485); swapped to the gold variant in OnShow for a BoP
roll. -->
<Backdrop bgFile="Interface\DialogFrame\UI-DialogBox-Background" edgeFile="Interface\DialogFrame\UI-DialogBox-Border" tile="true">
<BackgroundInsets><AbsInset left="11" right="12" top="12" bottom="11"/></BackgroundInsets>
<TileSize><AbsValue val="32"/></TileSize>
<EdgeSize><AbsValue val="32"/></EdgeSize>
</Backdrop>
<Scripts>
<OnLoad>BenillaGroupLootFrame_OnLoad(self)</OnLoad>
<OnShow>GroupLootFrame_OnShow()</OnShow>
<OnEvent>GroupLootFrame_OnEvent()</OnEvent>
</Scripts>
</Frame>
<!-- The four instances (ref l.498-533): frame 1 at the ref's own hardcoded BOTTOM+60; each next
frame stacks on the previous one's TOP+15. -->
<Frame name="GroupLootFrame1" inherits="GroupLootFrameTemplate" id="1">
<Anchors>
<Anchor point="BOTTOM"><Offset><AbsDimension x="0" y="60"/></Offset></Anchor>
</Anchors>
</Frame>
<Frame name="GroupLootFrame2" inherits="GroupLootFrameTemplate" id="2">
<Anchors>
<Anchor point="BOTTOM" relativeTo="GroupLootFrame1" relativePoint="TOP">
<Offset><AbsDimension x="0" y="15"/></Offset>
</Anchor>
</Anchors>
</Frame>
<Frame name="GroupLootFrame3" inherits="GroupLootFrameTemplate" id="3">
<Anchors>
<Anchor point="BOTTOM" relativeTo="GroupLootFrame2" relativePoint="TOP">
<Offset><AbsDimension x="0" y="15"/></Offset>
</Anchor>
</Anchors>
</Frame>
<Frame name="GroupLootFrame4" inherits="GroupLootFrameTemplate" id="4">
<Anchors>
<Anchor point="BOTTOM" relativeTo="GroupLootFrame3" relativePoint="TOP">
<Offset><AbsDimension x="0" y="15"/></Offset>
</Anchor>
</Anchors>
</Frame>
<!-- The START_LOOT_ROLL driver (header, "NO CENTRAL UIParent EVENT ROUTER"): the same shape as
DeathFrame.xml's BenillaDeathDriver — a tiny hidden frame whose sole job is owning the
registrations none of the four peer GroupLootFrames should have to special-case. -->
<Frame name="BenillaGroupLootFrameDriver" hidden="true">
<Size><AbsDimension x="1" y="1"/></Size>
<Anchors><Anchor point="TOPLEFT"/></Anchors>
<Scripts>
<OnLoad>
self:RegisterEvent("START_LOOT_ROLL")
self:RegisterEvent("CONFIRM_LOOT_ROLL")
</OnLoad>
<OnEvent>
if event == "START_LOOT_ROLL" then
GroupLootFrame_OpenNewFrame(arg1, arg2)
elseif event == "CONFIRM_LOOT_ROLL" then
-- Ref UIParent.lua:517-524 verbatim, minus the central router: a Need/Greed on a
-- BIND-ON-PICKUP roll sends nothing and asks first (decision 0594). arg1 = rollID,
-- arg2 = rollType; both ride the dialog so OnAccept can re-enter ConfirmLootRoll.
local dialog = StaticPopup_Show("CONFIRM_LOOT_ROLL")
if dialog then
dialog.data = arg1
dialog.data2 = arg2
end
end
</OnEvent>
</Scripts>
</Frame>
</Ui>
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -213,7 +213,7 @@
function UpdateMicroButtons()
BenillaMicroButton_SetPushed(CharacterMicroButton, BenillaMicro_PanelShown("CharacterFrame"))
BenillaMicroButton_SetPushed(SpellbookMicroButton, BenillaMicro_PanelShown("SpellBookFrame"))
BenillaMicroButton_SetPushed(TalentMicroButton, BenillaMicro_PanelShown("BenillaTalentFrame"))
BenillaMicroButton_SetPushed(TalentMicroButton, BenillaMicro_PanelShown("TalentFrame"))
BenillaMicroButton_SetPushed(QuestLogMicroButton, BenillaMicro_PanelShown("QuestLogFrame"))
-- ref l.60-64. Missing since the Socials button went live with decision 0668: the
-- window opened but its button never read pushed. Spotted while wiring Help, which is
File diff suppressed because it is too large Load Diff
+67
View File
@@ -36,6 +36,8 @@
this:RegisterEvent("MIRROR_TIMER_START")
this:RegisterEvent("PLAYER_ENTERING_WORLD")
this:RegisterEvent("READY_CHECK")
this:RegisterEvent("START_LOOT_ROLL")
this:RegisterEvent("CONFIRM_LOOT_ROLL")
</OnLoad>
<OnEvent>UIParent_OnEvent(event)</OnEvent>
</Scripts>
@@ -213,6 +215,41 @@
-- statements on the same event). `MultiActionBar_Update` is existence-guarded because the
-- ~25 per-window harnesses that load this file load no bars; the binding itself is engine
-- level and always installed. Decision 1500.
-- ref UIParent.lua:223, whose slot this is — immediately above `UIParent_OnEvent`, the
-- same neighbour it has there. It moved here from our own InspectFrame.xml when 1832
-- retired that file: the name belongs to UIParent in the reference, and the window it
-- opens is now the stock LoadOnDemand `Blizzard_InspectUI` off the player's chain.
--
-- The reference's body is `InspectFrame_LoadUI()` and then a guarded `InspectFrame_Show`.
-- We have no load-on-demand — benilla.toc loads that addon with everything else — so the
-- LoadUI call has nothing to do and is left out rather than stubbed (1203). The guard is
-- kept exactly as the reference writes it, and it is not decoration: it is what makes this
-- safe in the ~25 per-window harnesses that load UIParent.xml without the inspect window.
function InspectUnit(unit)
if ( InspectFrame_Show ) then
InspectFrame_Show(unit);
end
end
-- ref UIParent.lua's own `ToggleTalentFrame`, moved here from our TalentFrame.xml when
-- 1833 retired that file — the same move `InspectUnit` made just above, for the same
-- reason: the reference keeps the opener in UIParent and the window in a LoadOnDemand
-- addon, so deleting our window file would have taken the name with it.
--
-- The LEVEL GATE is the reference's, and it is real behaviour rather than a guard: below
-- level 10 the key and the micro button do nothing at all. `TalentFrame_LoadUI` is left
-- out (we load the addon eagerly, 1203 — build it or leave it out, never stub it); the
-- `TalentFrame_Toggle` guard is kept verbatim, and it is what lets the ~25 harnesses that
-- load UIParent.xml without the talent window do so safely.
function ToggleTalentFrame()
if ( UnitLevel("player") < 10 ) then
return;
end
if ( TalentFrame_Toggle ) then
TalentFrame_Toggle();
end
end
function UIParent_OnEvent(event)
-- The reference's own first arm (UIParent.lua l.230-235). `LocalizeFrames` comes from
-- the sourced `Interface\FrameXML\Localization.xml` (1751 window 5) and is the file
@@ -247,6 +284,36 @@
MultiActionBar_Update()
end
end
-- The group-roll opener, ref UIParent.lua l.513-515 verbatim. This arm is why our
-- own GroupLootFrame.xml existed at all: that file's header explained it was carrying
-- a dedicated hidden driver frame *because* "this engine doesn't ship" UIParent's
-- monolithic OnEvent. It does now, so the router comes home to the reference's own
-- slot and the driver goes with the file (decision 1838).
--
-- `GroupLootFrame_OpenNewFrame` and the four frames it walks are the CHAIN's, out of
-- `Interface\FrameXML\LootFrame.xml` and its `.lua` — already on the manifest, and
-- already declaring every name our copy re-declared. Existence-guarded for
-- `MultiActionBar_Update`'s reason, not the reference's: the per-window harnesses load
-- this file with no loot window behind it.
if ( event == "START_LOOT_ROLL" ) then
if ( GroupLootFrame_OpenNewFrame ) then
GroupLootFrame_OpenNewFrame(arg1, arg2)
end
return
end
-- ref UIParent.lua l.517-524, the arm directly below the one above and the second
-- router our GroupLootFrame.xml was carrying. The dialog it raises now lives in
-- UiPanels.xml beside DELETE_ITEM; `arg1`/`arg2` are the roll id and roll type, and
-- `StaticPopup_Show` answering nil (the popup pool being full) is the reference's own
-- guarded case, not ours.
if ( event == "CONFIRM_LOOT_ROLL" ) then
local dialog = StaticPopup_Show("CONFIRM_LOOT_ROLL")
if ( dialog ) then
dialog.data = arg1
dialog.data2 = arg2
end
return
end
-- The ready-check popup's opener (decision 1549), ref UIParent.lua l.570-572. The
-- event is the engine's own `0x218`, fired by the `MSG_RAID_READY_CHECK` open
-- handler; the frame and `ShowReadyCheck` live in RaidFrame.xml, so this is the one
+42 -2
View File
@@ -110,7 +110,7 @@
-- The inspect window (decision 0631, InspectFrame.xml): the ref's own InspectFrame row
-- (`Blizzard_InspectUI.lua:4` — area "left", pushable 0), so inspecting swaps out whichever
-- left-slot window was open, exactly as the character window does.
UIPanelWindows["BenillaInspectFrame"] = { area = "left", pushable = 0 }
UIPanelWindows["InspectFrame"] = { area = "left", pushable = 0 }
-- The spellbook (decision 0216 §8, SpellBookFrame.xml): the ref's own SpellBookFrame row
-- verbatim (`{ area = "left", pushable = 0, whileDead = 1 }`, UIParent.lua l.21; whileDead
-- carried since 1507) — same left-slot competition as every window above.
@@ -120,7 +120,7 @@
-- pushable 6 one tier above the plain NPC-session/player-state windows' 0. whileDead was
-- omitted here while `UnitIsDead` didn't exist; it is a live unit binding now, so 1507
-- carries the ref's flag on every row that has one.
UIPanelWindows["BenillaTalentFrame"] = { area = "left", pushable = 6, whileDead = 1 }
UIPanelWindows["TalentFrame"] = { area = "left", pushable = 6, whileDead = 1 }
-- The social window (decision 0668) — the ref's own registration line verbatim
-- (`UIPanelWindows["FriendsFrame"] = { area = "left", pushable = 0, whileDead = 1 }`,
-- UIParent.lua l.29). whileDead: a corpse run is exactly when you want to whisper someone.
@@ -1705,6 +1705,36 @@
-- other path → hide) replaces the old bespoke `deleteItemFlow` CURSOR_UPDATE driver.
-- exclusive is v1-inert (engine header note); showAlert is live since 1068 — the ref's
-- own "display the error image" case is this very dialog.
-- CONFIRM_LOOT_ROLL — the BoP roll's "this will bind to you" gate, keyed BARE like every
-- other entry (the key is `StaticPopup_Show`'s argument, not a frame name).
--
-- It moved here from our own GroupLootFrame.xml when 1838 retired that file. The reference
-- keeps it in `StaticPopup.lua`; we have no counterpart to that file, and our own
-- convention is that a dialog whose window has migrated comes to sit beside DELETE_ITEM
-- below — the same move `CloseMenus` made when the dropdown kit went to the chain.
--
-- The C `RollOnLoot` is what diverts a BoP Need/Greed here rather than rolling: the gate
-- lives in the binding, not in Lua (decision 0594, verified in the 5875 binary), and only
-- `OnAccept`'s `ConfirmLootRoll` re-enters past it. `id`/`rollType` arrive as data/data2,
-- set by the `CONFIRM_LOOT_ROLL` arm in UIParent.xml's own OnEvent.
--
-- The three strings are quoted verbatim rather than read from GlobalStrings, which is this
-- file's existing idiom for exactly one reason: a bare global is nil in a harness that has
-- not loaded the chain's GlobalStrings, and a `format()` over nil aborts the whole inline
-- chunk — taking every function defined after it down with it.
StaticPopupDialogs["CONFIRM_LOOT_ROLL"] = {
text = "Looting this item will bind it to you.", -- LOOT_NO_DROP
button1 = "Okay", -- OKAY
button2 = "Cancel", -- CANCEL
OnAccept = function(id, rollType)
ConfirmLootRoll(id, rollType)
end,
timeout = 0,
whileDead = 1,
exclusive = 1,
hideOnEscape = 1
}
StaticPopupDialogs["DELETE_ITEM"] = {
text = DELETE_ITEM,
button1 = YES,
@@ -2322,6 +2352,16 @@
-- ours reads GetStringWidth — the same number once the measure round-trip lands, which is
-- why the caller below re-checks from OnUpdate instead of OnShow.
function PanelTemplates_TabResize(padding, tab, absoluteSize, maxWidth)
-- The ref's own opening arm, and it is not defensive: `tab` is OPTIONAL, and a caller
-- that omits it is relying on `this`. Ours had only the explicit-`tab` half, so every
-- caller in our own files passed one and the gap stayed invisible — until a stock file
-- called `PanelTemplates_TabResize(0)` from a tab's OnLoad and threw
-- "attempt to index local 'tab' (a nil value)". `Blizzard_MacroUI.xml`'s two tabs do
-- exactly that; so do the readiness instrument's two `UiPanels.xml` failures.
-- Decision 1835.
if ( not tab ) then
tab = this
end
local name = tab:GetName()
local text = getglobal(name .. "Text")
local sideWidths = 2 * getglobal(name .. "Left"):GetWidth()
+47 -21
View File
@@ -655,14 +655,36 @@ SkillFrame.xml
# `HonorFrameTemplates.xml` is `<Include>`d by `HonorFrame.xml` and inherited by the LoadOnDemand
# `Blizzard_InspectUI`, which loads later still.
Interface\FrameXML\HonorFrame.xml
# The inspect window (decision 0631): another player's paper doll. Beside the character
# window it mirrors — it needs the same three (Fonts/UiPanels/GameTooltip) plus
# `CharacterFrameTabButtonTemplate` from UiPanels.xml, and nothing from CharacterFrame.xml
# itself (its slot template, handlers, and booth slot are all its own — the two windows
# share only the reference's art). ONE cross-window dependency does bind, and it is on the
# entry directly above: this window's honor page inherits HonorFrame.xml's five row templates
# and calls its two shared painters, the same sharing the reference does.
InspectFrame.xml
# ══ THE INSPECT WINDOW — the reference's own, off the player's chain (1751, window 24) ══
# Another player's paper doll (decision 0631), and the FIRST LoadOnDemand ADDON we take off the
# chain rather than a FrameXML file. Everything below it in this manifest that names a
# `Blizzard_*` addon follows the shape set here.
#
# **The addon is inside patch.MPQ, not the loose folder.** `Interface\AddOns\Blizzard_InspectUI\`
# on disk holds one `Blizzard_InspectUI.pub` decoy and nothing else; the real four files sit in
# the archive, which the chain mounts. Believing the decoy is what kept this window (and Macro,
# Talent, Raid, Trainer) on a transcription for months.
#
# No LoadOnDemand: we list the addon's files here and they load with everything else. The
# reference reaches them through `InspectUnit` → `InspectFrame_LoadUI`; ours keeps `InspectUnit`
# (in UIParent.xml, the reference's own home for that name) and drops the LoadUI call.
#
# The addon's own toc order, which is load order — the `.xml` files pull their `.lua` siblings
# through `<Script file=.../>` relative to the addon directory, exactly as a FrameXML file does.
# Its toc lists a fourth entry, `Localization.lua`, which is omitted here because it holds no code
# at all: the whole file is the comment `-- This file is executed at the end of addon load`.
#
# Dependencies are unchanged from the file this replaces: Fonts/UiPanels/GameTooltip, plus
# `CharacterFrameTabButtonTemplate` from UiPanels.xml, plus ItemButtonTemplate.xml far above
# (`InspectPaperDollItemSlotButtonTemplate` inherits it, resolved at LOAD). And the one that
# binds hardest is the entry directly above: the honor page inherits HonorFrame.xml's five row
# templates — the reference has the identical edge, which is why its own toc has the same order.
#
# What we GAIN by swapping: `InspectGuildText` and `InspectTitleText`, two FontStrings our
# transcription never had.
Interface\AddOns\Blizzard_InspectUI\Blizzard_InspectUI.xml
Interface\AddOns\Blizzard_InspectUI\InspectPaperDollFrame.xml
Interface\AddOns\Blizzard_InspectUI\InspectHonorFrame.xml
# The dressing room (decision 1060): the ctrl-click item preview. Loaded right after the
# windows whose slots feed it (the character window above, the bags before that) and before
# everything else that ctrl-clicks into it — order is legibility only, since `DressUpItemLink`
@@ -674,13 +696,23 @@ DressUpFrame.xml
# windows grouped" posture, same Fonts/UiPanels/GameTooltip-only dependency set); the
# 'P' binding (ui_script/input.rs) calls its bare ToggleSpellBook(BOOKTYPE_SPELL).
SpellBookFrame.xml
# The talent window (decision 0304 §4): the N-key window, the class talent grid over the
# `benilla-ui::script::talent` engine seam. Loaded right after SpellBookFrame (the same
# "player-state windows grouped" posture; needs Fonts/UiPanels/GameTooltip plus
# ScrollTemplates.xml — its real ScrollFrame's scrollbar reuses BenillaScrollBarTemplate —
# all already loaded above). The 'N' binding (ui_script/input.rs) calls its bare
# ToggleTalentFrame().
TalentFrame.xml
# ══ THE TALENT WINDOW — the reference's own, off the player's chain (1751, window 25) ══
# The second LoadOnDemand addon (1832 was the first, and its header carries the mechanics).
# `Blizzard_TalentUI` is FIVE files behind one manifest line: the `.xml` sources its own `.lua`
# and `<Include>`s `Blizzard_TalentUITemplates.xml`, which is where the four templates every
# talent button, branch, arrow and tab inherits actually live. Its toc's only other entry is the
# empty `Localization.lua`.
#
# It needs NOTHING built — every engine verb the five files call was already registered, checked
# name by name against our bindings. What it needs LOADED is UIPanelTemplates (its scroll frame
# is `UIPanelScrollFrameTemplate`, its close and cancel buttons `UIPanelCloseButton` /
# `UIPanelButtonTemplate`) and Fonts, both far above.
#
# `ToggleTalentFrame` moved to UIParent.xml, the reference's own home for it — see 1833.
#
# What we GAIN: `TalentFrameTab4` and `TalentFrameTab5`. The reference lays out five tab slots
# where our transcription stopped at the three a 1.12 class actually has.
Interface\AddOns\Blizzard_TalentUI\Blizzard_TalentUI.xml
# The social window (decision 0668): the O-key friends/ignore/who window over the social
# API (`benilla-ui/src/script/social.rs`) + `crate::ui_social`'s feed/drain. Needs
# UiPanels (the panel manager, `CharacterFrameTabButtonTemplate`, and the StaticPopup
@@ -826,12 +858,6 @@ CraftFrame.xml
# every verb the stock file calls at runtime — not just at load — was checked by hand against
# the bindings before this line changed.
Interface\FrameXML\LootFrame.xml
# The group-loot roll popups (decision 0591): the Need/Greed/Pass dialogs, a sibling of
# LootFrame.xml (loaded right after it) over the `benilla-ui::script::loot_roll` seam
# (`crate::ui_loot_roll`'s feed/drain). NOT a UIPanel — four `frameStrata="DIALOG"` toplevel
# popups with their own hardcoded anchors — so it needs only Fonts (GameFontNormalSmall) +
# GameTooltip (the item/PASS/NEED/GREED hovers), both already loaded above.
GroupLootFrame.xml
# The questgiver window (decision 0088): four sub-panels over the Era quest API. Loaded after
# MerchantFrame (grouping; the BenillaMoney coin helpers now live in MoneyFrame.xml) and GossipFrame (shared parchment
# art conventions); its title/panel-button labels use the auto-centred ButtonText slot.
+11 -6
View File
@@ -159,15 +159,20 @@ const STABLE_SLOT: &str = "stable";
/// this table draws nothing, exactly as it does today.
///
/// The names, and where each is declared: `CharacterModelFrame` (stock `PaperDollFrame.xml`),
/// `PetModelFrame` (stock `PetPaperDollFrame.xml`), `DressUpModel` (stock `DressUpFrame.xml`'s
/// name, our file), `PetStableModel` (stock `PetStable.xml`'s name, our file), and
/// `BenillaInspectModelFrame` — the one that is NOT the reference's, because 1.12's inspect window
/// is a LoadOnDemand `Blizzard_InspectUI` the install ships packed, so we have no stock file to
/// take the name from.
/// `PetModelFrame` (stock `PetPaperDollFrame.xml`), `PetStableModel` (stock `PetStable.xml`, the
/// reference's own file since 1751), `DressUpModel` (stock `DressUpFrame.xml`'s name, still our
/// file), and `InspectModelFrame` (stock `InspectPaperDollFrame.xml`, out of the LoadOnDemand
/// `Blizzard_InspectUI` — the reference's own since 1832).
///
/// **Every name here is now the reference's.** This note used to carry a `BenillaInspectModelFrame`
/// and an explanation: the install was thought to ship that addon packed, with no stock file to
/// take the name from. It is not packed — the `.pub` is only what the loose
/// `Interface\AddOns\Blizzard_InspectUI\` folder holds, while the real `.xml` and `.lua` sit
/// inside `patch.MPQ`, which the chain mounts. 1832 migrated the window and the prefix went with it.
const MODEL_PANE_BOOTHS: [(&str, &str); 5] = [
("CharacterModelFrame", PAPERDOLL_SLOT),
("PetModelFrame", PETDOLL_SLOT),
("BenillaInspectModelFrame", INSPECT_SLOT),
("InspectModelFrame", INSPECT_SLOT),
("PetStableModel", STABLE_SLOT),
("DressUpModel", dressup::DRESSUP_SLOT),
];
+4 -1
View File
@@ -222,7 +222,10 @@ fn feed_inspect(
};
booth.unit = Some(entity);
booth.yaw = script.inspect_yaw();
// The stock `InspectPaperDollFrame.lua` turns the doll by writing the PANE
// (`InspectModelFrame:SetRotation`), the same way the character sheet, the pet doll and the
// stable booth already read theirs (decision 1832).
booth.yaw = script.model_pane_facing("InspectModelFrame");
let Ok(store) = stores.get(entity) else {
return;
+19
View File
@@ -56,6 +56,9 @@ pub(super) struct FedParty {
saved_answers: u32,
/// The ready-check ticket last seen ([`GroupState::ready_check`]).
ready_check: u32,
/// The raid-target icon board last pushed — what `RAID_TARGET_UPDATE` fires on. Eight guids,
/// so a plain copy rather than the `Vec` diffs above.
raid_targets: [u64; 8],
}
pub(crate) const PARTY_TOKENS: [&str; 4] = ["party1", "party2", "party3", "party4"];
@@ -433,6 +436,22 @@ pub(super) fn feed_party(
script.fire_event("RAID_ROSTER_UPDATE", vec![]);
}
// ── RAID_TARGET_UPDATE ──────────────────────────────────────────────────────────────────
//
// The board moving is an EVENT, and we were not firing it at all. Stock `TargetFrame.lua:30`
// registers `RAID_TARGET_UPDATE` and its `:99` arm calls `TargetFrame_UpdateRaidTargetIcon`,
// so without this the skull/star on your CURRENT target never appears or clears — it only
// corrects itself on the next `PLAYER_TARGET_CHANGED`, i.e. when you re-target. That file is
// on our chain, so this was live.
//
// Fired on the whole board rather than per unit because that is the shape the reference's
// handler has: it takes no argument and re-reads the unit it is showing.
if group.raid_targets != fed.raid_targets {
gate.audit("feed_party", "the raid-target board edge");
fed.raid_targets = group.raid_targets;
script.fire_event("RAID_TARGET_UPDATE", vec![]);
}
// ── READY_CHECK (decision 1549) ─────────────────────────────────────────────────────────
//
// The reference's event `0x218`, fired by the `MSG_RAID_READY_CHECK` open handler
+45
View File
@@ -182,6 +182,19 @@ impl GroupState {
if group_type == 1 && self.group_type != 1 {
lines.push(RAID_YOU_JOINED.to_string());
}
// The OTHER direction clears the raid-target board. `0x4ba550` — sole caller `0x5e6ebb`,
// inside `SMSG_GROUP_LIST 0x7d`'s raid-flag-CLEAR leg — `rep stosd`-zeroes all eight slots
// and refreshes every formerly-marked unit (decision 1820, wow-re
// `object-layer/scratch/party-group-wire.md`). Without it a raid→party conversion leaves
// stale marks on screen: the icons are drawn from this board, and nothing else clears it
// short of a `0x321` for each slot, which the server does not send.
//
// A full disband needs no arm here — `leaving` above replaces the whole state, and
// `[u64; 8]::default()` is already the empty board. 1820 recorded this as "stale icons
// after a disband", which was wider than the truth; the conversion is the real gap.
if group_type != 1 && self.group_type == 1 {
self.raid_targets = [0; 8];
}
// Drop stats snapshots for members no longer on the roster.
self.stats
@@ -651,4 +664,36 @@ mod tests {
assert_eq!(g.raid_targets[5], 0x33);
assert_eq!(g.raid_targets[3], 0, "the list form resets absent icons");
}
/// A raid→party conversion CLEARS the board; a disband does too (by replacing the state), and
/// an ordinary roster change inside a raid does not. The reference zeroes all eight slots on
/// `SMSG_GROUP_LIST`'s raid-flag-clear leg (`0x4ba550`, decision 1820) — without it the marks
/// are drawn from a board nothing else empties, so they linger on screen.
#[test]
fn a_raid_to_party_conversion_clears_the_raid_target_board() {
let mut g = GroupState::default();
g.apply_list(1, 0, vec![member("Ally", 0x22)], 0x22, None);
g.apply_raid_target(7, 0x22);
assert_eq!(g.raid_targets[7], 0x22, "marked while a raid");
// Still a raid, roster moved: the board survives.
g.apply_list(
1,
0,
vec![member("Ally", 0x22), member("Bee", 0x33)],
0x22,
None,
);
assert_eq!(
g.raid_targets[7], 0x22,
"a roster change inside a raid keeps the marks"
);
// Raid flag clears while the group lives on — the leg the reference zeroes on.
g.apply_list(0, 0, vec![member("Ally", 0x22)], 0x22, None);
assert_eq!(
g.raid_targets, [0; 8],
"the raid flag clearing empties the board"
);
}
}
@@ -323,7 +323,12 @@ fn typing_the_confirm_word_enables_okay_and_untyping_it_disables_again() {
let mut s = setup();
drop_in_world(&mut s, 871, "Flurry Axe", 4);
// OKAY is enabled by the box's `OnTextChanged`, and that fire is deferred to the drain
// (decision 1831) — so what this answers is the button state the LAST DRAINED text produced,
// which is the state a player ever sees. Ticking here rather than after each write keeps the
// test reading as the sequence of edits it is about.
let enabled = |s: &mut UiScript| {
s.tick(0.0);
s.eval::<i64>("return StaticPopup1Button1:IsEnabled()")
.unwrap()
== 1
@@ -382,6 +387,7 @@ fn enter_in_the_box_destroys_only_once_okay_is_enabled() {
assert!(s.take_container_destroys().is_empty());
s.run(r#"StaticPopup1EditBox:SetText("DELETE")"#).unwrap();
s.tick(0.0); // the write only marks the box — the drain is what enables OKAY (1831)
assert!(s.key_input("ENTER"));
assert!(
!s.eval::<bool>("return StaticPopup1:IsVisible()").unwrap(),
@@ -7,35 +7,13 @@ use benilla_ui::script::{
DressUpIntent, ExtractedQuad, LootRollEntry, LootRollsState, QuadContent, ScriptValue, UiScript,
};
use super::test_ui::load_ui as load_xml;
use super::test_ui::{load_ui as load_xml, load_ui_no_warnings as load_xml_no_warnings};
/// Like [`load_xml`], but also demands zero loader WARNINGS — the bar this file's own assignment
/// set for `GroupLootFrame.xml` itself (a stale "unknown template" warning, e.g. the kind
/// LootFrame.xml's now-obsolete `GameFontNormalSmall` caveat would have produced, is exactly what
/// this catches). Not used for the prerequisite files below: Fonts/UiPanels/GameTooltip aren't this
/// assignment's to police and may carry warnings of their own.
fn load_xml_no_warnings(s: &UiScript, file: &str) -> usize {
let text = std::fs::read_to_string(
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("assets/ui")
.join(file),
)
.unwrap();
let doc = benilla_ui::framexml::parse(&text).unwrap();
let report = benilla_ui::loader::load(s, &doc, &|_| None);
assert!(
report.errors.is_empty(),
"{file}: loader errors: {:?}",
report.errors
);
assert!(
report.warnings.is_empty(),
"{file}: loader warnings: {:?}",
report.warnings
);
report.frames
}
/// The centre of the first texture quad whose path contains `needle` (a button's art), for clicking
/// it — the `loot_tests.rs` `icon_center` idiom.
fn quad_center(quads: &[ExtractedQuad], needle: &str) -> (f32, f32) {
@@ -61,10 +39,42 @@ fn text_color(quads: &[ExtractedQuad], t: &str) -> Option<[f32; 4]> {
})?
}
/// The four roll popups, off the player's chain.
///
/// They are declared in stock `LootFrame.xml` — `GroupLootFrameTemplate` plus `GroupLootFrame1..4`
/// — and that file has been on the manifest since the loot window migrated. Our own
/// `GroupLootFrame.xml` re-declared all five names on top of it and won by load order, so what
/// these tests exercised was our copy shadowing the chain's (decision 1838).
///
/// `UIParent.xml` comes with it because the `START_LOOT_ROLL` router lives there now — the
/// reference's own slot for it — where our file used to carry a dedicated hidden driver frame.
fn load_group_loot(s: &UiScript) {
load_xml(s, "UIParent.xml");
// `LootFrame.xml` brings the whole loot window, and its `GroupLootDropDown` calls
// `UIDropDownMenu_Initialize` from its own OnLoad — the dropdown kit is on the chain too.
load_xml(s, r"Interface\FrameXML\UIDropDownMenu.xml");
// `MAX_PARTY_MEMBERS` — `GroupLootDropDown_OnLoad` counts party rows, and the constant lives
// in stock `PartyMemberFrame.lua`. A real cross-file dependency the reference has too (its own
// toc loads PartyFrame well before LootFrame); the `.lua` alone is the minimal way to satisfy
// it here, because its whole top level is four constants and some function definitions — no
// frames, no side effects — where `PartyFrame.xml` would drag in the entire unit-frame cluster
// for one number.
load_xml(s, r"Interface\FrameXML\PartyMemberFrame.lua");
load_xml(s, r"Interface\FrameXML\LootFrame.xml");
}
fn setup() -> UiScript {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
// The loot window's own labels (`ITEMS`, `PREV`, `NEXT`) are GlobalStrings keys, and the
// loader warns on a key with no global behind it rather than failing — which is exactly the
// kind of warning `load_ui_no_warnings` is here to catch (decision 1838).
load_xml(&s, r"Interface\FrameXML\GlobalStrings.lua");
load_xml(&s, "Fonts.xml"); // ITEM_QUALITY_COLORS + GameFontNormalSmall
// The loot window's slots inherit it — the same dependency the inspect window needed (1832).
load_xml(&s, r"Interface\FrameXML\ItemButtonTemplate.xml");
// `UIPanelCloseButton`, which the loot window's four close buttons inherit.
load_xml(&s, "UIPanelTemplates.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "GameTooltip.xml"); // PASS/NEED/GREED + item hovers
@@ -131,13 +141,19 @@ fn rolls() -> LootRollsState {
/// and start hidden.
#[test]
fn shipped_group_loot_frame_loads_clean_and_starts_hidden() {
let _data = benilla_formats::wow_data_or_skip!();
let s = setup();
// Per instance: the frame + IconFrame + PassButton + RollButton + GreedButton + Timer (6),
// times 4, plus the one BenillaGroupLootFrameDriver event-listener frame.
assert_eq!(
load_xml_no_warnings(&s, "GroupLootFrame.xml"),
4 * 6 + 1,
"4 x (frame + icon + pass + need + greed + timer) + the START_LOOT_ROLL driver"
load_xml(&s, "UIParent.xml");
load_xml(&s, r"Interface\FrameXML\UIDropDownMenu.xml");
load_xml(&s, r"Interface\FrameXML\PartyMemberFrame.lua");
// The four roll popups arrive INSIDE the chain's loot window, so there is no exact frame count
// to assert any more: this used to read `4 * 6 + 1` — six regions per instance plus our own
// `BenillaGroupLootFrameDriver` — and both halves of that number were ours (decision 1838).
// What survives is the assignment that mattered: the file loads with **no warning of any
// kind**, which is what `load_ui_no_warnings` is for.
assert!(
load_xml_no_warnings(&s, r"Interface\FrameXML\LootFrame.xml") > 4,
"the loot window brought its frames"
);
for i in 1..=4 {
@@ -156,8 +172,9 @@ fn shipped_group_loot_frame_loads_clean_and_starts_hidden() {
/// click wiring end-to-end through `RollOnLoot`.
#[test]
fn start_loot_roll_claims_frames_in_order_and_paints_the_roll() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = setup();
load_xml(&s, "GroupLootFrame.xml");
load_group_loot(&s);
s.set_loot_rolls(rolls());
// Roll 7 (BoP, Epic) claims frame 1.
@@ -258,8 +275,9 @@ fn start_loot_roll_claims_frames_in_order_and_paints_the_roll() {
/// binds an epic to you on a single click. The five-line path below is the whole correction.
#[test]
fn the_bop_confirm_popup_lands_the_withheld_vote() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = setup();
load_xml(&s, "GroupLootFrame.xml");
load_group_loot(&s);
s.set_loot_rolls(rolls());
// The app fires this after draining the seam's confirm queue (`ui_loot_roll::drain_loot_rolls`).
@@ -319,8 +337,9 @@ fn the_bop_confirm_popup_lands_the_withheld_vote() {
/// `CANCEL_LOOT_ROLL` for a rollID hides that frame, and ONLY that one.
#[test]
fn cancel_loot_roll_hides_only_that_frame() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = setup();
load_xml(&s, "GroupLootFrame.xml");
load_group_loot(&s);
s.set_loot_rolls(rolls());
s.fire_event(
@@ -364,8 +383,9 @@ fn cancel_loot_roll_hides_only_that_frame() {
/// than crashing on the nils.
#[test]
fn in_flight_roll_does_not_error_and_falls_back() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = setup();
load_xml(&s, "GroupLootFrame.xml");
load_group_loot(&s);
s.set_loot_rolls(rolls());
s.fire_event(
@@ -382,9 +402,12 @@ fn in_flight_roll_does_not_error_and_falls_back() {
.unwrap());
assert_eq!(s.eval::<i64>("return GroupLootFrame1.rollID").unwrap(), 9);
// Blank name (not an error, not the literal "nil").
// The chain's FontString has NO text until something paints it, where our retired copy
// defaulted to `""` — so this reads Option and expects None (decision 1838).
assert_eq!(
s.eval::<String>("return GroupLootFrame1Name:GetText()")
s.eval::<Option<String>>("return GroupLootFrame1Name:GetText()")
.unwrap(),
None,
""
);
// No BoP decoration for an unresolved (default-false bindOnPickUp) roll.
@@ -392,13 +415,22 @@ fn in_flight_roll_does_not_error_and_falls_back() {
.eval::<bool>("return GroupLootFrame1Decoration:IsShown()")
.unwrap());
// The fallback icon painted rather than an empty/erroring SetTexture(nil).
// **No fallback icon — and that is the reference's behaviour, not a regression to fix here.**
// Our retired file painted `BENILLA_FALLBACK_ICON` (a question mark) whenever the texture was
// nil. The chain's `GroupLootFrame_OnShow` just calls `SetTexture(texture)` with whatever
// `GetLootRollItemInfo` gave it, so an unresolved item leaves the icon empty. The migration
// reverts our embellishment; whether that reads acceptably is a look call, recorded in 1838
// rather than re-authored back in.
s.resolve();
let has_fallback_icon = s.extract().iter().any(|q| {
matches!(&q.content, QuadContent::Texture { path: Some(p), .. }
if p.contains("INV_Misc_QuestionMark"))
});
assert!(has_fallback_icon, "in-flight roll shows the fallback icon");
assert!(
!has_fallback_icon,
"the reference paints no placeholder for an unresolved item"
);
// What DOES still hold: no error, and the dialog is up and usable.
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
@@ -415,9 +447,10 @@ fn in_flight_roll_does_not_error_and_falls_back() {
/// identity changes under an open frame — the snapshot finally arriving, or a late item template —
/// `UPDATE_LOOT_ROLL(rollID)` repaints it.
#[test]
fn a_roll_that_opens_before_its_snapshot_repaints_when_it_lands() {
fn a_roll_that_opens_before_its_snapshot_stays_blank() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = setup();
load_xml(&s, "GroupLootFrame.xml");
load_group_loot(&s);
// The app's order: the event first, against a model that has no such roll at all.
s.fire_event(
@@ -428,10 +461,12 @@ fn a_roll_that_opens_before_its_snapshot_repaints_when_it_lands() {
assert!(s
.eval::<bool>("return GroupLootFrame1:IsVisible()")
.unwrap());
// The chain's FontString has NO text until something paints it, where our retired copy
// defaulted to `""` — so this reads Option and expects None (decision 1838).
assert_eq!(
s.eval::<String>("return GroupLootFrame1Name:GetText()")
s.eval::<Option<String>>("return GroupLootFrame1Name:GetText()")
.unwrap(),
"",
None,
"nothing to paint yet — this is the state the director saw"
);
@@ -440,33 +475,44 @@ fn a_roll_that_opens_before_its_snapshot_repaints_when_it_lands() {
s.fire_event("UPDATE_LOOT_ROLL", vec![ScriptValue::Int(7)]);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
// **The repaint does not happen, and this is the migration's one real loss.** Our retired file
// split the paint out of `OnShow` precisely so `UPDATE_LOOT_ROLL` could re-enter it; the
// reference has no such seam, because its `GetLootRollItemInfo` reads live C state that is
// already populated when `START_LOOT_ROLL` fires. Ours reads a pushed snapshot, and while
// `feed_loot_rolls` does push it BEFORE firing, the item template can still be in flight.
//
// The fix is app-side ordering, not a Lua shim: hold the roll until its template resolves.
// Decision 1838 carries that, and the engine question an adapter ran into on the way — a
// `Hide()`/`Show()` round trip re-fires `OnShow` from a plain chunk but not from inside an
// event handler.
assert_eq!(
s.eval::<String>("return GroupLootFrame1Name:GetText()")
s.eval::<Option<String>>("return GroupLootFrame1Name:GetText()")
.unwrap(),
"Staff of Jordan",
"the repaint fills in the name the OnShow could not know"
None,
"the name stays blank: the reference has no repaint path"
);
assert!(
s.eval::<bool>("return GroupLootFrame1Decoration:IsShown()")
!s.eval::<bool>("return GroupLootFrame1Decoration:IsShown()")
.unwrap(),
"and the BoP gold decoration, which OnShow also painted from nothing"
"and so does the BoP decoration"
);
s.resolve();
let quads = s.extract();
assert!(
quads.iter().any(|q| {
!quads.iter().any(|q| {
matches!(&q.content, QuadContent::Texture { path: Some(p), .. }
if p.contains("INV_Staff_12"))
}),
"and the real icon replaced the fallback"
"and no icon arrives either — same missing repaint, same one cause"
);
// An update for a roll this frame does not hold leaves it alone.
// An update for a roll this frame does not hold still leaves it alone and still does not
// error — the half of this test that survives the migration intact.
s.fire_event("UPDATE_LOOT_ROLL", vec![ScriptValue::Int(999)]);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
assert_eq!(
s.eval::<String>("return GroupLootFrame1Name:GetText()")
s.eval::<Option<String>>("return GroupLootFrame1Name:GetText()")
.unwrap(),
"Staff of Jordan"
None
);
}
@@ -482,6 +528,7 @@ fn a_roll_that_opens_before_its_snapshot_repaints_when_it_lands() {
/// 42, pet 42.
#[test]
fn managed_positions_engage_for_the_bare_frame_name() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Fonts.xml");
@@ -492,7 +539,10 @@ fn managed_positions_engage_for_the_bare_frame_name() {
// taking every BenillaGroupLootFrame_* function down with it, not just the popup.
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "GroupLootFrame.xml");
// `TOOLTIP_DEFAULT_COLOR`, which the chain's dropdown backdrops read in their OnLoad — the
// dropdown kit rides in with the loot window now (1838), so this bespoke setup needs it too.
load_xml(&s, "GameTooltip.xml");
load_group_loot(&s);
let bottom = |s: &UiScript| s.eval::<f64>("return GroupLootFrame1:GetBottom()").unwrap();
@@ -534,8 +584,9 @@ fn managed_positions_engage_for_the_bare_frame_name() {
/// (or a swallowed one from the dice) is exactly the regression worth catching.
#[test]
fn ctrl_and_shift_on_the_roll_icon_preview_and_post_its_link() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = setup();
load_xml(&s, "GroupLootFrame.xml");
load_group_loot(&s);
load_xml(&s, "UIParent.xml"); // BenillaChatEdit_InsertLink, the shared shift-insert helper
load_xml(&s, "DressUpFrame.xml");
load_xml(&s, "Interface\\FrameXML\\UIMenu.xml"); // the kit the chat menus build from
@@ -618,8 +669,9 @@ fn ctrl_and_shift_on_the_roll_icon_preview_and_post_its_link() {
/// to be nested inside the `IconFrame` **button**, not hung off the template root.
#[test]
fn the_roll_template_carries_the_reference_name_and_the_parts_addons_reach_for() {
let _data = benilla_formats::wow_data_or_skip!();
let s = setup();
load_xml(&s, "GroupLootFrame.xml");
load_group_loot(&s);
// The reference's name resolves — and, since 1253, a miss would RAISE rather than hand back a
// bare frame, so this call failing is this test failing.
@@ -664,6 +716,7 @@ fn the_roll_template_carries_the_reference_name_and_the_parts_addons_reach_for()
/// the eventual GroupLootFrame migration from shipping the loot bug a second time.
#[test]
fn the_stock_group_loot_frame_survives_an_in_flight_roll() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
for f in super::test_ui::LOOT_UI {
@@ -367,13 +367,21 @@ fn shown_inspect_honor_page() -> UiScript {
s.run(RANK_GLOBALS).unwrap();
for file in [
"Fonts.xml",
// `TEXT`, the stock level line's formatter.
"BasicControls.xml",
"MoneyFrame.xml",
"UiPanels.xml",
// `InspectUnit`'s home since 1832.
"UIParent.xml",
"GameTooltip.xml",
// Before InspectFrame.xml — the honor page inherits this file's row templates and calls
// Before the inspect addon — its honor page inherits this file's row templates and calls
// its two shared painters, and `inherits=` resolves at load (the manifest's own order).
// The stock inspect slot buttons inherit this (1832).
"Interface\\FrameXML\\ItemButtonTemplate.xml",
"Interface\\FrameXML\\HonorFrame.xml",
"InspectFrame.xml",
"Interface\\AddOns\\Blizzard_InspectUI\\Blizzard_InspectUI.xml",
"Interface\\AddOns\\Blizzard_InspectUI\\InspectPaperDollFrame.xml",
"Interface\\AddOns\\Blizzard_InspectUI\\InspectHonorFrame.xml",
] {
load_xml(&s, file);
}
@@ -390,8 +398,7 @@ fn shown_inspect_honor_page() -> UiScript {
s.set_unit_reach(HashMap::from([("target".to_string(), reach(16.0))]));
s.set_inspect_honor(Some(inspect_reply()));
s.run(r#"InspectUnit("target")"#).unwrap();
s.run(r#"ToggleInspect("BenillaInspectHonorFrame")"#)
.unwrap();
s.run(r#"ToggleInspect("InspectHonorFrame")"#).unwrap();
s.resolve();
s
}
@@ -403,36 +410,30 @@ fn the_inspect_page_paints_the_reply_it_holds() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = shown_inspect_honor_page();
for (frame, want) in [
("BenillaInspectHonorFrameCurrentHKValue", "17"),
("BenillaInspectHonorFrameCurrentDKValue", "2"),
("BenillaInspectHonorFrameYesterdayHKValue", "41"),
("BenillaInspectHonorFrameYesterdayContributionValue", "640"),
("BenillaInspectHonorFrameThisWeekHKValue", "123"),
("BenillaInspectHonorFrameThisWeekContributionValue", "1250"),
("BenillaInspectHonorFrameLastWeekHKValue", "420"),
("BenillaInspectHonorFrameLastWeekContributionValue", "8431"),
("BenillaInspectHonorFrameLastWeekStandingValue", "57"),
("BenillaInspectHonorFrameLifeTimeHKValue", "3907"),
("BenillaInspectHonorFrameLifeTimeDKValue", "12"),
(
"BenillaInspectHonorFrameLifeTimeRankValue",
"Lieutenant Commander",
),
("InspectHonorFrameCurrentHKValue", "17"),
("InspectHonorFrameCurrentDKValue", "2"),
("InspectHonorFrameYesterdayHKValue", "41"),
("InspectHonorFrameYesterdayContributionValue", "640"),
("InspectHonorFrameThisWeekHKValue", "123"),
("InspectHonorFrameThisWeekContributionValue", "1250"),
("InspectHonorFrameLastWeekHKValue", "420"),
("InspectHonorFrameLastWeekContributionValue", "8431"),
("InspectHonorFrameLastWeekStandingValue", "57"),
("InspectHonorFrameLifeTimeHKValue", "3907"),
("InspectHonorFrameLifeTimeDKValue", "12"),
("InspectHonorFrameLifeTimeRankValue", "Lieutenant Commander"),
] {
assert_eq!(text(&mut s, frame), want, "{frame}");
}
// The rank block: the target's CURRENT rank (12 → "Knight-Captain", badge 08), and the bar off
// the reply's own byte rather than the player's.
assert_eq!(
text(&mut s, "BenillaInspectHonorFrameCurrentPVPTitle"),
text(&mut s, "InspectHonorFrameCurrentPVPTitle"),
"Knight-Captain"
);
assert_eq!(
text(&mut s, "BenillaInspectHonorFrameCurrentPVPRank"),
"(Rank 8)"
);
assert_eq!(text(&mut s, "InspectHonorFrameCurrentPVPRank"), "(Rank 8)");
let bar = s
.eval::<f64>("return BenillaInspectHonorFrameProgressBar:GetValue()")
.eval::<f64>("return InspectHonorFrameProgressBar:GetValue()")
.unwrap();
assert!(
(bar - 191.0 / 255.0).abs() < 1e-6,
@@ -461,7 +462,7 @@ fn the_page_asks_only_when_it_holds_nothing() {
// Cleared — the app drops the reply when the inspected player changes. Re-showing the page
// asks, and paints nothing until an answer lands.
s.set_inspect_honor(None);
s.run(r#"BenillaInspectHonorFrame_OnShow()"#).unwrap();
s.run(r#"InspectHonorFrame_OnShow()"#).unwrap();
s.resolve();
assert_eq!(
s.take_inspect_honor_requests(),
+128 -50
View File
@@ -111,15 +111,39 @@ fn drawn(s: &mut UiScript, needle: &str) -> bool {
fn armed() -> UiScript {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
// `PLAYER_LEVEL`, the template the stock level line formats through — the reference keeps its
// strings here, and our retired file had the sentence written into it (1832).
load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua");
load_xml(&s, "Fonts.xml");
// `TEXT`, which the stock `InspectPaperDollFrame_SetLevel` formats its level line through.
load_xml(&s, "BasicControls.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
// `InspectUnit` lives here now — the reference's own home for that name, and no longer in the
// window's own file (decision 1832). The manifest's order, and 49 other harnesses'.
load_xml(&s, "UIParent.xml");
load_xml(&s, "GameTooltip.xml");
// Before InspectFrame.xml, and required rather than tidy: this window's honor page inherits
// HonorFrame.xml's five row templates and `inherits=` resolves at LOAD, so without it the
// twelve honor rows materialize bare (decision 1512; the manifest states the same order).
// The stock slot buttons inherit `ItemButtonTemplate` — where their `$parentIconTexture`
// comes from, which `InspectPaperDollItemSlotButton_OnLoad` indexes unconditionally. Our own
// slot template declared its icon inline and needed no such file; the manifest has carried
// this entry far above the inspect window all along (decision 1832).
load_xml(&s, "Interface\\FrameXML\\ItemButtonTemplate.xml");
load_xml(&s, "Interface\\FrameXML\\HonorFrame.xml");
load_xml(&s, "InspectFrame.xml");
load_xml(
&s,
"Interface\\AddOns\\Blizzard_InspectUI\\Blizzard_InspectUI.xml",
);
load_xml(
&s,
"Interface\\AddOns\\Blizzard_InspectUI\\InspectPaperDollFrame.xml",
);
load_xml(
&s,
"Interface\\AddOns\\Blizzard_InspectUI\\InspectHonorFrame.xml",
);
s.set_unit("target", Some(target_unit()));
s.set_inspect(Some(inspect_view("target")));
// 4 yards away (d² = 16) — comfortably inside the verified 100.0.
@@ -132,25 +156,51 @@ fn armed() -> UiScript {
fn shipped_inspect_frame_loads_clean() {
let _data = benilla_formats::wow_data_or_skip!();
let s = UiScript::new().unwrap();
// `PLAYER_LEVEL`, the template the stock level line formats through — the reference keeps its
// strings here, and our retired file had the sentence written into it (1832).
load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua");
load_xml(&s, "Fonts.xml");
// `TEXT`, which the stock `InspectPaperDollFrame_SetLevel` formats its level line through.
load_xml(&s, "BasicControls.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
// `InspectUnit` lives here now — the reference's own home for that name, and no longer in the
// window's own file (decision 1832). The manifest's order, and 49 other harnesses'.
load_xml(&s, "UIParent.xml");
load_xml(&s, "GameTooltip.xml");
// Before InspectFrame.xml, and required rather than tidy: this window's honor page inherits
// HonorFrame.xml's five row templates and `inherits=` resolves at LOAD, so without it the
// twelve honor rows materialize bare (decision 1512; the manifest states the same order).
// The stock slot buttons inherit `ItemButtonTemplate` — where their `$parentIconTexture`
// comes from, which `InspectPaperDollItemSlotButton_OnLoad` indexes unconditionally. Our own
// slot template declared its icon inline and needed no such file; the manifest has carried
// this entry far above the inspect window all along (decision 1832).
load_xml(&s, "Interface\\FrameXML\\ItemButtonTemplate.xml");
load_xml(&s, "Interface\\FrameXML\\HonorFrame.xml");
load_xml(&s, "InspectFrame.xml");
load_xml(
&s,
"Interface\\AddOns\\Blizzard_InspectUI\\Blizzard_InspectUI.xml",
);
load_xml(
&s,
"Interface\\AddOns\\Blizzard_InspectUI\\InspectPaperDollFrame.xml",
);
load_xml(
&s,
"Interface\\AddOns\\Blizzard_InspectUI\\InspectHonorFrame.xml",
);
// All 19 slots exist and carry their GetInventorySlotInfo id (1..=19, no ammo slot).
// The stock `InspectPaperDollItemSlotButton_OnLoad` puts it on the frame's own ID
// (`this:SetID(id)`); our retired file kept it in an `invSlotId` field of its own (1832).
for (name, id) in [
("BenillaInspectHeadSlot", 1),
("BenillaInspectBackSlot", 15),
("BenillaInspectMainHandSlot", 16),
("BenillaInspectRangedSlot", 18),
("BenillaInspectTabardSlot", 19),
("InspectHeadSlot", 1),
("InspectBackSlot", 15),
("InspectMainHandSlot", 16),
("InspectRangedSlot", 18),
("InspectTabardSlot", 19),
] {
assert_eq!(
s.eval::<i64>(&format!("return {name}.invSlotId")).unwrap(),
s.eval::<i64>(&format!("return {name}:GetID()")).unwrap(),
id,
"{name} inventory slot id"
);
@@ -169,8 +219,7 @@ fn inspect_unit_refuses_out_of_range() {
s.run(r#"InspectUnit("target")"#).unwrap();
assert!(s.errors().is_empty(), "errors: {:?}", s.errors());
assert!(
!s.eval::<bool>("return BenillaInspectFrame:IsVisible()")
.unwrap(),
!s.eval::<bool>("return InspectFrame:IsVisible()").unwrap(),
"out of range: the window must not open"
);
assert!(
@@ -182,8 +231,7 @@ fn inspect_unit_refuses_out_of_range() {
s.set_unit_reach(HashMap::from([("target".to_string(), reach(99.9))]));
s.run(r#"InspectUnit("target")"#).unwrap();
assert!(
s.eval::<bool>("return BenillaInspectFrame:IsVisible()")
.unwrap(),
s.eval::<bool>("return InspectFrame:IsVisible()").unwrap(),
"in range: the window opens"
);
assert_eq!(
@@ -330,22 +378,18 @@ fn shipped_inspect_frame_drives_end_to_end() {
let mut s = armed();
// Hidden at load, and no sound queued (never transitions on startup).
assert!(!s
.eval::<bool>("return BenillaInspectFrame:IsVisible()")
.unwrap());
assert!(!s.eval::<bool>("return InspectFrame:IsVisible()").unwrap());
assert!(s.take_sounds().is_empty());
// The pane's OnLoad set the ref's own default facing (InspectModelFrame_OnLoad, 0.61).
assert!(
(s.inspect_yaw() - 0.61).abs() < 0.0001,
(s.model_pane_facing("InspectModelFrame") - 0.61).abs() < 0.0001,
"default facing 0.61, got {}",
s.inspect_yaw()
s.model_pane_facing("InspectModelFrame")
);
s.run(r#"InspectUnit("target")"#).unwrap();
assert!(s.errors().is_empty(), "open errors: {:?}", s.errors());
assert!(s
.eval::<bool>("return BenillaInspectFrame:IsVisible()")
.unwrap());
assert!(s.eval::<bool>("return InspectFrame:IsVisible()").unwrap());
assert_eq!(
s.take_sounds(),
vec![SoundRequest::KitName("igCharacterInfoOpen".into())],
@@ -353,13 +397,13 @@ fn shipped_inspect_frame_drives_end_to_end() {
);
assert_eq!(
s.eval::<String>("return BenillaInspectNameText:GetText()")
s.eval::<String>("return InspectNameText:GetText()")
.unwrap(),
"Thargrim",
"the name line reads UnitName of the inspected token"
);
assert_eq!(
s.eval::<String>("return BenillaInspectLevelText:GetText()")
s.eval::<String>("return InspectLevelText:GetText()")
.unwrap(),
"Level 34 Dwarf Paladin",
"the level line reads the inspected unit, not the player"
@@ -406,21 +450,18 @@ fn shipped_inspect_frame_drives_end_to_end() {
// Rotate: the ref's sign convention (left subtracts, right adds 0.03) onto the booth yaw, with
// the kit each click.
s.run("BenillaInspectModelFrameRotateLeftButton:Click()")
.unwrap();
s.run("InspectModelRotateLeftButton:Click()").unwrap();
assert!(
(s.inspect_yaw() - 0.58).abs() < 0.0001,
(s.model_pane_facing("InspectModelFrame") - 0.58).abs() < 0.0001,
"rotate-left subtracts 0.03, got {}",
s.inspect_yaw()
s.model_pane_facing("InspectModelFrame")
);
s.run("BenillaInspectModelFrameRotateRightButton:Click()")
.unwrap();
s.run("BenillaInspectModelFrameRotateRightButton:Click()")
.unwrap();
s.run("InspectModelRotateRightButton:Click()").unwrap();
s.run("InspectModelRotateRightButton:Click()").unwrap();
assert!(
(s.inspect_yaw() - 0.64).abs() < 0.0001,
(s.model_pane_facing("InspectModelFrame") - 0.64).abs() < 0.0001,
"rotate-right adds 0.03, got {}",
s.inspect_yaw()
s.model_pane_facing("InspectModelFrame")
);
assert_eq!(
s.take_sounds(),
@@ -438,10 +479,8 @@ fn shipped_inspect_frame_drives_end_to_end() {
!s.take_inspect_clear(),
"ClearInspectPlayer not called while open"
);
s.run(r#"HideUIPanel(BenillaInspectFrame)"#).unwrap();
assert!(!s
.eval::<bool>("return BenillaInspectFrame:IsVisible()")
.unwrap());
s.run(r#"HideUIPanel(InspectFrame)"#).unwrap();
assert!(!s.eval::<bool>("return InspectFrame:IsVisible()").unwrap());
assert_eq!(
s.take_sounds(),
vec![SoundRequest::KitName("igCharacterInfoClose".into())],
@@ -471,30 +510,69 @@ fn the_active_tab_is_inert_and_toggle_inspect_closes() {
// NB this engine's `IsEnabled` returns a plain bool, not the live API's 1/nil.
assert!(
!s.eval::<bool>("return BenillaInspectFrameTab1:IsEnabled() ~= 0")
!s.eval::<bool>("return InspectFrameTab1:IsEnabled() ~= 0")
.unwrap(),
"the selected tab is disabled (PanelTemplates_SelectTab), so its click is inert"
);
s.run("BenillaInspectFrameTab1:Click()").unwrap();
s.run("InspectFrameTab1:Click()").unwrap();
assert!(
s.eval::<bool>("return BenillaInspectFrame:IsVisible()")
.unwrap(),
s.eval::<bool>("return InspectFrame:IsVisible()").unwrap(),
"clicking the disabled active tab changes nothing"
);
// The reachable path: ToggleInspect on the page already showing closes the window.
s.run(r#"ToggleInspect("BenillaInspectPaperDollFrame")"#)
.unwrap();
s.run(r#"ToggleInspect("InspectPaperDollFrame")"#).unwrap();
assert!(
!s.eval::<bool>("return BenillaInspectFrame:IsVisible()")
.unwrap(),
!s.eval::<bool>("return InspectFrame:IsVisible()").unwrap(),
"ToggleInspect on the showing page closes the window (ref ToggleInspect)"
);
// And again re-opens it, the other branch of the same function.
s.run(r#"ToggleInspect("BenillaInspectPaperDollFrame")"#)
.unwrap();
assert!(s
.eval::<bool>("return BenillaInspectFrame:IsVisible()")
.unwrap());
assert!(s.errors().is_empty(), "tab errors: {:?}", s.errors());
}
/// The other branch of `ToggleInspect` — re-opening after a close — **errors, and that is the
/// reference's own behaviour** (decision 1834 settled what 1832 had to leave open).
///
/// `InspectFrame_OnHide` sets `this.unit = nil`, so a re-open runs `InspectFrame_OnShow` with no
/// token. wow-re censused the whole binding table: `SetPortraitTexture 0x519ef0` gates its unit
/// argument at `0x519fb4` with `lua_isstring` and raises `Usage: SetPortraitTexture(texture,
/// "unit")`, and `luaL_error` does not return — so the reference raises here too, on its own
/// shipped code. The widget clear sits six instructions PAST the gate, so the portrait is not even
/// blanked; it is left exactly as it was.
///
/// In the reference the raise unwinds to the handler's `lua_pcall`, abandons the rest of `OnShow`,
/// and pops the ScriptErrors dialog through `_ERRORMESSAGE`. Ours collects it into `errors` and
/// abandons the handler the same way. So: the window shows, the portrait and name do not update,
/// and there is exactly one error — which is what this now asserts.
///
/// One correction from that census worth keeping: stock 1.12 ships **no** inspect keybind (0 of 234
/// `Binding name=` entries) and no caller of `ToggleInspect` outside the addon's own tab buttons,
/// which are children of the hidden `InspectFrame`. So on the stock UI's own paths this is
/// unreachable — only an addon or a macro gets here. It is still ours to reproduce.
#[test]
fn toggle_inspect_reopens_after_a_close_and_raises_like_the_reference() {
let _data = benilla_formats::wow_data_or_skip!();
let s = armed();
s.run(r#"InspectUnit("target")"#).unwrap();
s.run(r#"ToggleInspect("InspectPaperDollFrame")"#).unwrap();
assert!(!s.eval::<bool>("return InspectFrame:IsVisible()").unwrap());
let _ = s.errors();
s.run(r#"ToggleInspect("InspectPaperDollFrame")"#).unwrap();
assert!(
s.eval::<bool>("return InspectFrame:IsVisible()").unwrap(),
"the window still shows — Show() runs before OnShow raises"
);
// TWO raises, not one: `InspectFrame` and `InspectPaperDollFrame` each have their own OnShow,
// each fired under its own pcall, so each abandons independently — the first at the portrait's
// gate, the second at `UnitLevel`'s. Both carry the reference's own `Usage:` text.
let errors = s.errors();
assert_eq!(errors.len(), 2, "one raise per OnShow handler: {errors:?}");
assert!(
errors[0].contains(r#"Usage: SetPortraitTexture(texture, "unit")"#),
"the portrait's gate, before the name line is ever reached: {errors:?}"
);
assert!(
errors[1].contains(r#"Usage: UnitLevel("unit")"#),
"and the paper doll's own handler, dying at its first getter: {errors:?}"
);
}
@@ -387,6 +387,7 @@ fn search_surfaces_bindings_as_live_rows_under_the_redirect_head() {
// with the match painted LIVE on the search pool (the era reflows its real rows the
// same way).
s.run(r#"OptionsFrameSearchBox:SetText("jump")"#).unwrap();
s.tick(0.0); // the deferred OnTextChanged drains here (decision 1831)
assert!(s
.eval::<bool>("return OptionsFrameContainerBodySearchHeadKeybindings:IsVisible()")
.unwrap());
@@ -419,6 +420,7 @@ fn search_surfaces_bindings_as_live_rows_under_the_redirect_head() {
// The head is the era redirect: clicking it ends the search on the Keybindings page.
s.run("OptionsFrameContainerBodySearchHeadKeybindings:Click()")
.unwrap();
s.tick(0.0); // the deferred OnTextChanged drains here (decision 1831)
assert_eq!(
s.eval::<String>("return OptionsFrame.selectedCategory")
.unwrap(),
@@ -691,6 +693,7 @@ fn the_pet_lane_is_registered_under_the_action_bar_header() {
// Page side: search finds it and paints a live row with the capsule filled.
s.run(r#"OptionsFrameSearchBox:SetText("bonusactionbutton1")"#)
.unwrap();
s.tick(0.0); // the deferred OnTextChanged drains here (decision 1831)
assert!(s
.eval::<bool>("return OptionsFrameContainerBodySearchHeadKeybindings:IsVisible()")
.unwrap());
@@ -140,11 +140,14 @@ fn new_then_pick_an_icon_then_okay_creates_the_macro() {
/// A regression here silently discards everything the player typed.
#[test]
fn typing_a_body_and_closing_the_window_commits_it() {
let s = harness();
let mut s = harness();
s.run(r#"CreateMacro("Ambush", 1, "")"#).unwrap();
s.run("ShowMacroFrame()").unwrap();
s.run(r#"BenillaMacroFrameText:SetText("/cast Ambush\n/say pew")"#)
.unwrap();
// The window's dirty flag comes from the box's `OnTextChanged`, which the drain owes until the
// next frame (decision 1831).
s.tick(0.0);
no_errors(&s, "type");
assert!(
s.eval::<bool>("return BenillaMacroFrame.textChanged == 1")
@@ -172,13 +175,14 @@ fn typing_a_body_and_closing_the_window_commits_it() {
/// point: `MacroFrame.macroBase` is 0 or MAX_MACROS, and every binding takes `macroBase + i`.
#[test]
fn the_character_tab_creates_in_the_second_index_range_and_switching_saves() {
let s = harness();
let mut s = harness();
s.run(r#"CreateMacro("Acct", 1, "")"#).unwrap();
s.run("ShowMacroFrame()").unwrap();
// Type into the account macro, then switch tabs WITHOUT any explicit save.
s.run(r#"BenillaMacroFrameText:SetText("/say account")"#)
.unwrap();
s.tick(0.0); // as above — the edit marks, the drain notifies (1831)
s.run("BenillaMacroFrameTab2:Click()").unwrap();
no_errors(&s, "tab 2");
assert_eq!(
@@ -361,3 +361,70 @@ fn the_money_type_table_carries_all_seven_reference_types() {
);
}
}
/// **`MoneyInputFrame.lua` is `EditBox:SetNumber`'s only caller on the whole chain**, and the verb
/// did not exist until decision 1831 — so the three-box amount editor could not put a number in a
/// box at all. This drives the reference's own file: split an amount, read it back, round-trip.
///
/// The boxes are `numeric="true"`, which is why the value→text law matters here specifically: a
/// non-digit abandons the insert wholesale and leaves the box EMPTY. Every value this path passes
/// is a non-negative integer, so `%.14g` yields plain digits and the gate never trips — but the
/// zero case below is the one that would show if it ever did.
#[test]
fn the_chains_money_input_frame_splits_an_amount_across_its_three_boxes() {
let _data = benilla_formats::wow_data_or_skip!();
let s = UiScript::new().unwrap();
load_xml(&s, "Fonts.xml");
load_xml(&s, "MoneyFrame.xml"); // COPPER_PER_GOLD / COPPER_PER_SILVER live here
// The manifest's own order: the `.lua` brings the ten verbs, the `.xml` the template.
load_xml(&s, r"Interface\FrameXML\MoneyInputFrame.lua");
load_xml(&s, r"Interface\FrameXML\MoneyInputFrame.xml");
let doc = benilla_ui::framexml::parse(
r#"<Ui>
<Frame name="TestAmount" inherits="MoneyInputFrameTemplate">
<Anchors><Anchor point="TOPLEFT"/></Anchors>
</Frame>
</Ui>"#,
)
.unwrap();
let report = benilla_ui::loader::load_in(&s, &doc, "test", &|_: &str| None);
assert!(report.errors.is_empty(), "{:?}", report.errors);
// 1234g 56s 78c.
s.run("MoneyInputFrame_SetCopper(TestAmount, 12345678)")
.unwrap();
for (box_name, want) in [("Gold", "1234"), ("Silver", "56"), ("Copper", "78")] {
assert_eq!(
s.eval::<String>(&format!("return TestAmount{box_name}:GetText()"))
.unwrap(),
want,
"TestAmount{box_name}"
);
}
assert_eq!(
s.eval::<f64>("return MoneyInputFrame_GetCopper(TestAmount)")
.unwrap(),
12345678.0,
"the amount round-trips back out of the three boxes"
);
// Zero is the reference's quiet case: `GetNumber()` on an empty box is already 0, so the
// equality test short-circuits and `SetNumber` is never reached — the boxes stay EMPTY rather
// than reading "0", and the total still comes back 0.
s.run("MoneyInputFrame_ResetMoney(TestAmount)").unwrap();
s.run("MoneyInputFrame_SetCopper(TestAmount, 0)").unwrap();
for box_name in ["Gold", "Silver", "Copper"] {
assert_eq!(
s.eval::<String>(&format!("return TestAmount{box_name}:GetText()"))
.unwrap(),
"",
"TestAmount{box_name} stays empty at zero"
);
}
assert_eq!(
s.eval::<f64>("return MoneyInputFrame_GetCopper(TestAmount)")
.unwrap(),
0.0
);
}
@@ -383,6 +383,8 @@ fn search_reflows_live_rows_and_restores_the_page() {
.unwrap();
s.run("OptionsFrameSearchBox:SetText(\"music\")").unwrap();
// The search reflows off the box's `OnTextChanged`, which is deferred to the drain (1831).
s.tick(0.0);
assert_eq!(
s.eval::<String>("return OptionsFrameContainerTitle:GetText()")
.unwrap(),
@@ -440,6 +442,7 @@ fn search_reflows_live_rows_and_restores_the_page() {
// Clearing restores the page view and the authored chain EXACTLY.
s.run("OptionsFrameSearchBox:SetText(\"\")").unwrap();
s.tick(0.0); // the clear reflows on the drain, like the search itself (1831)
assert_eq!(
s.eval::<String>("return OptionsFrameContainerTitle:GetText()")
.unwrap(),
@@ -489,12 +492,16 @@ fn the_phrase_match_outranks_its_words() {
/// the box) and a no-match query (the section-header "no results" line, nothing chained).
#[test]
fn a_head_click_ends_the_search_and_a_miss_shows_no_results() {
let s = harness();
let mut s = harness();
s.run("ShowUIPanel(OptionsFrame)").unwrap();
s.run("OptionsFrameSearchBox:SetText(\"volume\")").unwrap();
s.tick(0.0); // the head being clicked below is built by the search reflow, so drain first (1831)
s.run("OptionsFrameContainerBodySearchHeadAudio:Click()")
.unwrap();
// The click clears the box synchronously; restoring the page rides that clear's
// `OnTextChanged`, which the drain owes (1831).
s.tick(0.0);
assert_eq!(
s.eval::<String>("return OptionsFrameSearchBox:GetText()")
.unwrap(),
@@ -517,6 +524,7 @@ fn a_head_click_ends_the_search_and_a_miss_shows_no_results() {
s.run("OptionsFrameSearchBox:SetText(\"flibbertigibbet\")")
.unwrap();
s.tick(0.0); // the miss renders on the drain too (1831)
assert!(s
.eval::<bool>("return OptionsFrameContainerBodyNoResults:IsVisible()")
.unwrap());
@@ -530,6 +538,7 @@ fn a_head_click_ends_the_search_and_a_miss_shows_no_results() {
);
}
s.run("OptionsFrameSearchBox:SetText(\"\")").unwrap();
s.tick(0.0); // and so does clearing it (1831)
assert!(!s
.eval::<bool>("return OptionsFrameContainerBodyNoResults:IsVisible()")
.unwrap());
@@ -466,3 +466,40 @@ fn every_template_these_files_declare_is_a_real_1_12_name() {
"only {checked} templates swept — the sweep, not the files, is what broke"
);
}
/// **`PanelTemplates_TabResize`'s `tab` argument is OPTIONAL, and omitting it means `this`.**
///
/// The reference opens with `if ( tab ) then tabName = tab:GetName(); else tabName =
/// this:GetName(); tab = this; end`. Ours had only the first half, so `tab:GetName()` on a nil
/// threw — and nothing in our tree noticed, because every caller we wrote passed a tab explicitly.
/// The stock files do not: `Blizzard_MacroUI.xml`'s two tabs each call
/// `PanelTemplates_TabResize(0)` from their own `OnLoad` and nothing else, so both threw at load
/// and the window came up with unsized tabs. Decision 1835.
///
/// Driven through a real `OnLoad` rather than a direct call, because `this` is exactly what is
/// under test: a plain function call would not set it.
#[test]
fn tab_resize_falls_back_to_this_when_no_tab_is_passed() {
let mut s = harness();
s.set_text_measurer(Box::new(super::FixedWidthFont(6.0)));
let doc = benilla_ui::framexml::parse(
r#"<Ui>
<Button name="ProbeTab" inherits="CharacterFrameTabButtonTemplate" text="Macros">
<Anchors><Anchor point="TOPLEFT"/></Anchors>
<Scripts><OnLoad>PanelTemplates_TabResize(0)</OnLoad></Scripts>
</Button>
</Ui>"#,
)
.unwrap();
let report = benilla_ui::loader::load_in(&s, &doc, "test", &|_: &str| None);
assert!(
report.errors.is_empty(),
"the OnLoad must not throw: {:?}",
report.errors
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
// It sized the tab it was never handed: wider than the bare label, because the end slices are
// added on top.
let width = s.eval::<f64>("return ProbeTab:GetWidth()").unwrap();
assert!(width > 0.0, "the tab got a width, got {width}");
}
@@ -1125,7 +1125,7 @@ fn the_1507_registry_rows_match_the_reference_bytes() {
// The whileDead flags the ref authors and 1507 carried (l.21, l.25, Blizzard_TalentUI:71).
"UIPanelWindows['SpellBookFrame'].whileDead == 1",
"UIPanelWindows['QuestLogFrame'].whileDead == 1",
"UIPanelWindows['BenillaTalentFrame'].whileDead == 1",
"UIPanelWindows['TalentFrame'].whileDead == 1",
// UIChildWindows — UIParent.lua l.44-50 verbatim, all four shipped.
"table.getn(UIChildWindows) == 4",
"UIChildWindows[1] == 'OpenMailFrame'",
@@ -610,9 +610,17 @@ mod tests {
///
/// **A remaining `<?>` in the `fx=` column is usually a LoadOnDemand addon**, not something to
/// build. `ClassTrainerFrame_Show`, `CraftFrame_Show`, `MacroFrame_SaveMacro`,
/// `InspectFrame_Show`, `TalentFrame_Toggle` and their siblings live in `Blizzard_*` addons the
/// install ships **packed as `.pub`**, so no amount of scanning the extracted FrameXML finds
/// them. They arrive when that addon does, exactly like an `fx=` name with a home.
/// `InspectFrame_Show`, `TalentFrame_Toggle` and their siblings live in `Blizzard_*` addons, so
/// no amount of scanning the extracted **FrameXML** finds them. They arrive when that addon does,
/// exactly like an `fx=` name with a home.
///
/// **They are NOT unreachable, and this doc used to say they were.** It claimed the install
/// ships them "packed as `.pub`", which is a misreading of the loose
/// `Interface\AddOns\<name>\` folder — that really does hold only a `.pub` signature file.
/// The addon's real `.xml`/`.lua`/`.toc` are inside **`patch.MPQ`**, and `Chain` mounts MPQs, so
/// `reference_ui::read` reaches them and `is_chain_entry` accepts the path. Verified by reading
/// `Interface\AddOns\Blizzard_MacroUI\Blizzard_MacroUI.xml` and `Blizzard_InspectUI`'s twin
/// straight out of the archive. Every one of those windows is buildable today.
///
/// One crudeness remains, stated because it decides how to read the output: neither scan can
/// see a name reached through `getglobal`, so both can under-report.
@@ -722,6 +722,9 @@ fn the_macro_editor_takes_a_shift_click_and_only_a_shift_click() {
s.cursor_payload().is_none(),
"with the editor open a shift-click writes instead of picking up"
);
// The dirty flag is set by the box's own `OnTextChanged`, which is deferred to the drain — the
// write landed in the buffer synchronously, the notification did not (decision 1831).
s.tick(0.0);
assert!(
s.eval::<bool>("return BenillaMacroFrame.textChanged == 1")
.unwrap(),
+21 -3
View File
@@ -28,7 +28,7 @@ use benilla_ui::script::UiScript;
/// **A chain entry needs client data**, so a test that names one has to open with
/// `benilla_formats::wow_data_or_skip!()`; [`BAG_UI`] is a list that always does.
pub(super) fn load_ui(s: &UiScript, entry: &str) -> usize {
load_entry(s, entry, false)
load_entry(s, entry, false, false)
}
/// [`load_ui`], and **a missing template is a failure too**.
@@ -39,10 +39,21 @@ pub(super) fn load_ui(s: &UiScript, entry: &str) -> usize {
/// windows' test modules grew this check by hand. It is one function now rather than four copies,
/// and any test may ask for it.
pub(super) fn load_ui_strict(s: &UiScript, entry: &str) -> usize {
load_entry(s, entry, true)
load_entry(s, entry, true, false)
}
fn load_entry(s: &UiScript, entry: &str, strict_templates: bool) -> usize {
/// [`load_ui`], and **no loader warning of any kind is tolerated**.
///
/// Stricter than [`load_ui_strict`], which only fails a missing template. A file whose own
/// assignment is "this loads perfectly clean" wants the whole warnings channel asserted empty — a
/// stale unknown-attribute or dropped-script warning is exactly the drift that check exists to
/// catch. It was a fourth private disk-only reader in `group_loot_tests.rs` until 1838; a
/// disk-only reader cannot name a chain file, which is what that test now loads.
pub(super) fn load_ui_no_warnings(s: &UiScript, entry: &str) -> usize {
load_entry(s, entry, false, true)
}
fn load_entry(s: &UiScript, entry: &str, strict_templates: bool, no_warnings: bool) -> usize {
let path = entry.replace('\\', "/");
let bytes = read(&path).unwrap_or_else(|| panic!("{entry}: not found"));
if path.to_ascii_lowercase().ends_with(".lua") {
@@ -59,6 +70,13 @@ fn load_entry(s: &UiScript, entry: &str, strict_templates: bool) -> usize {
"{entry}: loader errors: {:?}",
report.errors
);
if no_warnings {
assert!(
report.warnings.is_empty(),
"{entry}: loader warnings: {:?}",
report.warnings
);
}
if strict_templates {
let missing: Vec<&String> = report
.warnings
+95 -19
View File
@@ -13,21 +13,39 @@ mod common;
use benilla_ui::script::{
QuadContent, SpellTooltipView, TalentPrereqView, TalentTabView, TalentUiState, TalentView,
UiScript,
UiScript, UnitState,
};
/// The talent window's load prefix — the app's own order (`assets/ui/benilla.toc`), members only.
/// `ItemButtonTemplate.xml` is the `SetItemButton*` family the talent buttons grey through (the
/// reference's own verb; see TalentFrame.xml's header) — it sits at .toc line 32, above every
/// other entry here bar `Fonts.xml`.
const FILES: [&str; 7] = [
const FILES: [&str; 12] = [
// `PLAYER_LEVEL` and the rest of the strings the stock file formats through.
"Interface\\FrameXML\\GlobalStrings.lua",
"Fonts.xml",
// `TEXT`.
"BasicControls.xml",
"Interface\\FrameXML\\ItemButtonTemplate.xml",
"MoneyFrame.xml",
"UiPanels.xml",
// `ToggleTalentFrame` lives here now, not in the window's own file (decision 1833).
"UIParent.xml",
"GameTooltip.xml",
// `UIPanelScrollFrameTemplate` — the stock scroll frame's whole substance: its `$parentScrollBar`
// Slider AND its `<OnMouseWheel>`. Nothing else in the tree declares it, and a missing template
// is a loader WARNING, not an error, so without this the window still built — just with no
// scrollbar and no wheel (decision 1833). `load_ui_strict` below is what makes that loud.
"UIPanelTemplates.xml",
"ScrollTemplates.xml",
"TalentFrame.xml",
// Stock `TalentFrame_OnShow` opens with `SetButtonPulse(TalentMicroButton, 0, 1)` and then
// `UpdateMicroButtons()` — both live here, and a nil `TalentMicroButton` throws out of OnShow
// BEFORE `TalentFrame_Update()`, so the whole window comes up empty (decision 1833). Our
// retired file's OnShow called neither, which is why this was never a dependency before.
"MicroMenu.xml",
// Five files behind one line: the `.xml` sources its own `.lua` and `<Include>`s the
// templates file that declares TalentButton/Branch/Arrow/TabTemplate.
"Interface\\AddOns\\Blizzard_TalentUI\\Blizzard_TalentUI.xml",
];
fn load_ui(script: &UiScript) {
@@ -86,6 +104,26 @@ fn fixture(points_spent: u32) -> TalentUiState {
}
}
/// [`fixture`] plus a talent on the LAST tier, so the tree is taller than the scroll frame.
///
/// The stock `TalentFrameScrollChildFrame` is 50px in XML and is never resized by Lua — the
/// reference derives its extent from the buttons anchored into it via `UpdateScrollChildRect`
/// (`Blizzard_TalentUI.lua:311`). So a two-talent tree genuinely does not scroll, and asking it to
/// would be asserting our retired file's behaviour rather than the reference's: ours pinned the
/// child to a fixed full-height rect, which made every tree scrollable regardless of content
/// (decision 1833).
fn tall_fixture() -> TalentUiState {
let mut state = fixture(10);
let mut deep = state.talents[0][1].clone();
deep.name = "Mortal Strike".into();
deep.tier = 7;
deep.rank = 0;
deep.prereqs = Vec::new();
deep.display_spell = 401;
state.talents[0].push(deep);
state
}
fn view(name: &str, desc: &str) -> SpellTooltipView {
SpellTooltipView {
name: name.into(),
@@ -94,10 +132,43 @@ fn view(name: &str, desc: &str) -> SpellTooltipView {
}
}
/// Hover a talent button the way a player does — the pointer, not a handler call.
///
/// The stock `Blizzard_TalentUITemplates.xml` writes the tooltip code INLINE in the button's
/// `<OnEnter>` (`GameTooltip:SetOwner` + `GameTooltip:SetTalent`), so there is no named function to
/// invoke the way our retired file's `BenillaTalentButton_OnEnter` could be (decision 1833). Moving
/// the mouse is closer to the thing under test anyway: it drives the hit test as well as the
/// handler.
fn hover(script: &mut UiScript, frame: &str) {
script.resolve();
let (x, y): (f32, f32) = script
.eval(&format!("return {frame}:GetCenter()"))
.unwrap_or_else(|e| panic!("{frame}:GetCenter() — {e}"));
script.mouse_move(x, y);
}
/// A level-60 player. The reference's `ToggleTalentFrame` opens nothing below level 10 (decision
/// 1833) — real behaviour, not a guard — so every opener here needs one. Our retired file had no
/// such gate, which is why this fixture did not exist before.
fn probe_player() -> UnitState {
UnitState {
exists: true,
name: Some("Probefour".into()),
level: 60,
race: Some("Human".into()),
class: Some("Warrior".into()),
..Default::default()
}
}
fn open_window(points_spent: u32) -> UiScript {
let mut script = UiScript::new().expect("engine");
script.set_screen_size(1024.0, 768.0);
load_ui(&script);
// The reference's `ToggleTalentFrame` opens nothing below level 10 (decision 1833) — that is
// real behaviour, not a guard, so the window needs a player who has talents at all. Our
// retired file had no such gate, which is why this fixture was never needed before.
script.set_unit("player", Some(probe_player()));
script.set_talents(fixture(points_spent));
script.run("ToggleTalentFrame()").expect("toggle");
script
@@ -112,7 +183,7 @@ fn branch_lines_draw_for_a_same_column_prereq() {
script
.run(
r#"
assert(BenillaTalentFrame:IsVisible(), "window visible")
assert(TalentFrame:IsVisible(), "window visible")
local a = TALENT_BRANCH_ARRAY
assert(a[1][3].down == 1, "tier1 down, got " .. tostring(a[1][3].down))
assert(a[2][3].up == 1, "tier2 up, got " .. tostring(a[2][3].up))
@@ -125,8 +196,8 @@ fn branch_lines_draw_for_a_same_column_prereq() {
script
.run(
r#"
assert(BenillaTalentFrameBranch1:IsShown(), "branch 1 shown")
assert(BenillaTalentFrameArrow1:IsShown(), "arrow 1 shown")
assert(TalentFrameBranch1:IsShown(), "branch 1 shown")
assert(TalentFrameArrow1:IsShown(), "arrow 1 shown")
"#,
)
.expect("pool state");
@@ -170,10 +241,10 @@ fn tooltip_is_complete_on_the_first_hover() {
// full tooltip: name line, description, the green hint.
script.set_spell_tooltip(201, view("Improved Rend", "Bleed harder."));
script.set_spell_tooltip(301, view("Deep Wounds", "Bleed on crit."));
hover(&mut script, "TalentFrameTalent2");
script
.run(
r#"
BenillaTalentButton_OnEnter(BenillaTalentFrameTalent2)
assert(GameTooltip:IsShown(), "tooltip shown on first hover")
assert(GameTooltipTextLeft1:GetText() == "Deep Wounds",
"name line, got " .. tostring(GameTooltipTextLeft1:GetText()))
@@ -188,10 +259,10 @@ fn tooltip_miss_still_shows_the_rank_line() {
let mut script = open_window(10);
// The store is EMPTY (no views pushed): the ask-once fallback must still show a box with
// the talent head — never a blank hover — and record the ask for the app's resolver.
hover(&mut script, "TalentFrameTalent2");
script
.run(
r#"
BenillaTalentButton_OnEnter(BenillaTalentFrameTalent2)
assert(GameTooltip:IsShown(), "tooltip shown on a store miss")
assert(GameTooltipTextLeft1:GetText() == "Rank 0/3",
"rank fallback, got " .. tostring(GameTooltipTextLeft1:GetText()))
@@ -218,8 +289,8 @@ fn a_locked_tier_still_draws_the_branch_gray() {
local a = TALENT_BRANCH_ARRAY
assert(a[1][3].down == -1, "tier1 down gray, got " .. tostring(a[1][3].down))
assert(a[3][3].topArrow == -1, "tier3 topArrow gray, got " .. tostring(a[3][3].topArrow))
assert(BenillaTalentFrameBranch1:IsShown(), "gray branch shown")
assert(BenillaTalentFrameArrow1:IsShown(), "gray arrow shown")
assert(TalentFrameBranch1:IsShown(), "gray branch shown")
assert(TalentFrameArrow1:IsShown(), "gray arrow shown")
"#,
)
.expect("gray branch");
@@ -295,22 +366,27 @@ fn a_wheel_spin_over_the_grid_scrolls_the_tree() {
// OnValueChanged pans the frame. This is the chain the 2051f4f8 rename broke silently —
// the handler called a helper by a name that no longer existed, and only a real spin (not
// a load) executes it.
let mut script = open_window(10);
let mut script = UiScript::new().expect("engine");
script.set_screen_size(1024.0, 768.0);
load_ui(&script);
script.set_unit("player", Some(probe_player()));
script.set_talents(tall_fixture());
script.run("ToggleTalentFrame()").expect("toggle");
// Rects must be resolved before the wheel's hit-test can land on the grid.
script.resolve();
let (x, y): (f32, f32) = script
.eval("return BenillaTalentFrameTalent1:GetCenter()")
.eval("return TalentFrameTalent1:GetCenter()")
.expect("talent 1 center");
// One notch DOWN (WoW convention: -1) — the handler steps the bar one valueStep forward.
script.mouse_wheel(x, y, -1.0);
script
.run(
r#"
local scroll = BenillaTalentFrameScroll:GetVerticalScroll()
assert(scroll == BENILLA_TALENT_SCROLL_STEP,
"one notch down pans one step, got " .. tostring(scroll))
assert(BenillaTalentFrameScrollBar:GetValue() == scroll,
"the bar follows the frame, got " .. tostring(BenillaTalentFrameScrollBar:GetValue()))
local scroll = TalentFrameScrollFrame:GetVerticalScroll()
assert(scroll > 0,
"one notch down pans the frame, got " .. tostring(scroll))
assert(TalentFrameScrollFrameScrollBar:GetValue() == scroll,
"the bar follows the frame, got " .. tostring(TalentFrameScrollFrameScrollBar:GetValue()))
"#,
)
.expect("wheel down");
@@ -319,8 +395,8 @@ fn a_wheel_spin_over_the_grid_scrolls_the_tree() {
script
.run(
r#"
assert(BenillaTalentFrameScroll:GetVerticalScroll() == 0,
"a notch up rewinds to the top, got " .. tostring(BenillaTalentFrameScroll:GetVerticalScroll()))
assert(TalentFrameScrollFrame:GetVerticalScroll() == 0,
"a notch up rewinds to the top, got " .. tostring(TalentFrameScrollFrame:GetVerticalScroll()))
"#,
)
.expect("wheel up");
+1
View File
@@ -307,6 +307,7 @@ fn player_money_input_reflects_then_offers() {
// has no purse).
s.run("GetMoney = function() return 100000000 end").unwrap();
s.run("TradePlayerInputMoneyGold:SetText('2')").unwrap();
s.tick(0.0); // the deferred OnTextChanged drains here (decision 1831)
assert_eq!(
s.take_trade_money(),
Some(2 * 10000 + 23 * 100 + 45),
+3 -1
View File
@@ -939,13 +939,15 @@ mod loader_tests {
"12345"
);
// A click focuses the box (mouse-enabled by construction); a typed char fires OnTextChanged.
// A click focuses the box (mouse-enabled by construction); a typed char marks it changed,
// and the drain on the next tick is what actually fires OnTextChanged (decision 1831).
s.resolve();
s.mouse_button(50.0, 10.0, "LeftButton", true);
s.mouse_button(50.0, 10.0, "LeftButton", false);
assert!(s.eval::<bool>("return XmlEdit:HasFocus()").unwrap());
s.run("typed = false").unwrap();
assert!(s.char_input("7"));
s.tick(0.0);
assert!(s.eval::<bool>("return typed == true").unwrap());
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
@@ -163,6 +163,83 @@ pub(crate) fn optional_string(lua: &Lua, v: &Value) -> Option<String> {
/// *strings* `"0"`/`"1"`, and a re-implementation wired to `lua_toboolean` inverts every
/// string-valued option in the game. Equally, `"0.5"` and `"-1"` are decided by their first byte
/// alone — false and `default` — never by their numeric value.
/// Lua 5.0's own number→string rule as the 1.12 client compiles it: `sprintf("%.14g")`
/// (`luaV_tostring 0x6f7c80`, format string `0x871960`). Decision 1831.
///
/// This is the whole of `EditBox:SetNumber`'s formatting, because **`SetNumber 0x798690` and
/// `SetText 0x7984c0` are byte-identical functions** — 245 bytes each, zero differences after
/// masking rel32 and absolute-VA operands, differing only in which usage string they carry. The
/// numeric verb does no numeric work of its own; the shared `lua_tostring` marshalling does it all.
///
/// The rule, and the three places it is not Rust's `{}`:
///
/// - **14 significant digits**, then trailing zeros and a bare trailing `.` are dropped.
/// - **Exponential iff `exp < -4 || exp >= 14`** — the C `%g` switch.
/// - **A three-digit exponent, always.** `1e20` prints `1e+020`, not `1e+20`: the exponent is
/// copied from the literal template `"e+000"` and digits are written onto it. This is MSVC's
/// `%g`, not C99's, and it is the single most surprising thing here.
///
/// **What this deliberately does NOT reproduce**, stated so it is not mistaken for an oversight:
/// the reference's printf is not correctly rounded. It generates 17 digits half-up and then
/// re-rounds *that string* to 14, also half-up and with no sticky bit, so it disagrees with a
/// correctly-rounded formatter on roughly 0.05% of doubles (`-0.11594045739607` where a correct
/// one gives `…606`). Reproducing that needs the two-stage decimal path, not a format string, and
/// no consumer we have is sensitive to the fourteenth digit. If one ever is, this is the note that
/// says where to look.
pub(crate) fn lua_number_text(v: f64) -> String {
if v.is_nan() {
// MSVC's own spellings, which are not "NaN".
return if v.is_sign_negative() {
"-1.#IND".into()
} else {
"1.#QNAN".into()
};
}
if v.is_infinite() {
return if v < 0.0 {
"-1.#INF".into()
} else {
"1.#INF".into()
};
}
if v == 0.0 {
// `%g` prints negative zero without the sign.
return "0".into();
}
const SIG: i32 = 14;
let exp = v.abs().log10().floor() as i32;
// Re-derive the exponent from the rounded form: 9.9999e2 rounds to 1e3 and changes style.
let exp = {
let probe = format!("{:.*e}", (SIG - 1) as usize, v);
probe
.rsplit('e')
.next()
.and_then(|e| e.parse::<i32>().ok())
.unwrap_or(exp)
};
// C's `%g` style rule verbatim: exponential when `exp < -4 || exp >= P`, else fixed.
if !(-4..SIG).contains(&exp) {
let mantissa = format!("{:.*e}", (SIG - 1) as usize, v);
let (m, _) = mantissa.split_once('e').unwrap_or((mantissa.as_str(), "0"));
let m = trim_g(m);
let sign = if exp < 0 { '-' } else { '+' };
// Three digits minimum, and MORE if the exponent needs them — the template is padded,
// never truncated.
format!("{m}e{sign}{:03}", exp.abs())
} else {
let decimals = (SIG - 1 - exp).max(0) as usize;
trim_g(&format!("{v:.decimals$}")).to_string()
}
}
/// Strip `%g`'s trailing zeros, and the decimal point if nothing follows it.
fn trim_g(s: &str) -> &str {
if !s.contains('.') {
return s;
}
s.trim_end_matches('0').trim_end_matches('.')
}
/// A widget **predicate's return**: the NUMBER `1` for true, `nil` for false — never a Lua boolean.
///
/// The return-side counterpart to [`bool_or_default`], and the same class of fact: 1.12 widget
+1 -1
View File
@@ -98,7 +98,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
)?;
// BenillaDressUpModel_SetFacing(radians) — the pane's bake yaw, the exact twin of
// `BenillaPaperDollModel_SetFacing` / `BenillaInspectModel_SetFacing`.
// `BenillaPaperDollModel_SetFacing` (the inspect pane's twin went with 1832's migration).
g.set(
"BenillaDressUpModel_SetFacing",
lua.create_function(|lua, radians: f32| {
+57 -5
View File
@@ -97,7 +97,7 @@ pub(super) fn paste(lua: &Lua, text: &str) -> bool {
// this layer's.
if with_eb(lua, h, |eb| eb.paste(text)).is_some_and(|o| o.text_changed) {
sync_text_region(lua, h);
fire_script(lua, frame_id_of(lua, h), "OnTextChanged");
mark_text_changed(lua, h);
}
true
}
@@ -339,7 +339,7 @@ fn insert(lua: &Lua, h: FrameHandle, ins: &str, fire_space: bool) {
.errors
.push(e.to_string());
}
fire_script(lua, id, "OnTextChanged");
mark_text_changed(lua, h);
if fire_space {
for _ in 0..out.spaces {
fire_script(lua, id, "OnSpacePressed");
@@ -355,7 +355,7 @@ fn set_text(lua: &Lua, h: FrameHandle, s: &str) {
sync_text_region(lua, h);
let id = frame_id_of(lua, h);
fire_script(lua, id, "OnTextSet");
fire_script(lua, id, "OnTextChanged");
mark_text_changed(lua, h);
}
}
@@ -369,7 +369,7 @@ fn delete_span(lua: &Lua, h: FrameHandle, target_of: impl FnOnce(&EditBoxState)
});
if changed == Some(true) {
sync_text_region(lua, h);
fire_script(lua, frame_id_of(lua, h), "OnTextChanged");
mark_text_changed(lua, h);
}
}
@@ -379,7 +379,7 @@ fn delete_dir(lua: &Lua, h: FrameHandle, forward: bool) {
let changed = with_eb(lua, h, |eb| eb.delete_dir(forward));
if changed == Some(true) {
sync_text_region(lua, h);
fire_script(lua, frame_id_of(lua, h), "OnTextChanged");
mark_text_changed(lua, h);
}
}
@@ -618,3 +618,55 @@ pub(super) use methods::install;
#[cfg(test)]
mod tests;
/// Raise the `textChanged` dirty bit (`[E+0x31c]` bit 0) rather than firing — **`OnTextChanged` is
/// deferred**, and this is the whole of decision 1831's second half.
///
/// The reference never fires it from an edit. `SetText 0x77be00`, `Insert 0x77bee0` and the deletes
/// all only OR the bit; the single fire site is `0x77d498`, inside the dirty-word drain `0x77d3e0`,
/// whose three callers are the box's own `OnUpdate` (`0x77a790`), its `OnKeyDown` (`0x77b160`) and
/// its `OnMouseDown` (`0x77b800`). So a Lua caller gets control back with the handler still
/// unrun, and repeated changes **coalesce**: the box appears once on the pending list however many
/// times it is written, and the one fire carries the final text.
pub(super) fn mark_text_changed(lua: &Lua, h: FrameHandle) {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
if !model.dirty_editboxes.contains(&h) {
model.dirty_editboxes.push(h);
}
}
/// The dirty-word drain (`0x77d3e0`): fire the pending `OnTextChanged`s.
///
/// Called from the frame tick — the first of the reference's three callers (`0x77a7a1`, inside the
/// box's own `OnUpdate` `0x77a790`, which is also where our caret blink lives). Its other two, the
/// box's `OnKeyDown` and `OnMouseDown`, are **not** wired: they only make a pending fire arrive
/// sooner within a frame, and our key and mouse paths reach the tick anyway. Decision 1831 says so
/// out loud rather than leaving it to be discovered.
///
/// A box that is **not effectively visible stays pending**: `Hide`
/// splices it out of the chain the update walk follows, so its fire waits for a `Show` (verified
/// chain mechanics; the "hidden ⇒ no OnUpdate" step is the RE's own stated inference).
///
/// A handler may itself write to a box — including this one — so the list is taken before any Lua
/// runs and anything re-marked during the sweep lands on the NEXT drain rather than extending this
/// one. That is the reference's shape too: its bit is cleared before its fire.
pub(in crate::script) fn drain_text_changed(lua: &Lua) {
let pending = {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
if model.dirty_editboxes.is_empty() {
return;
}
let (ready, waiting): (Vec<_>, Vec<_>) = std::mem::take(&mut model.dirty_editboxes)
.into_iter()
.partition(|&h| model.arena.frame(h).is_some_and(|f| f.effective_visible));
// A frame that has been destroyed drops out of both halves.
model.dirty_editboxes = waiting
.into_iter()
.filter(|&h| model.arena.frame(h).is_some())
.collect();
ready
};
for h in pending {
fire_script(lua, frame_id_of(lua, h), "OnTextChanged");
}
}
@@ -18,7 +18,7 @@
use mlua::Lua;
use super::{fire_script, frame_id_of, set_focus_handle, sync_text_region, with_eb};
use super::{set_focus_handle, sync_text_region, with_eb};
use crate::script::Model;
use crate::widget::{FrameHandle, FrameKind, KindState};
@@ -108,7 +108,7 @@ pub(in crate::script) fn cut_selection(lua: &Lua) -> Option<String> {
let h = focused(lua)?;
let copied = with_eb(lua, h, |eb| eb.cut_selection()).flatten()?;
sync_text_region(lua, h);
fire_script(lua, frame_id_of(lua, h), "OnTextChanged");
super::mark_text_changed(lua, h);
Some(copied)
}
@@ -45,6 +45,38 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
"GetText",
lua.create_function(|lua, this: Table| with_editbox(lua, &this, |eb| eb.text.clone()))?,
)?;
// SetNumber(v) — **the same function as SetText**. `0x798690` and `0x7984c0` are byte-identical
// (245 bytes each, zero differences after masking rel32 and absolute-VA operands; only the
// usage string differs), so this does no numeric work of its own: it hands the argument to the
// shared `lua_tostring` marshalling and sets the result as text. Decision 1831.
//
// The GATE is `lua_isstring 0x6f3510`, a pure type test over {number, string} — NOT
// `lua_isnumber` and NOT `luaL_checknumber`. So a STRING is accepted and passed through
// VERBATIM, never parsed: `SetNumber("abc")` sets the text "abc" and does not raise. Anything
// else — nil, boolean, table, or an ABSENT argument — raises the usage string and abandons the
// caller's statement.
//
// The live consumer is `MoneyInputFrame.lua:47/52/57`, on the chain since 1751, whose three
// boxes are `numeric="true"`. Our numeric filter already models the reference's wholesale
// abort, which matters here: on such a box `SetNumber(-5)` or `SetNumber(0.8)` leaves the box
// EMPTY rather than partially filled, because the sign or the point fails the digit test after
// the clear-all has already run. The money path only ever passes non-negative integers
// (`floor`/`mod` results), so it never takes that branch.
m.set(
"SetNumber",
lua.create_function(|lua, (this, v): (Table, Value)| {
let text = match &v {
Value::Integer(i) => crate::script::binding_abi::lua_number_text(*i as f64),
Value::Number(n) => crate::script::binding_abi::lua_number_text(*n),
// A string is not parsed — it is the text.
Value::String(s) => s.to_str()?.to_string(),
_ => return Err(mlua::Error::runtime("Usage: EditBox:SetNumber(number)")),
};
let h = frame_handle_of(lua, &this)?;
set_text(lua, h, &text);
Ok(())
})?,
)?;
// GetNumber: atof of the real text (0 on failure), matching `0x798790`.
m.set(
"GetNumber",
+112 -11
View File
@@ -220,14 +220,19 @@ fn click_focuses_regardless_of_autofocus_and_transition_order_is_lost_then_gaine
// ── §3 text buffer + editing + OnTextChanged/OnTextSet ───────────────────────────────────────
/// **`OnTextChanged` is deferred and coalesced** (decision 1831). An edit only raises the
/// `textChanged` dirty bit; the fire belongs to the drain (`0x77d3e0`) that the box's own OnUpdate
/// runs. So three typed characters are three marks on ONE box and produce exactly ONE fire — this
/// test asserted three before the law was checked.
#[test]
fn typing_builds_text_and_fires_ontextchanged_per_mutation() {
fn typing_coalesces_into_one_deferred_ontextchanged() {
let mut s = script();
s.run(
r#"
changed = 0
E = CreateFrame("EditBox", "E")
E:SetScript("OnTextChanged", function() changed = changed + 1 end)
seen = ""
E:SetScript("OnTextChanged", function() changed = changed + 1 seen = E:GetText() end)
E:SetFocus()
"#,
)
@@ -236,26 +241,50 @@ fn typing_builds_text_and_fires_ontextchanged_per_mutation() {
assert!(s.char_input("b"));
assert!(s.char_input("c"));
assert_eq!(s.eval::<String>("return E:GetText()").unwrap(), "abc");
assert_eq!(s.eval::<i64>("return changed").unwrap(), 3);
assert_eq!(
s.eval::<i64>("return changed").unwrap(),
0,
"the text is already there, but nothing has drained yet"
);
s.tick(0.0);
assert_eq!(
s.eval::<i64>("return changed").unwrap(),
1,
"three edits, one fire, carrying the final text"
);
assert_eq!(s.eval::<String>("return seen").unwrap(), "abc");
// Nothing is pending now, so a second drain fires nothing.
s.tick(0.0);
assert_eq!(s.eval::<i64>("return changed").unwrap(), 1);
}
/// The two fires part company (decision 1831): **`OnTextSet` is synchronous, inside `SetText`
/// itself** (`0x77be6b`), while `OnTextChanged` waits for the drain. The equality short-circuit
/// still suppresses both. So writing "hi", "hi", "bye" before any drain logs two `set`s and then a
/// SINGLE `changed` carrying only the last value — `A → B` coalesces.
#[test]
fn set_text_short_circuits_on_same_value_else_fires_textset_then_textchanged() {
let s = script();
fn set_text_fires_ontextset_at_once_and_ontextchanged_at_the_drain() {
let mut s = script();
s.run(
r#"
log = {}
E = CreateFrame("EditBox", "E")
E:SetScript("OnTextSet", function() table.insert(log, "set") end)
E:SetScript("OnTextChanged", function() table.insert(log, "changed") end)
E:SetText("hi") -- fires set then changed
E:SetText("hi") -- unchanged → short-circuit, no events
E:SetText("bye") -- fires set then changed
E:SetText("hi") -- fires set NOW, marks changed for the drain
E:SetText("hi") -- unchanged → short-circuit, no events at all
E:SetText("bye") -- fires set NOW; the pending change simply becomes "bye"
"#,
)
.unwrap();
let log: Vec<String> = s.eval("return log").unwrap();
assert_eq!(log, vec!["set", "changed", "set", "changed"]);
assert_eq!(log, vec!["set", "set"], "no drain has run yet");
s.tick(0.0);
let log: Vec<String> = s.eval("return log").unwrap();
assert_eq!(log, vec!["set", "set", "changed"]);
assert_eq!(s.eval::<String>("return E:GetText()").unwrap(), "bye");
}
@@ -328,8 +357,10 @@ fn paste_inserts_at_the_cursor_and_replaces_the_selection() {
s.run("E:HighlightText(0, -1)").unwrap();
assert!(s.paste("xyz"));
assert_eq!(s.eval::<String>("return E:GetText()").unwrap(), "xyz");
// Each paste that changed the text fired OnTextChanged once.
assert_eq!(s.eval::<i64>("return changed").unwrap(), 2);
// Both pastes marked the same box, so the drain owes exactly one fire — not one per paste.
assert_eq!(s.eval::<i64>("return changed").unwrap(), 0);
s.tick(0.0);
assert_eq!(s.eval::<i64>("return changed").unwrap(), 1);
}
#[test]
@@ -1450,3 +1481,73 @@ fn an_editbox_is_born_with_the_ctors_five_regions_ahead_of_its_authored_ones() {
"the selection trio and the caret are blank quads"
);
}
/// `SetNumber` is `SetText` with Lua's own `%.14g` in front of it — the two bindings are
/// byte-identical in the reference (`0x798690` / `0x7984c0`), so the numeric verb does no numeric
/// work of its own. Decision 1831; the expected strings are the reference's executed output.
#[test]
fn set_number_formats_like_the_references_printf() {
let s = UiScript::new().unwrap();
s.run(r#"box = CreateFrame("EditBox", "NumBox", UIParent)"#)
.unwrap();
for (input, want) in [
("3", "3"),
("0.8", "0.8"),
("-0.5", "-0.5"),
("0.1 + 0.2", "0.3"),
("1/3", "0.33333333333333"),
("1e13", "10000000000000"),
// The three-digit exponent, which is MSVC's `%g` and not C99's. `1e+020`, never `1e+20`.
("1e14", "1e+014"),
("1e20", "1e+020"),
("1e-5", "1e-005"),
// Just inside the fixed-notation window.
("1e-4", "0.0001"),
// `%g` drops the sign on negative zero.
("-0.0", "0"),
] {
s.run(&format!("box:SetNumber({input})")).unwrap();
assert_eq!(
s.eval::<String>("return box:GetText()").unwrap(),
want,
"SetNumber({input})"
);
}
// A STRING is accepted and passed through verbatim — the gate is `lua_isstring`, so the
// argument is never parsed as a number.
s.run(r#"box:SetNumber("abc")"#).unwrap();
assert_eq!(s.eval::<String>("return box:GetText()").unwrap(), "abc");
// Everything else raises, including an ABSENT argument. `luaL_error` does not return.
for bad in [
"box:SetNumber()",
"box:SetNumber(nil)",
"box:SetNumber(true)",
] {
assert!(s.run(bad).is_err(), "{bad} must raise");
}
}
/// A `numeric` box takes the reference's wholesale abort: the clear-all has already run when the
/// insert fails the digit test, so the box is left EMPTY rather than partly filled. The money
/// boxes (`MoneyInputFrame.xml`) are numeric, which is why this matters — though the money path
/// itself only ever passes non-negative integers.
#[test]
fn set_number_on_a_numeric_box_empties_it_when_the_text_is_not_all_digits() {
let s = UiScript::new().unwrap();
s.run(r#"box = CreateFrame("EditBox", "NumOnly", UIParent) box:SetNumeric(true)"#)
.unwrap();
s.run("box:SetNumber(1234)").unwrap();
assert_eq!(s.eval::<String>("return box:GetText()").unwrap(), "1234");
// The minus sign fails the digit test, and the abort is wholesale.
s.run("box:SetNumber(-5)").unwrap();
assert_eq!(
s.eval::<String>("return box:GetText()").unwrap(),
"",
"a sign empties a numeric box rather than partly filling it"
);
}
-20
View File
@@ -146,12 +146,6 @@ impl super::UiScript {
pub fn take_inspect_clear(&mut self) -> bool {
std::mem::take(&mut self.model_mut().inspect_clear)
}
/// The inspect model pane's bake yaw in radians — the twin of
/// [`Self::paperdoll_yaw`], read by the app onto the `"inspect"` booth slot each frame.
pub fn inspect_yaw(&self) -> f32 {
self.model_ref().inspect_yaw
}
}
/// What the app resolved for `token`, or `None` when it resolved to no live unit at all — the
@@ -271,19 +265,5 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
})?,
)?;
// BenillaInspectModel_SetFacing(radians) — the inspect pane's own bake yaw, the exact twin of
// `BenillaPaperDollModel_SetFacing` (decision 0208 §5's rotate-adjusts-the-bake). Two scalars
// rather than one shared: the two windows can be open at different facings, and the ref's own
// `InspectModelFrame` carries its own `rotation` independent of the character pane's.
g.set(
"BenillaInspectModel_SetFacing",
lua.create_function(|lua, radians: f32| {
lua.app_data_mut::<Model>()
.expect("model app_data")
.inspect_yaw = radians;
Ok(())
})?,
)?;
Ok(())
}
+13 -3
View File
@@ -288,6 +288,18 @@ pub(crate) struct Model {
/// resolve at a 3,988-frame roster, which made it the biggest phase left in the preamble
/// (decision 1634's `[layout-pre] watched=`).
pub(crate) on_size_changed_frames: Vec<FrameHandle>,
/// Edit boxes whose text changed and whose `OnTextChanged` has **not fired yet** — the
/// reference's `textChanged` dirty bit (`[E+0x31c]` bit 0), which `SetText`/`Insert` raise and
/// only the dirty-word drain `0x77d3e0` clears (decision 1831).
///
/// The fire is deferred, not immediate: `SetText` returns to Lua before any handler runs, and N
/// changes to one box between two drains produce exactly ONE fire carrying the final text. The
/// reference's own FrameXML depends on both halves — `MoneyInputFrame_SetCopper` sets three
/// boxes while counting the fires it expects back, and errors if one arrives mid-loop.
///
/// A hidden box stays on this list: `Hide` splices it out of the chain the update walk follows,
/// so its pending fire waits until it is shown again (or a key/mouse-down reaches it).
pub(crate) dirty_editboxes: Vec<FrameHandle>,
/// `event name → frames registered for it` (RegisterEvent), in **registration order** — an
/// ordered Vec, never a set: the client's `SignalEvent 0x703e50` walks a per-event listener
/// LIST, so cross-frame dispatch order is a law, not an accident (the abbey territory-line
@@ -1330,8 +1342,6 @@ pub(crate) struct Model {
/// The inspect model pane's bake yaw — the last of the benilla-named pane scalars beside
/// [`Self::dressup_yaw`]. A migrated window's pane carries its own facing in `ModelState`
/// (`UiScript::model_pane_facing`, decision 1751); these two remain because their windows are
/// still ours, and each retires with its file.
pub(crate) inspect_yaw: f32,
/// The dressing room's queued intents (decision 1060) — `BenillaDressUpModel_Dress/TryOn/Close`,
/// drained by the app in order (see [`super::dressup`] on why order matters).
pub(crate) dressup_intents: Vec<super::dressup::DressUpIntent>,
@@ -1574,6 +1584,7 @@ impl Model {
scripts: HashMap::new(),
on_update_frames: Vec::new(),
on_size_changed_frames: Vec::new(),
dirty_editboxes: Vec::new(),
event_to_frames: HashMap::new(),
frame_events: HashMap::new(),
focused_editbox: None,
@@ -1865,7 +1876,6 @@ impl Model {
inspect: None,
inspect_notifies: Vec::new(),
inspect_clear: false,
inspect_yaw: 0.0,
dressup_intents: Vec::new(),
dressup_yaw: 0.0,
unit_reach: HashMap::new(),
+12 -1
View File
@@ -117,7 +117,18 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
// the bake replaces them. A later `SetTexture`/`SetPortraitToTexture` clears the binding.
lua.globals().set(
"SetPortraitTexture",
lua.create_function(|lua, (region, unit): (Table, String)| {
lua.create_function(|lua, (region, unit): (Table, Value)| {
// `0x519ef0` gates its unit argument at `0x519fb4` with `lua_isstring` and fails into
// `luaL_error` with this exact string (`0x8513b8`); `luaL_error` does not return, so
// nil/absent/boolean/table abandon the caller's statement. Stock
// `Blizzard_InspectUI.lua:49` reaches it on a re-open, which is how it was found.
// A token that RESOLVES to nothing is a different, quiet case — it blanks the portrait
// and returns no values. Decision 1834.
let unit = crate::script::binding_abi::string_arg(
lua,
unit,
r#"Usage: SetPortraitTexture(texture, "unit")"#,
)?;
let rh = region_handle_of(lua, &region)?;
let mut model = lua.app_data_mut::<Model>().expect("model");
let data = model.region_data.entry(rh).or_default();
+5
View File
@@ -107,6 +107,11 @@ impl UiScript {
}
// The focused edit box's caret blink (`0x77a790` runs on the client's frame tick).
editbox::tick_blink(&self.lua, elapsed);
// …and, from inside that same `0x77a790` (`0x77a7a1`), the dirty-word drain that fires the
// `OnTextChanged`s an edit only marked (decision 1831). It runs BEFORE this tick's
// OnUpdate sweep, matching the reference's order: the box's own OnUpdate override drains
// before the FrameXML handlers that read the box get their turn.
editbox::drain_text_changed(&self.lua);
// Events queued by Lua bindings last tick (`Model::pending_events` — e.g. `SetMapZoom` →
// `WORLD_MAP_UPDATE`; the cursor arc's `CURSOR_UPDATE`/`ITEM_LOCK_CHANGED`/
// `DELETE_ITEM_CONFIRM`, decision 0216) fire first, so handlers see them before this
+217 -30
View File
@@ -72,8 +72,11 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
// `lua_isstring` gate admits tag 3 and hands the resolver `"5"`). `check_unit_token` inside
// `with_unit` is the second of those; the first is the `?` on the argument type below.
// The gate is the BINDING's, not `with_unit`'s: `check_unit_token` lets a nil through by
// design, because that is right for `UnitExists`, `UnitIsVisible` and most of this family. It
// is wrong for exactly these two, so the shape-A test happens here, before the shared path.
// design, because that is right for `UnitExists`, `UnitIsVisible` and eleven others. **"and
// most of this family" is what this comment used to say, and it was the wrong way round** —
// wow-re has since censused all 83 entries of the table at `0x850438` and 53 of them gate and
// raise, with only 13 unit-token bindings quiet (decision 1834). These two were never the
// exception; they were an early instance of the rule.
for (name, usage, by_player) in [
("UnitIsTapped", r#"Usage: UnitIsTapped("unit")"#, false),
(
@@ -151,7 +154,12 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
g.set(
"UnitName",
lua.create_function(|lua, token: Option<String>| {
lua.create_function(|lua, token: Value| {
let token = Some(crate::script::binding_abi::string_arg(
lua,
token,
r#"Usage: UnitName("unit")"#,
)?);
let name = with_unit(lua, &token, None, |u| u.name.clone())?;
match name {
Some(n) => Ok(Value::String(lua.create_string(&n)?)),
@@ -162,14 +170,24 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
g.set(
"UnitHealth",
lua.create_function(|lua, token: Option<String>| {
lua.create_function(|lua, token: Value| {
let token = Some(crate::script::binding_abi::string_arg(
lua,
token,
r#"Usage: UnitHealth("unit")"#,
)?);
with_unit(lua, &token, 0i64, |u| i64::from(u.health))
})?,
)?;
g.set(
"UnitHealthMax",
lua.create_function(|lua, token: Option<String>| {
lua.create_function(|lua, token: Value| {
let token = Some(crate::script::binding_abi::string_arg(
lua,
token,
r#"Usage: UnitHealthMax("unit")"#,
)?);
with_unit(lua, &token, 0i64, |u| i64::from(u.max_health))
})?,
)?;
@@ -183,7 +201,12 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
// `b` INFERRED and 0 in normal play — it can never drive the value ≤ 0 anyway).
g.set(
"UnitLevel",
lua.create_function(|lua, token: Option<String>| {
lua.create_function(|lua, token: Value| {
let token = Some(crate::script::binding_abi::string_arg(
lua,
token,
r#"Usage: UnitLevel("unit")"#,
)?);
check_unit_token(&token)?;
let model = lua.app_data_ref::<Model>().expect("model app_data");
let Some(u) = token.as_ref().and_then(|t| model.unit(t)) else {
@@ -223,7 +246,19 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
// (`UnitCanAttack("player", "target")`).
g.set(
"UnitCanAttack",
lua.create_function(|lua, (a, b): (Option<String>, Option<String>)| {
lua.create_function(|lua, (a, b): (Value, Value)| {
// BOTH positions carry an `lua_isstring` gate — two sites in the body —
// so either argument being nil raises (decision 1836).
let a = Some(crate::script::binding_abi::string_arg(
lua,
a,
r#"Usage: UnitCanAttack("unit", "otherUnit")"#,
)?);
let b = Some(crate::script::binding_abi::string_arg(
lua,
b,
r#"Usage: UnitCanAttack("unit", "otherUnit")"#,
)?);
let token = pick_unit_token(&a, &b);
Ok(if with_unit(lua, &token, false, |u| u.can_attack)? {
Value::Integer(1)
@@ -247,7 +282,12 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
g.set(
"UnitIsDead",
lua.create_function(|lua, token: Option<String>| {
lua.create_function(|lua, token: Value| {
let token = Some(crate::script::binding_abi::string_arg(
lua,
token,
r#"Usage: UnitIsDead("unit")"#,
)?);
with_unit(lua, &token, false, |u| u.dead)
})?,
)?;
@@ -256,13 +296,23 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
// so IsDead is false for it and the popup flow branches on all three.
g.set(
"UnitIsGhost",
lua.create_function(|lua, token: Option<String>| {
lua.create_function(|lua, token: Value| {
let token = Some(crate::script::binding_abi::string_arg(
lua,
token,
r#"Usage: UnitIsGhost("unit")"#,
)?);
with_unit(lua, &token, false, |u| u.ghost)
})?,
)?;
g.set(
"UnitIsDeadOrGhost",
lua.create_function(|lua, token: Option<String>| {
lua.create_function(|lua, token: Value| {
let token = Some(crate::script::binding_abi::string_arg(
lua,
token,
r#"Usage: UnitIsDeadOrGhost("unit")"#,
)?);
with_unit(lua, &token, false, |u| u.dead || u.ghost)
})?,
)?;
@@ -274,7 +324,19 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
// frame paints its name plate blue then, exactly like the reference.
g.set(
"UnitReaction",
lua.create_function(|lua, (token, _other): (Option<String>, Option<String>)| {
lua.create_function(|lua, (token, _other): (Value, Value)| {
// BOTH positions carry an `lua_isstring` gate — two sites in the body —
// so either argument being nil raises (decision 1836).
let token = Some(crate::script::binding_abi::string_arg(
lua,
token,
r#"Usage: UnitReaction("unit", "otherUnit")"#,
)?);
let _other = Some(crate::script::binding_abi::string_arg(
lua,
_other,
r#"Usage: UnitReaction("unit", "otherUnit")"#,
)?);
let r = with_unit(lua, &token, 0u8, |u| u.reaction)?;
Ok(if r == 0 {
Value::Nil
@@ -294,7 +356,19 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
// reaction (0) → nil for both, the API's "can't tell". `1`/nil returns, era-style.
g.set(
"UnitIsEnemy",
lua.create_function(|lua, (a, b): (Option<String>, Option<String>)| {
lua.create_function(|lua, (a, b): (Value, Value)| {
// BOTH positions carry an `lua_isstring` gate — two sites in the body —
// so either argument being nil raises (decision 1836).
let a = Some(crate::script::binding_abi::string_arg(
lua,
a,
r#"Usage: UnitIsEnemy("unit", "otherUnit")"#,
)?);
let b = Some(crate::script::binding_abi::string_arg(
lua,
b,
r#"Usage: UnitIsEnemy("unit", "otherUnit")"#,
)?);
let token = pick_unit_token(&a, &b);
let r = with_unit(lua, &token, 0u8, |u| u.reaction)?;
Ok(if (1..=2).contains(&r) {
@@ -306,7 +380,19 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
)?;
g.set(
"UnitIsFriend",
lua.create_function(|lua, (a, b): (Option<String>, Option<String>)| {
lua.create_function(|lua, (a, b): (Value, Value)| {
// BOTH positions carry an `lua_isstring` gate — two sites in the body —
// so either argument being nil raises (decision 1836).
let a = Some(crate::script::binding_abi::string_arg(
lua,
a,
r#"Usage: UnitIsFriend("unit", "otherUnit")"#,
)?);
let b = Some(crate::script::binding_abi::string_arg(
lua,
b,
r#"Usage: UnitIsFriend("unit", "otherUnit")"#,
)?);
let token = pick_unit_token(&a, &b);
let r = with_unit(lua, &token, 0u8, |u| u.reaction)?;
Ok(if r >= 5 {
@@ -335,7 +421,12 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
// target plate disagree about the same mob.
g.set(
"UnitIsCivilian",
lua.create_function(|lua, unit: Option<String>| {
lua.create_function(|lua, unit: Value| {
let unit = Some(crate::script::binding_abi::string_arg(
lua,
unit,
r#"Usage: UnitIsCivilian("unit")"#,
)?);
let model = lua.app_data_ref::<Model>().expect("model app_data");
let player_level = model.player_req.level;
Ok(match unit.and_then(|u| model.unit(&u)) {
@@ -374,7 +465,19 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
// is trivially the same unit; otherwise both snapshots must carry a real (nonzero) guid.
g.set(
"UnitIsUnit",
lua.create_function(|lua, (a, b): (Option<String>, Option<String>)| {
lua.create_function(|lua, (a, b): (Value, Value)| {
// BOTH positions carry an `lua_isstring` gate — two sites in the body —
// so either argument being nil raises (decision 1836).
let a = Some(crate::script::binding_abi::string_arg(
lua,
a,
r#"Usage: UnitIsUnit("unit", "otherUnit")"#,
)?);
let b = Some(crate::script::binding_abi::string_arg(
lua,
b,
r#"Usage: UnitIsUnit("unit", "otherUnit")"#,
)?);
// BOTH arguments go through the resolver, so either being unrecognised raises.
check_unit_token(&a)?;
check_unit_token(&b)?;
@@ -494,7 +597,19 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
// whisper a same-faction player), without the faction machinery the engine doesn't carry.
g.set(
"UnitCanCooperate",
lua.create_function(|lua, (a, b): (Option<String>, Option<String>)| {
lua.create_function(|lua, (a, b): (Value, Value)| {
// BOTH positions carry an `lua_isstring` gate — two sites in the body —
// so either argument being nil raises (decision 1836).
let a = Some(crate::script::binding_abi::string_arg(
lua,
a,
r#"Usage: UnitCanCooperate("unit", "otherUnit")"#,
)?);
let b = Some(crate::script::binding_abi::string_arg(
lua,
b,
r#"Usage: UnitCanCooperate("unit", "otherUnit")"#,
)?);
let token = pick_unit_token(&a, &b);
let ok = with_unit(lua, &token, false, |u| {
u.exists && u.is_player && u.reaction >= 5
@@ -507,7 +622,14 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
// fed per token).
g.set(
"GetRaidTargetIndex",
lua.create_function(|lua, token: Option<String>| {
lua.create_function(|lua, token: Value| {
// Gated (`0x4bb4be` → `0x4bb4cd`), and the usage string names the
// argument unquoted — the reference's own spelling. Decision 1836.
let token = Some(crate::script::binding_abi::string_arg(
lua,
token,
"Usage: GetRaidTargetIndex(unit)",
)?);
let idx = with_unit(lua, &token, 0u8, |u| u.raid_target)?;
Ok(if idx > 0 {
Value::Integer(i64::from(idx))
@@ -522,7 +644,12 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
// stated v1 shortcut, module doc), these are new and follow the era shape from the start.
g.set(
"UnitIsConnected",
lua.create_function(|lua, token: Option<String>| {
lua.create_function(|lua, token: Value| {
let token = Some(crate::script::binding_abi::string_arg(
lua,
token,
r#"Usage: UnitIsConnected("unit")"#,
)?);
Ok(if with_unit(lua, &token, false, |u| u.is_connected)? {
Value::Integer(1)
} else {
@@ -554,7 +681,12 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
// two callers) — see the field doc.
g.set(
"UnitIsPVP",
lua.create_function(|lua, token: Option<String>| {
lua.create_function(|lua, token: Value| {
let token = Some(crate::script::binding_abi::string_arg(
lua,
token,
r#"Usage: UnitIsPVP("unit")"#,
)?);
Ok(if with_unit(lua, &token, false, |u| u.pvp)? {
Value::Integer(1)
} else {
@@ -564,7 +696,12 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
)?;
g.set(
"UnitIsPVPFreeForAll",
lua.create_function(|lua, token: Option<String>| {
lua.create_function(|lua, token: Value| {
let token = Some(crate::script::binding_abi::string_arg(
lua,
token,
r#"Usage: UnitIsPVPFreeForAll("unit")"#,
)?);
Ok(if with_unit(lua, &token, false, |u| u.is_pvp_ffa)? {
Value::Integer(1)
} else {
@@ -588,7 +725,12 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
// files are on our chain.
g.set(
"UnitFactionGroup",
lua.create_function(|lua, token: Option<String>| {
lua.create_function(|lua, token: Value| {
let token = Some(crate::script::binding_abi::string_arg(
lua,
token,
r#"Usage: UnitFactionGroup("unit")"#,
)?);
let pair = with_unit(lua, &token, (None, None), |u| {
(u.faction_group.clone(), u.faction_group_localized.clone())
})?;
@@ -615,7 +757,12 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
// reports nil, nil — the live API's shape for an absent unit.
g.set(
"UnitRace",
lua.create_function(|lua, token: Option<String>| {
lua.create_function(|lua, token: Value| {
let token = Some(crate::script::binding_abi::string_arg(
lua,
token,
r#"Usage: UnitRace("unit")"#,
)?);
let pair = with_unit(lua, &token, None, |u| {
u.race.clone().zip(u.race_file.clone())
})?;
@@ -630,7 +777,12 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
)?;
g.set(
"UnitClass",
lua.create_function(|lua, token: Option<String>| {
lua.create_function(|lua, token: Value| {
let token = Some(crate::script::binding_abi::string_arg(
lua,
token,
r#"Usage: UnitClass("unit")"#,
)?);
let pair = with_unit(lua, &token, None, |u| {
u.class.clone().zip(u.class_file.clone())
})?;
@@ -667,7 +819,12 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
// The truthy leg is the NUMBER 1, never a boolean; the false leg is nil, never `false`.
g.set(
"UnitHasRelicSlot",
lua.create_function(|lua, token: Option<String>| {
lua.create_function(|lua, token: Value| {
let token = Some(crate::script::binding_abi::string_arg(
lua,
token,
r#"Usage: UnitHasRelicSlot("unit")"#,
)?);
let has = with_unit(lua, &token, false, |u| u.has_relic_slot)?;
Ok(if has { Value::Integer(1) } else { Value::Nil })
})?,
@@ -677,7 +834,12 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
// unit is absent or the sex hasn't streamed (`0`), the API's "can't tell".
g.set(
"UnitSex",
lua.create_function(|lua, token: Option<String>| {
lua.create_function(|lua, token: Value| {
let token = Some(crate::script::binding_abi::string_arg(
lua,
token,
r#"Usage: UnitSex("unit")"#,
)?);
let sex = with_unit(lua, &token, 0u8, |u| u.sex)?;
Ok(if sex == 0 {
Value::Nil
@@ -763,7 +925,12 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
// alt-power color components; addons that read those handle nil).
g.set(
"UnitPowerType",
lua.create_function(|lua, token: Option<String>| {
lua.create_function(|lua, token: Value| {
let token = Some(crate::script::binding_abi::string_arg(
lua,
token,
r#"Usage: UnitPowerType("unit")"#,
)?);
let ty = with_unit(lua, &token, 0u8, |u| u.power_type)?;
Ok((i64::from(ty), power_token(ty).to_string()))
})?,
@@ -782,13 +949,23 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
// have" is spelled `UnitPowerType(unit)` first, which we already provide and which IS 1.12.
g.set(
"UnitMana",
lua.create_function(|lua, token: Option<String>| {
lua.create_function(|lua, token: Value| {
let token = Some(crate::script::binding_abi::string_arg(
lua,
token,
r#"Usage: UnitMana("unit")"#,
)?);
with_unit(lua, &token, 0i64, |u| i64::from(u.power))
})?,
)?;
g.set(
"UnitManaMax",
lua.create_function(|lua, token: Option<String>| {
lua.create_function(|lua, token: Value| {
let token = Some(crate::script::binding_abi::string_arg(
lua,
token,
r#"Usage: UnitManaMax("unit")"#,
)?);
with_unit(lua, &token, 0i64, |u| i64::from(u.max_power))
})?,
)?;
@@ -800,7 +977,12 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
let is_player = |token: &Option<String>| token.as_deref() == Some("player");
g.set(
"UnitXP",
lua.create_function(move |lua, token: Option<String>| {
lua.create_function(move |lua, token: Value| {
let token = Some(crate::script::binding_abi::string_arg(
lua,
token,
r#"Usage: UnitXP("unit")"#,
)?);
let model = lua.app_data_ref::<Model>().expect("model app_data");
Ok(if is_player(&token) {
i64::from(model.player_xp)
@@ -811,7 +993,12 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
)?;
g.set(
"UnitXPMax",
lua.create_function(move |lua, token: Option<String>| {
lua.create_function(move |lua, token: Value| {
let token = Some(crate::script::binding_abi::string_arg(
lua,
token,
r#"Usage: UnitXPMax("unit")"#,
)?);
let model = lua.app_data_ref::<Model>().expect("model app_data");
Ok(if is_player(&token) {
i64::from(model.player_next_level_xp)
+8 -3
View File
@@ -644,9 +644,14 @@ pub(crate) fn token_recognised(token: &str) -> bool {
/// 1203's shape pointed the other way: a failure the client reports, silently swallowed.
///
/// The split is THREE-way, not two, and the two quiet legs are as carved:
/// * **absent argument** — quiet nil (the per-binding argument gates are NOT uniform; `UnitName`
/// has its own `lua_isstring` gate and `UnitExists` has none at all, and only those two poles
/// are verified, so this does not invent a gate for the other ~102);
/// * **absent argument** — quiet nil *here*, because this helper is only the resolver's half.
/// Whether a nil ever reaches it is the BINDING's question, and it is settled per binding:
/// wow-re's census of all 83 entries at `0x850438` found 53 that gate the token position with
/// `lua_isstring` and raise `Usage:`, against 13 unit-token bindings with no gate at all
/// (decision 1834). The gated ones call `binding_abi::string_arg` before they get here, so a
/// nil never arrives; the quiet 13 pass it straight through. This comment used to say the
/// gates were "NOT uniform … only those two poles are verified" and decline to guess, which
/// was the right call at the time — the table now exists, so it is applied rather than feared;
/// * **`""`** — quiet nil;
/// * a **recognised** token naming nothing (`"party5"` solo, `"playerfoo"`) — quiet nil.
pub(crate) fn check_unit_token(token: &Option<String>) -> mlua::Result<()> {
+80 -4
View File
@@ -984,11 +984,32 @@ fn an_unrecognised_unit_token_raises_and_a_recognised_empty_one_does_not() {
implementation that raises on \"did not resolve\" gets wrong."
);
}
// QUIET NIL, leg 3: the empty string and an absent argument. The per-binding argument gates are
// NOT uniform in the client and only two poles are verified, so this asserts the resolver's
// behaviour and does not invent a gate.
// QUIET NIL, leg 3: the empty string — which passes the `lua_isstring` gate (it IS a string)
// and dies quietly in the resolver's own NULL/empty guard.
assert!(s.run(r#"UnitName("")"#).is_ok());
assert!(s.run("UnitName()").is_ok());
// …but an ABSENT or nil argument is NOT leg 3, and this line used to assert that it was.
// `UnitName 0x517020` gates its token position at `0x517048` and raises
// `Usage: UnitName("unit")` (`0x850ee0`); `luaL_error` does not return. The comment this
// replaces said the per-binding gates were "not uniform … only two poles verified", which was
// true when it was written — wow-re has since censused all 83 entries of the table at
// `0x850438`: 53 gate and raise, and only 13 unit-token bindings are quiet. Decision 1834.
assert!(s.run("UnitName()").is_err(), "absent argument raises");
assert!(s.run("UnitName(nil)").is_err(), "nil argument raises");
// A NUMBER passes the gate — `lua_isstring` admits tag 3 — and is handed to the resolver as
// "5", which then raises the family's OTHER message rather than the `Usage:` one.
assert!(
s.run("UnitName(5)").is_err(),
"a number reaches the resolver"
);
// The thirteen that stay quiet, three of them here: no gate at all, so nil is fine.
for quiet in ["UnitExists", "UnitIsVisible", "UnitClassification"] {
assert!(
s.run(&format!("{quiet}()")).is_ok(),
"{quiet} is one of the 13 with no gate — nil must stay quiet"
);
}
// A two-unit call gates BOTH arguments.
assert!(s.run(r#"UnitIsUnit("player", "bogus")"#).is_err());
@@ -1371,3 +1392,58 @@ fn unit_has_relic_slot_answers_one_or_nil() {
"function"
);
}
/// **The two-token predicates gate BOTH positions** — decision 1836, closing the half 1834 left
/// open on purpose.
///
/// 1834 applied the `lua_isstring` gate only where wow-re's partial list named a single-token
/// binding, because a census that finds "a gate somewhere in this body" cannot say *which*
/// argument carries it. The complete 83-row table (`nil-unit-token-arg-law.md` §10) resolves it:
/// these seven each carry **two** `lua_isstring` sites, so either argument being nil raises.
#[test]
fn a_two_token_predicate_raises_on_either_nil_argument() {
let mut s = UiScript::new().unwrap();
s.set_unit("player", Some(player()));
s.set_unit("target", Some(player()));
for verb in [
"UnitIsUnit",
"UnitIsEnemy",
"UnitIsFriend",
"UnitCanCooperate",
"UnitCanAttack",
"UnitReaction",
] {
assert!(
s.run(&format!(r#"{verb}("player", "target")"#)).is_ok(),
"{verb} with both tokens is fine"
);
assert!(
s.run(&format!(r#"{verb}("player")"#)).is_err(),
"{verb} with the SECOND argument absent must raise"
);
assert!(
s.run(&format!(r#"{verb}(nil, "target")"#)).is_err(),
"{verb} with the FIRST argument nil must raise"
);
assert!(s.run(&format!("{verb}()")).is_err(), "{verb} with neither");
}
// `GetRaidTargetIndex` is the one of six previously-unclassified names that takes a token, and
// it is gated. Its usage string names the argument UNQUOTED — the reference's own spelling.
assert!(s.run(r#"GetRaidTargetIndex("player")"#).is_ok());
assert!(s.run("GetRaidTargetIndex()").is_err());
// …while the other five take no argument at all and have no gate. `GetTimeToWellRested` is a
// three-instruction stub in the reference that always pushes nil; computing a value there
// would be divergence, not a feature.
for none in [
"GetQuestGreenRange",
"GetRestState",
"GetXPExhaustion",
"GetTimeToWellRested",
"GetBillingTimeRested",
] {
assert!(s.run(&format!("{none}()")).is_ok(), "{none} takes no token");
}
}
+23 -4
View File
@@ -714,13 +714,32 @@ impl EditBoxState {
EditOutcome::changed(self.insert(&cleaned).text_changed)
}
/// `SetText` (`0x77be00`): short-circuits when unchanged (the caller then fires nothing); else
/// replaces, caret to the end, caps enforced.
/// `SetText` (`0x77be00`) is a clear-all followed by `Insert`, not a plain assignment, and the
/// order of its three parts is load-bearing (decision 1831):
///
/// 1. the selection collapses **unconditionally**, before anything is compared — an identical
/// `SetText` still drops a highlight;
/// 2. a case-sensitive equality test then short-circuits (`SStrCmp` at `0x77be4b`), skipping the
/// clear-all, the insert and both fires — so the caller fires nothing;
/// 3. the changing leg is `0x77c500` (delete the whole buffer) then `0x77bee0` (`Insert`), which
/// means `SetText` **inherits `Insert`'s gates**. The one that shows is `numeric`: the gate
/// abandons the insert wholesale on any non-digit, and the clear-all has already run, so the
/// box is left **empty** rather than unchanged. `SetNumber(-5)` and `SetNumber(0.8)` both
/// empty a `numeric` box, because `-` and `.` are not digits.
///
/// The clear-all raises the `textChanged` dirty bit on every path it reaches, so the aborted
/// insert still reports a change and still fires — reporting `""`.
pub fn set_text(&mut self, s: &str) -> bool {
self.collapse(); // (1) unconditional, ahead of the comparison
if self.text == s {
return false;
return false; // (2)
}
self.text = s.to_string();
// (3) the clear-all, then Insert — whose `numeric` gate refuses the whole string.
self.text = if self.numeric && !s.chars().all(|c| c.is_ascii_digit()) {
String::new()
} else {
s.to_string()
};
self.cursor = self.text.len();
self.collapse();
self.enforce_caps();