the character sheet is the reference's own file

- the character sheet is the reference's own — `CharacterFrame.xml`, `PaperDollFrame.xml` and `PetPaperDollFrame.xml` off the player's chain in the reference's own order, 2800 lines of ours deleted — and the one engine verb it needed had no pixels: a bare `<PlayerModel name="CharacterModelFrame">` drew nothing here, so the swap as written would have replaced a working paper doll with an empty rectangle, invisible to every loader and gap check; a `<Model>`/`<PlayerModel>` widget now draws its window's booth, keyed on the reference's own frame names, and the booth mirrors take their yaw off `PlayerModel:SetRotation`'s own state, which is what the reference's `Model_Rotate*` write
- what the stock file calls that was not there: `MicroButtonTooltipText(text, action)` (all five tab hovers), the two scripts `CharacterFrameTabButtonTemplate` carries — without them the five tabs are DEAD, since the reference's `PaperDollFrame` is `enableMouse` with a hit inset the tabs sit inside — `GetGuildInfo` on a recognised-but-unresolvable token answering `(nil, nil, 0)` rather than raising (`0x4c9330`), and `UnitAttackBothHands` pushing four values not two (`0x518810` calls the hand accessor twice; the reference's own Lua destructures two and says `FIXME` about the off hand, so no amount of running the sheet could expose it) — both skill verbs clamp a debuff deeper than the skill to zero as `0x5188a7` and `0x519298` do
- a gate for the question that bites AFTER a swap: for every chain window, census the bare `name(` call sites across it and its `.lua`, subtract what those define, keep what the reference's own `_G` carries, and require every survivor non-nil in a VM with the whole interface up — `LootFrame.xml` had shipped load-clean and raised at line 85, and the stock character sheet would have raised on the first tab hover; the stock sheet is driven end to end over a level-60 body with a +105 buff, a −12 debuff and a resistance school whose debuff outweighs its buff, at the rendered strings

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-09-02 17:02:55 +07:00
co-authored by Claude Fable 5.1
parent 47a3849939
commit 302e9a37c1
34 changed files with 1971 additions and 3718 deletions
File diff suppressed because it is too large Load Diff
+10 -3
View File
@@ -459,11 +459,18 @@
PlaySound("igInventoryRotateCharacter")
end
-- The character window's twin (CharacterFrame.xml's BenillaPaperDollModel_OnUpdate — the
-- same ref Model_OnUpdate, UIParent.lua:1444-1462, that EVERY 1.12 model pane wires:
-- PaperDollFrame l.222-224, DressUpFrame.xml:202-204, TabardFrame.xml:406-408). Held button
-- The ref's own Model_OnUpdate (UIParent.lua:1444-1462), which EVERY 1.12 model pane wires:
-- PaperDollFrame l.222-224, DressUpFrame.xml:202-204, TabardFrame.xml:406-408. Held button
-- = half a turn per second; the 0.03 click above is the tap. The ref's held-vs-click sign
-- flip is quoted, not corrected.
--
-- **This quartet retires with this window.** The character sheet's twin of it is gone: the
-- migrated paper doll is a real `<PlayerModel>` driven by the reference's own
-- `Model_OnLoad`/`Model_Rotate*`/`Model_OnUpdate` (UIParent.xml), because `<Model>` panes
-- draw their booth now and carry their own facing (decision 1810). This pane is still a
-- booth-texture Frame, so it still needs the `frame.facing` + `*_SetFacing` shape — until
-- the inspect window is the reference's own, which it cannot be while 1.12 ships it as a
-- LoadOnDemand addon packed as `.pub`.
function BenillaInspectModel_OnUpdate(frame, elapsed)
local step = elapsed * 2 * PI * ROTATIONS_PER_SECOND
if getglobal(frame:GetName() .. "RotateLeftButton"):GetButtonState() == "PUSHED" then
@@ -1,76 +0,0 @@
<!--
The reference's item-SLOT button templates: `PaperDollItemSlotButtonTemplate` (PaperDollFrame.xml
l.4-38) and `BagSlotButtonTemplate` (MainMenuBarBagButtons.xml l.17-53), which inherits it. Both
sit on `ItemButtonTemplate` (ItemButtonTemplate.xml), so the chain is
ItemButtonTemplate <- PaperDollItemSlotButtonTemplate <- BagSlotButtonTemplate
and all three are now declared, in that order, across three files the toc loads in that order.
**Why they are here rather than in CharacterFrame.xml and BagFrame.xml, where their subjects
live.** Load order, and only load order: BagFrame.xml (toc l.241) comes BEFORE
CharacterFrame.xml (l.256), so a bag template declared in the former could not inherit a doll
template declared in the latter. Neither is consumed by our own windows — our doll slots and our
four bag-bar slots are hand-wired to `BenillaPaperDollSlot_*` / `BenillaBagBarSlot_*` and do not
inherit these — so a file of their own, loaded after both, costs nothing and makes the chain
readable. This is the reference's published surface for ADDONS, kept where its dependencies are
satisfied.
**The handlers are the reference's own, not transcriptions.** `PaperDollItemSlotButton_OnLoad`,
`_OnEvent`, `_OnClick`, `_Update`, `_UpdateLock`, `_OnEnter` and `_OnUpdate` come from
`PaperDollFrame.lua`, which `ui_script::reference_ui` now SOURCES off the player's own patch
chain. That file earned its place because every one of those acts on `this` and `this:GetID()`
through live APIs and never on a named reference window — see the note beside SOURCED for what
was checked first, including the 18 collisions that order settles in our favour.
`MainMenuBarBagButtons.lua` is deliberately NOT sourced, which is why `BagSlotButton_OnClick`,
`_OnDrag`, `_OnShiftClick` and `_OnEnter` are ours in BagFrame.xml: all four of its versions walk
`ContainerFrame1..NUM_CONTAINER_FRAMES`, windows this client does not build, and two of them
call verbs (`PutItemInBag`, `PickupBagFromSlot`) it does not bind. Sourcing it would have handed
every caller a body that raises inside the addon's own click wrapper.
**pfUI is the measured caller**, and it fits the reference's contract exactly rather than by
luck: it names its bag-bar buttons `pfUIBagsBBag0Slot`.. with a NINE-character prefix, so the
reference's own `strsub(name, 10)` in `PaperDollItemSlotButton_OnLoad` yields `Bag0Slot` and
`GetInventorySlotInfo` answers slot 20. It then reads `$parentIconTexture`, `$parentNormalTexture`,
`GetHighlightTexture()` and `GetPushedTexture()` off what it built, and CHAINS the template's
OnEnter/OnLeave rather than replacing them (modules/bags.lua l.513-585).
OUT, named rather than stubbed (1134 §4): the bank arm. `BankItemButtonBagTemplate` and
`BankItemButtonGenericTemplate` inherit `BankItemButtonTemplate`, whose handlers live in
`BankFrame.lua` — not sourced — and whose OnEnter calls `this:GetInventorySlot()`, a widget
method we do not bind. pfUI reaches those only for its bank window.
-->
<Ui xmlns="http://www.blizzard.com/wow/ui/">
<!-- Ref PaperDollFrame.xml l.4-38, structure and scripts verbatim. The `$parentCooldown` Model
is not optional decoration: the sourced `PaperDollItemSlotButton_Update` resolves it by
name (`getglobal(this:GetName().."Cooldown")`) and calls `:Hide()` / CooldownFrame_SetTimer
on it every repaint, so a button without one raises on its first update. -->
<Button name="PaperDollItemSlotButtonTemplate" inherits="ItemButtonTemplate" virtual="true">
<Frames>
<Model name="$parentCooldown" inherits="CooldownFrameTemplate"/>
</Frames>
<Scripts>
<OnLoad>PaperDollItemSlotButton_OnLoad();</OnLoad>
<OnEvent>PaperDollItemSlotButton_OnEvent(event);</OnEvent>
<OnClick>PaperDollItemSlotButton_OnClick(arg1);</OnClick>
<OnShow></OnShow>
<OnDragStart>PaperDollItemSlotButton_OnClick("LeftButton", 1);</OnDragStart>
<OnReceiveDrag>PaperDollItemSlotButton_OnClick("LeftButton", 1);</OnReceiveDrag>
<OnEnter>PaperDollItemSlotButton_OnEnter();</OnEnter>
<OnLeave>
this.updateTooltip = nil;
GameTooltip:Hide();
ResetCursor();
</OnLeave>
<OnUpdate>PaperDollItemSlotButton_OnUpdate(arg1);</OnUpdate>
</Scripts>
</Button>
<!-- `BagSlotButtonTemplate` was declared here, transcribed from MainMenuBarBagButtons.xml
because that file could not be sourced. It IS sourced now — benilla.toc entry 48, one line
below this file — so the reference's own declaration is the live one and the copy is gone
(decision 1751, window 3). pfUI and anything else that inherits the template now inherits
the reference's, including the `$parentItemAnim` Model our copy structurally could not
carry. -->
</Ui>
@@ -136,6 +136,33 @@
button:RegisterForClicks("LeftButtonUp", "RightButtonUp")
end
-- ref MicroButtonTooltipText (MainMenuBarMicroButtons.lua l.11-18), VERBATIM — the
-- reference's own two-argument form: a label and a binding ACTION name, returning the
-- label with the bound key in NORMAL_FONT_COLOR parentheses, or the bare label when the
-- action is unbound.
--
-- It lives here because this file is our `MainMenuBarMicroButtons.xml`, which is where the
-- reference declares it. It is not the micro bar's own helper: the stock `CharacterFrame`
-- tab row calls it at all five `<OnEnter>` sites
-- (`MicroButtonTooltipText(TEXT(CHARACTER_INFO), "TOGGLECHARACTER0")` and its four
-- siblings), so migrating that window onto the chain (decision 1751) needs this name to
-- answer or every character-tab hover raises. `SpellBookFrame.xml` already guards on it
-- with `if MicroButtonTooltipText then`; that guard is now always true.
--
-- Ours below is the BUTTON-shaped variant, kept because our own micro buttons carry the
-- action in `button.binding` and the label in `button.label`, and because it renders the
-- key through the Key Bindings window's display law. The reference's version prints
-- `GetBindingKey`'s raw token, which is what the real client shows, so the two are not
-- folded together: this one is the reference's text, the other is ours.
function MicroButtonTooltipText(text, action)
if GetBindingKey(action) then
return text .. " " .. NORMAL_FONT_COLOR_CODE .. "(" .. GetBindingKey(action) .. ")"
.. FONT_COLOR_CODE_CLOSE
else
return text
end
end
-- ref MicroButtonTooltipText (l.11-18), the LIVE contract since 0997: `button.binding`
-- is the binding ACTION name and the key resolves through GetBindingKey at hover time
-- (rendered through the Key Bindings window's display law when it is loaded — the
@@ -1,773 +0,0 @@
<Ui>
<!--
benilla's PET paper doll (decision 1057) — the character window's **tab 2**, dressed from the
real 1.12 `PetPaperDollFrame.xml`/`.lua` (line cites below are into that extraction:
`ref-PetPaperDollFrame.xml`/`.lua`, plus `ref-PaperDollFrame.lua` for the shared setters and
`ref-CharacterFrame.lua` for the tab dispatch).
**Its own top-level file, loaded straight after `CharacterFrame.xml`** — the SkillFrame.xml
precedent. It carries the reference's `parent="CharacterFrame"`, and that attaches for real:
the loader does read a cross-file `parent=`, resolving the name by global lookup at load
(decision 1734), which is why the load order matters — an unresolvable name is not an error,
it warns and falls back to the enclosing element. The page is still sized and pinned to
`CharacterFrame`'s TOPLEFT by name, which gives the identical rect and is what the page was
built and approved against, and `BENILLA_CHARACTERFRAME_SUBFRAMES` (which
this page joins as entry 2) is what hides it when the whole window closes.
**Almost none of the page's Lua lives here, and that is the point.** The reference's own
`PaperDollFrame_Set{Armor,Damage,AttackPower,AttackBothHands,Defense}` take `(unit, prefix)`
and `PetPaperDollFrame_Update` (ref l.62-94) calls every one of them with `("pet", "Pet")` —
the pet sheet is the SAME code as the character sheet pointed at another unit. Our
transcription in `CharacterFrame.xml` already carries those parameters (and already carries
the ref's own `if unit == "pet" then return end` early-outs in the two RANGED setters, ref
l.470-473/l.541-544 — the pet page has no ranged rows, so the ref no-ops rather than
nil-indexing). So this file adds only what is genuinely pet-only: the stats/resistances pair
that reads the pet's own frames, the XP bar, loyalty, training points and the diet icon.
`prefix` here is `"Pet"`, so `prefix.."ArmorFrame"` resolves `PetArmorFrame`,
exactly as `"Character"` resolves the sibling page's.
**The tab is conditional, and `PetTab_Update` is the whole mechanism** (ref
`PetPaperDollFrame.lua:189-198`): no pet UI ⇒ tab 2 hides and tab 3 re-anchors onto tab 2's
LEFT, closing the gap; a pet ⇒ tab 2 shows and tab 3 goes back to the -16 chain. This used
to be the ref's arithmetic on a DIFFERENT page (tab 3 was Skills here, Reputation there);
`ReputationFrame.xml` took slot 3, so it is now the reference's function moving the
reference's tab, with nothing left to reconcile.
Named deferrals / deviations (each re-cited at its own site below):
- The model pane is a **booth-texture stand-in**, not a live `<PlayerModel>` widget — the
0208 §5 decision the character page already ships under. The ref's
`PetModelFrame:SetUnit("pet")` (ref l.67) therefore has no analogue: the app's
`petdoll` booth resolves the pet itself (decision 1057), so the pane samples a bake
that is already pointed at the right unit. `DISPLAY_SIZE_CHANGED`/`RefreshUnit` drop
with the widget, exactly as they do on the character page.
- The XP bar ships with **no numerals**: `TextStatusBar_Initialize`/
`SetTextStatusBarTextPrefix`/`ShowTextStatusBarText` have no engine surface (the same
gap `ActionBar.xml`'s own XP bar records — "No TextStatusBar text layer exists here at
all"). The bar itself, its dwarf-notch art, its range and its colour are all faithful;
only the "1234/5678" overlay is missing, and it lands for both bars at once when that
machinery does.
- `PET_UI_UPDATE` / `PET_UI_CLOSE` are **registered but nothing fires them yet** (nothing
in the app sources either event). Registered anyway rather than dropped: the ref's own
handler arms are re-expressed whole, and `UNIT_PET` + `PET_BAR_UPDATE` — both of which we
DO fire — already cover the same "the pet went away while its page was open" case
through the identical branch.
- **CLOSED (decision 1062):** the level line's family word and the diet tooltip both
landed. `UnitCreatureFamily("pet")` and `GetPetFoodTypes()` are real bindings now, fed
from `CreatureFamily.dbc` (the word + its pet-food mask) and `ItemPetFood.dbc` (the
eight diet names) through the pet's cached creature template; `BuildListString` is the
ref's own `UIParent.lua:1051` joiner, re-expressed in `UIParent.xml`. Both call sites
below dropped their existence guards. The ref's OWN `if ( UnitCreatureFamily("pet") )`
guard stays — nil is still reachable (a template with no family; the creature query not
yet answered) and a blank level line is the ref's answer to it.
- `PetPaperDollFrame_SetResistances` reads the school ids from the shared
`BENILLA_MAGICRES_IDS` table where the ref reads each frame's own `id=` attr
(`frame:GetID()`, ref l.112). Same five ids in the same order (6/2/3/4/5); the table is
what the sibling page already uses, and one convention across the two pages beats
matching the ref's two.
- The ref's `PetPaperDollCloseButton` (ref l.276-292) inherits `UIPanelButtonTemplate`,
which this codebase has never needed to define; its art/size/font are transcribed inline
from the shape every other panel button here uses (QuestFrame.xml's own precedent).
-->
<Script><![CDATA[
-- ── GlobalStrings this page uses (ref-GlobalStrings.lua, cited per line — the
-- `X = X or "..."` idiom is this codebase's convention: the real localized file, loaded off
-- the patch chain first, always wins; these fill in for the test harnesses, which load no
-- chain. Redeclared here rather than leaned on from CharacterFrame.xml so a subset load of
-- this file alone still resolves.) ────────────────────────────────────────────────────────
ATTACK_COLON = ATTACK_COLON or "Attack:" -- :70
DAMAGE_COLON = DAMAGE_COLON or "Damage:" -- :888
ATTACK_POWER_COLON = ATTACK_POWER_COLON or "Power:" -- :71
DEFENSE_COLON = DEFENSE_COLON or "Defense:" -- :917
ARMOR_COLON = ARMOR_COLON or "Armor:" -- :63
ATTACK_TOOLTIP = ATTACK_TOOLTIP or "Attack Rating" -- :75
DEFENSE_TOOLTIP = DEFENSE_TOOLTIP or "Defense Rating" -- :918
TRAINING_POINTS = TRAINING_POINTS or "Training Points:" -- :4305
UNIT_LEVEL_TEMPLATE = UNIT_LEVEL_TEMPLATE or "Level %d" -- :4436
PET_DIET_TEMPLATE = PET_DIET_TEMPLATE or "Diet: %s" -- :3037
NUM_PET_STATS = 5 -- (ref l.2)
NUM_PET_RESISTANCE_TYPES = 5 -- NUM_PET_RESISTANCE_TYPES (ref l.1)
-- ── The page ────────────────────────────────────────────────────────────────────────────
-- ref PetPaperDollFrame_OnLoad (l.4-29). The five labels live ONLY in the row template's
-- $parentLabel and Lua fills them — never a re-declared region in an instance (the sibling
-- page's OnLoad carries the why: this engine's template expansion APPENDS children, so a
-- re-declared $parentLabel makes a second anchorless FontString instead of merging).
-- `SetTextStatusBarTextPrefix(…, XP)` is dropped with the bar's numerals (header).
function PetPaperDollFrame_OnLoad()
local frame = this
getglobal("PetAttackFrameLabel"):SetText(ATTACK_COLON)
getglobal("PetDamageFrameLabel"):SetText(DAMAGE_COLON)
getglobal("PetAttackPowerFrameLabel"):SetText(ATTACK_POWER_COLON)
getglobal("PetDefenseFrameLabel"):SetText(DEFENSE_COLON)
getglobal("PetArmorFrameLabel"):SetText(ARMOR_COLON)
frame:RegisterEvent("PET_UI_UPDATE")
frame:RegisterEvent("PET_BAR_UPDATE")
frame:RegisterEvent("PET_UI_CLOSE")
frame:RegisterEvent("UNIT_PET")
frame:RegisterEvent("UNIT_PET_EXPERIENCE")
frame:RegisterEvent("UNIT_MODEL_CHANGED")
frame:RegisterEvent("UNIT_LEVEL")
frame:RegisterEvent("UNIT_RESISTANCES")
frame:RegisterEvent("UNIT_STATS")
frame:RegisterEvent("UNIT_DAMAGE")
frame:RegisterEvent("UNIT_RANGEDDAMAGE")
frame:RegisterEvent("UNIT_ATTACK_SPEED")
frame:RegisterEvent("UNIT_ATTACK_POWER")
frame:RegisterEvent("UNIT_RANGED_ATTACK_POWER")
frame:RegisterEvent("UNIT_DEFENSE")
frame:RegisterEvent("UNIT_ATTACK")
frame:RegisterEvent("UNIT_PET_TRAINING_POINTS")
PetTab_Update()
end
-- ref PetPaperDollFrame_OnEvent (l.31-48), all four arms. NOTE the shape: unlike the
-- character page, this one does NOT gate on visibility — the first arm has to run while the
-- page is HIDDEN, because that is what keeps the tab itself in step with whether a pet
-- exists. Only the last arm (a plain "the pet's numbers moved") is unit-token gated.
function PetPaperDollFrame_OnEvent()
local frame = this
if event == "PET_UI_UPDATE" or event == "PET_BAR_UPDATE"
or (event == "UNIT_PET" and arg1 == "player") then
-- The pet left while its own page was open: close the window (ToggleCharacter on
-- the visible page is the ref's own way of saying HideUIPanel), then re-fit the tab.
if frame:IsVisible() and not HasPetUI() then
ToggleCharacter("PetPaperDollFrame")
end
PetTab_Update()
PetPaperDollFrame_Update()
elseif event == "PET_UI_CLOSE" then
if frame:IsVisible() then
ToggleCharacter("PetPaperDollFrame")
end
PetTab_Update()
elseif event == "UNIT_PET_EXPERIENCE" then
PetExpBar_Update()
elseif arg1 == "pet" then
PetPaperDollFrame_Update()
end
end
-- ref PetPaperDollFrame_OnShow/_OnHide (l.50-60): the window's ONE name line is shared —
-- the pet page borrows it and puts the player's back on the way out.
function PetPaperDollFrame_OnShow()
getglobal("CharacterNameText"):Hide()
local petName = getglobal("PetNameText")
petName:Show()
petName:SetText(UnitName("pet"))
PetPaperDollFrame_Update()
end
function PetPaperDollFrame_OnHide()
getglobal("CharacterNameText"):Show()
getglobal("PetNameText"):Hide()
end
-- ref PetPaperDollFrame_Update (l.62-94). The `hasPetUI` early-out is load-bearing, not
-- defensive: every setter below reads the "pet" token, and with no pet those all answer the
-- absent shape — repainting from it would blank a page the ref leaves showing its last state
-- until the tab itself goes.
function PetPaperDollFrame_Update()
local hasPetUI, canGainXP = HasPetUI()
if not hasPetUI then return end
-- ref l.67 `PetModelFrame:SetUnit("pet")` — no analogue: the app's `petdoll` booth
-- resolves the pet itself (header).
--
-- The ref's own `if ( UnitCreatureFamily("pet") ) then` guard, kept (decision 1062):
-- nil is genuinely reachable — no family on the template, or the creature query that
-- carries the family id has not answered yet — and the reference's answer is a BLANK
-- level line rather than a bare "Level 58". Hoisted to a local because the ref calls
-- the binding twice for one answer.
local family = UnitCreatureFamily("pet")
if family then
getglobal("PetLevelText"):SetText(
format(UNIT_LEVEL_TEMPLATE, UnitLevel("pet")) .. " " .. family)
end
getglobal("PetLoyaltyText"):SetText(GetPetLoyalty())
PetExpBar_Update()
-- The two panes this page has to draw itself, for two DIFFERENT reasons (both below):
-- the resistance column genuinely differs from the character page's, while the stat
-- block is the same logic that only duplicates because PetStatFrame/CharacterStatFrame
-- share no prefix for a `prefix..` renderer to take.
PetPaperDollFrame_SetResistances()
PetPaperDollFrame_SetStats()
-- ...and then the shared rows, which are not this page's code at all: every one of
-- these is CharacterFrame.xml's own renderer, handed ("pet", "Pet") where the character
-- page hands it ("player", "Character"), so `prefix.."ArmorFrame"` resolves PetArmorFrame
-- here and CharacterArmorFrame there. That substitution IS the page (header comment).
-- Called top-to-bottom in the order the rows are anchored on screen — each renderer only
-- writes its own row's FontStrings from live API state, so nothing here reads anything
-- another one wrote and the order is free.
PaperDollFrame_SetAttackBothHands("pet", "Pet")
PaperDollFrame_SetAttackPower("pet", "Pet")
PaperDollFrame_SetDamage("pet", "Pet")
PaperDollFrame_SetDefense("pet", "Pet")
PaperDollFrame_SetArmor("pet", "Pet")
-- The pet sheet has no ranged rows at all, and these two say so themselves — both open
-- with the reference's own `if unit == "pet" then return end` (CharacterFrame.xml). They
-- are called anyway so that both pages drive the identical set of renderers and neither
-- can quietly fall behind the other.
PaperDollFrame_SetRangedDamage("pet", "Pet")
PaperDollFrame_SetRangedAttackPower("pet", "Pet")
-- The hunter-only block (ref l.83-93): the diet icon and the training-point line are
-- the two things a warlock's minion never has. `canGainXP` is HasPetUI's second return.
if canGainXP then
getglobal("PetPaperDollPetInfo"):Show()
local totalPoints, spent = GetPetTrainingPoints()
local text = getglobal("PetTrainingPointText")
text:SetText(totalPoints - spent)
text:Show()
getglobal("PetTrainingPointLabel"):Show()
else
getglobal("PetPaperDollPetInfo"):Hide()
getglobal("PetTrainingPointText"):Hide()
getglobal("PetTrainingPointLabel"):Hide()
end
end
-- ref PetPaperDollFrame_SetResistances (l.96-137). Deliberately NOT the character page's
-- version: the pet's has no resistance-quality subtext (no `RESISTANCE_TOOLTIP_SUBTEXT`
-- line at all) and colours the NUMBER off its own sign rather than off pos-vs-neg
-- magnitude — two real differences in the reference, kept rather than unified.
function PetPaperDollFrame_SetResistances()
for i = 1, NUM_PET_RESISTANCE_TYPES do
local resistId = BENILLA_MAGICRES_IDS[i]
local text = getglobal("PetMagicResText" .. i)
local frame = getglobal("PetMagicResFrame" .. i)
local base, resistance, positive, negative = UnitResistance("pet", resistId)
frame.tooltip = getglobal("RESISTANCE" .. resistId .. "_NAME")
-- Resistances can be negative: red below zero, green above, plain white at zero.
if resistance < 0 then
text:SetText(RED_FONT_COLOR_CODE .. resistance .. FONT_COLOR_CODE_CLOSE)
elseif resistance == 0 then
text:SetText(resistance)
else
text:SetText(GREEN_FONT_COLOR_CODE .. resistance .. FONT_COLOR_CODE_CLOSE)
end
if positive ~= 0 or negative ~= 0 then
frame.tooltip = frame.tooltip .. " ( " .. HIGHLIGHT_FONT_COLOR_CODE .. base
if positive > 0 then
frame.tooltip = frame.tooltip .. GREEN_FONT_COLOR_CODE .. " +" .. positive
end
if negative < 0 then
frame.tooltip = frame.tooltip .. " " .. RED_FONT_COLOR_CODE .. negative
end
frame.tooltip = frame.tooltip .. FONT_COLOR_CODE_CLOSE .. " )"
end
end
end
-- ref PetPaperDollFrame_SetStats (l.139-181) — byte-for-byte the character page's
-- _SetStats with the pet's own frame names and the "pet" token (the ref carries the same
-- duplication; unifying them would mean inventing a prefix the reference's own
-- `PetStatFrame`/`CharacterStatFrame` naming doesn't share).
function PetPaperDollFrame_SetStats()
for i = 1, NUM_PET_STATS do
local label = getglobal("PetStatFrame" .. i .. "Label")
local text = getglobal("PetStatFrame" .. i .. "StatText")
local frame = getglobal("PetStatFrame" .. i)
local statName = getglobal("SPELL_STAT" .. (i - 1) .. "_NAME")
label:SetText(statName .. ":")
local stat, effectiveStat, posBuff, negBuff = UnitStat("pet", i)
local tooltipText = HIGHLIGHT_FONT_COLOR_CODE .. statName .. " "
if posBuff == 0 and negBuff == 0 then
text:SetText(effectiveStat)
frame.tooltip = tooltipText .. effectiveStat .. FONT_COLOR_CODE_CLOSE
else
tooltipText = tooltipText .. effectiveStat
if posBuff > 0 or negBuff < 0 then
tooltipText = tooltipText .. " (" .. (stat - posBuff - negBuff) .. FONT_COLOR_CODE_CLOSE
end
if posBuff > 0 then
tooltipText = tooltipText .. FONT_COLOR_CODE_CLOSE .. GREEN_FONT_COLOR_CODE .. "+" .. posBuff .. FONT_COLOR_CODE_CLOSE
end
if negBuff < 0 then
tooltipText = tooltipText .. RED_FONT_COLOR_CODE .. " " .. negBuff .. FONT_COLOR_CODE_CLOSE
end
if posBuff > 0 or negBuff < 0 then
tooltipText = tooltipText .. HIGHLIGHT_FONT_COLOR_CODE .. ")" .. FONT_COLOR_CODE_CLOSE
end
frame.tooltip = tooltipText
if negBuff < 0 then
text:SetText(RED_FONT_COLOR_CODE .. effectiveStat .. FONT_COLOR_CODE_CLOSE)
else
text:SetText(GREEN_FONT_COLOR_CODE .. effectiveStat .. FONT_COLOR_CODE_CLOSE)
end
end
end
end
-- ref PetExpBar_Update (l.183-187). `min(0, currXP)` is the ref's own; XP can't be negative,
-- so it is just 0 — quoted, not simplified.
function PetExpBar_Update()
local currXP, nextXP = GetPetExperience()
local bar = getglobal("PetPaperDollFrameExpBar")
bar:SetMinMaxValues(math.min(0, currXP), nextXP)
bar:SetValue(currXP)
end
-- ref PetTab_Update (l.189-198) — the conditional tab, whole, and byte-for-byte the
-- reference's since the Reputation page took tab 3 (this file's header comment). The
-- re-anchor is what keeps the row gapless: with tab 2 hidden, tab 3 sits at tab 2's own LEFT
-- (i.e. exactly where tab 2 would have started) instead of -16 past its RIGHT. Only tab 3
-- is touched — tab 4 chains off tab 3, so the whole tail follows it.
function PetTab_Update()
local tab2 = getglobal("CharacterFrameTab2")
local tab3 = getglobal("CharacterFrameTab3")
-- Nil-guarded like CharacterFrame_ShowSubFrame's own loop: a test subset may load this
-- file without the window's tab row.
if not tab2 or not tab3 then return end
if not HasPetUI() then
tab2:Hide()
tab3:SetPoint("LEFT", "CharacterFrameTab2", "LEFT", 0, 0)
else
tab2:Show()
tab3:SetPoint("LEFT", "CharacterFrameTab2", "RIGHT", -16, 0)
end
end
]]></Script>
<Frame name="PetPaperDollFrame" enableMouse="true" parent="CharacterFrame" hidden="true" id="2">
<!-- The real 384×512 page (ref l.4, `setAllPoints="true"`). The explicit `<Size>` + TOPLEFT
`<Anchor>` is an exact equivalent of that here — CharacterFrame, the parent this frame
declares, is itself 384x512 (ref CharacterFrame.xml l.4-7) — and it is kept rather than
swapped because it is the geometry the page ships with, the SkillFrame.xml precedent. -->
<Size><AbsDimension x="384" y="512"/></Size>
<Anchors><Anchor point="TOPLEFT" relativeTo="CharacterFrame" relativePoint="TOPLEFT"/></Anchors>
<HitRectInsets><AbsInset left="0" right="30" top="0" bottom="75"/></HitRectInsets>
<Layers>
<!-- The quadrant background (ref l.9-57): the "General" top halves every
PaperDollInfoFrame page shares (they carry the portrait-ring cutout), plus this
page's own bottom pair. -->
<Layer level="BACKGROUND">
<Texture file="Interface\PaperDollInfoFrame\UI-Character-General-TopLeft">
<Size><AbsDimension x="256" y="256"/></Size>
<Anchors><Anchor point="TOPLEFT"><Offset><AbsDimension x="2" y="-1"/></Offset></Anchor></Anchors>
</Texture>
<Texture file="Interface\PaperDollInfoFrame\UI-Character-General-TopRight">
<Size><AbsDimension x="128" y="256"/></Size>
<Anchors><Anchor point="TOPRIGHT"><Offset><AbsDimension x="2" y="-1"/></Offset></Anchor></Anchors>
</Texture>
<Texture file="Interface\PetPaperDollFrame\UI-PetPaperDollFrame-BotLeft">
<Size><AbsDimension x="256" y="256"/></Size>
<Anchors><Anchor point="TOPLEFT"><Offset><AbsDimension x="2" y="-257"/></Offset></Anchor></Anchors>
</Texture>
<Texture file="Interface\PetPaperDollFrame\UI-PetPaperDollFrame-BotRight">
<Size><AbsDimension x="128" y="256"/></Size>
<Anchors><Anchor point="TOPLEFT"><Offset><AbsDimension x="258" y="-257"/></Offset></Anchor></Anchors>
</Texture>
<!-- The pet's name (ref l.58-69), sitting exactly where the window's own player
name line does — the two swap on this page's Show/Hide. -->
<FontString name="PetNameText" inherits="GameFontHighlight">
<Size><AbsDimension x="109" y="16"/></Size>
<Anchors><Anchor point="CENTER"><Offset><AbsDimension x="6" y="232"/></Offset></Anchor></Anchors>
</FontString>
<!-- "Level %d <Family>" (ref l.70-78) and the loyalty level under it (l.79-87). -->
<FontString name="PetLevelText" inherits="GameFontNormalSmall">
<Anchors>
<Anchor point="TOP" relativeTo="PetNameText" relativePoint="BOTTOM"><Offset><AbsDimension x="0" y="-6"/></Offset></Anchor>
</Anchors>
</FontString>
<FontString name="PetLoyaltyText" inherits="GameFontNormalSmall">
<Anchors>
<Anchor point="TOP" relativeTo="PetLevelText" relativePoint="BOTTOM"><Offset><AbsDimension x="0" y="-1"/></Offset></Anchor>
</Anchors>
</FontString>
<!-- Unspent training points (ref l.88-105) — shown for hunter pets only. -->
<FontString name="PetTrainingPointText" inherits="GameFontHighlightSmall">
<Anchors><Anchor point="BOTTOMRIGHT"><Offset><AbsDimension x="-135" y="86"/></Offset></Anchor></Anchors>
</FontString>
<FontString name="PetTrainingPointLabel" inherits="GameFontNormalSmall" text="TRAINING_POINTS">
<Anchors>
<Anchor point="RIGHT" relativeTo="PetTrainingPointText" relativePoint="LEFT"><Offset><AbsDimension x="-5" y="0"/></Offset></Anchor>
</Anchors>
</FontString>
</Layer>
</Layers>
<Frames>
<!-- The pet XP bar (ref l.109-176): 319×11 at BOTTOMLEFT (23,105), the two 160/159-wide
dwarf notch strips over it, the shared purple status-bar fill. The numerals are the
header's named deferral; everything else here is the ref's. -->
<!-- No `enableMouse`: the ref's bar takes the mouse only to show its numerals on hover
(l.166-172), and those are the deferral above — a mouse-enabled bar with nothing to
show would just eat clicks. It comes back with the numerals. -->
<StatusBar name="PetPaperDollFrameExpBar">
<Size><AbsDimension x="319" y="11"/></Size>
<Anchors><Anchor point="BOTTOMLEFT"><Offset><AbsDimension x="23" y="105"/></Offset></Anchor></Anchors>
<Layers>
<Layer level="OVERLAY">
<Texture name="PetPaperDollXPBar1" file="Interface\MainMenuBar\UI-MainMenuBar-Dwarf">
<Size><AbsDimension x="160" y="13"/></Size>
<Anchors><Anchor point="TOPLEFT"/></Anchors>
<TexCoords left="0.203125" right="0.8046875" top="0.2890625" bottom="0.33984375"/>
</Texture>
<Texture file="Interface\MainMenuBar\UI-MainMenuBar-Dwarf">
<Size><AbsDimension x="159" y="13"/></Size>
<Anchors><Anchor point="LEFT" relativeTo="PetPaperDollXPBar1" relativePoint="RIGHT"/></Anchors>
<TexCoords left="0.203125" right="0.8046875" top="0.2890625" bottom="0.33984375"/>
</Texture>
</Layer>
</Layers>
<BarTexture file="Interface\TargetingFrame\UI-StatusBar"/>
<BarColor r="0.58" g="0.0" b="0.55"/>
<Scripts>
<!-- The ref's OnEvent is the TextStatusBar CVar hook plus PetExpBar_Update; only
the latter has anything to do here, and the page's own UNIT_PET_EXPERIENCE
arm already drives it, so no per-bar registration ships. -->
<OnLoad>PetExpBar_Update()</OnLoad>
</Scripts>
</StatusBar>
<!-- The model pane (ref PlayerModel PetModelFrame, l.177-244): 318×224 at TOPLEFT
(25,-78) — wider than the character page's 233 because there is no equipment column
beside it. A plain Frame sampling the `petdoll` booth bake (header). -->
<Frame name="PetModelFrame">
<Size><AbsDimension x="318" y="224"/></Size>
<Anchors><Anchor point="TOPLEFT"><Offset><AbsDimension x="25" y="-78"/></Offset></Anchor></Anchors>
<Layers>
<!-- BACKGROUND, not ARTWORK — see decision 1070. The booth quad stands in for a
<PlayerModel> WIDGET, whose 3D content the reference renders outside the
layer batches entirely and with a transparent background; ours is one
OPAQUE texture inside them. The draw layer is BUCKET-WIDE and outranks the
frame (0884), so at ARTWORK this quad painted over every same-level sibling
region below OVERLAY — which is exactly how the diet icon two frames down
(BACKGROUND, the reference's own layer for it) became a mouse-enabled hole
with a working tooltip and nothing drawn (director, 2026-08-06). Sitting at
BACKGROUND, the pane's single region ties with such siblings and the link
stamp decides, restoring the reference's declaration order. A pane can only
ever stop covering things this way, never start. -->
<Layer level="BACKGROUND">
<Texture name="$parentTexture">
<Size><AbsDimension x="318" y="224"/></Size>
<Anchors><Anchor point="TOPLEFT"/></Anchors>
</Texture>
</Layer>
</Layers>
<Frames>
<!-- Rotate buttons (ref l.201-242): 35×35. NOTE the ref anchors the left one to
the PAGE, not to the pane (l.206) — (23,-76), two pixels up and left of the
pane's own corner — and the right one chains off its TOPRIGHT. -->
<Button name="PetModelFrameRotateLeftButton">
<Size><AbsDimension x="35" y="35"/></Size>
<Anchors><Anchor point="TOPLEFT" relativeTo="PetPaperDollFrame" relativePoint="TOPLEFT"><Offset><AbsDimension x="23" y="-76"/></Offset></Anchor></Anchors>
<NormalTexture file="Interface\Buttons\UI-RotationLeft-Button-Up"/>
<PushedTexture file="Interface\Buttons\UI-RotationLeft-Button-Down"/>
<HighlightTexture file="Interface\Buttons\ButtonHilight-Round" alphaMode="ADD"/>
<Scripts>
<OnLoad>self:RegisterForClicks("LeftButtonDown", "LeftButtonUp")</OnLoad>
<OnClick>BenillaPaperDollModel_RotateLeft(getglobal("PetModelFrame"))</OnClick>
</Scripts>
</Button>
<Button name="PetModelFrameRotateRightButton">
<Size><AbsDimension x="35" y="35"/></Size>
<Anchors><Anchor point="TOPLEFT" relativeTo="PetModelFrameRotateLeftButton" relativePoint="TOPRIGHT"/></Anchors>
<NormalTexture file="Interface\Buttons\UI-RotationRight-Button-Up"/>
<PushedTexture file="Interface\Buttons\UI-RotationRight-Button-Down"/>
<HighlightTexture file="Interface\Buttons\ButtonHilight-Round" alphaMode="ADD"/>
<Scripts>
<OnLoad>self:RegisterForClicks("LeftButtonDown", "LeftButtonUp")</OnLoad>
<OnClick>BenillaPaperDollModel_RotateRight(getglobal("PetModelFrame"))</OnClick>
</Scripts>
</Button>
</Frames>
<Scripts>
<!-- The four Model_* helpers are the sibling page's, shared: they drive
`frame.setFacing`, which each pane sets to its own booth's yaw binding
(decision 1057 — that generalization is what let this page reuse them
instead of growing a third copy of the held-spin loop). No OnMouseUp/
OnReceiveDrag here: the ref's pet pane is not a drop target (a pet wears
nothing), so it stays mouse-inert. -->
<OnLoad>
self.setFacing = BenillaPetPaperDollModel_SetFacing
BenillaPaperDollModel_OnLoad(self)
BenillaSetBoothTexture(getglobal(self:GetName() .. "Texture"), "petdoll")
</OnLoad>
<OnUpdate>BenillaPaperDollModel_OnUpdate(self, arg1)</OnUpdate>
</Scripts>
</Frame>
<!-- The diet icon (ref PetPaperDollPetInfo, l.245-275): 24×23 under the rotate buttons.
Its texcoords are STATIC in the reference — this icon is the pet's DIET affordance
(its tooltip lists what it eats), not a happiness readout; the happiness face that
does move lives on the pet unit frame (decision 1005/0990). Shown for hunter pets
only. -->
<Frame name="PetPaperDollPetInfo" enableMouse="true">
<Size><AbsDimension x="24" y="23"/></Size>
<Anchors>
<Anchor point="TOPLEFT" relativeTo="PetModelFrameRotateLeftButton" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="7" y="0"/></Offset></Anchor>
</Anchors>
<Layers>
<Layer level="BACKGROUND">
<Texture file="Interface\PetPaperDollFrame\UI-PetHappiness">
<Size><AbsDimension x="24" y="23"/></Size>
<Anchors><Anchor point="TOPLEFT"/></Anchors>
<TexCoords left="0" right="0.1875" top="0" bottom="0.359375"/>
</Texture>
</Layer>
</Layers>
<Scripts>
<OnLoad>self:EnableMouse(true)</OnLoad>
<OnEnter>
-- ref l.267-270, unguarded now that both halves exist (decision 1062):
-- `GetPetFoodTypes` is a real binding and `BuildListString` is the ref's
-- own UIParent.lua joiner, re-expressed in UIParent.xml.
--
-- `BuildListString()` returns NIL on an empty list and `format`'s "%s"
-- would error on it, so the pairing matters. Two things keep it safe, and
-- both are the reference's: this frame is shown for HUNTER pets only (the
-- `canGainXP` block above), and in the shipped `CreatureFamily.dbc` every
-- family with a ZERO food mask is a warlock minion or "Remote Control" —
-- every hunter-tameable family has a real diet. `GetPetFoodTypes` is
-- itself hunter-gated in the binary too (`0x6116e0`), so all three agree.
GameTooltip:SetOwner(self, "ANCHOR_RIGHT")
GameTooltip:SetText(format(PET_DIET_TEMPLATE, BuildListString(GetPetFoodTypes())))
GameTooltip:Show()
</OnEnter>
<OnLeave>GameTooltip:Hide()</OnLeave>
</Scripts>
</Frame>
<!-- The page's own Close button (ref PetPaperDollCloseButton, l.276-292) — the panel
button art/size/font inline, since no UIPanelButtonTemplate ships (header). -->
<Button name="PetPaperDollCloseButton" text="CLOSE">
<Size><AbsDimension x="80" y="22"/></Size>
<Anchors><Anchor point="CENTER" relativePoint="TOPLEFT"><Offset><AbsDimension x="305" y="-422"/></Offset></Anchor></Anchors>
<NormalFont inherits="GameFontNormal"/>
<HighlightFont inherits="GameFontHighlight"/>
<NormalTexture file="Interface\Buttons\UI-Panel-Button-Up"><TexCoords left="0" right="0.625" top="0" bottom="0.6875"/></NormalTexture>
<PushedTexture file="Interface\Buttons\UI-Panel-Button-Down"><TexCoords left="0" right="0.625" top="0" bottom="0.6875"/></PushedTexture>
<HighlightTexture file="Interface\Buttons\UI-Panel-Button-Highlight" alphaMode="ADD"><TexCoords left="0" right="0.625" top="0" bottom="0.6875"/></HighlightTexture>
<Scripts>
<!-- ref l.289: `HideUIPanel(this:GetParent():GetParent())` — the window, two up
from the button through the page. Named directly here: this engine has no
cross-file parenting, so the page is not really the window's child (header).
CLOSE is the ref's own literal `text="CLOSE"` attribute — the GlobalStrings
key, resolved by the loader's text lookup like every other one here. -->
<OnClick>HideUIPanel(getglobal("CharacterFrame"))</OnClick>
</Scripts>
</Button>
<!-- The attributes block (ref PetAttributesFrame, l.293-516): 230×78 at TOPLEFT
(67,-300), two 114×78 stat-background plates side by side. The pet's plate is the
SIMPLE two-column one — the character page's is three stacked 3-piece columns
because it also carries a ranged block, which a pet has none of. -->
<Frame name="PetAttributesFrame">
<Size><AbsDimension x="230" y="78"/></Size>
<Anchors><Anchor point="TOPLEFT"><Offset><AbsDimension x="67" y="-300"/></Offset></Anchor></Anchors>
<Layers>
<Layer level="BACKGROUND">
<Texture file="Interface\PaperDollInfoFrame\UI-Character-StatBackground">
<Size><AbsDimension x="114" y="78"/></Size>
<Anchors><Anchor point="TOPLEFT"/></Anchors>
<TexCoords left="0" right="0.89" top="0" bottom="0.61"/>
</Texture>
<Texture file="Interface\PaperDollInfoFrame\UI-Character-StatBackground">
<Size><AbsDimension x="114" y="78"/></Size>
<Anchors><Anchor point="TOPLEFT"><Offset><AbsDimension x="115" y="0"/></Offset></Anchor></Anchors>
<TexCoords left="0" right="0.89" top="0" bottom="0.61"/>
</Texture>
</Layer>
</Layers>
<Frames>
<!-- The 5 primaries (ref l.335-419): TOPLEFT (6,-5), chained BOTTOMLEFT with a
1px gap. Labels are the template's $parentLabel, filled by _SetStats. -->
<Frame name="PetStatFrame1" inherits="BenillaStatRowTemplate" id="1">
<Anchors><Anchor point="TOPLEFT"><Offset><AbsDimension x="6" y="-5"/></Offset></Anchor></Anchors>
<Scripts>
<OnLoad>self:EnableMouse(true)</OnLoad>
<OnEnter>PaperDollStatTooltip("pet", "STRENGTH")</OnEnter>
<OnLeave>GameTooltip:Hide()</OnLeave>
</Scripts>
</Frame>
<Frame name="PetStatFrame2" inherits="BenillaStatRowTemplate" id="2">
<Anchors><Anchor point="TOPLEFT" relativeTo="PetStatFrame1" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-1"/></Offset></Anchor></Anchors>
<Scripts>
<OnLoad>self:EnableMouse(true)</OnLoad>
<OnEnter>PaperDollStatTooltip("pet", "AGILITY")</OnEnter>
<OnLeave>GameTooltip:Hide()</OnLeave>
</Scripts>
</Frame>
<Frame name="PetStatFrame3" inherits="BenillaStatRowTemplate" id="3">
<Anchors><Anchor point="TOPLEFT" relativeTo="PetStatFrame2" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-1"/></Offset></Anchor></Anchors>
<Scripts>
<OnLoad>self:EnableMouse(true)</OnLoad>
<OnEnter>PaperDollStatTooltip("pet", "STAMINA")</OnEnter>
<OnLeave>GameTooltip:Hide()</OnLeave>
</Scripts>
</Frame>
<Frame name="PetStatFrame4" inherits="BenillaStatRowTemplate" id="4">
<Anchors><Anchor point="TOPLEFT" relativeTo="PetStatFrame3" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-1"/></Offset></Anchor></Anchors>
<Scripts>
<OnLoad>self:EnableMouse(true)</OnLoad>
<OnEnter>PaperDollStatTooltip("pet", "INTELLECT")</OnEnter>
<OnLeave>GameTooltip:Hide()</OnLeave>
</Scripts>
</Frame>
<Frame name="PetStatFrame5" inherits="BenillaStatRowTemplate" id="5">
<Anchors><Anchor point="TOPLEFT" relativeTo="PetStatFrame4" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-1"/></Offset></Anchor></Anchors>
<Scripts>
<OnLoad>self:EnableMouse(true)</OnLoad>
<OnEnter>PaperDollStatTooltip("pet", "SPIRIT")</OnEnter>
<OnLeave>GameTooltip:Hide()</OnLeave>
</Scripts>
</Frame>
<!-- The right-hand column (ref l.420-514): attack rating, attack power, damage,
defense, armor — the second plate's own chain, rooted at (122,-5) of the
attributes frame. -->
<Frame name="PetAttackFrame" inherits="BenillaStatRowTemplate">
<Anchors><Anchor point="TOPLEFT" relativeTo="PetAttributesFrame" relativePoint="TOPLEFT"><Offset><AbsDimension x="122" y="-5"/></Offset></Anchor></Anchors>
<Scripts>
<OnLoad>self:EnableMouse(true)</OnLoad>
<!-- ref l.429-433: the pet's attack row shows the BARE tooltip title, with
no ATTACK_TOOLTIP_SUBTEXT line — the character page's own row adds it.
Kept as the ref has it. -->
<OnEnter>
GameTooltip:SetOwner(self, "ANCHOR_RIGHT")
GameTooltip:SetText(ATTACK_TOOLTIP, 1.0, 1.0, 1.0)
GameTooltip:Show()
</OnEnter>
<OnLeave>GameTooltip:Hide()</OnLeave>
</Scripts>
</Frame>
<Frame name="PetAttackPowerFrame" inherits="BenillaStatRowTemplate">
<Anchors><Anchor point="TOPLEFT" relativeTo="PetAttackFrame" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-1"/></Offset></Anchor></Anchors>
<Scripts>
<OnLoad>self:EnableMouse(true)</OnLoad>
<OnEnter>
GameTooltip:SetOwner(self, "ANCHOR_RIGHT")
GameTooltip:SetText(self.tooltip)
GameTooltip:AddLine(self.tooltipSubtext, NORMAL_FONT_COLOR.r, NORMAL_FONT_COLOR.g, NORMAL_FONT_COLOR.b, 1)
GameTooltip:Show()
</OnEnter>
<OnLeave>GameTooltip:Hide()</OnLeave>
</Scripts>
</Frame>
<Frame name="PetDamageFrame" inherits="BenillaStatRowTemplate">
<Anchors><Anchor point="TOPLEFT" relativeTo="PetAttackPowerFrame" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-1"/></Offset></Anchor></Anchors>
<Scripts>
<OnLoad>self:EnableMouse(true)</OnLoad>
<!-- ref l.468-470: the pet's damage row reuses the CHARACTER damage
hover verbatim — it reads `self.damage`/`self.dps`, which
_SetDamage filled with the pet's numbers. -->
<OnEnter>CharacterDamageFrame_OnEnter()</OnEnter>
<OnLeave>GameTooltip:Hide()</OnLeave>
</Scripts>
</Frame>
<Frame name="PetDefenseFrame" inherits="BenillaStatRowTemplate">
<Anchors><Anchor point="TOPLEFT" relativeTo="PetDamageFrame" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-1"/></Offset></Anchor></Anchors>
<Scripts>
<OnLoad>self:EnableMouse(true)</OnLoad>
<OnEnter>
GameTooltip:SetOwner(self, "ANCHOR_RIGHT")
GameTooltip:SetText(DEFENSE_TOOLTIP, 1.0, 1.0, 1.0)
GameTooltip:Show()
</OnEnter>
<OnLeave>GameTooltip:Hide()</OnLeave>
</Scripts>
</Frame>
<Frame name="PetArmorFrame" inherits="BenillaStatRowTemplate">
<Anchors><Anchor point="TOPLEFT" relativeTo="PetDefenseFrame" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-1"/></Offset></Anchor></Anchors>
<Scripts>
<OnLoad>self:EnableMouse(true)</OnLoad>
<OnEnter>
GameTooltip:SetOwner(self, "ANCHOR_RIGHT")
GameTooltip:SetText(self.tooltip)
GameTooltip:AddLine(self.tooltipSubtext, NORMAL_FONT_COLOR.r, NORMAL_FONT_COLOR.g, NORMAL_FONT_COLOR.b, 1)
GameTooltip:Show()
</OnEnter>
<OnLeave>GameTooltip:Hide()</OnLeave>
</Scripts>
</Frame>
</Frames>
</Frame>
<!-- The resistance column (ref PetResistanceFrame, l.517-635): 32×160 whose RIGHT edge
sits 347px in from the page's left — 50px further right than the character page's
297, because the pet page's wider model pane pushes it out. The five icons carry
their own texcoords into the shared ResistanceIcons sheet, in the ref's own school
order (arcane/fire/nature/frost/shadow = BENILLA_MAGICRES_IDS). -->
<Frame name="PetResistanceFrame">
<Size><AbsDimension x="32" y="160"/></Size>
<Anchors>
<Anchor point="TOPRIGHT" relativeTo="PetPaperDollFrame" relativePoint="TOPLEFT"><Offset><AbsDimension x="347" y="-77"/></Offset></Anchor>
</Anchors>
<Frames>
<Frame name="PetMagicResFrame1" inherits="BenillaMagicResistanceFrameTemplate" id="6">
<Anchors><Anchor point="TOP" relativeTo="PetResistanceFrame" relativePoint="TOP"/></Anchors>
<Layers>
<Layer level="BACKGROUND">
<Texture file="Interface\PaperDollInfoFrame\UI-Character-ResistanceIcons">
<TexCoords left="0" right="1.0" top="0.2265625" bottom="0.33984375"/>
</Texture>
<FontString name="PetMagicResText1" inherits="GameFontHighlightSmall" text="X">
<Anchors><Anchor point="BOTTOM"><Offset><AbsDimension x="0" y="3"/></Offset></Anchor></Anchors>
</FontString>
</Layer>
</Layers>
</Frame>
<Frame name="PetMagicResFrame2" inherits="BenillaMagicResistanceFrameTemplate" id="2">
<Anchors><Anchor point="TOP" relativeTo="PetMagicResFrame1" relativePoint="BOTTOM"/></Anchors>
<Layers>
<Layer level="BACKGROUND">
<Texture file="Interface\PaperDollInfoFrame\UI-Character-ResistanceIcons">
<TexCoords left="0" right="1.0" top="0" bottom="0.11328125"/>
</Texture>
<FontString name="PetMagicResText2" inherits="GameFontHighlightSmall" text="X">
<Anchors><Anchor point="BOTTOM"><Offset><AbsDimension x="0" y="3"/></Offset></Anchor></Anchors>
</FontString>
</Layer>
</Layers>
</Frame>
<Frame name="PetMagicResFrame3" inherits="BenillaMagicResistanceFrameTemplate" id="3">
<Anchors><Anchor point="TOP" relativeTo="PetMagicResFrame2" relativePoint="BOTTOM"/></Anchors>
<Layers>
<Layer level="BACKGROUND">
<Texture file="Interface\PaperDollInfoFrame\UI-Character-ResistanceIcons">
<TexCoords left="0" right="1.0" top="0.11328125" bottom="0.2265625"/>
</Texture>
<FontString name="PetMagicResText3" inherits="GameFontHighlightSmall" text="X">
<Anchors><Anchor point="BOTTOM"><Offset><AbsDimension x="0" y="3"/></Offset></Anchor></Anchors>
</FontString>
</Layer>
</Layers>
</Frame>
<Frame name="PetMagicResFrame4" inherits="BenillaMagicResistanceFrameTemplate" id="4">
<Anchors><Anchor point="TOP" relativeTo="PetMagicResFrame3" relativePoint="BOTTOM"/></Anchors>
<Layers>
<Layer level="BACKGROUND">
<Texture file="Interface\PaperDollInfoFrame\UI-Character-ResistanceIcons">
<TexCoords left="0" right="1.0" top="0.33984375" bottom="0.453125"/>
</Texture>
<FontString name="PetMagicResText4" inherits="GameFontHighlightSmall" text="X">
<Anchors><Anchor point="BOTTOM"><Offset><AbsDimension x="0" y="3"/></Offset></Anchor></Anchors>
</FontString>
</Layer>
</Layers>
</Frame>
<Frame name="PetMagicResFrame5" inherits="BenillaMagicResistanceFrameTemplate" id="5">
<Anchors><Anchor point="TOP" relativeTo="PetMagicResFrame4" relativePoint="BOTTOM"/></Anchors>
<Layers>
<Layer level="BACKGROUND">
<Texture file="Interface\PaperDollInfoFrame\UI-Character-ResistanceIcons">
<TexCoords left="0" right="1.0" top="0.453125" bottom="0.56640625"/>
</Texture>
<FontString name="PetMagicResText5" inherits="GameFontHighlightSmall" text="X">
<Anchors><Anchor point="BOTTOM"><Offset><AbsDimension x="0" y="3"/></Offset></Anchor></Anchors>
</FontString>
</Layer>
</Layers>
</Frame>
</Frames>
</Frame>
</Frames>
<Scripts>
<OnLoad>PetPaperDollFrame_OnLoad()</OnLoad>
<OnEvent>PetPaperDollFrame_OnEvent()</OnEvent>
<OnShow>PetPaperDollFrame_OnShow()</OnShow>
<OnHide>PetPaperDollFrame_OnHide()</OnHide>
</Scripts>
</Frame>
</Ui>
+40 -64
View File
@@ -23,20 +23,22 @@
Named deferrals / deviations, each re-cited at its own site below:
- **The model pane is a booth-texture stand-in, not a live `<PlayerModel>`** — the same
0208 §5 decision the character and pet paper dolls already ship under. The reference
declares `<PlayerModel name="PetStableModel">` (xml l.182-280); ours is a plain
`<Frame>` whose one texture samples an app-side bake, so the widget's own script trio
goes with the widget: `Model_OnLoad`/`Model_OnUpdate` (xml l.194-203) and the
`DISPLAY_SIZE_CHANGED` → `this:RefreshUnit()` pair (xml l.196-200) have no analogue
here, exactly as `PetPaperDollFrame.xml` records for its own pane. `Model_RotateLeft`/
`Model_RotateRight` (xml l.222/241) become `BenillaPaperDollModel_RotateLeft`/`_Right`,
the shared helpers `CharacterFrame.xml` defines.
- The pane's booth is the `"stable"` slot in `crates/benilla-app/src/portrait`, and its yaw
binding is `BenillaStableModel_SetFacing`, the fourth beside the paperdoll/petdoll/
inspect three. The app resolves `GetSelectedStablePet()`'s slot into either the live
pet's entity or — for a stabled or dismissed pet, which has no world object at all —
the creature query's `CreatureDisplayInfo` id, and the booth mirrors whichever it got.
- **The model pane is the reference's own `<PlayerModel name="PetStableModel">`**
(xml l.182-280), and so are the four scripts on it: `Model_OnLoad`, `Model_OnUpdate`
and the two `Model_Rotate*` the buttons call (UIParent.xml). It was a plain `<Frame>`
sampling a booth bake through `BenillaSetBoothTexture`, with the rotate quartet borrowed
from our CharacterFrame.xml — both went when the character window migrated (decision
1751): the widget draws its own booth now, and the yaw the app mirrors is the pane's
own `SetRotation` state.
One thing the reference's pane does that ours still does not: `DISPLAY_SIZE_CHANGED` →
`this:RefreshUnit()` (xml l.196-200) is a no-op here, because the pane holds the unit
TOKEN and the app re-resolves it every frame — there is no cached appearance to
invalidate (`script/modelframe.rs`'s `RefreshUnit`).
- The pane's booth is the `"stable"` slot in `crates/benilla-app/src/portrait`, joined to
the pane by name in that module's `MODEL_PANE_BOOTHS`. The app resolves
`GetSelectedStablePet()`'s slot into either the live pet's entity or — for a stabled or
dismissed pet, which has no world object at all — the creature query's
`CreatureDisplayInfo` id, and the booth mirrors whichever it got.
- **`GetPetIcon()` does not exist in this client** (reference/1.12-globals.tsv has it as
an engine global; benilla has never needed it). `PetStable_Update` calls it at lua l.51
and l.161-162 and it is transcribed verbatim rather than guarded: the window's current
@@ -84,16 +86,6 @@
-- iterate 1..NUM_PET_STABLE_SLOTS and handle 0 on its own.
NUM_PET_STABLE_SLOTS = 2
-- ── The model pane's yaw ─────────────────────────────────────────────────────────────────
-- Every booth pane names its own engine yaw binding in `frame.setFacing`
-- (`BenillaPaperDollModel_SetFacing`, `BenillaPetPaperDollModel_SetFacing`,
-- `BenillaInspectModel_SetFacing`); this pane's is `BenillaStableModel_SetFacing`. The
-- named forwarder stays because `frame.setFacing` wants a plain function value and this
-- file's OnLoad is where the pane's own name for it belongs.
function BenillaPetStableModel_SetFacing(radians)
BenillaStableModel_SetFacing(radians)
end
-- ── The window ──────────────────────────────────────────────────────────────────────────
-- lua l.3-9, verbatim. UNIT_PET is here because a pet summoned or dismissed while the
-- window is open changes slot 0 without the server sending a stable list.
@@ -525,40 +517,29 @@
</Layers>
<Frames>
<!-- The model pane (ref `<PlayerModel name="PetStableModel">`, xml l.182-280): 313x223
at TOPLEFT (23,-76). A plain Frame sampling a booth bake, not a live 3D widget
(header, deferral 1) — so the reference's `Model_OnLoad`, its
`DISPLAY_SIZE_CHANGED`/`RefreshUnit` pair and its `Model_OnUpdate` all drop with
the widget, exactly as they do on the two paper dolls.
at TOPLEFT (23,-76), the reference's own widget type since decision 1751 gave
`<Model>`/`<PlayerModel>` panes a draw of their own (portrait/mod.rs's
`MODEL_PANE_BOOTHS`).
The `$parentTexture` this used to carry is gone with it, and so is decision 1070's
BACKGROUND-not-ARTWORK note: that workaround existed because a booth *texture* is
an opaque region in a BUCKET-WIDE draw layer that outranks the frame (0884), so at
ARTWORK it painted over the diet icon below. The pane's own draw slot has no such
problem — it emits at the FRAME's z, which is what puts every child above it, and
that is the reference's own layering rather than a tie we had to arrange.
The pane keeps its reference NAME because the Lua passes it by name to
`SetPetStablePaperdoll` at four sites, and an addon may too. -->
<Frame name="PetStableModel">
`SetPetStablePaperdoll` at four sites, an addon may too, and the app now joins the
booth to the pane by that name. -->
<PlayerModel name="PetStableModel">
<Size><AbsDimension x="313" y="223"/></Size>
<Anchors>
<Anchor point="TOPLEFT"><Offset><AbsDimension x="23" y="-76"/></Offset></Anchor>
</Anchors>
<Layers>
<!-- BACKGROUND, not ARTWORK — decision 1070, and PetPaperDollFrame.xml's own
pane carries the same note. The booth quad stands in for a widget whose 3D
content the reference renders outside the layer batches with a transparent
background; ours is one OPAQUE texture inside them, and the draw layer is
BUCKET-WIDE and outranks the frame (0884). At ARTWORK this quad would paint
over the diet icon below it, which is exactly the bug that pane hit. At
BACKGROUND the two tie and declaration order decides, which restores the
reference's own ordering. -->
<Layer level="BACKGROUND">
<Texture name="$parentTexture">
<Size><AbsDimension x="313" y="223"/></Size>
<Anchors><Anchor point="TOPLEFT"/></Anchors>
</Texture>
</Layer>
</Layers>
<Frames>
<!-- Rotate buttons (xml l.206-247): 35x35, the left one at the pane's own
TOPLEFT and the right one chained off its TOPRIGHT. The click helpers are
CharacterFrame.xml's shared `BenillaPaperDollModel_Rotate*` in place of the
reference's `Model_RotateLeft/Right`, and they drive `frame.setFacing`,
which the pane names for itself in OnLoad below. -->
TOPLEFT and the right one chained off its TOPRIGHT, calling the
reference's own `Model_RotateLeft`/`Model_RotateRight` (UIParent.xml). -->
<Button name="PetStableModelRotateLeftButton">
<Size><AbsDimension x="35" y="35"/></Size>
<Anchors>
@@ -569,7 +550,7 @@
<HighlightTexture file="Interface\Buttons\ButtonHilight-Round" alphaMode="ADD"/>
<Scripts>
<OnLoad>this:RegisterForClicks("LeftButtonDown", "LeftButtonUp")</OnLoad>
<OnClick>BenillaPaperDollModel_RotateLeft(getglobal("PetStableModel"))</OnClick>
<OnClick>Model_RotateLeft(this:GetParent())</OnClick>
</Scripts>
</Button>
<Button name="PetStableModelRotateRightButton">
@@ -582,7 +563,7 @@
<HighlightTexture file="Interface\Buttons\ButtonHilight-Round" alphaMode="ADD"/>
<Scripts>
<OnLoad>this:RegisterForClicks("LeftButtonDown", "LeftButtonUp")</OnLoad>
<OnClick>BenillaPaperDollModel_RotateRight(getglobal("PetStableModel"))</OnClick>
<OnClick>Model_RotateRight(this:GetParent())</OnClick>
</Scripts>
</Button>
<!-- The diet icon (xml l.248-278): 24x23 under the rotate buttons, its texcoords
@@ -621,19 +602,14 @@
</Frame>
</Frames>
<Scripts>
<!-- The pane's booth + its yaw. `BenillaPaperDollModel_OnLoad` seeds the
reference's own 0.61 default facing and pushes it through `setFacing`; the
OnUpdate is the shared held-button continuous spin. The booth token is
"stable" and the yaw goes through this file's named forwarder onto
`BenillaStableModel_SetFacing`. -->
<OnLoad>
this.setFacing = BenillaPetStableModel_SetFacing
BenillaPaperDollModel_OnLoad(this)
BenillaSetBoothTexture(getglobal(this:GetName() .. "Texture"), "stable")
</OnLoad>
<OnUpdate>BenillaPaperDollModel_OnUpdate(this, arg1)</OnUpdate>
<!-- The reference's own pair (xml l.194-203): `Model_OnLoad` seeds the 0.61
default facing through `SetRotation`, and `Model_OnUpdate` is the
held-button continuous spin. The app mirrors this pane's facing onto the
"stable" booth each frame (`ui_stable`). -->
<OnLoad>Model_OnLoad()</OnLoad>
<OnUpdate>Model_OnUpdate(arg1, this)</OnUpdate>
</Scripts>
</Frame>
</PlayerModel>
<!-- The three slots (xml l.281-333). `id=` is what `this:GetID()` reads in the
template's handlers, and it IS the binding's slot index: 0 is the pet at your side,
@@ -23,7 +23,7 @@
identical rect and is what the page was built and approved against, not a workaround for
something the engine cannot do.
`CharacterFrame.xml`'s `CharacterFrame_OnHide` hide-loop over
`BENILLA_CHARACTERFRAME_SUBFRAMES` is what closes it with the window; joining that table as
`CHARACTERFRAME_SUBFRAMES` is what closes it with the window; joining that table as
entry 3 is all this page has to do to get that.
THE TAB IS THE REFERENCE'S OWN SLOT 3 AGAIN. Until this page, `SkillFrame` sat at id 3 with
@@ -587,7 +587,7 @@
<!-- id=3, the reference's own (ref l.211): a page's id is the slot it occupies in the WINDOW'S
OWN tab row, and ToggleCharacter selects the tab from it (ref CharacterFrame.lua:11).
`character_tests.rs` pins every page's id against its slot in
BENILLA_CHARACTERFRAME_SUBFRAMES. -->
CHARACTERFRAME_SUBFRAMES. -->
<Frame name="ReputationFrame" enableMouse="true" parent="CharacterFrame" hidden="true" id="3">
<!-- The real 384x512 page. The explicit `<Size>` + TOPLEFT `<Anchor>` below is an exact
equivalent of the reference's `setAllPoints="true"` here — CharacterFrame, the parent
+2 -2
View File
@@ -25,7 +25,7 @@
paints ON TOP of it (later file = higher z, the established house convention every top-level
window already relies on implicitly). Because a true engine Hide-cascade doesn't reach a
non-child, `CharacterFrame.xml`'s own `CharacterFrame_OnHide` was extended (its own
header comment) to loop-hide every `BENILLA_CHARACTERFRAME_SUBFRAMES` entry — the fix this
header comment) to loop-hide every `CHARACTERFRAME_SUBFRAMES` entry — the fix this
page's non-nested shape requires, done once so later non-nested tabs (Pet/Reputation/Honor)
inherit it for free.
@@ -542,7 +542,7 @@
ToggleCharacter selects the tab from it (ref CharacterFrame.lua:11). This was 3 while
Reputation (the ref's 3) was not built; ReputationFrame.xml landed and took that slot, so
Skills is back on the reference's number. `character_tests.rs` pins every page's id against
its slot in BENILLA_CHARACTERFRAME_SUBFRAMES, which is what made the renumber loud rather
its slot in CHARACTERFRAME_SUBFRAMES, which is what made the renumber loud rather
than silent — exactly as this comment predicted. -->
<Frame name="SkillFrame" enableMouse="true" parent="CharacterFrame" hidden="true" id="4">
<!-- The real 384×512 page. No <Anchors relativeTo> substitute for true parenting exists
+36 -4
View File
@@ -2418,10 +2418,36 @@
pre-fit state). Its OnShow *also* sets the highlight width, which is dead code in the
reference and dropped here: this highlight is anchored LEFT/RIGHT, and two opposing anchors
resolve the size from the span without consulting the width at all (wow-re layout.md,
VERIFIED). The small tab now says the same thing the same way (1004). One deviation: the ref's OnLoad
SetFrameLevel(+4) raise (our loader has no cross-frame level raise wired into templates
yet; the tabs sit below the window art's hit-rect so nothing overlaps them). OnClick is
the instance's to define (the ref merchant overrides it too).
VERIFIED). The small tab now says the same thing the same way (1004).
**The OnLoad raise and the OnClick are the reference's own, and both were dropped here
until decision 1810.** The note that stood in their place said the raise was unnecessary —
"the tabs sit below the window art's hit-rect so nothing overlaps them" — and that "OnClick
is the instance's to define". Both were true of our own CharacterFrame.xml, and both are
false of the reference's, which is the file that runs now:
- the reference's `PaperDollFrame` is `setAllPoints enableMouse="true"` with a
`bottom=45` hit inset, and the reference's tabs are anchored INSIDE the window (CENTER
to BOTTOMLEFT +60,+62), so the page's hit rect covers the whole row. Frame levels come
out CharacterFrame 1 / PaperDollFrame 2 / tabs 2, later-created winning — the page eats
every tab click. `SetFrameLevel(+4)` is what lifts the row out, and is why the
reference has it.
- stock `CharacterFrame.xml` gives its five tabs only `<OnEnter>`/`<OnLeave>`; the
dispatch lives HERE. Without it `CharacterFrameTab4:GetScript("OnClick")` is nil and
the character window's tabs do nothing at all.
The body hardcodes `CharacterFrame`, which reads odd on a shared template until you check
the other consumers: the reference's `MerchantFrameTab1/2` and every one of ours
(Auction/Friends/Inspect/Mail/Talent) declares its own `<OnClick>`, and an instance's
handler REPLACES the inherited one rather than adding to it. So this reaches exactly the
window it names, which is the reference's own arrangement.
Still ours, and still why this template is not simply sourced off the chain:
`Interface\FrameXML\CharacterFrameTemplates.xml` fits its text in `<OnShow>` through
`PanelTemplates_TabResize(0)`, which needs the reference's SYNCHRONOUS string measure. Ours
lands a frame late, so the fit runs from `BenillaTabButton_OnUpdate` instead and an
OnShow-only fit would leave every tab at its pre-fit 115px. That divergence is 1004's, it
spans six windows, and it is what this template's own migration waits on.
-->
<Button name="CharacterFrameTabButtonTemplate" virtual="true">
<Size><AbsDimension x="115" y="32"/></Size>
@@ -2472,6 +2498,12 @@
</Anchors>
</HighlightTexture>
<Scripts>
<!-- ref CharacterFrameTemplates.xml:69-81, verbatim. -->
<OnLoad>this:SetFrameLevel(this:GetFrameLevel() + 4)</OnLoad>
<OnClick>
PanelTemplates_Tab_OnClick(CharacterFrame)
CharacterFrameTab_OnClick()
</OnClick>
<OnUpdate>BenillaTabButton_OnUpdate(self)</OnUpdate>
</Scripts>
</Button>
+53 -47
View File
@@ -74,26 +74,6 @@ Interface\FrameXML\Localization.xml
# words with `GetText("FACTION_STANDING_LABEL"..id, gender)` — and fourteen more call sites across
# FriendsFrame, StaticPopup and the quest windows are waiting behind it.
Interface\FrameXML\LocaleProperties.lua
# ── A reference file sourced ABOVE ours, so OUR bodies win every collision ─────────────────────
# This one is not a migrated window: it is here for one family of frame-agnostic globals that
# addons call by name, while the window that file also drives is still ours. So it loads first and
# our later files overwrite the names we own. (A migrated window is the other case, and sits at its
# window's own position — `Interface\FrameXML\ContainerFrame.xml` below is the first of them.)
#
# PaperDollFrame.lua earns its place for ONE family: `PaperDollItemSlotButton_OnLoad`/`_OnEvent`/
# `_OnClick`/`_Update`/`_UpdateLock`/`_OnEnter`/`_OnUpdate` — frame-agnostic in the same sense,
# acting on `this`/`this:GetID()` through live APIs and never on a named reference window.
# `PaperDollItemSlotButtonTemplate` and `BagSlotButtonTemplate` are declared over them
# (ItemSlotButtonTemplates.xml), and pfUI builds its bag bar from the latter.
#
# Its top level is four constants (NUM_RESISTANCE_TYPES, NUM_STATS, NUM_SHOPPING_TOOLTIPS,
# ATTACK_POWER_MAGIC_NUMBER) — nothing touches a frame at load, so sourcing it cannot raise. 18 of
# its 29 functions collide with ours (the PaperDollFrame_Set* stat family, the two damage-frame
# hovers, PaperDollFormatStat, PaperDollStatTooltip) and this position settles all of them the
# documented way: CharacterFrame.xml loads far below and its bodies win. Not one of the seven
# names we are here for is in that overlap, and our own doll slots keep calling
# `BenillaPaperDollSlot_*`, untouched.
Interface\FrameXML\PaperDollFrame.lua
# The reference's own second file, in its own position: TEXT/message/_ERRORMESSAGE, the
# ScriptErrors dialog, and the DialogBoxFrame + DialogButton*Texture kit it is built from.
# Straight after Fonts.xml because those are the only things it needs (GameFontRedLarge,
@@ -559,18 +539,52 @@ Interface\FrameXML\RaidWarning.xml
# (Cooldown.xml), `UIPanelCloseButton` (UIPanelTemplates.xml) — plus `parent="UIParent"`. It
# cannot load "first" the way a Lua-only sourcing had to.
Interface\FrameXML\ContainerFrame.xml
# The reference's item-SLOT button base, `PaperDollItemSlotButtonTemplate` — published for addons
# (pfUI builds its bag bar from what inherits it) and, since window 3, a LOAD-TIME dependency of the
# entry directly below: stock `MainMenuBarBagButtons.xml` inherits it on all five bag buttons, and
# an `inherits=` is resolved at load. That requirement is what moved this file up from below
# CharacterFrame.xml, where it sat only because the chain crossed the old BagFrame -> CharacterFrame
# the wrong way. It needs ItemButtonTemplate.xml above it for the base and Cooldown.xml for the
# $parentCooldown the doll template carries — both far above.
# ══ THE CHARACTER WINDOW — the reference's own, off the player's chain (decision 1751) ══
# `CharacterFrame` + its five-tab row and portrait/name header (CharacterFrame.xml), the paper doll
# itself — model pane, 19 equipment slots, the ammo slot, the five attributes, the melee and ranged
# stat blocks, the five resistances (PaperDollFrame.xml) — and the pet page over the same templates
# (PetPaperDollFrame.xml). Our `CharacterFrame.xml` (2027 lines) and `PetPaperDollFrame.xml` (773)
# stood here and are deleted.
#
# `BagSlotButtonTemplate` used to be declared here too, transcribed from MainMenuBarBagButtons.xml
# because that file could not be sourced. It is sourced now, one entry down, so the reference's own
# declaration is the live one and the copy is deleted — the whole point of the migration.
ItemSlotButtonTemplates.xml
# THREE FILES IN THE REFERENCE'S OWN ORDER, and every edge between them is resolved at LOAD:
# - CharacterFrame.xml declares `CharacterFrame`, which the other two name in `parent=` (1734).
# - PaperDollFrame.xml declares `StatFrameTemplate`, `AttributeFrameTemplate` and
# `MagicResistanceFrameTemplate`, which PetPaperDollFrame.xml inherits on 11 frames.
# - PaperDollFrame.xml also declares `PaperDollItemSlotButtonTemplate`, which stock
# `MainMenuBarBagButtons.xml` below inherits on all five bag buttons.
#
# THAT LAST EDGE IS WHY THE BLOCK SITS HERE rather than below the bag bar where ours did.
# `ItemSlotButtonTemplates.xml` held a transcribed copy of that template for exactly one reason:
# our CharacterFrame.xml loaded after the bag bar and could not declare it in time. The reference's
# own toc has no such problem (CharacterFrame 53, PaperDollFrame 54, MainMenuBarBagButtons 59), so
# the copy is deleted and the reference's declaration is the live one — the same retirement
# `BagSlotButtonTemplate` took one window earlier.
#
# `PaperDollFrame.lua` used to be a manifest line of its own, sourced far above everything so that
# our bodies would win its 18 collisions. It arrives through PaperDollFrame.xml's own
# `<Script file=>` now, at this position, and the collisions went with the file that had them.
#
# What the block needs from above, all far up: Fonts.xml (every `inherits=` font object),
# ItemButtonTemplate.xml (the slot template's base, and the `SetItemButton*` family),
# Cooldown.xml (`CooldownFrameTemplate` + `CooldownFrame_SetTimer`), GameTooltip.xml, UiPanels.xml
# (`CharacterFrameTabButtonTemplate`, `UIPanelWindows`), UIPanelTemplates.xml (`UIPanelCloseButton`,
# `UIPanelButtonTemplate`, and `PanelTemplates_SetNumTabs`, which `CharacterFrame_OnLoad` calls at
# LOAD), TextStatusBar.xml (`SetTextStatusBarTextPrefix`, called at load too, plus the
# `TextStatusBar` the pet page's XP bar inherits), UnitFrames.xml (`PlayerFrameHealthBar` /
# `PlayerFrameManaBar`, named in that same OnLoad), ActionBar.xml (`MainMenuExpBar`, likewise),
# MicroMenu.xml (`MicroButtonTooltipText`, the five tab hovers) and UIParent.xml
# (`Model_OnLoad`/`_RotateLeft`/`_RotateRight`/`_OnUpdate` — the model pane's turntable, the
# reference's own code living in our UIParent.lua counterpart).
#
# Two names it writes live BELOW it, and that is fine because both are runtime rather than load:
# `HonorLevelText` and `HonorGuildText` (HonorFrame.xml), which `PaperDollFrame_SetLevel` and
# `_SetGuild` fill "while we're at it" — reached only from OnShow and the UNIT_* events, long after
# that file has loaded.
Interface\FrameXML\CharacterFrame.xml
Interface\FrameXML\PaperDollFrame.xml
# Tab 2, the reference's file as well. `PetTab_Update` shows or hides `CharacterFrameTab2` off
# `HasPetUI()` and re-anchors tab 3 over the gap, so a classless character's row has no hole.
Interface\FrameXML\PetPaperDollFrame.xml
# ══ THE BAG BAR — the reference's own, off the player's chain (decision 1751, window 3) ══
# `MainMenuBarBackpackButton`, `CharacterBag0Slot`..`CharacterBag3Slot`, `KeyRingButton`,
# `BagSlotButtonTemplate` and the `BagSlotButton_*`/`BackpackButton_*`/`Disable_BagButtons` family
@@ -584,8 +598,10 @@ ItemSlotButtonTemplates.xml
#
# THE POSITION IS THE REQUIREMENT: `inherits="PaperDollItemSlotButtonTemplate"` on the four bag
# slots and `inherits="ItemButtonTemplate"` on the backpack are resolved at LOAD, and so is
# `parent="MainMenuBarArtFrame"` — so this sits below ItemSlotButtonTemplates.xml (above) and
# ActionBar.xml (which declares the art frame). Being BELOW the sourced ContainerFrame.xml is
# `parent="MainMenuBarArtFrame"` — so this sits below the character-window block (above, which is
# where stock `PaperDollFrame.xml` declares that template) and below ActionBar.xml (which declares
# the art frame). It sat below a transcribed copy of the template in `ItemSlotButtonTemplates.xml`
# until the character window migrated and made that copy redundant (decision 1810). Being BELOW the sourced ContainerFrame.xml is
# legibility, not necessity, in either direction: every verb each file calls of the other resolves
# at call time, `KEYRING_CONTAINER` included (no top-level read of it exists in ContainerFrame.lua).
#
@@ -605,17 +621,6 @@ Interface\FrameXML\StackSplitFrame.xml
# detour benilla answers differently (0765). Must load AFTER them, since it captures at load the
# body it wraps; its own header says what it stopped needing to do when the XML became sourceable.
ContainerFrameAdapters.xml
# The character window (decision 0208 phase 1a): the C-key paper doll. Loaded after BagFrame
# (needs nothing from it, but keeps player-state windows grouped) and before the NPC session
# windows (Gossip/Merchant/Quest*) — it depends only on Fonts/UiPanels/GameTooltip, all
# already loaded above.
CharacterFrame.xml
# The Pet tab (decision 1057): the same separate-top-level-file shape as SkillFrame below,
# and ahead of it because it is the earlier tab. It leans on CharacterFrame.xml for real —
# the `(unit, prefix)` stat setters, the `BenillaStatRow`/`BenillaMagicResistanceFrame`
# templates and the tab row it raises tab 2 of — all of which only exist once that file has
# loaded, so this order is a requirement here, not just tidiness.
PetPaperDollFrame.xml
# The Reputation tab: the same separate-top-level-file shape as the two pages around it, seated
# BETWEEN them because that is the real client's tab order (Character 1, Pet 2, Reputation 3,
# Skills 4 — ref CharacterFrame.xml:79-168), and the page's `id=` is that slot. Its hard
@@ -770,9 +775,10 @@ TaxiFrame.xml
# windows grouped" cluster beside Trainer/Bank/Taxi. Two of its dependencies are resolved at
# LOAD and so must sit above it: MoneyFrame (its purse and cost displays inherit
# SmallMoneyFrameTemplate) and UIPanelTemplates (UIPanelButtonTemplate / UIPanelCloseButton).
# A third is resolved at load too, and is the reason this cannot sit up with the other NPC
# windows' dependencies: the model pane's own <OnLoad> calls CharacterFrame.xml's shared
# `BenillaPaperDollModel_OnLoad`, so that file must already have run. The rest
# A third is resolved at load too: the model pane is a `<PlayerModel>` whose own <OnLoad> calls
# the reference's `Model_OnLoad` (UIParent.xml, far above). That used to be CharacterFrame.xml's
# `BenillaPaperDollModel_OnLoad`, which is why this entry sat below the character window; it no
# longer has to, and stays here only because the NPC-session cluster is where it belongs. The rest
# (ItemButtonTemplate's SetItemButtonTexture, GameTooltip, UiPanels' ShowUIPanel/HideUIPanel,
# UIParent's BuildListString) are resolved when the window repaints, so their order is free.
PetStable.xml
+38
View File
@@ -142,6 +142,44 @@ const PETDOLL_SLOT: &str = "petdoll";
/// [`StableBooth::unit`] at the pet entity ([`PETDOLL_SLOT`]'s case exactly), the cached one points
/// [`StableBooth::display_id`] at the display and the mirror comes off a [`PortraitStandIn`].
const STABLE_SLOT: &str = "stable";
/// **Which booth a `<Model>`/`<PlayerModel>` pane samples** — the one join between the reference's
/// own frame names and this module's body-bake slots.
///
/// The reference draws a body pane by rendering the unit into the widget live; benilla renders it
/// into an off-screen bake this module keeps *per window* (the doc above: framing, resolution and
/// yaw differ per pane, so they are separate booths). Which bake a pane samples is therefore a
/// fact about the window, not about the widget — and the pane's global name is what identifies the
/// window, because since decision 1751 that name is the reference's own.
///
/// This table replaces `BenillaSetBoothTexture(region, token)`, the Lua binding that used to carry
/// the same join: a file of ours put a Texture inside the pane and named the slot. A migrated
/// window's file is the reference's, so the join cannot live in Lua any more — and it should not,
/// because a stock addon creating a `<PlayerModel>` never called it either. A pane that is not in
/// 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.
const MODEL_PANE_BOOTHS: [(&str, &str); 5] = [
("CharacterModelFrame", PAPERDOLL_SLOT),
("PetModelFrame", PETDOLL_SLOT),
("BenillaInspectModelFrame", INSPECT_SLOT),
("PetStableModel", STABLE_SLOT),
("DressUpModel", dressup::DRESSUP_SLOT),
];
/// The booth a named model pane samples, or `None` for a pane no window has claimed.
pub(crate) fn model_pane_booth(name: &str) -> Option<&'static str> {
MODEL_PANE_BOOTHS
.iter()
.find(|(pane, _)| *pane == name)
.map(|(_, slot)| *slot)
}
/// World is layer 0, the UI quad pass layer 1; portraits sit on their own high layers so nothing in the
/// world leaks into a booth and vice-versa (one layer per slot: base, base+1, …).
const PORTRAIT_LAYER_BASE: usize = 2;
+16 -5
View File
@@ -27,9 +27,10 @@
//! `locked` (decision 0208 phase 1b — the doll twin of `ui_items::feed_containers`'s bag-slot
//! `.locked`), and its resolved `equip_slots` (`ui_items::find_equip_slot` over the item
//! template's `inventoryType` — the cursor arc's "fit rule", decision 0216/0218).
//! - **The paper-doll booth yaw**: the pane's `Model:SetRotation` transcription writes the VM-side
//! value ([`UiScript::paperdoll_yaw`]); the feed mirrors it onto [`PaperDollBooth`] so the booth
//! re-bakes at the new facing (decision 0208 §5).
//! - **The paper-doll booth yaw**: the stock pane's rotate buttons call the reference's own
//! `Model_RotateLeft`/`_RotateRight`, which write `CharacterModelFrame:SetRotation`; the feed
//! mirrors that pane's facing onto [`PaperDollBooth`] so the booth re-bakes at the new angle
//! (decision 0208 §5, off the pane itself since 1751).
//!
//! Events, fired on snapshot transitions (grouped by the ref's own registration set,
//! `PaperDollFrame.lua:14-28` — arg1 `"player"`): `UNIT_STATS`, `UNIT_RESISTANCES`, `UNIT_DAMAGE`,
@@ -505,6 +506,7 @@ pub(crate) fn unit_combat_stats(store: &ObjectStore) -> UnitCombatStats {
has_offhand: false,
has_wand: false,
main_weapon_skill: (0, 0),
offhand_weapon_skill: (0, 0),
ranged_weapon_skill: (0, 0),
defense_skill: (0, 0),
}
@@ -524,6 +526,7 @@ fn combat_stats(store: &ObjectStore, items: &mut Items, commands: &NetCommands)
.is_some_and(|t| t.class == ITEM_CLASS_WEAPON && t.subclass == SUBCLASS_WAND);
let main_skill = weapon_skill_id(store, items, commands, SLOT_MAIN_HAND);
let offhand_skill = weapon_skill_id(store, items, commands, SLOT_OFF_HAND);
let ranged_skill_id = slot_entry(store, items, SLOT_RANGED)
.and_then(|e| items.template(e, 0, commands))
.filter(|t| t.class == ITEM_CLASS_WEAPON)
@@ -533,6 +536,10 @@ fn combat_stats(store: &ObjectStore, items: &mut Items, commands: &NetCommands)
has_offhand,
has_wand,
main_weapon_skill: skill_pair(store, main_skill),
// `UnitAttackBothHands`'s SECOND pair (decision 1810) — the binding pushes one per hand,
// and `weapon_skill_id` already answers Unarmed for an empty or non-weapon off hand, which
// is what a shield or an empty hand reads as.
offhand_weapon_skill: skill_pair(store, offhand_skill),
ranged_weapon_skill: ranged_skill_id.map_or((0, 0), |id| skill_pair(store, id)),
// `UnitDefense`'s pair. Its repaint wire is `SKILL_LINES_CHANGED` (which the ref's
// `PaperDollFrame` registers, l.28, and `watch_skill_ups` already fires), NOT a
@@ -979,9 +986,13 @@ pub(crate) fn feed_char(
let Some(mut script) = script else {
return;
};
// The pane's Model:SetRotation transcription owns the yaw; the booth mirrors it (0208 §5).
// The pane owns the yaw and the booth mirrors it (0208 §5). Read off the widget's own
// `SetRotation` state since decision 1751 put the reference's `PaperDollFrame.xml` on the
// chain: the stock file declares a real `<PlayerModel name="CharacterModelFrame">` and drives
// it with `Model_OnLoad`/`Model_Rotate*`/`Model_OnUpdate`, where ours used to call a
// benilla-named `BenillaPaperDollModel_SetFacing` into a scalar on the VM.
// Write-if-different: an unconditional write would mark the booth changed every frame.
let yaw = script.paperdoll_yaw();
let yaw = script.model_pane_facing("CharacterModelFrame");
if booth.yaw != yaw {
booth.yaw = yaw;
}
+4 -4
View File
@@ -9,9 +9,9 @@
//! `PetPaperDollFrame_Update` calls `PaperDollFrame_SetDamage/_SetAttackPower/_SetArmor/…` with
//! `unit = "pet"` (ref `PetPaperDollFrame.lua:73-81`) and its own two setters read
//! `UnitStat("pet", i)` / `UnitResistance("pet", id)`. Nothing here is a parallel API.
//! - **Pointing the body booth**: [`PetDollBooth`]'s `unit` gets the resolved pet entity and its
//! `yaw` the VM-side value the pane's rotate buttons wrote
//! ([`UiScript::pet_paperdoll_yaw`]) — the `crate::ui_inspect` pane's arrangement exactly.
//! - **Pointing the body booth**: [`PetDollBooth`]'s `unit` gets the resolved pet entity, and its
//! `yaw` the facing the stock pane's rotate buttons wrote onto `PetModelFrame` itself
//! (`UiScript::model_pane_facing` — decision 1751).
//!
//! **Why a module of its own rather than more of [`crate::ui_pet_stats`]**, which already resolves
//! the same pet: these are the *shared* paper-doll surface (every value passes through a binding
@@ -62,7 +62,7 @@ fn feed_pet_doll(
// The pane's rotate buttons own the yaw; the booth mirrors it (the inspect pane's arrangement,
// decision 0631 §4). Written every frame, pet or no pet — a stale yaw would snap the model the
// moment one is summoned.
booth.yaw = script.pet_paperdoll_yaw();
booth.yaw = script.model_pane_facing("PetModelFrame");
let pet_guid = bar.spells.pet_guid;
let store = (pet_guid != 0).then(|| pet.store(pet_guid)).flatten();
@@ -1,17 +1,18 @@
//! The shipped **character window** driven end-to-end, engine-only (no Bevy): the real
//! `assets/ui/CharacterFrame.xml` loaded behind `Fonts.xml`/`UiPanels.xml`/`GameTooltip.xml` and fed
//! a synthetic player snapshot + combat-stats + one equipped item — mirroring `questlog_tests.rs`'s
//! harness for the paper-doll slice (decision 0208 phase 1a).
//! The shipped **character window** driven end-to-end, engine-only (no Bevy) — and since decision
//! 1751's character swap, "shipped" means the **reference's own**
//! `Interface\FrameXML\CharacterFrame.xml`, `PaperDollFrame.xml` and `PetPaperDollFrame.xml`, read
//! off the player's patch chain. Our `assets/ui/CharacterFrame.xml` and `PetPaperDollFrame.xml` are
//! deleted; [`super::test_ui::CHARACTER_UI`] is the load list, in `benilla.toc`'s own order, and
//! every test here opens with `wow_data_or_skip!()` because a chain entry needs the install.
//!
//! NOTE (session state, not a property of this file): at the time this test was written, the
//! `benilla` crate's non-test code (`ui_unit.rs`, `ui_script/mod.rs`'s `demo_unit_feed`) had not yet
//! been updated for `UnitState`'s new `race`/`class`/`class_file`/`race_file`/`sex` fields (a
//! concurrent, unrelated change landing in `benilla-ui/src/script/unit.rs` this same session) — so
//! `cargo test -p benilla` could not compile AT ALL, for reasons entirely outside this file. Every
//! assertion below was independently cross-verified against the real `benilla-ui` engine through a
//! throwaway external harness (a scratch Cargo project depending on `benilla-ui` by path) before
//! landing here, so its correctness doesn't rest on `cargo test -p benilla` having run — but that
//! command itself is still owed once the unrelated breakage clears, per this crate's own gates.
//! The fixtures are unchanged (a synthetic player snapshot + combat stats + one equipped item), but
//! the behaviour under them is the stock file's, not ours. Where the two differed the divergence is
//! named at the test that used to pin it, with the stock file and line that replaces it — the
//! per-window re-examination 1751 asks for, done in the open rather than silently.
//!
//! **Handlers are driven through the mouse, never called by name.** The reference's slot handlers
//! read `this` (`PaperDollItemSlotButton_OnClick(button, ignoreModifiers)` takes the MOUSE button
//! and gets the frame from `this`, `PaperDollFrame.lua:647`), and only the engine sets `this`.
use benilla_ui::script::{
ExtractedQuad, InvSlotView, InventorySlots, QuadContent, ScriptValue, SoundRequest, UiScript,
@@ -117,18 +118,32 @@ fn backpack_with_fitting_helm() -> benilla_ui::script::ContainerState {
}
}
/// The loader itself: every file the window depends on parses and materializes with no errors —
/// 59 frames (the container + tab + paper-doll page: 19 slots + ammo + 5 attribute rows + armor/
/// attack/damage/ranged rows + 5 resistance frames + the model pane + its 2 rotate buttons + chrome).
/// The loader itself: every file the window depends on parses and materializes with no errors, and
/// the two chain files this module owns each land their own frame count.
///
/// **A count is a fingerprint of a file, not a target** (decision 1800's closing note). These two
/// are the reference's — `CharacterFrame.xml`'s container + close button + name frame + five tabs,
/// and `PaperDollFrame.xml`'s 19 slots + ammo + their cooldown children + the stat/resistance rows
/// + the model pane and its two rotate buttons. They change only when the player's own file does.
#[test]
fn shipped_character_frame_loads_clean() {
let _data = benilla_formats::wow_data_or_skip!();
let s = UiScript::new().unwrap();
load_xml(&s, "Fonts.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "GameTooltip.xml");
load_xml(&s, "CharacterFrame.xml");
let mut counts = std::collections::HashMap::new();
for f in super::test_ui::CHARACTER_UI {
counts.insert(*f, super::test_ui::load_ui_strict(&s, f));
}
assert!(s.errors().is_empty(), "load errors: {:?}", s.errors());
assert_eq!(
counts.get("Interface\\FrameXML\\CharacterFrame.xml"),
Some(&8),
"the reference's own CharacterFrame.xml"
);
assert_eq!(
counts.get("Interface\\FrameXML\\PaperDollFrame.xml"),
Some(&75),
"the reference's own PaperDollFrame.xml"
);
}
/// The whole contract in one end-to-end drive: `ToggleCharacter` (the 'C' binding's entry point)
@@ -142,11 +157,9 @@ fn shipped_character_frame_drives_end_to_end() {
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");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "GameTooltip.xml");
load_xml(&s, "CharacterFrame.xml");
for f in super::test_ui::CHARACTER_UI {
super::test_ui::load_ui_strict(&s, f);
}
s.set_unit("player", Some(player_unit()));
s.set_player_combat_stats(Some(combat_stats()));
@@ -322,13 +335,15 @@ fn shipped_character_frame_drives_end_to_end() {
);
// Rotating the model pane advances the booth yaw from the ref's default 0.61 by +0.03/click and
// plays the rotate kit (ref UIParent.lua:1421-1442).
s.run("BenillaPaperDollModel_RotateRight(CharacterModelFrame)")
.unwrap();
// plays the rotate kit. The pane's own rotate button calls `Model_RotateRight(this:GetParent())`
// (stock `PaperDollFrame.xml:265`), the reference's shared turntable out of `UIParent.lua:1421`,
// declared in our `UIParent.xml` — our `BenillaPaperDollModel_*` pair is gone with the file
// that used it (decision 1751).
s.run("Model_RotateRight(CharacterModelFrame)").unwrap();
assert!(
(s.paperdoll_yaw() - 0.64).abs() < 0.001,
(s.model_pane_facing("CharacterModelFrame") - 0.64).abs() < 0.001,
"yaw = {}",
s.paperdoll_yaw()
s.model_pane_facing("CharacterModelFrame")
);
assert_eq!(
s.take_sounds(),
@@ -358,11 +373,9 @@ fn close_button_draws_above_the_paper_doll_page() {
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");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "GameTooltip.xml");
load_xml(&s, "CharacterFrame.xml");
for f in super::test_ui::CHARACTER_UI {
super::test_ui::load_ui_strict(&s, f);
}
s.set_unit("player", Some(player_unit()));
// The window must be SHOWN for extract() to emit quads (hidden="true" by default).
s.run(r#"ToggleCharacter("PaperDollFrame")"#).unwrap();
@@ -395,54 +408,100 @@ fn close_button_draws_above_the_paper_doll_page() {
);
}
/// Before the app's first player snapshot lands, `UnitRace`/`UnitClass` answer nil,nil (this
/// engine's own timing — unlike the real client's synchronous local data, this codebase's race/
/// class stream in). The level line's "?" placeholders (CharacterFrame.xml's own `SetLevel`
/// comment) keep `format()` from erroring outright, rather than the window failing to open at all.
/// **The level/race/class line, and the one event that repaints it.** `PaperDollFrame_SetLevel`
/// formats `PLAYER_LEVEL` ("Level %d %s %s", `GlobalStrings.lua:3075`) from `UnitLevel`/`UnitRace`/
/// `UnitClass` and writes it to BOTH `CharacterLevelText` and the honor page's `HonorLevelText`
/// "while we at it" (stock `PaperDollFrame.lua:100-103`); `UNIT_LEVEL` for `"player"` is the only
/// event that calls it again (`PaperDollFrame.lua:48-49`), and only while the page is visible
/// (`:42-44`).
///
/// **Retired divergence (decision 1751's character swap).** This test used to be
/// `level_line_survives_no_player_snapshot_yet`: it opened the window with NO player snapshot and
/// pinned `"Level 0 ? ?"`, because our deleted `assets/ui/CharacterFrame.xml` carried `or "?"`
/// placeholders around the two nils. The reference has no such guard — `PaperDollFrame.lua:101`
/// passes `UnitRace("player")` straight into `format`, which raises `bad argument #3 to 'format'`
/// on a nil — so on the stock file that state is not "survived", it is a hard error, and pinning
/// our softening would have been pinning the thing the swap deleted.
///
/// It is also **unreachable in the app**, which is why nothing replaces the guard rather than the
/// expectation moving to "it raises": `ui_unit::feed_units` pushes `set_unit("player", …)` earlier
/// in the same system than it fires `PLAYER_ENTERING_WORLD`, and that fire is gated on our avatar's
/// descriptor existing (decisions 1087/1094) — while the window can only be opened in-world at all.
/// So the state under test is the one below: a snapshot is always there first.
#[test]
fn level_line_survives_no_player_snapshot_yet() {
fn level_line_reads_the_snapshot_and_repaints_on_unit_level() {
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");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "GameTooltip.xml");
load_xml(&s, "CharacterFrame.xml");
for f in super::test_ui::CHARACTER_UI {
super::test_ui::load_ui_strict(&s, f);
}
s.set_unit("player", Some(player_unit()));
s.set_player_combat_stats(Some(combat_stats()));
// No set_unit("player", ...) call at all — UnitLevel/UnitRace/UnitClass answer their absent
// shapes (0 / nil,nil / nil,nil, `unit.rs`'s own contract).
s.run(r#"ToggleCharacter("PaperDollFrame")"#).unwrap();
assert!(s.errors().is_empty(), "open errors: {:?}", s.errors());
assert_eq!(
s.eval::<String>("return CharacterLevelText:GetText()")
.unwrap(),
"Level 0 ? ?"
"Level 12 Night Elf Warrior"
);
// The honor page's twin, written by the same call — the reason `HonorFrame.xml` is in
// CHARACTER_UI at all (its absence loads clean and raises on the first SHOW).
assert_eq!(
s.eval::<String>("return HonorLevelText:GetText()").unwrap(),
"Level 12 Night Elf Warrior"
);
// A stray extractor's frame-count sanity check: some frame renders (the window itself), no
// panics anywhere in the drive.
let quads: Vec<ExtractedQuad> = {
s.resolve();
s.extract()
};
assert!(!quads.is_empty());
// Ding: a new snapshot plus `UNIT_LEVEL` for the player, the app's own sequencing.
let mut leveled = player_unit();
leveled.level = 13;
s.set_unit("player", Some(leveled));
s.fire_event("UNIT_LEVEL", vec![ScriptValue::Str("player".into())]);
assert!(s.errors().is_empty(), "level-up errors: {:?}", s.errors());
assert_eq!(
s.eval::<String>("return CharacterLevelText:GetText()")
.unwrap(),
"Level 13 Night Elf Warrior",
"UNIT_LEVEL repaints the line (PaperDollFrame.lua:48-49)"
);
// …and it is really on screen, not merely in the FontString.
s.resolve();
let quads: Vec<ExtractedQuad> = s.extract();
assert!(
quads.iter().any(|q| matches!(&q.content,
QuadContent::Text { text: Some(t), .. } if t == "Level 13 Night Elf Warrior")),
"the level line renders"
);
}
/// Decision 0208 phase 1b, end to end: clicking an OCCUPIED doll slot picks the item up onto the
/// cursor (the SAME payload `GetCursorInfo`/`CursorHasItem` read) and dims the slot's icon
/// (`IsInventoryItemLocked` — the held-here derivation, no server round-trip). A second click on
/// the SAME slot cancels: the cursor empties and the dim clears.
///
/// **Driven through the mouse since decision 1751's character swap**, not `s.run("Handler(frame)")`:
/// the stock handler is `PaperDollItemSlotButton_OnClick(button, ignoreModifiers)`
/// (`PaperDollFrame.lua:647`), which takes the MOUSE button as its first argument and reads the
/// frame off `this` — and only the engine sets `this`. That also puts the template's own
/// `RegisterForClicks("LeftButtonUp", "RightButtonUp")` (`PaperDollFrame.lua:86`) and its
/// `<OnClick>PaperDollItemSlotButton_OnClick(arg1)</OnClick>` (`PaperDollFrame.xml:15-17`) under
/// test, which the old by-name call skipped.
///
/// **Retired divergence:** the dim used to be asserted as "darker than 0.5". The reference's exact
/// value is 0.5 — `PaperDollItemSlotButton_UpdateLock` calls
/// `SetItemButtonDesaturated(this, 1, 0.5, 0.5, 0.5)` (`PaperDollFrame.lua:729-737`), and
/// `ItemButtonTemplate.lua:61-82` both desaturates the icon and vertex-colours it with exactly
/// those numbers. So the assertion is the pair, `desaturated` included.
#[test]
fn clicking_an_occupied_doll_slot_picks_it_up_and_locks_it() {
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");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "GameTooltip.xml");
load_xml(&s, "CharacterFrame.xml");
for f in super::test_ui::CHARACTER_UI {
super::test_ui::load_ui_strict(&s, f);
}
s.set_unit("player", Some(player_unit()));
s.set_player_combat_stats(Some(combat_stats()));
s.set_inventory_slots(inventory_with_head_item());
@@ -455,8 +514,13 @@ fn clicking_an_occupied_doll_slot_picks_it_up_and_locks_it() {
assert!(!s.eval::<bool>("return IsInventoryItemLocked(1)").unwrap());
assert!(!s.eval::<bool>("return CursorHasItem()").unwrap());
s.run(r#"BenillaPaperDollSlot_OnClick(CharacterHeadSlot, "LeftButton")"#)
.unwrap();
super::test_ui::click(&mut s, "CharacterHeadSlot", "LeftButton");
// The repaint is one frame behind the pickup, and that is the reference's shape, not a
// convenience: `PickupInventoryItem` locks the slot and QUEUES `ITEM_LOCK_CHANGED(-1, id)`
// (`benilla-ui/src/script/cursor/doll.rs:78`, the one transition seam of decision 0216), and
// that event is what runs `PaperDollItemSlotButton_UpdateLock` (stock
// `PaperDollFrame.lua:601-604`). The app ticks every frame; a test has to say so.
s.tick(0.0);
assert!(s.errors().is_empty(), "click errors: {:?}", s.errors());
assert!(s.eval::<bool>("return CursorHasItem()").unwrap());
@@ -468,63 +532,91 @@ fn clicking_an_occupied_doll_slot_picks_it_up_and_locks_it() {
s.eval::<bool>("return IsInventoryItemLocked(1)").unwrap(),
"the picked slot locks"
);
// The icon vertex-dims (the bag slots' own convention, this file's own Update comment).
// The icon desaturates and vertex-dims to the reference's own 0.5 triple.
s.resolve();
let head_icon_dim = s.extract().iter().any(|q| {
matches!(&q.content, QuadContent::Texture { path: Some(p), color: Some(c), .. }
if p.contains("INV_Helmet_01") && c[0] < 0.5)
matches!(&q.content, QuadContent::Texture { path: Some(p), color: Some(c), desaturated, .. }
if p.contains("INV_Helmet_01") && *desaturated && c[0] == 0.5 && c[1] == 0.5 && c[2] == 0.5)
});
assert!(head_icon_dim, "the picked slot's icon dims");
// Clicking the SAME slot again cancels — mirrors the bag's own same-slot-cancel contract.
s.run(r#"BenillaPaperDollSlot_OnClick(CharacterHeadSlot, "LeftButton")"#)
.unwrap();
super::test_ui::click(&mut s, "CharacterHeadSlot", "LeftButton");
s.tick(0.0);
assert!(!s.eval::<bool>("return CursorHasItem()").unwrap());
assert!(!s.eval::<bool>("return IsInventoryItemLocked(1)").unwrap());
s.resolve();
let head_icon_lit = s.extract().iter().any(|q| {
matches!(&q.content, QuadContent::Texture { path: Some(p), color: Some(c), desaturated, .. }
if p.contains("INV_Helmet_01") && !*desaturated && c[0] == 1.0)
});
assert!(head_icon_lit, "cancelling clears the dim");
}
/// Decision 0208 phase 1b's `CURSOR_UPDATE` highlight: holding a BAG item that fits ONLY the
/// head slot locks the head slot's highlight and leaves every other slot unlocked (the neck slot
/// checked here) — the wiring test for `BenillaPaperDollSlot_LockHighlight`/`_UnlockHighlight`
/// (a Lua-level spy, since no `GetVertexColor` getter exists to read the emulated tint back —
/// this file's own header comment on the emulation).
/// head slot lights the head slot's highlight ring and leaves every other slot dark (the neck slot
/// checked here). Stock `PaperDollItemSlotButton_OnEvent`'s `CURSOR_UPDATE` arm is
/// `if ( CursorCanGoInSlot(this:GetID()) ) then this:LockHighlight() else this:UnlockHighlight()`
/// (`PaperDollFrame.lua:609-616`).
///
/// **Retired divergence (decision 1751's character swap).** This used to spy on a pair of our own
/// `BenillaPaperDollSlot_LockHighlight`/`_UnlockHighlight` wrappers, which vertex-tinted the
/// Quickslot2 ring because the engine had no `LockHighlight`. It has both verbs now
/// (`benilla-ui/src/script/button.rs`), the stock file calls them, and the emulation is gone with
/// the file that held it — so the assertion is the real locked-highlight state, read where a player
/// reads it: `ItemButtonTemplate`'s own `HighlightTexture`
/// (`Interface\Buttons\ButtonHilight-Square`, `ItemButtonTemplate.xml:44`) is emitted for a locked
/// button and not for an unlocked, unhovered one (`ButtonState::region_visible`). The mouse is
/// parked clear of the doll first so nothing is hovered and the lock is the only thing that can
/// light a ring.
#[test]
fn cursor_update_highlights_fitting_doll_slots_while_holding_a_bag_item() {
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");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "GameTooltip.xml");
load_xml(&s, "CharacterFrame.xml");
for f in super::test_ui::CHARACTER_UI {
super::test_ui::load_ui_strict(&s, f);
}
s.set_unit("player", Some(player_unit()));
s.set_player_combat_stats(Some(combat_stats()));
// The window must be SHOWN for extract() to emit the slots' quads at all.
s.run(r#"ToggleCharacter("PaperDollFrame")"#).unwrap();
super::test_ui::unhover(&mut s);
s.resolve();
s.run(
r#"
lockHighlighted, unlockHighlighted = {}, {}
local origLock, origUnlock = BenillaPaperDollSlot_LockHighlight, BenillaPaperDollSlot_UnlockHighlight
BenillaPaperDollSlot_LockHighlight = function(b) lockHighlighted[b:GetName()] = true; origLock(b) end
BenillaPaperDollSlot_UnlockHighlight = function(b) unlockHighlighted[b:GetName()] = true; origUnlock(b) end
"#,
)
.unwrap();
/// Is `slot`'s highlight ring in the render list? (`None` for "no such owner drew anything".)
fn ring_lit(s: &mut UiScript, slot: &str) -> bool {
s.resolve();
let quads = s.extract();
quads.iter().any(|q| {
s.quad_owner_name(q.target).as_deref() == Some(slot)
&& matches!(&q.content,
QuadContent::Texture { path: Some(p), .. } if p.contains("ButtonHilight-Square"))
})
}
assert!(
!ring_lit(&mut s, "CharacterHeadSlot"),
"nothing is held yet, so no ring is lit"
);
s.set_container(0, Some(backpack_with_fitting_helm()));
s.run("PickupContainerItem(0, 1)").unwrap();
s.tick(0.0); // dispatches the queued CURSOR_UPDATE to every registered doll slot
assert!(s.errors().is_empty(), "{:?}", s.errors());
// The engine's own predicate agrees with the fixture's `equip_slots: vec![1]` — the input the
// reference's arm branches on, pinned beside the effect so a wrong answer here can't read as a
// wiring failure there.
assert!(s.eval::<bool>("return CursorCanGoInSlot(1)").unwrap());
assert!(!s.eval::<bool>("return CursorCanGoInSlot(2)").unwrap());
assert!(
s.eval::<bool>("return lockHighlighted['CharacterHeadSlot'] == true")
.unwrap(),
ring_lit(&mut s, "CharacterHeadSlot"),
"the fitting slot locks its highlight"
);
assert!(
s.eval::<bool>("return unlockHighlighted['CharacterNeckSlot'] == true")
.unwrap(),
!ring_lit(&mut s, "CharacterNeckSlot"),
"a non-fitting slot stays unhighlighted"
);
}
@@ -537,11 +629,9 @@ fn model_pane_click_auto_equips_a_held_bag_item() {
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");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "GameTooltip.xml");
load_xml(&s, "CharacterFrame.xml");
for f in super::test_ui::CHARACTER_UI {
super::test_ui::load_ui_strict(&s, f);
}
s.set_unit("player", Some(player_unit()));
s.set_player_combat_stats(Some(combat_stats()));
s.run(r#"ToggleCharacter("PaperDollFrame")"#).unwrap();
@@ -567,19 +657,33 @@ fn model_pane_click_auto_equips_a_held_bag_item() {
assert_eq!(s.take_container_autoequips(), vec![(0, 1)]);
}
/// A BROKEN equipped item (durability 0) paints its doll slot red — the icon AND the slot ring,
/// both the ref's 0.9,0,0 (PaperDollItemSlotButton_Update l.670-676; director-directed with the
/// armor guy). Repairing it restores both to white.
/// A BROKEN equipped item (durability 0) paints its doll slot's **ring** red — the reference's own
/// 0.9,0,0 on that slot's `NormalTexture` (`Interface\Buttons\UI-Quickslot2`,
/// `ItemButtonTemplate.xml:31`). Repairing it restores the ring to white, and no neighbouring slot
/// is ever tinted. Director-directed with the armor guy.
///
/// **Retired divergence (decision 1751's character swap): the ICON does not go red, and in the
/// reference it never did.** Stock `PaperDollItemSlotButton_Update` really does call BOTH setters
/// on a broken item — `SetItemButtonTextureVertexColor(this, 0.9, 0, 0)` and
/// `SetItemButtonNormalTextureVertexColor(this, 0.9, 0, 0)` (`PaperDollFrame.lua:670-672`) — and
/// `ItemButtonTemplate.lua` paints `$parentIconTexture` from the first and `$parentNormalTexture`
/// from the second (`:53-59` / `:84-90`). But the same `Update` ends with
/// `PaperDollItemSlotButton_UpdateLock()` (`:714`), whose unlocked arm is
/// `SetItemButtonDesaturated(this, nil)` (`:735`), and that function's `if ( not desaturated )`
/// branch overwrites `r,g,b` with 1,1,1 and re-`SetVertexColor`s **the icon**
/// (`ItemButtonTemplate.lua:71-81`). Forty-four lines later in the same call, the icon's red is
/// gone; only the ring survives. Our deleted `assets/ui/CharacterFrame.xml` painted both and made
/// them stick, which is why this test asserted the icon — that half is the transcription's, not the
/// reference's, and it goes with the file. Verified on a real run: the head slot's `INV_Helmet_01`
/// quad extracts white while its `UI-Quickslot2` quad extracts [0.9, 0, 0, 1].
#[test]
fn broken_equipped_item_tints_its_doll_slot_red() {
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");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "GameTooltip.xml");
load_xml(&s, "CharacterFrame.xml");
for f in super::test_ui::CHARACTER_UI {
super::test_ui::load_ui_strict(&s, f);
}
s.set_unit("player", Some(player_unit()));
s.set_player_combat_stats(Some(combat_stats()));
@@ -593,32 +697,42 @@ fn broken_equipped_item_tints_its_doll_slot_red() {
);
assert!(s.errors().is_empty(), "update errors: {:?}", s.errors());
s.resolve();
let color_of = |s: &mut UiScript, needle: &str| {
s.extract().iter().find_map(|q| match &q.content {
/// The colour of `slot`'s own quad whose texture path contains `needle` — asked per OWNER, so
/// the nineteen slots that share the ring art can be told apart.
fn slot_color(s: &mut UiScript, slot: &str, needle: &str) -> Option<[f32; 4]> {
s.resolve();
let quads = s.extract();
quads.iter().find_map(|q| match &q.content {
QuadContent::Texture {
path: Some(p),
color,
..
} if p.contains(needle) => Some(color.unwrap_or([1.0, 1.0, 1.0, 1.0])),
} if p.contains(needle) && s.quad_owner_name(q.target).as_deref() == Some(slot) => {
Some(color.unwrap_or([1.0, 1.0, 1.0, 1.0]))
}
_ => None,
})
};
}
let red = [0.9, 0.0, 0.0, 1.0];
let white = [1.0, 1.0, 1.0, 1.0];
assert_eq!(
color_of(&mut s, "INV_Helmet_01"),
slot_color(&mut s, "CharacterHeadSlot", "Quickslot2"),
Some(red),
"the broken helm's icon paints red"
"the broken slot's ring paints red"
);
assert_eq!(
slot_color(&mut s, "CharacterNeckSlot", "Quickslot2"),
Some(white),
"…and only that slot's — its neighbours share the art and rest white"
);
// The icon stays white: see the retired divergence in this test's doc comment.
assert_eq!(
slot_color(&mut s, "CharacterHeadSlot", "INV_Helmet_01"),
Some(white),
"the icon's red is overwritten by UpdateLock's SetItemButtonDesaturated(this, nil)"
);
// The head slot's ring (its NormalTexture Quickslot2 art) paints red too. Other doll slots
// share the art but rest white — assert the red one exists among them.
let ring_red = s.extract().iter().any(|q| {
matches!(&q.content,
QuadContent::Texture { path: Some(p), color: Some(c), .. }
if p.contains("Quickslot2") && (c[0] - 0.9).abs() < 1e-5 && c[1] == 0.0)
});
assert!(ring_red, "the broken slot's ring paints red");
// Repaired: both restore to white.
// Repaired: the ring restores to white.
let mut inv = inventory_with_head_item();
inv[1].as_mut().unwrap().durability = Some((40, 40));
s.set_inventory_slots(inv);
@@ -628,16 +742,10 @@ fn broken_equipped_item_tints_its_doll_slot_red() {
);
s.resolve();
assert_eq!(
color_of(&mut s, "INV_Helmet_01"),
Some([1.0, 1.0, 1.0, 1.0]),
"repair restores the icon"
slot_color(&mut s, "CharacterHeadSlot", "Quickslot2"),
Some(white),
"repair restores the ring"
);
let ring_red = s.extract().iter().any(|q| {
matches!(&q.content,
QuadContent::Texture { path: Some(p), color: Some(c), .. }
if p.contains("Quickslot2") && (c[0] - 0.9).abs() < 1e-5 && c[1] == 0.0)
});
assert!(!ring_red, "repair restores the ring");
assert!(s.errors().is_empty(), "errors: {:?}", s.errors());
}
@@ -654,17 +762,16 @@ fn tab_round_trip_with_a_selected_skill_by_point() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Fonts.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "GameTooltip.xml");
load_xml(&s, "ScrollTemplates.xml");
// Not optional, though this list ran without it: a MISSING template is a loader *warning*, not
// an error, so an under-loaded list passes load_xml's assert and then fails later on geometry
// that silently never got built. SkillDetailScrollFrame inherits UIPanelScrollFrameTemplate.
load_xml(&s, "UIPanelTemplates.xml");
load_xml(&s, "CharacterFrame.xml");
load_xml(&s, "SkillFrame.xml");
for f in super::test_ui::CHARACTER_UI {
super::test_ui::load_ui_strict(&s, f);
}
// `ScrollTemplates.xml` and `SkillFrame.xml` used to be loaded again here, because the old
// per-file preamble did not carry them and a MISSING template is a loader *warning*, not an
// error (`SkillDetailScrollFrame` inherits `UIPanelScrollFrameTemplate`, so it would have
// loaded clean with no trough and failed later on geometry that silently never got built).
// `CHARACTER_UI` carries both now — `CharacterFrame_ShowSubFrame` hides all five pages by name
// on the first `ToggleCharacter` — and `load_ui_strict` fails on the warning, so a second load
// would only re-declare frames the list already built.
s.set_unit("player", Some(player_unit()));
s.set_skills(SkillsState {
entries: vec![
@@ -744,8 +851,8 @@ fn tab_round_trip_with_a_selected_skill_by_point() {
assert!(shown(&mut s, "PaperDollFrame"), "opens on the doll page");
// Tab to Skills — by point, the director's gesture.
let tab2 = text_center(&mut s, "Skills");
click(&mut s, tab2);
let skills_tab = text_center(&mut s, "Skills");
click(&mut s, skills_tab);
assert!(shown(&mut s, "SkillFrame"), "Skills tab shows the page");
assert!(!shown(&mut s, "PaperDollFrame"), "doll page yields");
@@ -808,13 +915,13 @@ fn tab_round_trip_with_a_selected_skill_by_point() {
click(&mut s, row);
// Tab BACK to Character — the reported failure.
let tab1 = text_center(&mut s, "Character");
let character_tab = text_center(&mut s, "Character");
assert_eq!(
s.hit_test_name(tab1.0, tab1.1).as_deref(),
s.hit_test_name(character_tab.0, character_tab.1).as_deref(),
Some("CharacterFrameTab1"),
"the Character tab OWNS its point while Skills is up (the wheel catcher must not)"
);
click(&mut s, tab1);
click(&mut s, character_tab);
assert!(
shown(&mut s, "PaperDollFrame"),
"the Character tab switches back (the 2026-07-17 report)"
@@ -822,11 +929,11 @@ fn tab_round_trip_with_a_selected_skill_by_point() {
assert!(!shown(&mut s, "SkillFrame"), "Skills page yields");
// Once more around — "flaky" only shows up on repetition.
let tab2 = text_center(&mut s, "Skills");
click(&mut s, tab2);
let skills_tab = text_center(&mut s, "Skills");
click(&mut s, skills_tab);
assert!(shown(&mut s, "SkillFrame"), "second trip to Skills");
let tab1 = text_center(&mut s, "Character");
click(&mut s, tab1);
let character_tab = text_center(&mut s, "Character");
click(&mut s, character_tab);
assert!(shown(&mut s, "PaperDollFrame"), "second trip back");
assert!(
@@ -853,13 +960,9 @@ fn rotate_arrows_tap_twice_and_spin_while_held() {
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");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "GameTooltip.xml");
// ROTATIONS_PER_SECOND lives here (the ref's UIParent.lua:2), like it does in the real client.
load_xml(&s, "UIParent.xml");
load_xml(&s, "CharacterFrame.xml");
for f in super::test_ui::CHARACTER_UI {
super::test_ui::load_ui_strict(&s, f);
}
s.set_unit("player", Some(player_unit()));
s.set_player_combat_stats(Some(combat_stats()));
s.run(r#"ToggleCharacter("PaperDollFrame")"#).unwrap();
@@ -878,7 +981,7 @@ fn rotate_arrows_tap_twice_and_spin_while_held() {
// Press and HOLD. The press edge is itself a click (−0.03 off the 0.61 default).
s.mouse_move(bx, by);
s.mouse_button(bx, by, "LeftButton", true);
let pressed = s.paperdoll_yaw();
let pressed = s.model_pane_facing("CharacterModelFrame");
assert!(
(pressed - 0.58).abs() < 1e-4,
"the press edge nudges once: {pressed}"
@@ -887,7 +990,7 @@ fn rotate_arrows_tap_twice_and_spin_while_held() {
// Held for half a second: half of half a turn = +π/2 (the held-LEFT branch ADDS).
s.tick(0.5);
assert!(s.errors().is_empty(), "OnUpdate errors: {:?}", s.errors());
let spun = s.paperdoll_yaw();
let spun = s.model_pane_facing("CharacterModelFrame");
assert!(
(spun - (pressed + std::f32::consts::FRAC_PI_2)).abs() < 1e-3,
"half a second held spins half of half a turn: {pressed} → {spun}"
@@ -895,14 +998,14 @@ fn rotate_arrows_tap_twice_and_spin_while_held() {
// Release: the second click edge (another −0.03), and the spin stops dead.
s.mouse_button(bx, by, "LeftButton", false);
let released = s.paperdoll_yaw();
let released = s.model_pane_facing("CharacterModelFrame");
assert!(
(released - (spun - 0.03)).abs() < 1e-4,
"the release edge nudges again: {spun} → {released}"
);
s.tick(0.5);
assert!(
(s.paperdoll_yaw() - released).abs() < 1e-6,
(s.model_pane_facing("CharacterModelFrame") - released).abs() < 1e-6,
"a released button does not keep spinning"
);
@@ -917,9 +1020,9 @@ fn rotate_arrows_tap_twice_and_spin_while_held() {
};
s.mouse_move(rx, ry);
s.mouse_button(rx, ry, "LeftButton", true);
let before = s.paperdoll_yaw();
let before = s.model_pane_facing("CharacterModelFrame");
s.tick(0.25);
let after = s.paperdoll_yaw();
let after = s.model_pane_facing("CharacterModelFrame");
assert!(
(after - (before - std::f32::consts::FRAC_PI_4)).abs() < 1e-3,
"the right arrow spins the other way: {before} → {after}"
@@ -947,39 +1050,60 @@ fn a_keybind_page_switch_moves_the_tab_row_with_it() {
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");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "UIParent.xml");
load_xml(&s, "GameTooltip.xml");
load_xml(&s, "ScrollTemplates.xml");
load_xml(&s, "UIPanelTemplates.xml");
load_xml(&s, "OptionsFrameTemplates.xml");
load_xml(&s, "CharacterFrame.xml");
load_xml(&s, "PetPaperDollFrame.xml");
load_xml(&s, "ReputationFrame.xml");
load_xml(&s, "SkillFrame.xml");
load_xml(&s, "Interface\\FrameXML\\HonorFrame.xml");
for f in super::test_ui::CHARACTER_UI {
super::test_ui::load_ui_strict(&s, f);
}
s.set_unit("player", Some(player_unit()));
// THE INVARIANT the fix rests on: a page's `id=` is its slot in this window's own tab row, and
// `BENILLA_CHARACTERFRAME_SUBFRAMES` is the same 1:1 mapping written the other way round. It
// did its job: this loop is what made the Reputation page's arrival LOUD, since Skills had to
// move from 3 to 4 in the same breath. All five slots are the reference's own now
// (Character/Pet/Reputation/Skills/Honor, ref `CharacterFrame.xml:79-168`), so the loop covers
// the whole row rather than a prefix of it — there is nothing left past the end for a page to
// arrive into unnoticed.
for i in 1..=5 {
let id: i64 = s
.eval(&format!(
"return getglobal(BENILLA_CHARACTERFRAME_SUBFRAMES[{i}]):GetID()"
))
.unwrap();
// THE INVARIANT the fix rests on: a page's `id=` is the slot in this window's tab row that the
// tab row's own dispatch sends to that page. `ToggleCharacter` selects the row with
// `PanelTemplates_SetTab(CharacterFrame, subFrame:GetID())` (stock `CharacterFrame.lua:10`), so
// if a page's id and its tab's id ever disagree, a keybind lights the wrong tab.
//
// **Retired divergence (decision 1751's character swap).** This used to loop over
// `BENILLA_CHARACTERFRAME_SUBFRAMES` asserting `page i must carry id=i`, because our deleted
// `assets/ui/CharacterFrame.xml` listed the pages in tab order. The reference does not:
// `CHARACTERFRAME_SUBFRAMES` is `{PaperDoll, PetPaperDoll, Skill, Reputation, Honor}`
// (`CharacterFrame.lua:1`) while the tab row is Character/Pet/**Reputation**/**Skills**/Honor
// (`CharacterFrame.xml:79-168`) — index 3 is SkillFrame with id 4, index 4 is ReputationFrame
// with id 3. That list is a set to iterate for show/hide, not an ordering, so the ordering
// assertion moves to where the reference really states it: `CharacterFrameTab_OnClick`'s five
// name branches (`CharacterFrame.lua:35-48`).
let row: [(i64, &str); 5] = [
(1, "PaperDollFrame"),
(2, "PetPaperDollFrame"),
(3, "ReputationFrame"),
(4, "SkillFrame"),
(5, "HonorFrame"),
];
for (id, page) in row {
assert_eq!(
id, i,
"page {i} of BENILLA_CHARACTERFRAME_SUBFRAMES must carry id={i}"
s.eval::<i64>(&format!("return {page}:GetID()")).unwrap(),
id,
"{page} is the page CharacterFrameTab{id} toggles, so it must carry id={id}"
);
assert_eq!(
s.eval::<i64>(&format!("return CharacterFrameTab{id}:GetID()"))
.unwrap(),
id,
);
// …and it is one of the five the show/hide sweep knows about, so a page cannot arrive with
// a tab and stay invisible to `CharacterFrame_ShowSubFrame` (`CharacterFrame.lua:25-33`).
assert!(
s.eval::<bool>(&format!(
"for _, v in CHARACTERFRAME_SUBFRAMES do if v == \"{page}\" then return true end \
end return false"
))
.unwrap(),
"{page} must be in CHARACTERFRAME_SUBFRAMES"
);
}
assert_eq!(
s.eval::<i64>("return getn(CHARACTERFRAME_SUBFRAMES)")
.unwrap(),
5,
"…and there is nothing past the end for a sixth page to arrive into unnoticed"
);
let selected = |s: &mut UiScript| {
s.eval::<i64>("return PanelTemplates_GetSelectedTab(CharacterFrame)")
@@ -1025,6 +1149,22 @@ fn a_keybind_page_switch_moves_the_tab_row_with_it() {
// Character tab is inert while the Character page is up. The window only closed because the row
// still said Skills: tab 1 was left enabled, the click reached `ToggleCharacter`, and the page
// it named was already visible — the HideUIPanel arm.
//
// The MECHANISM is asserted first and the click second, deliberately: a tab whose OnClick never
// ran would sail through the click assertions below for the wrong reason, and this is the one
// fact that cannot.
assert_eq!(
s.eval::<i64>("return CharacterFrameTab1:IsEnabled()")
.unwrap(),
0,
"the selected tab is DISABLED — that is what makes the click below inert"
);
assert_eq!(
s.eval::<i64>("return CharacterFrameTab4:IsEnabled()")
.unwrap(),
1,
"…while an unselected one stays clickable"
);
s.run("CharacterFrameTab1:Click()").unwrap();
assert!(
shown(&mut s, "CharacterFrame"),
@@ -1041,14 +1181,13 @@ fn a_keybind_page_switch_moves_the_tab_row_with_it() {
/// rest; this client had every other member of the kit and not that one, because our own windows
/// wire their tabs to their own handlers and never reached for the generic entry point.
///
/// **It is built on a row of this test's own, and that is the finding, not a convenience.** The
/// reference's tab buttons carry `id="1".."4"` (ref `CharacterFrame.xml:79-133`) and ours carry
/// none: our row is id-based through `BenillaCharacterFrameTab_OnClick(id)`, which closes over the
/// number instead of reading it off the widget. So `CharacterFrameTab2:GetID()` is **0** here, and
/// driving OUR row through the generic entry point would select tab 0 — correct code meeting a
/// window that does not obey the contract it reads. An addon's own tabs do carry ids, which is why
/// the four callers above work; this builds a row that obeys the reference's contract and drives
/// that.
/// **It is built on a row of this test's own, and that is deliberate.** When it was written our
/// character row carried no `id=` at all (it was id-based through a `BenillaCharacterFrameTab_OnClick(id)`
/// closure), so driving it through the generic entry point would have selected tab 0. Decision
/// 1751's swap fixed that from the other end — the row is the reference's own now and every tab
/// carries its id (`CharacterFrame.xml:79-168`) — but the standalone row stays: this test is about
/// the KIT, and an addon's window is exactly what it builds, so it must not need a shipped window
/// loaded to run.
///
/// The `this`/frame split is the whole reason the function exists: `this` is the clicked TAB and
/// the owning FRAME is a separate argument, which is the shape `SimpleActionSets.xml:342` relies on
@@ -12,15 +12,20 @@
//! ignoreModifiers)` — which takes the MOUSE button as its first argument and reads the frame from
//! `this`, so a test cannot call it directly any more. These drive the mouse instead
//! ([`super::test_ui::click`]), which is the stronger test regardless: it puts the template's own
//! `RegisterForClicks("LeftButtonUp", "RightButtonUp")` and script wiring under test too. The doll
//! slots below are still ours (`BenillaPaperDollSlot_OnClick`) and are still called directly.
//! `RegisterForClicks("LeftButtonUp", "RightButtonUp")` and script wiring under test too.
//!
//! **The doll slots went the same way.** `CharacterFrame.xml` and `PaperDollFrame.xml` are the
//! reference's own too now, so `BenillaPaperDollSlot_OnClick` is gone and the fork is stock
//! `PaperDollItemSlotButton_OnClick(button, ignoreModifiers)` (`PaperDollFrame.lua:647-662`) —
//! which reads `this` exactly as the bag handler does. Those tests drive the mouse as well, over a
//! window that is genuinely OPEN, which is the only state a player can click a doll slot from.
use benilla_ui::script::{
ContainerSlot, ContainerState, DressUpIntent, InvSlotView, InventorySlots, SoundRequest,
UiScript,
UiScript, UnitState,
};
use super::test_ui::{bag_slot_button, click, load_ui as load_xml, BAG_UI};
use super::test_ui::{bag_slot_button, click, load_ui as load_xml, BAG_UI, CHARACTER_UI};
/// The jerky stack every bag fixture here uses — a real 1.12 link, quality white.
const JERKY_LINK: &str = "|cffffffff|Hitem:117|h[Tough Jerky]|h|r";
@@ -75,6 +80,41 @@ fn load_room_with_bags(s: &UiScript) {
}
}
/// The character window **open on its paper doll**, with the room and a chat edit box beside it —
/// the two files [`CHARACTER_UI`] does not already carry. (`UIDropDownMenu.xml`,
/// `Interface\FrameXML\UIMenu.xml` and `UnitPopup.xml` are in both lists; loading either twice
/// would redeclare its frames.)
///
/// The player behind it carries **both** halves of the race and class pairs: `UnitRace`/`UnitClass`
/// answer `(localized, file)` or `nil, nil` — the binding `zip`s them — and stock
/// `PaperDollFrame_SetLevel` formats all three into `CharacterLevelText` unguarded
/// (`PaperDollFrame.lua:100-104`) on every show.
///
/// **Needs client data**, like [`load_room_with_bags`].
fn shown_paper_doll() -> UiScript {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
for file in CHARACTER_UI {
super::test_ui::load_ui_strict(&s, file);
}
for file in ["DressUpFrame.xml", "ChatFrame.xml"] {
super::test_ui::load_ui_strict(&s, file);
}
s.set_unit(
"player",
Some(UnitState {
exists: true,
level: 60,
race: Some("Human".into()),
race_file: Some("Human".into()),
class: Some("Warrior".into()),
class_file: Some("WARRIOR".into()),
..UnitState::default()
}),
);
s
}
/// A backpack holding a 5-stack of Tough Jerky in slot 1, opened; returns the NAME of the button
/// showing that slot.
///
@@ -230,26 +270,17 @@ fn ctrl_clicking_a_chat_link_previews_it() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The paper doll's own slots (ref PaperDollFrame.lua:647-655): ctrl previews what you are
/// wearing, shift posts its link. Both read the unit-keyed `GetInventoryItemLink`, the binding this
/// arc added — so this also pins that the getter answers for `"player"`.
/// The paper doll's own slots (`PaperDollFrame.lua:647-662`): ctrl previews what you are wearing,
/// shift posts its link. Both read the unit-keyed `GetInventoryItemLink`, the binding this arc
/// added — so this also pins that the getter answers for `"player"`.
///
/// Since 1751 that handler is the reference's own `PaperDollItemSlotButton_OnClick(button,
/// ignoreModifiers)`, which reads the frame from `this` — so this drives the mouse over a window
/// that is really open, exactly as the bag tests above do.
#[test]
fn the_paper_doll_slots_preview_and_post_what_you_wear() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
for file in [
"Fonts.xml",
"MoneyFrame.xml",
"UiPanels.xml",
"UIParent.xml",
"GameTooltip.xml",
"CharacterFrame.xml",
"DressUpFrame.xml",
"Interface\\FrameXML\\UIMenu.xml", // the kit ChatMenu/EmoteMenu/VoiceMacroMenu build from
"ChatFrame.xml",
] {
load_xml(&s, file);
}
let _data = benilla_formats::wow_data_or_skip!();
let mut s = shown_paper_doll();
let mut slots = InventorySlots::default();
slots[1] = Some(InvSlotView {
item_id: 1234,
@@ -267,11 +298,13 @@ fn the_paper_doll_slots_preview_and_post_what_you_wear() {
.unwrap(),
"|cff1eff00|Hitem:1234:0:0:0|h[Test Helm]|h|r"
);
s.run(r#"ToggleCharacter("PaperDollFrame")"#).unwrap();
let _ = s.take_dressup_intents();
s.resolve();
// CTRL on the head slot → the room, wearing the helm.
s.set_modifiers(false, true, false);
s.run("BenillaPaperDollSlot_OnClick(CharacterHeadSlot, \"LeftButton\")")
.unwrap();
click(&mut s, "CharacterHeadSlot", "LeftButton");
s.set_modifiers(false, false, false);
assert_eq!(
s.take_dressup_intents(),
@@ -281,8 +314,7 @@ fn the_paper_doll_slots_preview_and_post_what_you_wear() {
// SHIFT with chat open → the link; the item is never picked up either way.
assert!(s.focus_editbox("ChatFrameEditBox"));
s.set_modifiers(true, false, false);
s.run("BenillaPaperDollSlot_OnClick(CharacterHeadSlot, \"LeftButton\")")
.unwrap();
click(&mut s, "CharacterHeadSlot", "LeftButton");
s.set_modifiers(false, false, false);
assert_eq!(
s.eval::<String>("return ChatFrameEditBox:GetText()")
@@ -297,26 +329,17 @@ fn the_paper_doll_slots_preview_and_post_what_you_wear() {
}
/// A slot whose item template has not answered yet has **no link** — a state the real client never
/// has (its item cache is synchronous), so the reference carries no guard and `EditBox:Insert`
/// takes a string. Shift-clicking such a slot must post nothing and raise nothing; the click after
/// the answer lands works normally.
/// has (its item cache is synchronous), so the reference carries no guard: stock
/// `PaperDollItemSlotButton_OnClick` shift-inserts `GetInventoryItemLink(...)` straight
/// (`PaperDollFrame.lua:653`). Shift-clicking such a slot must post nothing and raise nothing.
///
/// That is now the ENGINE's promise rather than a transcription's guard: `EditBox:Insert(nil)` is a
/// no-op because the reference reads the argument through `lua_tostring` (decision 1800), and this
/// test is what holds the stock line above harmless over an in-flight item.
#[test]
fn shift_clicking_an_unresolved_slot_posts_nothing_and_never_raises() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
for file in [
"Fonts.xml",
"MoneyFrame.xml",
"UiPanels.xml",
"UIParent.xml",
"GameTooltip.xml",
"CharacterFrame.xml",
"DressUpFrame.xml",
"Interface\\FrameXML\\UIMenu.xml", // the kit ChatMenu/EmoteMenu/VoiceMacroMenu build from
"ChatFrame.xml",
] {
load_xml(&s, file);
}
let _data = benilla_formats::wow_data_or_skip!();
let mut s = shown_paper_doll();
// The slot is occupied but unresolved: an item id with no name/quality yet, so no link.
let mut slots = InventorySlots::default();
slots[1] = Some(InvSlotView {
@@ -325,11 +348,13 @@ fn shift_clicking_an_unresolved_slot_posts_nothing_and_never_raises() {
..Default::default()
});
s.set_inventory_slots(slots);
s.run(r#"ToggleCharacter("PaperDollFrame")"#).unwrap();
let _ = s.take_dressup_intents();
s.resolve();
assert!(s.focus_editbox("ChatFrameEditBox"));
s.set_modifiers(true, false, false);
s.run("BenillaPaperDollSlot_OnClick(CharacterHeadSlot, \"LeftButton\")")
.unwrap();
click(&mut s, "CharacterHeadSlot", "LeftButton");
s.set_modifiers(false, false, false);
assert_eq!(
s.eval::<String>("return ChatFrameEditBox:GetText()")
@@ -341,8 +366,7 @@ fn shift_clicking_an_unresolved_slot_posts_nothing_and_never_raises() {
// And ctrl on the same unresolved slot is inert too (DressUpItemLink's own nil guard).
s.set_modifiers(false, true, false);
s.run("BenillaPaperDollSlot_OnClick(CharacterHeadSlot, \"LeftButton\")")
.unwrap();
click(&mut s, "CharacterHeadSlot", "LeftButton");
s.set_modifiers(false, false, false);
assert!(
s.take_dressup_intents().is_empty(),
@@ -119,6 +119,7 @@ fn content_kind(c: &QuadContent) -> &'static str {
QuadContent::Frame => "frame",
QuadContent::Minimap { .. } => "minimap",
QuadContent::Cooldown { .. } => "cooldown",
QuadContent::ModelPane { .. } => "modelpane",
QuadContent::Texture { .. } => "texture",
QuadContent::ColorWheel => "colorwheel",
QuadContent::ColorValue { .. } => "colorvalue",
@@ -1312,6 +1313,56 @@ fn convert_entry(
rect, eq.z, eq.alpha, fraction, flash, clip, assets, images, out,
);
}
// A `<Model>`/`<PlayerModel>` pane's content: the off-screen body bake its window keeps,
// sampled square edge to edge. The pane→booth join is
// [`crate::portrait::model_pane_booth`], whose doc says why it is a table of frame names.
//
// This is the same draw the `portrait_unit` arm below makes for a Texture region, and it
// used to BE that draw: until decision 1751 a file of ours put a Texture inside each pane
// and named the slot with `BenillaSetBoothTexture`. A migrated window runs the reference's
// own file, which declares a bare `<PlayerModel>` and no Texture at all — so the widget
// itself has to draw, or the character sheet's paper doll is an empty rectangle.
//
// A pane with no name, or one no window claims, draws nothing (`SetModel` panes included:
// this engine holds their scene and renders no M2 into it). Nothing is stubbed white.
QuadContent::ModelPane { name } => {
use crate::portrait::PortraitSource;
let Some(slot) = name.as_deref().and_then(crate::portrait::model_pane_booth) else {
return;
};
// The aspect the bake must render at, and the fact that it is on screen at all
// (decision 1069) — published before the readiness check below for the same reason the
// `portrait_unit` arm does it there: a pane whose bake has not landed yet is still a
// pane being drawn, and gating the publish on the image would be a standoff.
if rect.height() > 0.0 {
booths
.panes
.0
.insert(slot.to_string(), rect.width() / rect.height());
}
// The bake is a render target and carries PREMULTIPLIED colour; the 2D stand-in the
// slot shows while a model streams is an ordinary straight-alpha BLP.
let (handle, premultiplied) = match booths.images.0.get(slot) {
Some(PortraitSource::Live(h)) => (Some(h.clone()), true),
Some(PortraitSource::File(p)) => (
assets.as_mut().and_then(|a| a.sprite_texture(p, images)),
false,
),
None => (None, false),
};
let Some(handle) = handle else {
return;
};
out.push(UiQuad {
rect,
z_key: eq.z,
texture: Some(handle),
color: [1.0, 1.0, 1.0, eq.alpha],
premultiplied,
clip,
..default()
});
}
QuadContent::Texture {
path,
color,
@@ -2199,6 +2250,72 @@ mod extract_gate_tests {
);
}
/// **A `<PlayerModel>` pane draws its window's booth, and an unclaimed one draws nothing**
/// (decision 1810).
///
/// The engine verb the stock character sheet needed. `PaperDollFrame.xml` declares a bare
/// `<PlayerModel name="CharacterModelFrame">` with no texture in it at all — our own deleted
/// file put a `<Texture>` there and named the booth with `BenillaSetBoothTexture` — so without
/// this the migrated window's paper doll is an empty rectangle, which no loader error and no
/// missing global would have said a word about.
///
/// Both halves are asserted because the second is what keeps the first honest: the join is a
/// table of frame names ([`crate::portrait::model_pane_booth`]), and a pane no window claims —
/// pfUI's anonymous `CreateFrame("Model")` autocast shine is the corpus case — must draw
/// nothing rather than borrow somebody's bake.
#[test]
fn a_named_model_pane_samples_its_booth_and_an_unclaimed_one_draws_nothing() {
let mut app = app_from_script(
r#"
local doll = CreateFrame("PlayerModel", "CharacterModelFrame")
doll:SetPoint("TOPLEFT", 0, 0)
doll:SetSize(233, 224)
doll:SetUnit("player")
local stray = CreateFrame("PlayerModel", "SomeAddonsModelPane")
stray:SetPoint("TOPLEFT", 300, 0)
stray:SetSize(233, 224)
stray:SetUnit("player")
"#,
);
let bake = app
.world_mut()
.resource_mut::<Assets<Image>>()
.add(Image::default());
app.world_mut().resource_mut::<PortraitImages>().0.insert(
"paperdoll".to_string(),
crate::portrait::PortraitSource::Live(bake.clone()),
);
app.update();
let quads = &app.world().resource::<UiQuads>().quads;
let panes: Vec<_> = quads
.iter()
.filter(|q| q.texture.as_ref() == Some(&bake))
.collect();
assert_eq!(
panes.len(),
1,
"exactly the claimed pane draws the paper doll's bake"
);
assert!(
panes[0].premultiplied,
"a render-target bake carries premultiplied colour"
);
// …and the pane published its aspect, which is what lets the booth render at the shape it
// will be stretched into (decision 1069) and what tells it it is on screen at all.
let aspect = app
.world()
.resource::<crate::portrait::BoothPanes>()
.0
.get("paperdoll")
.copied()
.expect("the pane publishes its aspect");
assert!(
(aspect - 233.0 / 224.0).abs() < 0.01,
"the pane's own rect is the aspect, got {aspect}"
);
}
/// **A booth bake reaches the quad pass flagged PREMULTIPLIED, an ordinary region does not**
/// (decision 1347). This is the wiring half of the paper-doll/dressing-room fix, and it is the
/// half that can silently rot: the shader's `select(a, k, premultiplied)` is only correct if
@@ -169,13 +169,12 @@ const KNOWN: &[Known] = &[
// we have not built, and the interaction the player would notice is the TOOLTIP those handlers
// show — not the click-blocking the flag gives. Declaring `enableMouse="true"` here would make
// the frame swallow the click and hand back nothing, which is worse than the gap.
Known {
frame: "PetPaperDollFrameExpBar",
flag: Flag::Mouse,
why:
"the reference bar inherits TextStatusBar, whose OnEnter/OnLeave show the value text; \
ours is a plain StatusBar. Wants the status-text arc, not the flag.",
},
// `PetPaperDollFrameExpBar` RETIRED here (decision 1751's character-sheet window). It read
// "the reference bar inherits TextStatusBar, whose OnEnter/OnLeave show the value text; ours is
// a plain StatusBar" — true of OUR `PetPaperDollFrame.xml`, which is deleted. The pet page is
// the reference's own file now, so its XP bar inherits `TextStatusBar` because it IS the
// reference's declaration, and the divergence has nothing left to describe.
//
// Seven `*ScrollFrame` mouse entries RETIRED here (1795). They read "our faux scroll pane is a
// Frame with an explicit `<OnMouseWheel>`, not a ScrollFrame" — a divergence that existed only
// because OUR `ScrollFrame` ctor took the mouse and the reference's does not. Correcting the
@@ -275,14 +274,16 @@ const KNOWN: &[Known] = &[
Known {
frame: "TargetofTargetHealthBar",
flag: Flag::Mouse,
why: "same as PetPaperDollFrameExpBar — a plain StatusBar where the reference inherits \
TextStatusBar.",
why: "a plain StatusBar where the reference inherits TextStatusBar, whose OnEnter/OnLeave \
show the value text. Wants the status-text arc, not the flag. (The pet page's XP bar \
was this entry's twin until its window became the reference's own.)",
},
Known {
frame: "TargetofTargetManaBar",
flag: Flag::Mouse,
why: "same as PetPaperDollFrameExpBar — a plain StatusBar where the reference inherits \
TextStatusBar.",
why: "a plain StatusBar where the reference inherits TextStatusBar, whose OnEnter/OnLeave \
show the value text. Wants the status-text arc, not the flag. (The pet page's XP bar \
was this entry's twin until its window became the reference's own.)",
},
Known {
frame: "TradePlayerItem7",
@@ -1,5 +1,6 @@
//! The shipped **Honor tab** (`assets/ui/HonorFrame.xml`) driven end-to-end, engine-only (no
//! Bevy) — the per-window test module the skills/reputation files establish (decision 1512).
//! The shipped **Honor tab** (`Interface\\FrameXML\\HonorFrame.xml`, the reference's own) driven
//! end-to-end, engine-only (no Bevy) — the per-window test module the skills/reputation files
//! establish (decision 1512).
//!
//! What it pins is the PAINT law, the half `benilla-ui`'s own `script::pvp::tests` structurally
//! cannot reach: that module drives the thirteen globals and asserts their tuples, and stops at the
@@ -51,9 +52,13 @@ fn state() -> HonorState {
}
/// The eight rank titles this fixture can ask for, defined as the install's `GlobalStrings.lua`
/// defines them. Set by hand rather than read off the chain so the pane's law is pinned on a
/// machine with no client data; [`the_real_global_strings_name_the_rank`] is the same assertion
/// against the player's actual file.
/// defines them — verbatim (`GlobalStrings.lua:2795`, `:3075`, `:3150-3161`, `:3297`).
///
/// **The inspect page's, since 1751.** The character page loads the player's own file as its first
/// [`super::test_ui::CHARACTER_UI`] entry, because stock `PaperDollFrame_OnLoad` sets seven labels
/// from it; [`shown_inspect_honor_page`] loads no such thing, so these are what its titles read.
/// [`the_real_global_strings_name_the_rank`] is the same assertion against the player's actual
/// file, on both sides.
const RANK_GLOBALS: &str = r#"
NONE = "None"
RANK = "Rank"
@@ -64,27 +69,27 @@ const RANK_GLOBALS: &str = r#"
PVP_RANK_14_0 = "Champion"
"#;
/// The character window, whose fifth tab this page is. Every file it needs — including
/// `Interface\\FrameXML\\HonorFrame.xml` itself, which the shared list carries because
/// `PaperDollFrame_SetLevel` writes `HonorLevelText` on every show
/// (`PaperDollFrame.lua:103`, and `_SetGuild` writes `HonorGuildText` at `:123`) — is
/// [`super::test_ui::CHARACTER_UI`]; this page adds nothing of its own. It was a hand-copied list until the window became the reference's (decision 1751), which
/// is when the list stopped being short enough for a copy to stay honest.
fn load_page(s: &UiScript) {
for file in [
"Fonts.xml",
"MoneyFrame.xml",
"UiPanels.xml",
"UIParent.xml",
"GameTooltip.xml",
"Interface\\FrameXML\\TextStatusBar.lua",
"Interface\\FrameXML\\TextStatusBar.xml",
"ScrollTemplates.xml",
"UIPanelTemplates.xml",
"OptionsFrameTemplates.xml",
"CharacterFrame.xml",
"Interface\\FrameXML\\HonorFrame.xml",
] {
load_xml(s, file);
for file in super::test_ui::CHARACTER_UI {
super::test_ui::load_ui_strict(s, file);
}
}
/// An Alliance, male, level-60 player — the two facts the rank title's GlobalString key is built
/// from (side → the team digit, sex → the `_FEMALE` twin), plus the level line's input.
///
/// **Both halves of the race and class pairs, since 1751.** `UnitRace`/`UnitClass` answer
/// `(localized, file)` or `nil, nil` — the binding `zip`s the two, so a snapshot carrying only the
/// localized name reports the unit as raceless. Stock `PaperDollFrame_SetLevel` writes
/// `HonorLevelText` off `format(TEXT(PLAYER_LEVEL), UnitLevel, UnitRace, UnitClass)` — unguarded,
/// on every show of this page (`PaperDollFrame.lua:100-104`) — so a half-filled snapshot raises
/// there. Ours never called it from the honor tab, which is why the gap sat unseen.
fn alliance_player() -> UnitState {
UnitState {
exists: true,
@@ -92,7 +97,9 @@ fn alliance_player() -> UnitState {
sex: 2,
faction_group: Some("Alliance".into()),
race: Some("Human".into()),
race_file: Some("Human".into()),
class: Some("Warrior".into()),
class_file: Some("WARRIOR".into()),
pvp_rank: 12,
..UnitState::default()
}
@@ -103,7 +110,6 @@ fn alliance_player() -> UnitState {
fn shown_honor_page() -> UiScript {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
s.run(RANK_GLOBALS).unwrap();
load_page(&s);
s.set_unit("player", Some(alliance_player()));
s.set_honor(Some(state()));
@@ -1,5 +1,10 @@
//! The shipped **pet paper doll** driven end-to-end, engine-only (decision 1057): the real
//! `assets/ui/PetPaperDollFrame.xml` behind `CharacterFrame.xml`, fed synthetic pet snapshots.
//! The shipped **pet paper doll** driven end-to-end, engine-only (decision 1057) — and since
//! decision 1751's character swap, "shipped" is the reference's own
//! `Interface\FrameXML\PetPaperDollFrame.xml`, read off the player's patch chain behind the
//! reference's `CharacterFrame.xml` and `PaperDollFrame.xml`. Our
//! `assets/ui/PetPaperDollFrame.xml` is deleted; [`super::test_ui::CHARACTER_UI`] is the load
//! list, and every test here opens with `wow_data_or_skip!()` because a chain entry needs the
//! install.
//!
//! The page's rows are the character page's own code with a different unit token, and
//! `character_tests.rs` already pins those. What is genuinely new — and what these test — is the
@@ -11,23 +16,20 @@ use benilla_ui::script::{
PetStats, QuadContent, ScriptValue, UiScript, UnitCombatStats, UnitState,
};
use super::test_ui::load_ui as load_xml;
/// The page's production load prefix — it leans on `CharacterFrame.xml` for the shared setters,
/// the row templates and the tab row itself, exactly as the manifest orders them.
/// The page's production load prefix. [`super::test_ui::CHARACTER_UI`] is the whole character
/// block in `benilla.toc`'s order — the pet page cannot stand on its own file, because
/// `PetPaperDollFrame_Update` calls seven `PaperDollFrame_Set*` setters with a `"pet"` unit
/// token (stock `PetPaperDollFrame.lua:75-81`) and `PetTab_Update` moves a tab that
/// `CharacterFrame.xml` declares.
///
/// **Callers must open with `benilla_formats::wow_data_or_skip!()` themselves** — the macro
/// `return`s from the function it is written in, so a helper cannot hold the guard for its caller.
fn load_pet_page() -> UiScript {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Fonts.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
// `UIParent.xml` is the manifest's third file and this page needs one thing out of it:
// `BuildListString`, the reference's own `UIParent.lua:1051` joiner behind the diet tooltip
// (decision 1062). Loaded in manifest order rather than bolted on at the end.
load_xml(&s, "UIParent.xml");
load_xml(&s, "GameTooltip.xml");
load_xml(&s, "CharacterFrame.xml");
load_xml(&s, "PetPaperDollFrame.xml");
for f in super::test_ui::CHARACTER_UI {
super::test_ui::load_ui_strict(&s, f);
}
s.set_unit("player", Some(player_unit()));
s
}
@@ -130,23 +132,48 @@ fn take_pet(s: &mut UiScript) {
s.fire_event("UNIT_PET", vec![ScriptValue::Str("player".into())]);
}
/// The loader itself, plus the page's own frame count.
///
/// **A count is a fingerprint of a file, not a target** (decision 1800's closing note): this one
/// is the reference's `PetPaperDollFrame.xml` — the page, the five stat rows and five resistance
/// frames, the XP bar, the model pane with its two rotate buttons, the diet icon and the close
/// button — and it moves only when the player's own file does. It is new with decision 1751's
/// character swap: this test asserted no count while the file it loaded was ours, because a count
/// of our own transcription fingerprints nothing but the transcription.
#[test]
fn shipped_pet_page_loads_clean() {
let s = load_pet_page();
let _data = benilla_formats::wow_data_or_skip!();
let s = UiScript::new().unwrap();
let mut pet_frames = 0;
for f in super::test_ui::CHARACTER_UI {
let n = super::test_ui::load_ui_strict(&s, f);
if *f == "Interface\\FrameXML\\PetPaperDollFrame.xml" {
pet_frames = n;
}
}
assert!(s.errors().is_empty(), "load errors: {:?}", s.errors());
assert_eq!(pet_frames, 34, "the reference's own PetPaperDollFrame.xml");
// The page itself exists and starts down, like every other subframe.
assert!(!s
.eval::<bool>("return PetPaperDollFrame:IsVisible()")
.unwrap());
}
/// **The conditional tab, both ways.** With no pet the Pet tab is down and Skills slides onto its
/// spot; with a pet the Pet tab comes up *at that very spot* and Skills moves out past it. Pinning
/// "Skills-when-closed sits exactly where Pet-when-open does" is what proves the re-anchor really
/// ran — a tab merely hidden, with the row left as-authored, would leave a gap and pass a weaker
/// check.
/// **The conditional tab, both ways.** With no pet the Pet tab is down and the tab beside it slides
/// onto its spot; with a pet the Pet tab comes up *at that very spot* and its neighbour moves out
/// past it. Pinning "tab-3-when-closed sits exactly where tab 2 stands when open" is what proves
/// the re-anchor really ran — a tab merely hidden, with the row left as-authored, would leave a gap
/// and pass a weaker check. `PetTab_Update` is the whole mechanism, and it is four lines
/// (stock `PetPaperDollFrame.lua:189-198`).
///
/// **The name was stale, not the test.** It said "Skills closes the gap" from when Skills WAS tab
/// 3; the Reputation page moved it to 4 long before this file went to the chain, and the tab
/// `PetTab_Update` re-anchors is and always was `CharacterFrameTab3` — REPUTATION in the
/// reference's row (`CharacterFrame.xml:115`) and in our deleted copy alike. Renamed so it says
/// what it checks; the assertions are untouched.
#[test]
fn the_pet_tab_rises_and_falls_with_the_pet_and_skills_closes_the_gap() {
fn the_pet_tab_rises_and_falls_with_the_pet_and_the_next_tab_closes_the_gap() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = load_pet_page();
// The rects only exist while something lays the window out.
s.run(r#"ToggleCharacter("PaperDollFrame")"#).unwrap();
@@ -168,11 +195,11 @@ fn the_pet_tab_rises_and_falls_with_the_pet_and_skills_closes_the_gap() {
let open3 = tab_left(&mut s, 3);
assert_eq!(
open2, closed3,
"with no pet, Skills stood exactly where the Pet tab stands with one"
"with no pet, tab 3 stood exactly where the Pet tab stands with one"
);
assert!(
open3 > open2,
"…and a pet pushes Skills out past it ({open3} vs {open2})"
"…and a pet pushes tab 3 out past it ({open3} vs {open2})"
);
take_pet(&mut s);
@@ -185,11 +212,12 @@ fn the_pet_tab_rises_and_falls_with_the_pet_and_skills_closes_the_gap() {
assert!(s.errors().is_empty(), "errors: {:?}", s.errors());
}
/// The reference's `ToggleCharacter` guard (ref `CharacterFrame.lua:4-6`): asking for the pet page
/// The reference's `ToggleCharacter` guard (stock `CharacterFrame.lua:4-6`): asking for the pet page
/// with no pet does **nothing at all** — it does not open the window on some other tab, and it does
/// not close an already-open one.
#[test]
fn asking_for_the_pet_page_without_a_pet_does_nothing() {
let _data = benilla_formats::wow_data_or_skip!();
let s = load_pet_page();
s.run(r#"ToggleCharacter("PetPaperDollFrame")"#).unwrap();
assert!(
@@ -208,10 +236,11 @@ fn asking_for_the_pet_page_without_a_pet_does_nothing() {
}
/// The page paints the **pet's** numbers, not the player's — the whole point of un-gating the stat
/// bindings. The player's own snapshot is deliberately absent here: anything that leaked through
/// the `"player"` path would read zeros and this would fail.
/// bindings. No player COMBAT-STATS snapshot is pushed here (only the unit one the window needs to
/// open), so anything that leaked through the `"player"` path would read zeros and this would fail.
#[test]
fn the_page_reads_the_pets_own_snapshot() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = load_pet_page();
give_pet(&mut s);
s.run(r#"ToggleCharacter("PetPaperDollFrame")"#).unwrap();
@@ -226,7 +255,8 @@ fn the_page_reads_the_pets_own_snapshot() {
"expected {want:?} on the page; drew {drawn:?}"
);
}
// Unspent training points = total - spent (ref l.85-86), not either number by itself.
// Unspent training points = total - spent (stock `PetPaperDollFrame.lua:85-86`), not either
// number by itself.
assert!(
drawn.iter().any(|t| t == "40"),
"unspent training points (170-130); drew {drawn:?}"
@@ -234,11 +264,13 @@ fn the_page_reads_the_pets_own_snapshot() {
assert!(s.errors().is_empty(), "errors: {:?}", s.errors());
}
/// The window has ONE name line and the two pages take turns holding it (ref l.50-60) — the pet's
/// The window has ONE name line and the two pages take turns holding it (stock
/// `PetPaperDollFrame.lua:50-60`) — the pet's
/// name replaces the player's on show and hands it back on hide. A page that forgot the hand-back
/// would leave the character sheet nameless.
#[test]
fn the_page_borrows_the_windows_name_line_and_gives_it_back() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = load_pet_page();
give_pet(&mut s);
@@ -258,11 +290,13 @@ fn the_page_borrows_the_windows_name_line_and_gives_it_back() {
assert!(s.errors().is_empty(), "errors: {:?}", s.errors());
}
/// A pet dismissed or killed while its own page is open **closes the window** (ref l.32-37). This
/// A pet dismissed or killed while its own page is open **closes the window** (stock
/// `PetPaperDollFrame.lua:32-37`). This
/// is the one arm that must run while the page is visible and the pet is already gone, so it is
/// also the arm that would blow up on a page that repainted before checking.
#[test]
fn the_pet_leaving_closes_the_page_under_it() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = load_pet_page();
give_pet(&mut s);
s.run(r#"ToggleCharacter("PetPaperDollFrame")"#).unwrap();
@@ -277,9 +311,11 @@ fn the_pet_leaving_closes_the_page_under_it() {
}
/// A warlock's minion: `HasPetUI` says yes, its second return says no. The page opens in full and
/// the three hunter-only pieces stay down (1005's class gate, seen from the page's side).
/// the three hunter-only pieces stay down (stock `PetPaperDollFrame.lua:83-93`; 1005's class gate,
/// seen from the page's side).
#[test]
fn a_minion_gets_the_page_without_the_hunter_furniture() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = load_pet_page();
s.set_unit("pet", Some(pet_unit()));
s.set_pet_stats(true, PetStats::default()); // has_ui, but hunter_pet == false
@@ -310,16 +346,16 @@ fn a_minion_gets_the_page_without_the_hunter_furniture() {
}
/// **The level line, both ways** (decision 1062). The reference guards its whole `SetText` on
/// `UnitCreatureFamily("pet")` (ref `PetPaperDollFrame.lua:68-70`), so the family is not decoration
/// on an existing line — it is the *condition* for the line existing at all. With a family the row
/// reads "Level 58 Imp"; without one the page shows no level at all, which is the state 1057
/// shipped in and the hole this closes.
/// `UnitCreatureFamily("pet")` (stock `PetPaperDollFrame.lua:68-70`), so the family is not
/// decoration on an existing line — it is the *condition* for the line existing at all. With a
/// family the row reads "Level 58 Imp"; without one nothing about the pet reaches the line, which
/// is the state 1057 shipped in and the hole this closes.
///
/// The minion is deliberate: a warlock's Imp fails `HasPetUI`'s second return, so this also pins
/// that the family word rides OUTSIDE 1005's hunter gate. Gating it would blank this line for every
/// warlock in the game.
///
/// **The two halves are two separate page loads on purpose.** The ref's guard skips the `SetText`;
/// **The two halves are two separate page loads on purpose.** The guard skips the `SetText`;
/// it does not clear the FontString — so pushing a family and then taking it away leaves the old
/// word on screen (this test found that by asserting the opposite first). That is the reference's
/// own shape and it is unreachable in play: a family is a property of the pet's *template*, it
@@ -327,7 +363,8 @@ fn a_minion_gets_the_page_without_the_hunter_furniture() {
/// reachable is "the page painted before the creature query answered", which is a page that never
/// had a family — the second half here.
#[test]
fn the_level_line_names_the_family_and_is_blank_without_one() {
fn the_level_line_names_the_family_and_is_untouched_without_one() {
let _data = benilla_formats::wow_data_or_skip!();
let with_family = |family: Option<String>| {
let mut s = load_pet_page();
s.set_unit("pet", Some(pet_unit()));
@@ -352,25 +389,38 @@ fn the_level_line_names_the_family_and_is_blank_without_one() {
"UNIT_LEVEL_TEMPLATE + the family word; drew {drawn:?}"
);
// No family — the creature query has not answered, or the template has none. The line is
// absent, not "Level 58" and not "Level 58 " with a trailing space: the ref's guard skips the
// whole SetText, which is why nil (never "") is the binding's contract.
// No family — the creature query has not answered, or the template has none. The guard skips
// the whole `SetText`, so the FontString keeps whatever it already held: nothing derived from
// the pet is written, which is why nil (never "") is the binding's contract.
//
// **Retired divergence (decision 1751's character swap).** This used to read "no family ⇒ no
// level line at all", because our deleted `assets/ui/PetPaperDollFrame.xml` declared
// `PetLevelText` with no `text=` and it started empty. The reference declares it
// `text="Level level race class"` (stock `PetPaperDollFrame.xml:70`) — a design-time
// placeholder, and not a GlobalStrings key, so the loader's `text=` lookup falls through to the
// literal and that literal is what a player sees in this state, un-replaced. Both spellings say
// the same thing about the guard; this one says it about the file that ships.
let drawn = with_family(None);
assert!(
!drawn.iter().any(|t| t.starts_with("Level")),
"no family ⇒ no level line at all; drew {drawn:?}"
drawn.iter().any(|t| t == "Level level race class"),
"no family ⇒ the XML's own design-time placeholder is left standing; drew {drawn:?}"
);
assert!(
!drawn.iter().any(|t| t.starts_with("Level 58")),
"…and nothing derived from the pet reaches the line; drew {drawn:?}"
);
}
/// **The diet tooltip** (decision 1062): the happiness-art icon under the rotate buttons is the
/// pet's DIET affordance, and its hover runs the reference's own
/// `format(PET_DIET_TEMPLATE, BuildListString(GetPetFoodTypes()))` (ref
/// `PetPaperDollFrame.xml:269`) — the vararg binding feeding the `UIParent.lua` joiner.
/// `format(PET_DIET_TEMPLATE, BuildListString(GetPetFoodTypes()))` (stock
/// `PetPaperDollFrame.xml:267-270`) — the vararg binding feeding the `UIParent.lua` joiner.
///
/// Driven through the frame's real `OnEnter`, not by re-typing the expression, so a guard left in
/// the XML or a mis-anchored `GetScript` slot fails here.
/// Driven through the pointer, not by re-typing the expression, so a guard left in the XML or a
/// frame that has stopped taking the mouse fails here.
#[test]
fn hovering_the_diet_icon_lists_what_the_pet_eats() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = load_pet_page();
give_pet(&mut s);
s.run(r#"ToggleCharacter("PetPaperDollFrame")"#).unwrap();
@@ -396,28 +446,38 @@ fn hovering_the_diet_icon_lists_what_the_pet_eats() {
let rect = icon.rect.expect("…with a resolved rect");
assert!(
(rect.right - rect.left - 24.0).abs() < 0.5 && (rect.top - rect.bottom - 23.0).abs() < 0.5,
"24x23 (ref PetPaperDollPetInfo), got {}x{}",
"24x23 (stock `PetPaperDollFrame.xml:245-248`), got {}x{}",
rect.right - rect.left,
rect.top - rect.bottom
);
// …and it draws ON TOP of the model pane it sits inside — the actual regression. The pane's
// booth quad is OPAQUE and the icon's rect is wholly within it, so being in the render list is
// not the same as being seen. The pane rides BACKGROUND for exactly this reason (decision
// 1070): at ARTWORK the draw layer, which is bucket-wide and outranks the frame (0884), buried
// the icon no matter which frame was declared later.
// …and it draws ON TOP of the model pane it sits inside — the actual regression. The pane is
// opaque and the icon's rect is wholly within it, so being in the render list is not the same
// as being seen. The pane rides BACKGROUND for exactly this reason (decision 1070): at ARTWORK
// the draw layer, which is bucket-wide and outranks the frame (0884), buried the icon no
// matter which frame was declared later.
//
// The pane is found by NAME rather than by "a texture quad with an empty path", which is what
// this used to look for. Our deleted `assets/ui/PetPaperDollFrame.xml` declared `PetModelFrame`
// as a plain `<Frame>` carrying one opaque BACKGROUND booth texture standing in for a widget
// this engine did not draw; the reference declares it `<PlayerModel>` (stock
// `PetPaperDollFrame.xml:177`), which is a real widget here now and extracts as
// `QuadContent::ModelPane`. The old needle matches nothing against it — this asserts the
// containment itself rather than folding it into a `find`, so a pane that stopped covering the
// icon fails loudly instead of dropping out of the search.
let pane = quads
.iter()
.find(|q| {
matches!(&q.content, QuadContent::Texture { path, .. }
if path.as_deref().unwrap_or_default().is_empty())
&& q.rect.is_some_and(|r| {
r.left <= rect.left
&& r.right >= rect.right
&& r.bottom <= rect.bottom
&& r.top >= rect.top
})
matches!(&q.content, QuadContent::ModelPane { name: Some(n) } if n == "PetModelFrame")
})
.expect("the pet model pane's booth quad covers the icon's rect");
.expect("the pet model pane is in the render list");
let pane_rect = pane.rect.expect("…with a resolved rect");
assert!(
pane_rect.left <= rect.left
&& pane_rect.right >= rect.right
&& pane_rect.bottom <= rect.bottom
&& pane_rect.top >= rect.top,
"the icon sits wholly inside the pane, which is what makes the z-order matter"
);
assert!(
icon.z > pane.z,
"the diet icon must paint over the pane it sits in (icon z={:#x}, pane z={:#x})",
@@ -425,11 +485,18 @@ fn hovering_the_diet_icon_lists_what_the_pet_eats() {
pane.z
);
s.run(
"local f = PetPaperDollPetInfo \
f:GetScript(\"OnEnter\")(f)",
)
.unwrap();
let centre = super::test_ui::centre_of(&mut s, "PetPaperDollPetInfo");
// Through the MOUSE, not `GetScript("OnEnter")(f)`: the reference's handler is inline XML that
// opens `GameTooltip:SetOwner(this, "ANCHOR_RIGHT")` (stock `PetPaperDollFrame.xml:268`), and
// only the engine sets `this` — calling the script by hand passes nil and raises. Driving the
// pointer also puts the frame's `enableMouse` and the hit test under test, which is the half
// that was never covered.
super::test_ui::hover(&mut s, "PetPaperDollPetInfo");
assert_eq!(
s.hit_test_name(centre.0, centre.1).as_deref(),
Some("PetPaperDollPetInfo"),
"the diet icon owns its own point inside the model pane"
);
assert_eq!(
s.eval::<String>("return GameTooltipTextLeft1:GetText()")
.unwrap(),
@@ -445,6 +512,7 @@ fn hovering_the_diet_icon_lists_what_the_pet_eats() {
/// is hunter-gated. Quietly returning `""` here would hide that coupling.
#[test]
fn build_list_string_is_a_plain_comma_join_that_nils_on_nothing() {
let _data = benilla_formats::wow_data_or_skip!();
let s = load_pet_page();
assert_eq!(
s.eval::<String>("return BuildListString('Meat', 'Fish', 'Cheese')")
+310 -82
View File
@@ -36,10 +36,14 @@
//!
//! **Order decides, and the manifest is the order.** A name defined by both goes to whichever line
//! is later. That is the whole rule; there is no precedence machinery. A file sourced *before* our
//! own (`PaperDollFrame.lua`, which is there for one frame-agnostic button family and collides on
//! eighteen names we drive ourselves) has its collisions overwritten by ours; a file sourced at the
//! position of the window it replaces owns its names outright, which is what migrating a window
//! means.
//! own has its collisions overwritten by ours; a file sourced at the position of the window it
//! replaces owns its names outright, which is what migrating a window means.
//!
//! The load-bearing example was `PaperDollFrame.lua`, sourced far above everything for one
//! frame-agnostic button family while our own `CharacterFrame.xml` won the eighteen names it
//! collided on. Decision 1751 migrated that window, so the file arrives at its own position now
//! and there is nothing left for it to collide with — which is what a finished migration looks
//! like.
//!
//! **Nothing is stubbed silently.** A reference body that reaches for something this client does
//! not have raises, naming it — which is loud, correct, and strictly better than a no-op that
@@ -251,6 +255,80 @@ mod tests {
);
}
/// Strip what is not code, before any call census over a FrameXML file.
///
/// Both `Name(` and `:Name(` counted calls inside comments until decision 1800: the round that
/// built `PickupMerchantItem` also asked wow-re for `ShowInventorySellCursor`, which
/// [`chain_gap_report`] had named as `PaperDollFrame.xml`'s last engine gap — and the answer
/// was that stock `PaperDollFrame.lua:754-756` has the call **commented out**, all three
/// lines. A real binding, never called, blocking a window that was not blocked.
///
/// Line-based and deliberately simple: Lua `--` to end of line (but not `--[[`, which opens a
/// block), Lua `--[[ … ]]` blocks, and XML `<!-- … -->` blocks. It does not track string
/// literals, so a `"--"` inside a string truncates that line — which can only ever cause an
/// UNDER-report, the safe direction for every reader of it.
fn strip_comments(text: &str) -> String {
let mut out = String::with_capacity(text.len());
let mut in_xml = false;
let mut in_lua_block = false;
for line in text.lines() {
let mut rest = line;
let mut kept = String::new();
loop {
if in_xml {
match rest.find("-->") {
Some(i) => {
in_xml = false;
rest = &rest[i + 3..];
}
None => break,
}
} else if in_lua_block {
match rest.find("]]") {
Some(i) => {
in_lua_block = false;
rest = &rest[i + 2..];
}
None => break,
}
} else {
let xml = rest.find("<!--");
let lua = rest.find("--");
match (xml, lua) {
(Some(x), Some(l)) if x <= l => {
kept.push_str(&rest[..x]);
in_xml = true;
rest = &rest[x + 4..];
}
(_, Some(l)) => {
kept.push_str(&rest[..l]);
if rest[l..].starts_with("--[[") {
in_lua_block = true;
rest = &rest[l + 4..];
} else {
// A plain `--` comment runs to end of line.
rest = "";
break;
}
}
(Some(x), None) => {
kept.push_str(&rest[..x]);
in_xml = true;
rest = &rest[x + 4..];
}
(None, None) => break,
}
}
}
if !in_xml && !in_lua_block {
kept.push_str(rest);
}
out.push_str(&kept);
out.push('\n');
}
out
}
/// **A chain entry really loads off the player's install, and order really decides.**
///
/// The two halves of this module's rule, asserted rather than described, because nothing else
@@ -286,20 +364,41 @@ mod tests {
// …and its constants, which the corpus reads directly.
assert_eq!(s.eval::<i64>("return NUM_BAG_FRAMES").unwrap(), 4);
assert_eq!(s.eval::<i64>("return NUM_CONTAINER_FRAMES").unwrap(), 12);
// The `PaperDollFrame.lua` line's own reason to exist.
// `PaperDollFrame.lua` used to be a manifest line of its own, sourced far above everything
// for exactly this family. It arrives through stock `PaperDollFrame.xml`'s own
// `<Script file=>` now, at the character window's position (decision 1751) — so this
// assertion also proves a chain `.xml` really brings its `.lua`.
assert!(s
.eval::<bool>("return type(PaperDollItemSlotButton_OnLoad) == \"function\"")
.unwrap());
// Order decides: `PaperDollFrame.lua` is sourced ABOVE CharacterFrame.xml, so OUR body of
// a colliding name is the live one. `PaperDollFrame_SetLevel` is in the 18-name overlap.
// **The 18-name overlap this test used to assert the winner of is GONE.** Our
// `CharacterFrame.xml` redefined 18 of `PaperDollFrame.lua`'s 29 functions and won them all
// by loading later; that file is deleted and the reference's own bodies are the only ones.
// The check that replaces it is the swap's, not the collision's: the live bodies are the
// reference's, and ours are not merely shadowed but absent.
assert!(
s.eval::<bool>(
"return type(PaperDollFrame_SetLevel) == \"function\" \
and BenillaPaperDollSlot_OnLoad ~= nil"
and type(CHARACTERFRAME_SUBFRAMES) == \"table\" \
and table.getn(CHARACTERFRAME_SUBFRAMES) == 5 \
and BenillaPaperDollSlot_OnLoad == nil"
)
.unwrap(),
"our character sheet's own bodies must still be the live ones"
"the character sheet's bodies must be the reference's own now"
);
// Order still decides, and it is still the whole rule — so it is asserted directly rather
// than through whichever window happens to collide this month. Two chunks, the same name,
// and the later one stands; `publish_global`'s non-overwriting rule (RF-0023) applies to
// FRAMES, never to a plain Lua global, and confusing the two has produced confident wrong
// diagnoses before.
s.run("function _order_probe() return 1 end").unwrap();
s.run("function _order_probe() return 2 end").unwrap();
assert_eq!(
s.eval::<i64>("return _order_probe()").unwrap(),
2,
"the later definition of a colliding name is the live one"
);
assert!(s.errors().is_empty(), "{:#?}", s.errors());
}
@@ -561,79 +660,6 @@ mod tests {
.into_iter()
.collect();
// Strip what is not code before either scan. Both `Name(` and `:Name(` counted calls
// inside comments until 1800: the round that built `PickupMerchantItem` also asked wow-re
// for `ShowInventorySellCursor`, which this report had named as `PaperDollFrame.xml`'s
// last engine gap — and the answer was that stock `PaperDollFrame.lua:754-756` has the
// call **commented out**, all three lines. A real binding, never called, blocking a window
// that was not blocked.
//
// Line-based and deliberately simple: Lua `--` to end of line (but not `--[[`, which opens
// a block), Lua `--[[ … ]]` blocks, and XML `<!-- … -->` blocks. It does not track string
// literals, so a `"--"` inside a string truncates that line — which can only ever cause an
// UNDER-report, the safe direction for an instrument that ranks work.
let strip_comments = |text: &str| -> String {
let mut out = String::with_capacity(text.len());
let mut in_xml = false;
let mut in_lua_block = false;
for line in text.lines() {
let mut rest = line;
let mut kept = String::new();
loop {
if in_xml {
match rest.find("-->") {
Some(i) => {
in_xml = false;
rest = &rest[i + 3..];
}
None => break,
}
} else if in_lua_block {
match rest.find("]]") {
Some(i) => {
in_lua_block = false;
rest = &rest[i + 2..];
}
None => break,
}
} else {
let xml = rest.find("<!--");
let lua = rest.find("--");
match (xml, lua) {
(Some(x), Some(l)) if x <= l => {
kept.push_str(&rest[..x]);
in_xml = true;
rest = &rest[x + 4..];
}
(_, Some(l)) => {
kept.push_str(&rest[..l]);
if rest[l..].starts_with("--[[") {
in_lua_block = true;
rest = &rest[l + 4..];
} else {
// A plain `--` comment runs to end of line.
rest = "";
break;
}
}
(Some(x), None) => {
kept.push_str(&rest[..x]);
in_xml = true;
rest = &rest[x + 4..];
}
(None, None) => break,
}
}
}
if !in_xml && !in_lua_block {
kept.push_str(rest);
}
out.push_str(&kept);
out.push('\n');
}
out
};
// `:Name(` — a method call, receiver unknown.
let called_methods = |text: &str| -> std::collections::HashSet<String> {
let b: Vec<char> = text.chars().collect();
@@ -1322,6 +1348,208 @@ mod tests {
}
}
/// **Every global a MIGRATED window calls is answered by the loaded interface.**
///
/// The gate the loot window and the character sheet both wanted and neither had.
/// [`chain_readiness_report`] asks "does the stock file LOAD"; [`chain_gap_report`] asks "what
/// would I have to BUILD before migrating it". Neither asks the question that actually bites
/// after a swap: *the file is on the chain now — does everything it calls exist?* A missing
/// FrameXML function is invisible to both, because loading a file never runs the body that
/// calls it: `LootFrame.xml` shipped load-clean and raised at `LootFrame.lua:85` the first
/// time it met real data, and stock `CharacterFrame.xml` would have raised on the first tab
/// HOVER, because `MicroButtonTooltipText` did not exist here (it does now — `MicroMenu.xml`,
/// our `MainMenuBarMicroButtons.xml` counterpart, is where the reference declares it).
///
/// So: for every chain `.xml` in the manifest, census the bare `Name(` call sites across it
/// and its `.lua`, subtract what those two define themselves, keep the names the reference's
/// own `_G` carries (`reference/1.12-globals.tsv` — anything else is a local, a widget method,
/// or a table field, and 1.12's widget methods are not globals), and require every survivor to
/// be non-nil in a VM with the whole shipped manifest up.
///
/// **A gate rather than an instrument** (the `assert` is the point): for a window we have
/// already migrated, the answer must be zero, and a missing name is a raise waiting for a
/// player's first click on it.
///
/// Three limits, stated because they decide what a green run is worth. It cannot see a name
/// reached through `getglobal` (both censuses share that blind spot). It cannot see a WIDGET
/// method — those are not in `_G`, which is exactly why `chain_gap_report` grew a separate
/// `method=` column (1798). And "the name exists" is not "the body is right": arity and
/// semantics are decision 1793's problem and the window's own tests', not this one's.
#[test]
fn every_global_a_migrated_window_calls_is_answered() {
let _data = benilla_formats::wow_data_or_skip!();
// The reference's own global table — the filter that keeps this from drowning in locals.
let tsv = concat!(
env!("CARGO_MANIFEST_DIR"),
"/../../reference/1.12-globals.tsv"
);
let text = std::fs::read_to_string(tsv).expect("the reference surface");
let reference: std::collections::HashSet<&str> = text
.lines()
.filter(|l| !l.starts_with('#'))
.filter_map(|l| l.split('\t').next())
.collect();
let mut s = UiScript::new().expect("VM");
s.set_screen_size(1024.0, 768.0);
let failures = super::super::manifest::load_default_ui(&s);
assert!(failures.is_empty(), "the shipped manifest: {failures:#?}");
let have: std::collections::HashSet<String> = s
.eval::<Vec<String>>(
"local t = {} for k in pairs(_G) do table.insert(t, k) end return t",
)
.expect("dump _G")
.into_iter()
.collect();
// **The gaps this gate found on the day it was written, each still open, each named with
// the window that reaches it and the click that would.** They are listed rather than
// silenced: a KNOWN entry here is a defect we have and have not fixed, not a tolerance —
// and the assertion below refuses an entry that no longer describes one, so fixing a gap
// forces its line out (`frame_flag_gate`'s rule, applied to a second gate).
//
// None of them belongs to the character sheet, which is the window that prompted this and
// reads clean. Each belongs to whichever window's migration left it, and each is one
// binding or one sourced file away.
const KNOWN: &[(&str, &str, &str)] = &[
(
"ContainerFrame.xml",
"KeyRingButtonIDToInvSlotID",
"an engine binding (`1.12-globals.tsv`). `ContainerFrame.lua:617` hovers a KEYRING \
slot with it, so the raise needs the keyring open and a key hovered. Ours drives \
keyring tooltips through `ContainerFrameAdapters.xml`'s wrapper (0765), which is \
why nothing has hit it — the wrapper answers first for our own rows.",
),
(
"DurabilityFrame.xml",
"UpdateInventoryAlertStatus",
"an engine binding. `DurabilityFrame.lua:81` calls it from the armor guy's own \
update; our `inventory_alerts` snapshot is recomputed on every inventory push \
instead, so the recompute exists and only the Lua verb that forces one does not.",
),
(
"QuestTimerFrame.xml",
"QuestLog_SetSelection",
"a FrameXML function, declared in stock `QuestLogFrame.lua:308` — which we do not \
source, because our own `QuestLogFrame.xml` is that window. Clicking a quest \
TIMER raises (`QuestTimerFrame.lua:43`). It arrives with the quest log's own \
migration and not before.",
),
(
"QuestTimerFrame.xml",
"QuestLog_Update",
"the twin of the line above, `QuestLogFrame.lua:107`, called on the next line \
(`QuestTimerFrame.lua:44`).",
),
(
"UnitFrame.xml",
"UnitPlayerControlled",
"an engine binding, and the most reachable gap here: `UnitFrame_OnEnter` \
(`UnitFrame.lua:62`) calls it while HOVERING any unit frame, behind \
`SHOW_NEWBIE_TIPS == \"1\"` — which is the shipped default. wow-re has it carved \
(`0x516410`, VERIFIED, `scratch/unit-verbs-controlled-charmed-creaturetype.md`): \
one return, `UNIT_FIELD_FLAGS` bit 3, the DOUBLE 1.0 when set and nil when clear, \
raising on an unrecognised token. `UnitState` carries no such flag yet, so it is \
a field + a push + a binding — the unit frames' own slice.",
),
];
let toc = &super::super::addons::Addon::builtin().toc.files;
let mut missing: Vec<(String, String)> = Vec::new();
for entry in toc.iter().filter(|f| super::is_chain_entry(f)) {
let leaf = entry.rsplit(['\\', '/']).next().unwrap_or(entry);
let mut text = String::new();
let mut cands = vec![entry.replace('\\', "/")];
if let Some(stem) = entry.strip_suffix(".xml") {
cands.push(format!("{stem}.lua").replace('\\', "/"));
}
for cand in &cands {
if let Some(b) = super::read(cand) {
text.push_str(&String::from_utf8_lossy(&b));
text.push('\n');
}
}
let text = strip_comments(&text);
let defines: std::collections::HashSet<String> = text
.lines()
.filter_map(|l| l.trim_start().strip_prefix("function "))
.map(|r| r.chars().take_while(|c| super::is_word(*c)).collect())
.collect();
// `name(` at a call position: any identifier not preceded by `.` or `:` (a field or a
// method) and followed by `(`.
//
// **Not capitalised-only.** An earlier cut of this filtered on a leading capital, on
// the reasoning that 1.12's globals are named that way — most are, and the ones that
// are not are the ones a stat tooltip is built out of: `strupper`, `strsub`, `abs`,
// `max`, `floor`, `format`, `getglobal`. `reference/1.12-globals.tsv` is the filter
// that actually belongs here, and it does not care about case.
let b: Vec<char> = text.chars().collect();
let mut called: std::collections::HashSet<String> = std::collections::HashSet::new();
let mut i = 0;
while i < b.len() {
if (b[i].is_ascii_alphabetic() || b[i] == '_')
&& (i == 0 || !super::is_word(b[i - 1]))
&& (i == 0 || (b[i - 1] != '.' && b[i - 1] != ':'))
{
let mut j = i;
while j < b.len() && super::is_word(b[j]) {
j += 1;
}
let mut k = j;
while k < b.len() && b[k].is_whitespace() {
k += 1;
}
if k < b.len() && b[k] == '(' {
called.insert(b[i..j].iter().collect());
}
i = j;
continue;
}
i += 1;
}
let mut gaps: Vec<&String> = called
.iter()
.filter(|n| !defines.contains(*n))
.filter(|n| reference.contains(n.as_str()))
.filter(|n| !have.contains(*n))
.collect();
gaps.sort();
for n in gaps {
missing.push((leaf.to_string(), n.clone()));
}
}
missing.sort();
let news: Vec<String> = missing
.iter()
.filter(|(f, n)| !KNOWN.iter().any(|(kf, kn, _)| kf == f && kn == n))
.map(|(f, n)| format!("{f} calls {n}, which nothing answers to"))
.collect();
assert!(
news.is_empty(),
"a MIGRATED window calls a global this client does not have — load-clean and dead on \
the first click that reaches it:\n {}",
news.join("\n ")
);
// …and the other direction: a KNOWN entry whose gap has been closed is documentation
// claiming a defect we do not have, so it must go with the fix.
let stale: Vec<String> = KNOWN
.iter()
.filter(|(kf, kn, _)| !missing.iter().any(|(f, n)| f == kf && n == kn))
.map(|(kf, kn, why)| format!("{kf} / {kn} — claimed: {why}"))
.collect();
assert!(
stale.is_empty(),
"{} KNOWN entr(y/ies) name a gap that is closed — delete them:\n {}",
stale.len(),
stale.join("\n ")
);
}
/// **A chain `.xml` that does not source its own `.lua` needs TWO manifest lines**, and
/// getting it wrong is silent.
///
@@ -12,11 +12,14 @@
//! The fixture deliberately mirrors the engine module's own (Alliance over Ironforge + Stormwind,
//! Steamwheedle over Booty Bay, the parentless bucket last) so a failure here reads against a shape
//! that is already pinned one layer down, and the two can be compared line for line.
//!
//! **The window around the page is the reference's own since 1751** — `CharacterFrame.xml` and
//! `PaperDollFrame.xml` off the player's chain — so every test here opens with
//! `wow_data_or_skip!()` and loads [`super::test_ui::CHARACTER_UI`]. The page itself
//! (`ReputationFrame.xml`) and the watch bar (`ActionBar.xml`) are still ours.
use benilla_ui::script::{FactionEntry, ReputationState, UiScript, UnitState};
use super::test_ui::load_ui as load_xml;
/// An ordinary bar row: visible, not a header, `standing_id` 5 ("Friendly") sitting 1000 into a
/// 6000-wide rank window. The same numbers `benilla-ui`'s own fixture uses.
fn entry(faction_id: u32, rep_list_id: u32, parent_id: u32, name: &str) -> FactionEntry {
@@ -76,28 +79,36 @@ fn state() -> ReputationState {
}
}
/// The manifest prefix this page really needs, in manifest order: the fonts, the panel manager, the
/// managed-position pass, the tooltip, the numerals machinery + Cooldown + ActionBar (the watch bar
/// and every frame it drives live there), the two scroll/shared-widget kits the page's list and
/// popup inherit from, then the window and the page.
/// The character window, whose third tab this page is — [`super::test_ui::CHARACTER_UI`], which
/// already carries `ReputationFrame.xml` (stock `CharacterFrame_ShowSubFrame` hides all five pages
/// by name, unguarded) and `ActionBar.xml` (the watch bar, and the show/hide pair's
/// `ShowWatchedReputationBarText`). This page adds nothing of its own.
///
/// It was a hand-copied manifest prefix until the window became the reference's (decision 1751):
/// stock `CharacterFrame_OnLoad` reaches into four other files before it does anything else, so the
/// list stopped being short enough for a per-module copy to stay honest.
fn load_page(s: &UiScript) {
for file in [
"Fonts.xml",
"MoneyFrame.xml",
"UiPanels.xml",
"UIParent.xml",
"GameTooltip.xml",
"Interface\\FrameXML\\TextStatusBar.lua",
"Interface\\FrameXML\\TextStatusBar.xml",
"Cooldown.xml",
"ActionBar.xml",
"ScrollTemplates.xml",
"UIPanelTemplates.xml",
"OptionsFrameTemplates.xml",
"CharacterFrame.xml",
"ReputationFrame.xml",
] {
load_xml(s, file);
for file in super::test_ui::CHARACTER_UI {
super::test_ui::load_ui_strict(s, file);
}
}
/// A level-`level` player behind the window — enough of one that opening it is quiet.
///
/// **Race and class carry both halves of their pairs**, which is not decoration since 1751:
/// `UnitRace`/`UnitClass` answer `(localized, file)` or `nil, nil` — the binding `zip`s the two —
/// and stock `PaperDollFrame_SetLevel` formats level, race and class into `CharacterLevelText`
/// unguarded (`PaperDollFrame.lua:100-104`), on every show of the window this page is a tab of.
/// A `UnitState::default()` player therefore raises the moment `ToggleCharacter` runs.
fn player(level: u32) -> UnitState {
UnitState {
exists: true,
level,
race: Some("Human".into()),
race_file: Some("Human".into()),
class: Some("Warrior".into()),
class_file: Some("WARRIOR".into()),
..UnitState::default()
}
}
@@ -106,14 +117,7 @@ fn shown_reputation_page() -> UiScript {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_page(&s);
s.set_unit(
"player",
Some(UnitState {
exists: true,
level: 40,
..UnitState::default()
}),
);
s.set_unit("player", Some(player(40)));
s.set_reputation(state());
s.run(r#"ToggleCharacter("ReputationFrame")"#).unwrap();
s.resolve();
@@ -151,6 +155,7 @@ fn shown(s: &mut UiScript, name: &str) -> bool {
/// wrong tab. Skills moved to 4 in the same breath and is asserted here for the same reason.
#[test]
fn the_reputation_page_opens_on_the_windows_third_tab() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = shown_reputation_page();
assert!(
s.eval::<bool>("return ReputationFrame:IsVisible()")
@@ -163,21 +168,36 @@ fn the_reputation_page_opens_on_the_windows_third_tab() {
3,
"and the tab row selects 3 — the reference's own slot for Reputation"
);
// **The id IS the tab slot**, and that is the whole coupling: `ToggleCharacter` selects the tab
// with `PanelTemplates_SetTab(CharacterFrame, subFrame:GetID())`
// (`CharacterFrame.lua:10`), while `CharacterFrameTab_OnClick` hardcodes which page each tab
// opens (`:41-43` — tab 3 → Reputation, tab 4 → Skills). A page whose `id=` disagreed would
// open on click and light the wrong tab.
assert_eq!(
s.eval::<i64>("return ReputationFrame:GetID()").unwrap(),
3,
"the id IS the tab slot; BENILLA_CHARACTERFRAME_SUBFRAMES[3] must name this page"
"the id IS the tab slot the reference's tab 3 opens"
);
assert_eq!(
s.eval::<String>("return BENILLA_CHARACTERFRAME_SUBFRAMES[3]")
.unwrap(),
"ReputationFrame"
s.eval::<i64>("return SkillFrame:GetID()").unwrap(),
4,
"and Skills is the reference's 4 beside it"
);
assert_eq!(
s.eval::<String>("return BENILLA_CHARACTERFRAME_SUBFRAMES[4]")
.unwrap(),
"SkillFrame",
"and Skills moved to the reference's 4 with it"
// **`CHARACTERFRAME_SUBFRAMES` is the hide-all list, not the tab map** — re-pointed here at
// 1751, because our deleted `CharacterFrame.xml` wrote the two as one thing and the
// reference's does not. Stock line 1 is
// `{ "PaperDollFrame", "PetPaperDollFrame", "SkillFrame", "ReputationFrame", "HonorFrame" }` —
// Skills THIRD and Reputation FOURTH, the opposite of their tab slots — and it is only ever
// walked by `CharacterFrame_ShowSubFrame`, which shows the one it was named and hides the rest
// (`CharacterFrame.lua:25-33`). So what this page needs from the list is membership: a page
// missing from it is a page nothing ever hides, which is the "one page at a time" assertion at
// the end of this test.
assert!(
s.eval::<bool>(
"for _, v in CHARACTERFRAME_SUBFRAMES do if v == \"ReputationFrame\" then return true end end return false"
)
.unwrap(),
"the hide-all list names this page"
);
// Eight rows in fifteen slots: the tail goes dark on BOTH twins, and a list that fits raises
@@ -205,6 +225,7 @@ fn the_reputation_page_opens_on_the_windows_third_tab() {
/// global leaves the folded rows on screen (ReputationFrame.xml's deviation 3).
#[test]
fn a_header_row_paints_its_fold_icon_and_folds_on_click() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = shown_reputation_page();
assert_eq!(text_of(&mut s, "ReputationHeader1"), "Alliance");
@@ -259,6 +280,7 @@ fn a_header_row_paints_its_fold_icon_and_folds_on_click() {
/// absolute.
#[test]
fn a_bar_row_paints_name_standing_and_the_faction_bar_colour() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = shown_reputation_page();
assert_eq!(text_of(&mut s, "ReputationBar2FactionName"), "Ironforge");
@@ -325,10 +347,11 @@ fn a_bar_row_paints_name_standing_and_the_faction_bar_colour() {
/// 0251), so this is the assertion that the slots really are re-bound and not just clipped.
#[test]
fn the_scroll_offset_rebinds_the_fixed_row_slots() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_page(&s);
s.set_unit("player", Some(UnitState::default()));
s.set_unit("player", Some(player(40)));
// One header over twenty children: 21 visible rows against 15 slots.
let mut entries = vec![header(469, 11, "Alliance")];
@@ -403,10 +426,11 @@ fn the_scroll_offset_rebinds_the_fixed_row_slots() {
/// bar again closes it, which is the reference's own toggle (l.152-153).
#[test]
fn clicking_a_bar_opens_the_detail_popup_on_that_faction() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_page(&s);
s.set_unit("player", Some(UnitState::default()));
s.set_unit("player", Some(player(40)));
// Ironforge at war and peace-forced — the two flags the popup reads back off the row.
let mut st = state();
for e in &mut st.entries {
@@ -512,17 +536,11 @@ fn clicking_a_bar_opens_the_detail_popup_on_that_faction() {
/// `ExhaustionTick_Update` as unreachable while this bar could never show.
#[test]
fn the_watch_bar_shows_the_watched_factions_progress_and_swaps_at_max_level() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_page(&s);
s.set_unit(
"player",
Some(UnitState {
exists: true,
level: 40,
..UnitState::default()
}),
);
s.set_unit("player", Some(player(40)));
assert!(
!shown(&mut s, "ReputationWatchBar"),
"nothing watched: the bar stays down, as it has since it was a stub"
@@ -587,14 +605,7 @@ fn the_watch_bar_shows_the_watched_factions_progress_and_swaps_at_max_level() {
.unwrap();
// Ding to 60: the reputation strip takes the XP bar's place, art and all.
s.set_unit(
"player",
Some(UnitState {
exists: true,
level: 60,
..UnitState::default()
}),
);
s.set_unit("player", Some(player(60)));
s.fire_event(
"PLAYER_LEVEL_UP",
vec![benilla_ui::script::ScriptValue::Int(60)],
@@ -11,10 +11,13 @@
//! descriptor is (`benilla-ui`'s `SkillEntry::mono`; wow-re `0x4d3610`'s `4d38b1` branch). A
//! hunter's `Beast Mastery` on vmangos arrives as `300/300` and must still read gray and
//! numberless, exactly as it does in the real client.
//!
//! **The window around the page is the reference's own since 1751** — `CharacterFrame.xml` and
//! `PaperDollFrame.xml` off the player's chain — so every test that opens the page loads
//! [`super::test_ui::CHARACTER_UI`] and opens with `wow_data_or_skip!()`. The page itself
//! (`assets/ui/SkillFrame.xml`) is still ours.
use benilla_ui::script::{QuadContent, SkillEntry, SkillsState, UiScript};
use super::test_ui::load_ui as load_xml;
use benilla_ui::script::{QuadContent, SkillEntry, SkillsState, UiScript, UnitState};
fn skill(
skill_id: u32,
@@ -45,25 +48,37 @@ fn skill(
/// The Skills page, shown, with one line of each shape — a hunter's real vmangos numbers:
/// `Beast Mastery 300/300` (single-rank, `SkillRaceClassInfo` 0x410), `Defense 12/60` (a normal
/// weapon line), `Cloth 1/1` (an armor proficiency the server itself caps).
///
/// The window around the page is the reference's own since 1751, so the file list is
/// [`super::test_ui::CHARACTER_UI`] — which already carries `SkillFrame.xml` (stock
/// `CharacterFrame_ShowSubFrame` hides all five pages by name, unguarded) and the
/// `UIPanelTemplates.xml` this list learned by hand a year ago: a MISSING template is a loader
/// *warning*, not an error, so an under-loaded list passes and then fails later on geometry that
/// silently never got built (`SkillDetailScrollFrame` inherits `UIPanelScrollFrameTemplate`).
/// [`super::test_ui::load_ui_strict`] is that lesson made into a check.
fn shown_skills_page() -> UiScript {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
// UIPanelTemplates.xml is NOT optional even though this list ran without it for a year: a
// MISSING template is a loader *warning*, not an error, so an under-loaded list passes the
// assert in load_xml and then fails later on geometry that silently never got built.
// SkillDetailScrollFrame inherits UIPanelScrollFrameTemplate from that file.
for f in [
"Fonts.xml",
"MoneyFrame.xml",
"UiPanels.xml",
"GameTooltip.xml",
"ScrollTemplates.xml",
"UIPanelTemplates.xml",
"CharacterFrame.xml",
"SkillFrame.xml",
] {
load_xml(&s, f);
for f in super::test_ui::CHARACTER_UI {
super::test_ui::load_ui_strict(&s, f);
}
// A player behind the window, carrying BOTH halves of the race and class pairs:
// `UnitRace`/`UnitClass` answer `(localized, file)` or `nil, nil` — the binding `zip`s them —
// and stock `PaperDollFrame_SetLevel` formats level, race and class into `CharacterLevelText`
// unguarded (`PaperDollFrame.lua:100-104`) every time this window is shown. Ours never did,
// which is why this page could be opened with no player at all.
s.set_unit(
"player",
Some(UnitState {
exists: true,
level: 60,
race: Some("Tauren".into()),
race_file: Some("Tauren".into()),
class: Some("Hunter".into()),
class_file: Some("HUNTER".into()),
..UnitState::default()
}),
);
s.set_skills(SkillsState {
entries: vec![
skill(50, "Beast Mastery", 300, 300, true, (7, "Class Skills", 2)),
@@ -89,6 +104,24 @@ fn row(s: &mut UiScript, i: u32) -> (String, [f32; 4]) {
(text, [c.0, c.1, c.2, c.3])
}
/// The quads **this page** draws — `s.extract()` narrowed to targets whose nearest named owner is
/// one of the page's own frames.
///
/// Not a convenience: since 1751 the window around the page is the reference's own, so a test VM
/// carries the whole [`super::test_ui::CHARACTER_UI`] — the action bar, the micro menu and the unit
/// frames are loaded and *visible*, and their quads land in the same extract. An unfiltered
/// "nothing draws at alpha 0.5" over that list asserts something about the action bar, not about
/// this page's troughs.
fn page_quads(s: &UiScript) -> Vec<benilla_ui::script::ExtractedQuad> {
s.extract()
.into_iter()
.filter(|q| {
s.target_owner_name(q.target)
.is_some_and(|n| n.starts_with("Skill"))
})
.collect()
}
/// Row slot `i`'s **trough** colour — the `$parentBackground` texture behind the fill, which the
/// ref recolours per branch alongside the fill (`SkillFrame.lua:158` normal / `:167` proficiency).
fn row_bg(s: &mut UiScript, i: u32) -> [f32; 4] {
@@ -102,6 +135,7 @@ fn row_bg(s: &mut UiScript, i: u32) -> [f32; 4] {
#[test]
fn a_single_rank_line_paints_gray_with_no_rank_text() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = shown_skills_page();
assert!(
s.eval::<bool>("return SkillFrame:IsVisible()").unwrap(),
@@ -142,8 +176,7 @@ fn a_single_rank_line_paints_gray_with_no_rank_text() {
// — a real texel — and the vertex colour MULTIPLIES it, alpha included (the composition law,
// `benilla-ui` `script::tests::regions`): the proficiency's white reaches the screen at
// `0.2 x 0.5 = 0.1`, not 0.5. Every solid-colour quad this page emits is one of these troughs.
let solids: Vec<[f32; 4]> = s
.extract()
let solids: Vec<[f32; 4]> = page_quads(&s)
.iter()
.filter_map(|q| match &q.content {
QuadContent::Texture {
@@ -295,6 +328,7 @@ fn a_real_hunters_block_lists_exactly_what_the_reference_client_lists() {
/// (80x22 centred on the page's TOPLEFT + (305,-422)), and that it closes the window.
#[test]
fn the_pages_close_button_sits_where_the_reference_seats_it_and_closes_the_window() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = shown_skills_page();
assert!(
@@ -330,18 +364,24 @@ fn the_pages_close_button_sits_where_the_reference_seats_it_and_closes_the_windo
);
// Its label is the CLOSE global string's seat, in the panel-button gold.
let label = s
.extract()
// **"Close", not "CLOSE".** An XML `text=` attribute is a GlobalStrings LOOKUP, not a literal
// (`loader::Loader::resolve_text`, wow-re `rf28-typed-widget-loadxml.md` l.36 —
// `FrameScript_GetText 0x703bf0`), and `CLOSE = "Close"` (`GlobalStrings.lua:760`). This test
// read "CLOSE" while the page's list carried no `GlobalStrings.lua`: it was asserting our
// loader's miss-fallback (the LITERAL, a deliberate divergence for benilla-authored files),
// not the label the reference client draws. [`super::test_ui::CHARACTER_UI`] loads the
// player's own strings first, as the app does, so this is the real label now.
let label = page_quads(&s)
.iter()
.find_map(|q| match &q.content {
QuadContent::Text {
text: Some(t),
color,
..
} if t == "CLOSE" => Some(*color),
} if t == "Close" => Some(*color),
_ => None,
})
.expect("the CLOSE label draws");
.expect("the Close label draws");
assert_eq!(
label,
Some([1.0, 0.82, 0.0, 1.0]),
@@ -368,10 +408,12 @@ fn the_pages_close_button_sits_where_the_reference_seats_it_and_closes_the_windo
/// cap), not 3px above it, which is where the offset copied from `TrainerFrame.xml` put it.
#[test]
fn the_collapse_all_fold_wears_the_row_font_and_the_references_seat() {
let _data = benilla_formats::wow_data_or_skip!();
let s = shown_skills_page();
let label = s
.extract()
// "All", not "ALL" — the same GlobalStrings lookup as the CLOSE button above
// (`ALL = "All"`, `GlobalStrings.lua:45`). The face is what this test is about, not the word.
let label = page_quads(&s)
.iter()
.find_map(|q| match &q.content {
QuadContent::Text {
@@ -379,10 +421,10 @@ fn the_collapse_all_fold_wears_the_row_font_and_the_references_seat() {
color,
font_height,
..
} if t == "ALL" => Some((*color, *font_height)),
} if t == "All" => Some((*color, *font_height)),
_ => None,
})
.expect("the ALL label draws");
.expect("the All label draws");
assert_eq!(
label,
(Some([1.0, 1.0, 1.0, 1.0]), Some(12.0)),
@@ -423,6 +465,7 @@ fn the_collapse_all_fold_wears_the_row_font_and_the_references_seat() {
/// reference's, and only the first is what a cold load sees.
#[test]
fn the_expand_tab_fits_its_label_once_a_measure_answers() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = shown_skills_page();
assert_eq!(
s.eval::<f64>("return SkillFrameExpandButtonFrame:GetWidth()")
+88 -8
View File
@@ -144,6 +144,80 @@ pub(super) const LOOT_UI: &[&str] = &[
"PartyFrame.xml",
];
/// What the **character window** needs before `Interface\FrameXML\CharacterFrame.xml`,
/// `PaperDollFrame.xml` and `PetPaperDollFrame.xml` will load and behave — the same shape as
/// [`BAG_UI`] / [`LOOT_UI`] / [`MERCHANT_UI`], and grown the same way (decision 1751).
///
/// This one is the longest of the four, and the reason is `CharacterFrame_OnLoad`: it is the only
/// migrated window whose LOAD-time body reaches outside its own file, and it reaches into four
/// others at once —
///
/// * **`Interface\FrameXML\TextStatusBar.lua`** for `SetTextStatusBarTextPrefix`, which it calls
/// three times before doing anything else. Its `.xml` twin comes too, because the pet page's XP
/// bar inherits the `TextStatusBar` template it declares and a missing template is a loader
/// *warning* — the bar would load with no art and no text region and read as a pass.
/// * **`UnitFrames.xml`** for `PlayerFrameHealthBar` / `PlayerFrameManaBar`, the frames those three
/// calls name, plus `PetFrameHealthBar` / `PetFrameManaBar` which `CharacterFrame_OnShow` shows
/// the text on. (Ours: the unit frames are not migrated. When they are, this entry becomes the
/// stock `PlayerFrame.xml` / `PetFrame.xml` pair.)
/// * **`ActionBar.xml`** for `MainMenuExpBar` — the third bar of that prefix call — and for
/// `ShowWatchedReputationBarText` / `HideWatchedReputationBarText`, which the window's
/// show/hide pair calls.
/// * **`UIPanelTemplates.xml`** for `PanelTemplates_SetNumTabs`/`_SetTab`, the last two lines of
/// that same OnLoad.
///
/// And two more that only bite later, which is exactly why they are written down:
///
/// * **`Interface\FrameXML\HonorFrame.xml`** — `PaperDollFrame_SetLevel` and `_SetGuild` write
/// `HonorLevelText` and `HonorGuildText` "while we're at it" (`PaperDollFrame.lua:100`/`:120`).
/// Nothing touches them at load, so leaving this out loads clean and then raises the first time
/// the window is SHOWN. It sits below the character block in the manifest for the same reason.
/// * **`MicroMenu.xml`** — `UpdateMicroButtons` (called by both `CharacterFrame_OnShow` and
/// `_OnHide`) and `MicroButtonTooltipText` (all five tab hovers). A tab hover is the only thing
/// that reaches the second one, so its absence is invisible until a test hovers a tab.
///
/// Needs client data, like its three siblings: open with `benilla_formats::wow_data_or_skip!()`.
pub(super) const CHARACTER_UI: &[&str] = &[
// The reference's own localized strings. The stock file has no `X = X or "…"` fallbacks —
// `PaperDollFrame_OnLoad` sets seven labels from them at LOAD, and `PaperDollFrame_SetStats`
// concatenates `SPELL_STAT0_NAME`..`4` on every repaint.
"Interface\\FrameXML\\GlobalStrings.lua",
"Fonts.xml",
"BasicControls.xml", // TEXT(), which every one of those label sets goes through
"Interface\\FrameXML\\ItemButtonTemplate.xml", // PaperDollItemSlotButtonTemplate's base
"MoneyFrame.xml",
"UIParent.xml", // Model_OnLoad/_Rotate*/_OnUpdate — the model panes' turntable
"UiPanels.xml", // CharacterFrameTabButtonTemplate, UIPanelWindows, Show/HideUIPanel
"GameTooltip.xml",
"Cooldown.xml", // CooldownFrameTemplate + CooldownFrame_SetTimer, per equipment slot
"UIPanelTemplates.xml",
// The unit frames' four right-click dropdowns call `UIDropDownMenu_Initialize` at LOAD, so the
// kit and the menu table it initialises from both precede them — the manifest's own order
// (175 → 189 → 193 → 263).
"UIDropDownMenu.xml",
"Interface\\FrameXML\\UIMenu.xml",
"UnitPopup.xml",
"Interface\\FrameXML\\TextStatusBar.lua",
"Interface\\FrameXML\\TextStatusBar.xml",
"UnitFrames.xml",
"ActionBar.xml",
"MicroMenu.xml",
// The two page files these three need before the window can be OPENED, which is not the same
// as before it can load: `CHARACTERFRAME_SUBFRAMES` lists all five pages by name and
// `CharacterFrame_ShowSubFrame` calls `getglobal(value):Hide()` on each one it is not showing
// (`CharacterFrame.lua:25-32`), unguarded. A missing page is `attempt to index a nil value` on
// the very first `ToggleCharacter` — load-clean, then dead on the first click. Their own
// template dependencies come with them.
"ScrollTemplates.xml", // SkillFrame's faux list + trough
"OptionsFrameTemplates.xml", // ReputationFrame's detail check boxes
"Interface\\FrameXML\\CharacterFrame.xml",
"Interface\\FrameXML\\PaperDollFrame.xml",
"Interface\\FrameXML\\PetPaperDollFrame.xml",
"ReputationFrame.xml",
"SkillFrame.xml",
"Interface\\FrameXML\\HonorFrame.xml",
];
pub(super) const BAG_UI: &[&str] = &[
// The reference's own localized strings — `BACKPACK_TOOLTIP`, `EQUIP_CONTAINER`, `KEYRING`,
// the `*_FONT_COLOR_CODE` pair. The app loads this at VM setup, ahead of the manifest
@@ -154,11 +228,6 @@ pub(super) const BAG_UI: &[&str] = &[
// is the better answer, and these tests already gate on the install.
"Interface\\FrameXML\\GlobalStrings.lua",
"Fonts.xml",
// The reference's own `PaperDollItemSlotButton_*` family, sourced at manifest entry 2. Not
// optional since 1751's third window: stock `BagSlotButtonTemplate`'s OnLoad *is*
// `PaperDollItemSlotButton_OnLoad()`, which is what gives each bag button its inventory-slot
// id (20..23 via `GetInventorySlotInfo`), its six event registrations and its first paint.
"Interface\\FrameXML\\PaperDollFrame.lua",
// `TEXT()` — the reference's own identity-function wrapper, which stock
// `MainMenuBarBackpackButton`'s OnEnter calls (`GameTooltip:SetText(TEXT(BACKPACK_TOOLTIP)…)`)
// and `BagSlotButton_OnEnter` calls for `EQUIP_CONTAINER`. Manifest entry 3, and not optional
@@ -183,9 +252,20 @@ pub(super) const BAG_UI: &[&str] = &[
"MicroMenu.xml",
"UIPanelTemplates.xml",
"Interface\\FrameXML\\ContainerFrame.xml",
// `PaperDollItemSlotButtonTemplate`, which every bag button inherits — resolved at load, so it
// has to precede the bar exactly as it does in the manifest.
"ItemSlotButtonTemplates.xml",
// `PaperDollItemSlotButtonTemplate` and the `PaperDollItemSlotButton_*` family behind it,
// which every bag button inherits and runs — resolved at load, so this has to precede the bar
// exactly as it does in the manifest. Stock `BagSlotButtonTemplate`'s OnLoad *is*
// `PaperDollItemSlotButton_OnLoad()`, which gives each bag button its inventory-slot id
// (20..23 via `GetInventorySlotInfo`), its six event registrations and its first paint.
//
// The whole paper-doll file, because that is where the reference declares both — our
// `ItemSlotButtonTemplates.xml` held a transcribed copy of the template only because our own
// character window loaded too late to declare it, and it is deleted (decision 1751). Its
// companion `Interface\\FrameXML\\CharacterFrame.xml` is deliberately NOT here: this list is
// the bags, `PaperDollFrame` only names `CharacterFrame` in `parent=` (a missing parent is a
// loader warning, not an error), and `CharacterFrame_OnLoad` would drag in the unit frames,
// the XP bar and the text-status-bar file for a window no bag test opens.
"Interface\\FrameXML\\PaperDollFrame.xml",
// The bag BAR itself, the reference's own since 1751's third window: MainMenuBarBackpackButton,
// CharacterBag0..3Slot, KeyRingButton, `BagSlotButtonTemplate`, and `KEYRING_CONTAINER`.
"Interface\\FrameXML\\MainMenuBarBagButtons.xml",
@@ -9,60 +9,90 @@
//! `ContainerFrameItemButton_OnEnter` on one of its recycled `ContainerFrame1..12` now. Same two
//! calls arm the compare (`GameTooltip:SetOwner` + `SetBagItem`), so what these tests pin is
//! unchanged — but the handler reads `this`, so they drive the mouse
//! ([`super::test_ui::hover`]) instead of calling it. The doll end is still ours
//! (`BenillaPaperDollSlot_OnEnter`) and is still called directly.
//! ([`super::test_ui::hover`]) instead of calling it.
//!
//! **The doll end went the same way.** `CharacterFrame.xml` and `PaperDollFrame.xml` are the
//! reference's own too now, so `BenillaPaperDollSlot_OnEnter` is gone and the hover is stock
//! `PaperDollItemSlotButton_OnEnter` (`PaperDollFrame.lua:739-764`), which reads `this` as well.
//! The doll-slot names (`CharacterHeadSlot` and kin) are unchanged; the mouse is what reaches them.
use benilla_ui::script::{
ContainerSlot, ContainerState, InvSlotView, InventorySlots, ItemTemplateView, UiScript,
UnitState,
};
use super::test_ui::{bag_slot_button, hover, load_ui as load_xml, BAG_UI};
use super::test_ui::{bag_slot_button, hover, BAG_UI, CHARACTER_UI};
/// The window set the compare flow crosses **without a bag window**: fonts, UIParent, panels, both
/// tooltip files and the character window (the listener's doll slots). Deliberately install-free,
/// so the chat-link test still runs on a bare checkout.
/// The two shared lists overlap heavily — `GlobalStrings`, the fonts, `UIParent`, the tooltip,
/// `Cooldown`, the action bar and `Interface\FrameXML\PaperDollFrame.xml` are in both — and
/// loading one file twice redeclares its frames. So a list is walked once and anything already
/// loaded is skipped, which also keeps the ORDER the first list asked for.
fn load_once(s: &UiScript, seen: &mut Vec<&'static str>, files: &[&'static str]) {
for f in files {
if seen.contains(f) {
continue;
}
seen.push(f);
super::test_ui::load_ui_strict(s, f);
}
}
/// The window set the compare flow crosses **without a bag window**: the whole character window
/// (the listener's doll slots), plus [`ROUTER_UI`].
///
/// **Needs client data.** It always did — `Interface\FrameXML\ItemRef.xml` has been a chain entry
/// since it was pointed there — but the comment here claimed "deliberately install-free" until
/// 1751 made the claim impossible to miss. Callers open with `wow_data_or_skip!`.
fn harness() -> UiScript {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
for f in [
"Fonts.xml",
"MoneyFrame.xml",
"UiPanels.xml",
"UIParent.xml",
"GameTooltip.xml",
"Interface\\FrameXML\\ItemRef.xml",
"Interface\\FrameXML\\MerchantFrame.xml",
"Cooldown.xml",
"CharacterFrame.xml",
] {
load_xml(&s, f);
}
let mut seen = Vec::new();
load_once(&s, &mut seen, CHARACTER_UI);
load_once(&s, &mut seen, &ROUTER_UI);
s.set_money(0);
s.set_unit("player", Some(player()));
s
}
/// The two reference files this harness carries beyond the character window: `MerchantFrame.xml`,
/// the other window the compare flow crosses, and `ItemRef.xml`, which declares the chat-link
/// router's own `ItemRefTooltip`. Both were in this harness's hand-copied list before 1751; the
/// manifest's order is the one kept (`FrameXML.toc` 63 → 77).
const ROUTER_UI: [&str; 2] = [
"Interface\\FrameXML\\MerchantFrame.xml",
"Interface\\FrameXML\\ItemRef.xml",
];
/// A level-60 player carrying **both** halves of the race and class pairs: `UnitRace`/`UnitClass`
/// answer `(localized, file)` or `nil, nil` — the binding `zip`s them — and stock
/// `PaperDollFrame_SetLevel` formats all three into `CharacterLevelText` unguarded
/// (`PaperDollFrame.lua:100-104`) on every show of the character window.
fn player() -> UnitState {
UnitState {
exists: true,
level: 60,
race: Some("Human".into()),
race_file: Some("Human".into()),
class: Some("Warrior".into()),
class_file: Some("WARRIOR".into()),
..UnitState::default()
}
}
/// [`harness`] with the REFERENCE's bag windows (1751) — the hover source. `BAG_UI` is the ordered
/// set a test needs before it can open one and already carries the fonts, panels, tooltip,
/// item-button template and bag bar; `UIParent.xml` leads because it inherits nothing and every
/// window below parents to it.
/// set a test needs before it can open one; `CHARACTER_UI` leads because the manifest seats the
/// character block above the containers (`FrameXML.toc` 53-58 → 65) and because it carries
/// `PaperDollFrame.xml`, which `MainMenuBarBagButtons` inherits its `BagSlotButtonTemplate` body
/// from.
///
/// **Needs client data**: `BAG_UI` names a chain entry, so its callers open with
/// `wow_data_or_skip!`.
/// **Needs client data**: both lists name chain entries, so callers open with `wow_data_or_skip!`.
fn harness_with_bags() -> UiScript {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "UIParent.xml");
for f in BAG_UI {
load_xml(&s, f);
}
for f in [
"Interface\\FrameXML\\ItemRef.xml",
"Interface\\FrameXML\\MerchantFrame.xml",
"CharacterFrame.xml",
] {
load_xml(&s, f);
}
let mut seen = Vec::new();
load_once(&s, &mut seen, CHARACTER_UI);
load_once(&s, &mut seen, BAG_UI);
load_once(&s, &mut seen, &ROUTER_UI);
s.set_money(0);
s
}
@@ -156,7 +186,7 @@ fn seed_items(s: &mut UiScript) {
fn shift_compare_over_a_bag_item_seats_on_the_doll_slot() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = harness_with_bags();
s.set_unit("player", Some(UnitState::default()));
s.set_unit("player", Some(player()));
seed_items(&mut s);
// Open the bag and hover the helm slot. The button is ASKED for by its own `GetID()` — the
@@ -236,6 +266,7 @@ fn shift_compare_over_a_bag_item_seats_on_the_doll_slot() {
/// close button hides it.
#[test]
fn item_ref_tooltip_renders_a_chat_link() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = harness();
seed_items(&mut s);
s.run(
@@ -272,7 +303,7 @@ fn item_ref_tooltip_renders_a_chat_link() {
fn doll_hover_renders_the_live_instance_and_never_self_compares() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = harness_with_bags();
s.set_unit("player", Some(UnitState::default()));
s.set_unit("player", Some(player()));
seed_items(&mut s);
// Break the equipped helm: instance pair (0, 40); the template stays authored-full.
let mut inv: InventorySlots = Default::default();
@@ -326,9 +357,10 @@ fn doll_hover_renders_the_live_instance_and_never_self_compares() {
);
s.set_modifiers(false, false, false);
// The doll hover: the live pair, not the template's 40/40.
s.run("BenillaPaperDollSlot_OnEnter(CharacterHeadSlot)")
.unwrap();
// The doll hover: the live pair, not the template's 40/40. Stock
// `PaperDollItemSlotButton_OnEnter` reads `this`, so the mouse is what reaches it — and moving
// OFF the bag button is part of what this test needs anyway.
hover(&mut s, "CharacterHeadSlot");
assert!(s.errors().is_empty(), "hover errors: {:?}", s.errors());
let found: String = s
.eval(
+1 -1
View File
@@ -366,7 +366,7 @@ fn feed_stable_booth(
};
// Written every frame, selection or none — a stale yaw would snap the model the moment one is
// picked (the pet paper doll's arrangement).
booth.yaw = script.stable_yaw();
booth.yaw = script.model_pane_facing("PetStableModel");
// `GetSelectedStablePet()`'s own answer: `0` = the summoned pet, `1..=2` a bought stable slot,
// `-1` nothing. Reading the binding's translation rather than the raw petNumber is deliberate —
+160 -11
View File
@@ -1,5 +1,6 @@
//! Drives the REAL `assets/ui/CharacterFrame.xml` stat rows through the engine — the first test
//! that executes the paper doll's own `PaperDollFrame_Set*` Lua at all.
//! Drives the REAL `Interface\FrameXML\PaperDollFrame.xml` stat rows through the engine — the
//! first test that executes the paper doll's own `PaperDollFrame_Set*` Lua at all, and since
//! decision 1751 that Lua is the reference's own rather than a transcription of it.
//!
//! That gap is why this file exists. The character sheet's whole buff-decomposition surface — the
//! green number, the red number, the `(base +x -y)` tooltip — sat broken from the day it shipped
@@ -19,17 +20,38 @@ mod common;
use benilla_ui::script::{UiScript, UnitCombatStats, UnitState};
/// The character window's load prefix, in `assets/ui/benilla.toc` order. The .toc's own entry says
/// it "depends only on Fonts/UiPanels/GameTooltip"; `ItemButtonTemplate.xml` joins them because the
/// doll's equipment slots are `SetItemButton*` buttons.
const FILES: [&str; 6] = [
/// The paper doll's load prefix, in `assets/ui/benilla.toc` order.
///
/// **`CharacterFrame.xml` is deliberately not here.** These tests repaint the stat rows directly;
/// they never open the window, and `PaperDollFrame`'s only tie to its container is a `parent=`
/// name, which the loader warns about and falls back from. Pulling the container in would drag
/// `CharacterFrame_OnLoad`'s four external dependencies (the unit frames, the XP bar,
/// `TextStatusBar.lua`, the panel-tab kit — see `ui_script::test_ui::CHARACTER_UI`) into a test
/// about arithmetic.
///
/// `GlobalStrings.lua` and `BasicControls.xml` are not scenery either: the stock file has no
/// `X = X or "…"` fallbacks, and every row here formats a real string — `SPELL_STAT0_NAME`..`4`
/// through `TEXT()` (`PaperDollFrame.lua:143`), `RESISTANCE<n>_NAME` and
/// `RESISTANCE_TOOLTIP_SUBTEXT` (`:184`/`:224`), `ARMOR` and `ARMOR_TOOLTIP` (`:236`/`:249`).
const FILES: [&str; 10] = [
"Interface\\FrameXML\\GlobalStrings.lua",
"Fonts.xml",
"BasicControls.xml",
// The reference's own since 1751 window 24 — `common::load_ui` speaks both stores.
"Interface\\FrameXML\\ItemButtonTemplate.xml",
"MoneyFrame.xml",
// `Model_OnLoad` — the model pane's `<OnLoad>` calls it, so this is a LOAD-time dependency of
// the paper doll, not of the window. The reference declares it in `UIParent.lua`; ours lives
// in our counterpart of that file.
"UIParent.xml",
"UiPanels.xml",
"GameTooltip.xml",
"CharacterFrame.xml",
// `CooldownFrameTemplate` and `CooldownFrame_SetTimer`. Not scenery: every one of the 20 slot
// buttons runs `PaperDollItemSlotButton_Update` from its own OnLoad, which calls
// `CooldownFrame_SetTimer` on `$parentCooldown` unconditionally (`PaperDollFrame.lua:692`) —
// so leaving this out is 20 loader errors before the first assertion.
"Cooldown.xml",
"Interface\\FrameXML\\PaperDollFrame.xml",
];
fn load_ui(script: &UiScript) {
@@ -86,6 +108,12 @@ fn text_of(script: &mut UiScript, region: &str) -> String {
.unwrap_or_else(|e| panic!("reading {region}: {e}"))
}
fn tooltip_of_field(script: &mut UiScript, frame: &str, field: &str) -> String {
script
.eval::<String>(&format!(r#"return getglobal("{frame}").{field}"#))
.unwrap_or_else(|e| panic!("reading {frame}.{field}: {e}"))
}
fn tooltip_of(script: &mut UiScript, frame: &str) -> String {
script
.eval::<String>(&format!(r#"return getglobal("{frame}").tooltip"#))
@@ -163,13 +191,16 @@ fn resistance_rows_colour_by_which_half_is_bigger() {
let mut s = seated();
painted(&mut s);
// The five magic rows are the sheet's own school order (BENILLA_MAGICRES_IDS); resolve each
// row's school from the frame rather than assuming, so a row-order change cannot silently
// re-point these assertions.
// Resolve each row's school from the FRAME's own `id=`, never from a table: that is exactly
// what the reference's `PaperDollFrame_SetResistances` does (`UnitResistance("player",
// frame:GetID())`), and it means a row-order change cannot silently re-point these assertions.
// It used to read our deleted file's `BENILLA_MAGICRES_IDS`; the stock XML carries the same
// mapping on the frames themselves (`MagicResFrame1 id="6"` arcane, then 2..5 fire/nature/
// frost/shadow — `PaperDollFrame.xml:654-758`).
let row_of = |s: &mut UiScript, school: i64| -> usize {
s.eval::<i64>(&format!(
"for i = 1, NUM_RESISTANCE_TYPES do \
if BENILLA_MAGICRES_IDS[i] == {school} then return i end \
if getglobal(\"MagicResFrame\"..i):GetID() == {school} then return i end \
end return 0"
))
.expect("the school→row map") as usize
@@ -191,6 +222,124 @@ fn resistance_rows_colour_by_which_half_is_bigger() {
);
}
/// **The melee block: the seven-value `UnitDamage` unpacked positionally by the reference's own
/// Lua**, and the arithmetic it does with the last three.
///
/// The stat rows above pin `UnitStat`/`UnitResistance`/`UnitArmor`. This one pins the family
/// decision 1793 warns about hardest, because its consumer is arithmetic rather than a lookup:
/// `PaperDollFrame_SetDamage` destructures **seven** values and immediately divides by the
/// seventh. One value short and `percent` is nil — `(minDamage / nil)` raises; one value long and
/// every term after it is off by a slot and the numbers come out plausible and wrong. Asserting
/// the rendered strings is what tells those two apart from correct.
///
/// The numbers below are the reference's own formula, not ours: `displayMin`/`displayMax` are the
/// raw range floored/ceiled, while the TOOLTIP range is the same numbers with the multiplier
/// divided out and the flat bonuses subtracted (`PaperDollFrame.lua:279-283`) — which is why
/// "40 - 62" and "27 - 47" are both right and different.
#[test]
fn the_melee_block_unpacks_seven_values_and_does_the_references_arithmetic() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = seated();
// 40.5-61.5 at 2.6 s, +12/-3 flat and a x1.1 multiplier: every one of the seven slots is
// distinct and non-zero, so a slot read from the wrong position cannot coincide with the
// right answer.
s.set_player_combat_stats(Some(UnitCombatStats {
main_attack_time_ms: 2600,
min_damage: 40.5,
max_damage: 61.5,
physical_bonus_pos: 12,
physical_bonus_neg: -3,
damage_percent: 1.1,
attack_power: 780,
attack_power_pos: 30,
attack_power_neg: -10,
..stats()
}));
s.run("PaperDollFrame_SetDamage() PaperDollFrame_SetAttackPower()")
.expect("the melee rows repaint");
// The ROW: the raw range, green because the bonuses are net positive.
assert_eq!(
text_of(&mut s, "CharacterDamageFrameStatText"),
format!("{GREEN}40 - 62|r")
);
// The TOOLTIP: the same range with the multiplier divided out and the flat bonuses removed,
// then each modifier appended in the reference's own order and colours.
assert_eq!(
tooltip_of_field(&mut s, "CharacterDamageFrame", "damage"),
format!("27 - 47{GREEN} +12|r{RED} -3|r{GREEN} x110%|r")
);
let speed: f64 = s
.eval("return CharacterDamageFrame.attackSpeed")
.expect("the hover's attack speed");
assert!((speed - 2.6).abs() < 1e-6, "got {speed}");
// dps = fullDamage / speed, fullDamage = (base + pos + neg) * percent = 51.0.
let dps: f64 = s.eval("return CharacterDamageFrame.dps").expect("the dps");
assert!((dps - 51.0 / 2.6).abs() < 0.01, "got {dps}");
// No offhand weapon: the reference NILS the offhand speed, which is what its hover branches on.
assert!(s
.eval::<Option<f64>>("return CharacterDamageFrame.offhandAttackSpeed")
.unwrap()
.is_none());
// Attack power goes through `PaperDollFormatStat`, whose colour law is "red if anything is
// negative, green otherwise" — so a mixed pair is RED even though the net is positive.
assert_eq!(
text_of(&mut s, "CharacterAttackPowerFrameStatText"),
format!("{RED}800|r")
);
let ap_tip = tooltip_of(&mut s, "CharacterAttackPowerFrame");
assert!(
ap_tip.contains("800") && ap_tip.contains("(780") && ap_tip.contains("+30"),
"the AP tooltip names the effective, the base and the buff, got {ap_tip:?}"
);
// The plain leg: no bonuses and no multiplier, so `totalBonus == 0` and the row carries no
// colour escape at all — the branch a wrong `percent` would silently take.
s.set_player_combat_stats(Some(UnitCombatStats {
main_attack_time_ms: 2600,
min_damage: 40.5,
max_damage: 61.5,
damage_percent: 1.0,
..stats()
}));
s.run("PaperDollFrame_SetDamage()").unwrap();
assert_eq!(text_of(&mut s, "CharacterDamageFrameStatText"), "40 - 62");
}
/// **The ranged block with nothing in the ranged slot** — the `NOT_APPLICABLE` fallback all three
/// ranged rows share, and the `PaperDollFrame.noRanged` latch that carries it between them.
///
/// It is the reachable state for eight of nine classes at level 1, and it is the branch that
/// decides whether `PaperDollFrame_SetRangedDamage` unpacks `UnitRangedDamage`'s six values at
/// all — so it has to be pinned before the numbers are worth anything.
#[test]
fn the_ranged_rows_fall_back_to_not_applicable_with_an_empty_ranged_slot() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = seated();
s.run(
"PaperDollFrame_SetRangedAttack() PaperDollFrame_SetRangedDamage() PaperDollFrame_SetRangedAttackPower()",
)
.expect("the ranged rows repaint");
let na: String = s
.eval("return NOT_APPLICABLE")
.expect("the ref's own string");
for row in [
"CharacterRangedAttackFrameStatText",
"CharacterRangedDamageFrameStatText",
"CharacterRangedAttackPowerFrameStatText",
] {
assert_eq!(text_of(&mut s, row), na, "{row}");
}
// …and the hover has nothing to show, which is the guard
// `CharacterRangedDamageFrame_OnEnter` opens with.
assert!(s
.eval::<Option<String>>("return CharacterRangedDamageFrame.damage")
.unwrap()
.is_none());
}
/// Armor is the one row of this family the local server never decomposes — item armor lands in
/// `UNIT_FIELD_RESISTANCES[0]` itself, not in the buff split — so its plain-number leg is the live
/// shape and worth pinning as such.
+152 -88
View File
@@ -154,6 +154,10 @@ pub struct UnitCombatStats {
/// WHICH skill via [`weapon_subclass_skill`] (unarmed = [`SKILL_UNARMED`]) and reads the pair
/// from `PLAYER_SKILL_INFO`; `UnitAttackBothHands` serves it verbatim.
pub main_weapon_skill: (u32, i32),
/// The OFF hand's, read the same way. `UnitAttackBothHands` pushes both hands (§4 of wow-re's
/// `pet-paperdoll-stat-api.md`: `0x518810` calls `[vtbl+0xb0]` twice, hand 0 then hand 1), and
/// an empty or non-weapon off hand is Unarmed exactly as the main hand is.
pub offhand_weapon_skill: (u32, i32),
/// The ranged weapon's skill pair (`UnitRangedAttack`).
pub ranged_weapon_skill: (u32, i32),
/// The [`SKILL_DEFENSE`] line's `(value, temp+perm bonus)` pair, read out of `PLAYER_SKILL_INFO`
@@ -200,6 +204,7 @@ impl Default for UnitCombatStats {
ranged_min_damage: 0.0,
ranged_max_damage: 0.0,
main_weapon_skill: (0, 0),
offhand_weapon_skill: (0, 0),
ranged_weapon_skill: (0, 0),
defense_skill: (0, 0),
has_wand: false,
@@ -466,21 +471,6 @@ impl super::UiScript {
pub fn take_inventory_uses(&mut self) -> Vec<u32> {
std::mem::take(&mut self.model_mut().inventory_uses)
}
/// The paper-doll model pane's yaw in radians (`BenillaPaperDollModel_SetFacing` wrote it) —
/// a persistent value the app samples each frame to pose the booth's body bake (decision 0208
/// §5), not a drain. `0.0` until Lua sets it (the ref's own default, 0.61, is authored in the
/// window's OnLoad).
pub fn paperdoll_yaw(&self) -> f32 {
self.model_ref().paperdoll_yaw
}
/// The **pet** paper doll pane's yaw (`BenillaPetPaperDollModel_SetFacing` wrote it) — the
/// exact twin of [`Self::paperdoll_yaw`] and of `UiScript::inspect_yaw`, sampled each frame by
/// `crate::ui_pet_doll` onto its booth (decision 1057).
pub fn pet_paperdoll_yaw(&self) -> f32 {
self.model_ref().pet_paperdoll_yaw
}
}
/// Read a unit's combat-stats snapshot under a short model borrow, routed by token: `"player"` and
@@ -502,6 +492,16 @@ fn with_unit_stats<T>(
f(pushed.unwrap_or(&absent))
}
/// The post-processing both skill-shaped bindings share: **`if (mod + base) < 0 then mod = -base`**
/// — so the pair can never sum below zero (`0x519298` for `UnitDefense`, `0x5188a7` per hand for
/// `UnitAttackBothHands`; wow-re `ui/scratch/pet-paperdoll-stat-api.md` §4). A debuff deeper than
/// the skill itself reads as "reduced to 0", never as a negative total.
fn skill_clamped((base, modifier): (u32, i32)) -> (i64, i64) {
let base = i64::from(base);
let modifier = i64::from(modifier);
(base, if modifier + base < 0 { -base } else { modifier })
}
/// A **non-player** unit's answer to both skill-shaped questions — `UnitDefense` and
/// `UnitAttackBothHands` — which is `UNIT_FIELD_LEVEL * 5`, flat.
///
@@ -839,27 +839,39 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
})?,
)?;
// UnitAttackBothHands("player") → (base, modifier) — the main-hand weapon-skill line: skill
// value + its temp+perm bonus, resolved app-side via the weapon_subclass_skill table (unarmed
// = SKILL_UNARMED when nothing's equipped).
// UnitAttackBothHands("player") → (mainBase, mainMod, offBase, offMod) — **FOUR** returns, one
// pair per hand: each hand's weapon-skill line, value + its temp+perm bonus, resolved app-side
// via the weapon_subclass_skill table (unarmed = SKILL_UNARMED for an empty or non-weapon
// hand).
//
// A non-player unit takes the same virtual fork `UnitDefense` does (`0x6136b0`, wow-re
// `ui/scratch/pet-paperdoll-stat-api.md`) and lands on the identical `level * 5` — and that
// one **ignores its hand index outright**, so both hands answer the same pair. The pet sheet's
// Attack row therefore reads 300 at level 60, exactly like its Defense row.
// **It answered two until decision 1810**, which is 1793's shape at its quietest: `0x518810`
// calls `[vtbl+0xb0]` TWICE, hand 0 then hand 1, and pushes `(base0, mod0, base1, mod1)`
// (VERIFIED, wow-re `ui/scratch/pet-paperdoll-stat-api.md` §4). The reference's own
// `PaperDollFrame_SetAttackBothHands` destructures only the first two — its next line is
// `-- FIXME: The offhand stats aren't displayed yet.` — so putting the stock character sheet on
// the chain could never have exposed this. An addon reading four gets two nils.
//
// A non-player unit takes the same virtual fork `UnitDefense` does (`0x6136b0`) and lands on
// the identical `level * 5` — and that one **ignores its hand index outright**, so both hands
// answer the same pair. The pet sheet's Attack row reads 300 at level 60, like its Defense row.
g.set(
"UnitAttackBothHands",
lua.create_function(|lua, token: Option<String>| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
Ok(match token.as_deref() {
Some("player") => model.player_combat_stats.as_ref().map_or((0, 0), |s| {
(
i64::from(s.main_weapon_skill.0),
i64::from(s.main_weapon_skill.1),
)
}),
Some("pet") => (cgunit_skill(&model, "pet"), 0),
_ => (0, 0),
Some("player") => model
.player_combat_stats
.as_ref()
.map_or((0, 0, 0, 0), |s| {
let (mb, mm) = skill_clamped(s.main_weapon_skill);
let (ob, om) = skill_clamped(s.offhand_weapon_skill);
(mb, mm, ob, om)
}),
Some("pet") => {
let base = cgunit_skill(&model, "pet");
(base, 0, base, 0)
}
_ => (0, 0, 0, 0),
})
})?,
)?;
@@ -895,9 +907,10 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
lua.create_function(|lua, token: Option<String>| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
Ok(match token.as_deref() {
Some("player") => model.player_combat_stats.as_ref().map_or((0, 0), |s| {
(i64::from(s.defense_skill.0), i64::from(s.defense_skill.1))
}),
Some("player") => model
.player_combat_stats
.as_ref()
.map_or((0, 0), |s| skill_clamped(s.defense_skill)),
Some("pet") => (cgunit_skill(&model, "pet"), 0),
_ => (0, 0),
})
@@ -1174,33 +1187,6 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
})?,
)?;
// BenillaPaperDollModel_SetFacing(radians) — the model pane's rotate buttons write the bake
// yaw here (persistent; the app samples UiScript::paperdoll_yaw each frame — decision 0208
// §5's rotate-adjusts-the-bake). Benilla-named: the real client's Model:SetFacing is a widget
// method on a live 3D pane; our doctrine-consistent still carries one scalar.
g.set(
"BenillaPaperDollModel_SetFacing",
lua.create_function(|lua, radians: f32| {
lua.app_data_mut::<Model>()
.expect("model app_data")
.paperdoll_yaw = radians;
Ok(())
})?,
)?;
// BenillaPetPaperDollModel_SetFacing(radians) — the PET pane's own bake yaw (decision 1057),
// the exact twin of `BenillaInspectModel_SetFacing`: a third scalar, because tab 1 and tab 2
// are two panes that can sit at two different facings.
g.set(
"BenillaPetPaperDollModel_SetFacing",
lua.create_function(|lua, radians: f32| {
lua.app_data_mut::<Model>()
.expect("model app_data")
.pet_paperdoll_yaw = radians;
Ok(())
})?,
)?;
Ok(())
}
@@ -1240,6 +1226,7 @@ mod tests {
ranged_min_damage: 31.0,
ranged_max_damage: 47.0,
main_weapon_skill: (25, 2),
offhand_weapon_skill: (20, 0),
ranged_weapon_skill: (18, 0),
defense_skill: (55, 4),
has_wand: false,
@@ -1488,9 +1475,10 @@ mod tests {
(52, 0, -3)
);
assert_eq!(
s.eval::<(i64, i64)>(r#"return UnitAttackBothHands("player")"#)
s.eval::<(i64, i64, i64, i64)>(r#"return UnitAttackBothHands("player")"#)
.unwrap(),
(25, 2)
(25, 2, 20, 0),
"FOUR values, one pair per hand"
);
assert_eq!(
s.eval::<(i64, i64)>(r#"return UnitRangedAttack("player")"#)
@@ -1509,9 +1497,57 @@ mod tests {
(0, 0, 0)
);
assert_eq!(
s.eval::<(i64, i64)>(r#"return UnitAttackBothHands("target")"#)
s.eval::<(i64, i64, i64, i64)>(r#"return UnitAttackBothHands("target")"#)
.unwrap(),
(0, 0)
(0, 0, 0, 0)
);
}
/// **`UnitAttackBothHands` pushes FOUR values, one pair per hand — and it pushed two until
/// decision 1810.**
///
/// The quietest shape decision 1793 describes: `0x518810` calls `[vtbl+0xb0]` twice, hand 0
/// then hand 1, and pushes `(base0, mod0, base1, mod1)` (VERIFIED, wow-re
/// `ui/scratch/pet-paperdoll-stat-api.md` §4). The reference's own
/// `PaperDollFrame_SetAttackBothHands` destructures only the first two — its very next line is
/// `-- FIXME: The offhand stats aren't displayed yet.` — so no amount of running the stock
/// character sheet could have exposed it. An addon reading four got two nils.
///
/// The pet leg is here too because it is the same call: `CGUnit_C 0x6136b0` takes a hand index
/// and **never reads it**, so both hands answer the identical `level * 5`.
#[test]
fn attack_both_hands_answers_a_pair_per_hand() {
let mut s = UiScript::new().unwrap();
s.set_player_combat_stats(Some(UnitCombatStats {
main_weapon_skill: (300, 5),
offhand_weapon_skill: (275, -12),
..stats()
}));
assert_eq!(
s.eval::<(i64, i64, i64, i64)>(r#"return UnitAttackBothHands("player")"#)
.unwrap(),
(300, 5, 275, -12),
"each hand's own skill line, in hand order"
);
// …and the per-hand clamp the binding applies after each virtual call
// (`0x5188a7`: `if (out2 + out1 < 0) out2 = -out1`), which `UnitDefense` shares.
s.set_player_combat_stats(Some(UnitCombatStats {
main_weapon_skill: (40, -100),
offhand_weapon_skill: (40, -40),
defense_skill: (30, -90),
..stats()
}));
assert_eq!(
s.eval::<(i64, i64, i64, i64)>(r#"return UnitAttackBothHands("player")"#)
.unwrap(),
(40, -40, 40, -40),
"a debuff deeper than the skill reads as reduced-to-0, never a negative total"
);
assert_eq!(
s.eval::<(i64, i64)>(r#"return UnitDefense("player")"#)
.unwrap(),
(30, -30),
"the same clamp, the same law"
);
}
@@ -1650,10 +1686,10 @@ mod tests {
"a level-60 pet: level * 5, modifier flat 0"
);
assert_eq!(
s.eval::<(i64, i64)>(r#"return UnitAttackBothHands("pet")"#)
s.eval::<(i64, i64, i64, i64)>(r#"return UnitAttackBothHands("pet")"#)
.unwrap(),
(300, 0),
"the Attack row takes the same fork — and ignores its hand index"
(300, 0, 300, 0),
"the Attack row takes the same fork — and ignores its hand index, so both pairs match"
);
// The pet's snapshot carries neither pair, which is the whole point: the numbers above
// cannot have come from it.
@@ -1949,29 +1985,57 @@ mod tests {
.is_err());
}
/// The character and pet paper dolls' facings are **the panes' own `SetRotation` state**, read
/// back by name — not the two benilla-named scalars they were until decision 1751.
///
/// The two tabs are two `<PlayerModel>`s that can sit at different facings, which is the whole
/// reason this is per-pane at all: turning one must leave the other alone. It used to be
/// guaranteed by there being two fields on `Model`; now it is guaranteed by there being two
/// frames, which is also what the reference relies on (`Model_RotateLeft(model)` takes the
/// pane).
#[test]
fn paperdoll_facing_persists_for_the_app_to_sample() {
fn each_model_pane_carries_its_own_facing_for_the_app_to_sample() {
let s = UiScript::new().unwrap();
assert_eq!(s.paperdoll_yaw(), 0.0, "unset default");
s.run("BenillaPaperDollModel_SetFacing(0.61)").unwrap();
assert_eq!(s.paperdoll_yaw(), 0.61);
// Persistent, not a drain — two reads see the same value; a later write replaces it.
assert_eq!(s.paperdoll_yaw(), 0.61);
s.run("BenillaPaperDollModel_SetFacing(-0.5)").unwrap();
assert_eq!(s.paperdoll_yaw(), -0.5);
}
assert_eq!(
s.model_pane_facing("CharacterModelFrame"),
0.0,
"a pane whose file has not loaded"
);
s.run(
r#"
CreateFrame("PlayerModel", "CharacterModelFrame")
CreateFrame("PlayerModel", "PetModelFrame")
"#,
)
.unwrap();
// The reference's own default, as `Model_OnLoad` writes it.
s.run("CharacterModelFrame:SetRotation(0.61)").unwrap();
assert_eq!(s.model_pane_facing("CharacterModelFrame"), 0.61);
// Persistent, not a drain — two reads see the same value.
assert_eq!(s.model_pane_facing("CharacterModelFrame"), 0.61);
assert_eq!(
s.model_pane_facing("PetModelFrame"),
0.0,
"the pet pane did not move"
);
/// The pet pane's yaw is a THIRD scalar, not a share of the character pane's: the two tabs can
/// sit at different facings, so a write to either must leave the other alone.
#[test]
fn pet_paperdoll_facing_is_independent_of_the_character_panes() {
let s = UiScript::new().unwrap();
assert_eq!(s.pet_paperdoll_yaw(), 0.0, "unset default");
s.run("BenillaPetPaperDollModel_SetFacing(1.2)").unwrap();
assert_eq!(s.pet_paperdoll_yaw(), 1.2);
assert_eq!(s.paperdoll_yaw(), 0.0, "the character pane did not move");
s.run("BenillaPaperDollModel_SetFacing(0.61)").unwrap();
assert_eq!(s.pet_paperdoll_yaw(), 1.2, "…and neither did the pet pane");
assert_eq!(s.paperdoll_yaw(), 0.61);
s.run("PetModelFrame:SetRotation(1.2)").unwrap();
assert_eq!(s.model_pane_facing("PetModelFrame"), 1.2);
assert_eq!(
s.model_pane_facing("CharacterModelFrame"),
0.61,
"…and neither did the character pane"
);
s.run("CharacterModelFrame:SetRotation(-0.5)").unwrap();
assert_eq!(s.model_pane_facing("CharacterModelFrame"), -0.5);
// A name nothing declares reads 0.0 rather than raising — the app samples this every
// frame, including before the window's file has loaded.
assert_eq!(s.model_pane_facing("NoSuchModelFrame"), 0.0);
// …and so does a name that IS declared but is not a model pane, which is the shape a
// renamed window would take.
s.run(r#"CreateFrame("Frame", "NotAPane")"#).unwrap();
assert!(s.model_pane("NotAPane").is_none());
}
}
+8
View File
@@ -126,6 +126,14 @@ impl UiScript {
});
QuadContent::Cooldown { fraction, flash }
}
// A `<Model>`/`<PlayerModel>` pane's content hole, carrying the pane's own
// name so the app can join it to the bake that window keeps (see
// [`QuadContent::ModelPane`]). Both widget kinds share `KindState::Model`
// because the client's `CGCharacterModelBase` extends `CSimpleModel`, and
// both draw the same way here.
Some(crate::widget::KindState::Model(_)) => QuadContent::ModelPane {
name: frame.and_then(|f| f.name.clone()),
},
_ => QuadContent::Frame,
};
(
+44 -7
View File
@@ -439,16 +439,26 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
// nil. The binding pushes 3 on both legs (`mov eax,3`), so a caller destructuring the third
// gets `0`. Returning nil there would be a different contract.
//
// A unit token the resolver does not recognise **raises**, rather than answering nils — that
// is the binding's own behaviour, and swallowing it would hide a typo'd token forever.
// **RECOGNISED and UNRESOLVABLE are two different things**, and this binding conflated them
// until the stock character sheet came off the chain (decision 1751). It resolves its argument
// through the GENERAL unit-token resolver `0x515940`, so an *unrecognised* token raises via the
// L-less `luaL_error 0x7040e0` at `0x515c1a` — but "a recognised-but-unresolvable token, a
// non-player, a guildless player and a not-yet-arrived cache record ALL return (nil, nil, 0)"
// (wow-re `system/ui` ledger, `0x4c9330`, VERIFIED, `scratch/guild-api-carve.md`).
//
// Ours raised on any token the model held no unit for, which includes `"player"` before the
// app has pushed the first snapshot. Stock `PaperDollFrame_SetGuild` calls
// `GetGuildInfo("player")` UNGUARDED out of `PaperDollFrame_OnShow` (`PaperDollFrame.lua:117`,
// reached from `:569`), so opening the character window in the frames before that push raised
// where the reference prints an empty guild line. [`check_unit_token`] is the resolver's own
// three-way split and every other unit binding already goes through it.
g.set(
"GetGuildInfo",
lua.create_function(|lua, unit: String| {
lua.create_function(|lua, unit: Option<String>| {
crate::script::unit::check_unit_token(&unit)?;
let model = lua.app_data_ref::<Model>().expect("model app_data");
let Some(state) = model.unit(&unit) else {
return Err(mlua::Error::RuntimeError(format!(
"GetGuildInfo: unknown unit '{unit}'"
)));
let Some(state) = unit.as_deref().and_then(|u| model.unit(u)) else {
return Ok((Value::Nil, Value::Nil, Value::Integer(0)));
};
let Some(guild) = state.guild.as_ref() else {
return Ok((Value::Nil, Value::Nil, Value::Integer(0)));
@@ -917,6 +927,33 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
mod tests {
use super::*;
/// **`GetGuildInfo` splits three ways, not two** — wow-re `0x4c9330` (VERIFIED,
/// `scratch/guild-api-carve.md`): only an *unrecognised* token raises; a **recognised**
/// token that resolves to nothing answers `(nil, nil, 0)` like a guildless player does.
///
/// The distinction is not academic. Stock `PaperDollFrame_SetGuild` calls
/// `GetGuildInfo("player")` unguarded from `PaperDollFrame_OnShow`, so before this split
/// existed, opening the character window in the frames before the app pushed its first player
/// snapshot raised — found by putting the reference's own `PaperDollFrame.xml` on the chain
/// (decision 1751). `"player"` with no unit behind it is the whole test.
#[test]
fn a_recognised_but_unresolved_unit_answers_nils_and_only_an_unknown_token_raises() {
let s = crate::script::UiScript::new().unwrap();
// Recognised, resolves to nothing — the paper doll's own case at world entry.
let (name, rank, idx): (Option<String>, Option<String>, i64) = s
.eval(r#"return GetGuildInfo("player")"#)
.expect("a recognised token must not raise");
assert_eq!((name, rank, idx), (None, None, 0));
// …and the third value is the NUMBER zero on that leg, not nil (`mov eax,3` on both).
assert!(s
.eval::<bool>(r#"local a,b,c = GetGuildInfo("party3") return c == 0"#)
.unwrap());
// Unrecognised: the resolver's own raise, unchanged.
assert!(s
.eval::<i64>(r#"return GetGuildInfo("nosuchunit")"#)
.is_err());
}
/// The thirteen-bit table is the API contract, and its non-monotonicity is the trap: Promote
/// and Demote sit at indices 5 and 6 with bits ABOVE Invite and Remove at 7 and 8. A
/// `1 << (i - 1)` implementation passes indices 1-4 and fails the other nine, so this asserts
+4 -8
View File
@@ -1272,7 +1272,6 @@ pub(crate) struct Model {
/// 12th) — recomputed on every inventory push, which fires `UPDATE_INVENTORY_ALERTS`
/// unconditionally, the client's own shape (see [`char_stats`]).
pub(crate) inventory_alerts: [u8; 12],
pub(crate) paperdoll_yaw: f32,
/// Inventory-slot ids queued by `UseInventoryItem` (decision 0208 phase 1b, `cursor`'s `doll`
/// submodule) — drained by the app into `CMSG_USE_ITEM` against the equipped position.
pub(crate) inventory_uses: Vec<u32>,
@@ -1291,12 +1290,11 @@ pub(crate) struct Model {
pub(crate) inspect_notifies: Vec<String>,
/// `ClearInspectPlayer` was called — drained by the app, which drops its inspect target.
pub(crate) inspect_clear: bool,
/// The inspect model pane's bake yaw, the twin of [`Self::paperdoll_yaw`].
/// 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 **pet** paper doll's model-pane bake yaw (decision 1057) — a third scalar for the same
/// reason the inspect pane got a second: character tab 1 and tab 2 are two panes that can sit
/// at two different facings, and the ref carries a `rotation` per `<PlayerModel>`.
pub(crate) pet_paperdoll_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>,
@@ -1821,14 +1819,12 @@ impl Model {
inventory_slots: Default::default(),
bank_bag_slots: Default::default(),
inventory_alerts: [0; 12],
paperdoll_yaw: 0.0,
inventory_uses: Vec::new(),
weapon_enchants: [None; 2],
inspect: None,
inspect_notifies: Vec::new(),
inspect_clear: false,
inspect_yaw: 0.0,
pet_paperdoll_yaw: 0.0,
dressup_intents: Vec::new(),
dressup_yaw: 0.0,
unit_reach: HashMap::new(),
@@ -432,3 +432,42 @@ fn playermodel_install(lua: &Lua) -> mlua::Result<()> {
lua.set_named_registry_value(REG_PLAYERMODEL_METHODS, m)
}
impl crate::script::UiScript {
/// A named model pane's scene state, or `None` if no live frame carries that name as a
/// `Model`/`PlayerModel`.
///
/// **The read side of the pane, for a host that draws it.** The app keeps one off-screen body
/// bake per window (this module's header: the scene is ours, the pixels are the host's), and
/// every frame it has to ask the pane the reference drives which way it is turned. Before
/// decision 1751 that came from benilla-named globals a file of ours called
/// (`BenillaPaperDollModel_SetFacing` and its four siblings, one scalar per window on
/// [`Model`]); the stock FrameXML instead calls
/// `Model_OnLoad`/`Model_RotateLeft`/`Model_RotateRight`/`Model_OnUpdate`, which write
/// `PlayerModel:SetRotation` — i.e. this state, on the pane itself. So the host reads the pane.
///
/// By NAME rather than by handle because that is what the host knows: the character sheet's
/// bake belongs to `CharacterModelFrame`, and since 1751 that name is the reference's own. A
/// linear scan, because a name index would be a second store to keep true and a loaded
/// interface holds single-digit model panes.
pub fn model_pane(&self, name: &str) -> Option<ModelState> {
self.model_ref()
.arena
.iter_frames()
.find_map(|(_, f)| match (&f.name, &f.kind_state) {
(Some(n), KindState::Model(m)) if n == name => Some(m.clone()),
_ => None,
})
}
/// A named model pane's yaw in radians — [`Self::model_pane`]'s one hot field, and `0.0` for a
/// pane that does not exist yet (its window's file not loaded, or its `OnLoad` not run).
///
/// `0.0` rather than an `Option` because every caller is a per-frame booth mirror whose only
/// other answer would be "keep the last one", and a pane that has not loaded has no last one.
/// The reference's own default, 0.61, is authored in `Model_OnLoad`, so a real pane only reads
/// 0.0 before it loads.
pub fn model_pane_facing(&self, name: &str) -> f32 {
self.model_pane(name).map_or(0.0, |m| m.facing)
}
}
+9 -4
View File
@@ -168,10 +168,15 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
// BenillaSetBoothTexture(textureRegion, slotToken) — the **square** twin of
// SetPortraitTexture (decision 0208 §5): the same `portrait_unit` booth-image binding
// WITHOUT the circular mask, for the paper doll's rectangular model pane (its texture region
// samples the booth's body bake edge to edge — no frame ring to mask for). Benilla-named:
// the real client's pane is a live 3D `<PlayerModel>`; ours is the doctrine-consistent
// still (0105/0118), so the binding is ours, not the live API's.
// WITHOUT the circular mask, for a body pane whose texture region samples the booth's bake
// edge to edge (no frame ring to mask for). Benilla-named because it is not the live API: it
// is the pane→booth join written in Lua.
//
// **RETIRING.** A migrated window's file is the reference's, which declares a bare
// `<PlayerModel>` and no texture at all, so the join moved app-side
// (`portrait::MODEL_PANE_BOOTHS`) and the widget draws itself (decision 1810). Two callers are
// left — the dressing room and the inspect window, both still our own files — and this goes
// with the later of them.
lua.globals().set(
"BenillaSetBoothTexture",
lua.create_function(|lua, (region, token): (Table, String)| {
-33
View File
@@ -145,13 +145,6 @@ pub(crate) struct StableModel {
pub(crate) selected: i32,
pub(crate) intents: Vec<StableIntent>,
pub(crate) close: bool,
/// The model pane's bake yaw, in radians — the fifth of the pane scalars beside
/// `paperdoll_yaw` / `inspect_yaw` / `pet_paperdoll_yaw` / `dressup_yaw`, written by
/// `BenillaStableModel_SetFacing` and sampled each frame by the app onto the `"stable"` booth
/// slot. It lives on the window's own transient state rather than on [`super::Model`] because
/// that is what it is: a pane facing that must survive the snapshot replacement every list
/// packet performs, exactly like [`Self::selected`].
pub(crate) yaw: f32,
}
/// The zero state is the client's `0` — "nothing selected" — not `-1`, which is its encoding for
@@ -166,9 +159,6 @@ impl Default for StableModel {
selected: 0,
intents: Vec::new(),
close: false,
// `0.0` like every other pane scalar: `BenillaPaperDollModel_OnLoad` seeds the
// reference's own 0.61 default through `frame.setFacing` when the window loads.
yaw: 0.0,
}
}
}
@@ -211,13 +201,6 @@ impl super::UiScript {
let model = self.model_mut();
selected_slot(&model.stable)
}
/// The stable model pane's bake yaw (`BenillaStableModel_SetFacing` wrote it) — the exact twin
/// of [`Self::paperdoll_yaw`] / [`Self::pet_paperdoll_yaw`], sampled each frame by the app onto
/// the `"stable"` booth slot.
pub fn stable_yaw(&self) -> f32 {
self.model_ref().stable.yaw
}
}
/// The petNumber→slot translation behind `GetSelectedStablePet` (§7.1).
@@ -577,22 +560,6 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
})?,
)?;
// BenillaStableModel_SetFacing(radians) — the stable pane's own bake yaw, the exact twin of
// `BenillaPaperDollModel_SetFacing` / `BenillaPetPaperDollModel_SetFacing`. Benilla-named for
// the same reason they are: the real client's pane is a live `<PlayerModel>` widget whose
// `SetRotation` is a widget method, and our booth still carries one scalar (decision 0208 §5).
// `PetStable.xml`'s `BenillaPetStableModel_SetFacing` shim forwards to this.
g.set(
"BenillaStableModel_SetFacing",
lua.create_function(|lua, radians: f32| {
lua.app_data_mut::<Model>()
.expect("model app_data")
.stable
.yaw = radians;
Ok(())
})?,
)?;
// ClosePetStables() — client-side close, no packet exists (vmangos has no close opcode): flag
// the app to clear its session.
g.set(
+18
View File
@@ -94,6 +94,24 @@ pub enum QuadContent {
/// hold to the half, 1→0 over the back half) — applied app-side.
flash: Option<f32>,
},
/// A `Model` / `PlayerModel` widget's own draw slot: the 3D pane's content hole. The engine
/// core carries the resolved rect and the pane's identity; the app renderer puts pixels in it
/// — the same division of labour [`QuadContent::Minimap`] and [`QuadContent::Cooldown`] use,
/// and for the same reason (the scene state is [`crate::widget::ModelState`]; the render is
/// not this crate's).
///
/// **Why the NAME travels rather than the scene.** benilla draws a body pane by sampling an
/// off-screen bake the app already keeps per *window* (the paper doll's, the inspect window's,
/// the pet page's), and which bake a pane samples is a fact about that window, not about the
/// widget. So the seam carries what the app needs to make the join — the pane's global frame
/// name, which since decision 1751 is the reference's own — and nothing it would have to
/// invent a meaning for. A pane with no name, or one no window has claimed, draws nothing;
/// that is also what a `SetModel` pane does today, and it is honest rather than a white slab.
ModelPane {
/// The pane's global frame name (`$parent`-expanded), or `None` for an anonymous
/// `CreateFrame("Model")` — pfUI's autocast shine is the corpus example of the latter.
name: Option<String>,
},
/// A `Texture` region: a BLP path *or* a solid/vertex color (or both — a tinted texture).
Texture {
path: Option<String>,