the dressing room opens, and the pet gets its paper doll

- ctrl-click an item to try it on: the dressing room dresses your double, and shift-click posts the item link to chat
- the pet tab lands in the character window: paper doll stats, family and diet, the right-click menu, and dropping food on the pet feeds it
- water-only spells refuse on dry land: aquatic form gates to swimming, client-side like the real one
- the macro icon picker's white squares get their art back, and option rows explain themselves on hover

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-08-07 16:02:21 +07:00
co-authored by Claude Fable 5
parent 31c224548c
commit d99ad8bc7d
109 changed files with 11304 additions and 2532 deletions
+25 -7
View File
@@ -100,11 +100,28 @@
local bagId = button:GetParent():GetID()
if mouseButton == "LeftButton" then
local info = C_Container.GetContainerItemInfo(bagId, button.slot)
-- The stack-split trigger — SHIFT + left-click on an unlocked stack of ≥2, the
-- reference fork verbatim (ContainerFrame.lua:567-577; ctrl's dress-up and the
-- chat-link insert are named deferrals). `ignoreModifiers` (the drag-trio calls,
-- both true) suppresses the fork, the reference's own drag/receive contract.
-- The modifier fork, the reference's own order (ContainerFrame.lua:563-579;
-- BankFrame.lua:141-152 for a bank slot, which is this same handler at container
-- -1). `ignoreModifiers` (the drag-trio calls, both true) suppresses it whole —
-- the reference's drag/receive contract.
--
-- CTRL: preview the item on your own character in the dressing room (decision 1060).
if IsControlKeyDown() and not ignoreModifiers then
DressUpItemLink(C_Container.GetContainerItemLink(bagId, button.slot))
return
end
-- SHIFT: post the item's link into the chat edit box if it's open, ELSE split the
-- stack. The ref's own `else` (l.567-577) — and note it gates on `IsShown()` here
-- where its siblings use `IsVisible()`; ours are the same thing (the edit box's only
-- parent is UIParent), so the ref's own word is quoted at each site.
if IsShiftKeyDown() and not ignoreModifiers then
-- The visibility test is the REF'S fork, not the helper's: with chat open this
-- arm never splits, even in the one benilla state where nothing can be posted
-- (an unresolved template — `BenillaChatEdit_InsertLink`'s own note).
if ChatFrameEditBox and ChatFrameEditBox:IsShown() then
BenillaChatEdit_InsertLink(C_Container.GetContainerItemLink(bagId, button.slot))
return
end
if info and not info.isLocked and (info.stackCount or 1) >= 2 then
button.SplitStack = function(btn, n)
SplitContainerItem(bagId, btn.slot, n)
@@ -175,12 +192,13 @@
-- mode → the sell cursor over this slot; else a readable item (a mail letter copy —
-- right-click opens the reader) → the Inspect magnifier; anything else restores the
-- base cursor. The OnUpdate re-enter keeps it current while hovering (the ref's own
-- loop). The ref's `IsControlKeyDown() and hasItem` magnifier arm (the Ctrl+click
-- dress-up preview cursor) is omitted with the dress-up feature itself.
-- loop). The ref's `IsControlKeyDown() and hasItem` magnifier arm (l.638) is the
-- dress-up preview's own cursor tell — it landed with the dressing room (decision
-- 1060); `info` is non-nil above, which is the ref's `hasItem`.
if BenillaMerchantFrame and BenillaMerchantFrame:IsShown()
and BenillaMerchantFrame.selectedTab == 1 and not InRepairMode() then
ShowContainerSellCursor(bagId, button.slot)
elseif info.isReadable then
elseif info.isReadable or IsControlKeyDown() then
ShowInspectCursor()
else
ResetCursor()
+112 -33
View File
@@ -33,18 +33,25 @@
`AutoEquipCursorItem`/`CursorCanGoInSlot`/`IsInventoryItemLocked`, joining the paper doll
onto the ONE cursor-payload space via its sentinel `EQUIPMENT_BAG` bag id (decision 0216
§1, extended by decision 0208's own phasing, §"Phasing"/"1a"/"1b", and 0216/0218's
cursor-arc design). Tabs 2-5 (Pet/Reputation/Skills/Honor) are still OMITTED, the 0138
cursor-arc design). Tabs 2-5 (Pet/Reputation/Skills/Honor) were all OMITTED at 1b, the 0138
merchant-tab precedent ("tabs arrive with their pages") — `BENILLA_CHARACTERFRAME_SUBFRAMES`
only lists the one page that exists.
only lists the pages that exist. Skills arrived with decision 0437 phase 4
(`SkillFrame.xml`) and **Pet with decision 1057** (`PetPaperDollFrame.xml`), which takes the
real client's own slot 2; Reputation and Honor are still out. Three things here are shared
WITH the pet page rather than owned by this one, and each is flagged at its definition: the
`(unit, prefix)` stat setters (including `_SetDefense`, which exists for the pet row alone),
the `BenillaPaperDollModel_*` pane quartet (driven through each pane's own `frame.setFacing`),
and the tab row itself, whose tab 2 the pet page raises and hides.
Named deferrals/OMITs (each re-cited at its own site below):
- Guild line (`CharacterGuildText`) and the PVP rank name (`UnitPVPName` — plain `UnitName`
ships instead): both decision 0208's own named deferrals (guild needs `CMSG_GUILD_QUERY`;
rank needs the honor arc). `CharacterTitleText` (also PVP-rank-driven) is dropped with it.
- Shift-click chat-link insert and ctrl-click `DressUpItemLink` (the dressing room): both
named deferrals this phase too — the left-click fork recognizes the modifiers and does
nothing, rather than silently falling through to a pickup a real shift/ctrl-click
would never have triggered.
- Shift-click chat-link insert (decision 1059) and ctrl-click `DressUpItemLink` (the
dressing room, decision 1060) both LANDED — the left-click fork below is now the
reference's own three arms (ctrl → preview, shift → post the link, else pick up).
They were named deferrals when this file was written; the note stays because the fork
below reads as a transcription of that history.
- The ammo slot (id 0) LOADS ammo by entry (`CMSG_SET_AMMO`) — it does NOT swap into an
equip slot (decision 0526; wow-re `cursor-dragdrop-slots.md`: the client's auto-equip
sender forks ammo-class → SET_AMMO). So it can't share the 19 named slots'
@@ -134,9 +141,13 @@
-- if one ever loads, wins over this fallback) ──────────────────────────────────────────────
CHARACTER = CHARACTER or "Character" -- :481
SKILLS = SKILLS or "Skills" -- :3477 (decision 0437 phase 4, the Skills tab label)
PET = PET or "Pet" -- :3010 (the Pet tab label, decision 1057)
PLAYER_LEVEL = PLAYER_LEVEL or "Level %d %s %s" -- :3075
ARMOR = ARMOR or "Armor" -- :62
ARMOR_COLON = ARMOR_COLON or "Armor:" -- :63
DEFENSE_COLON = DEFENSE_COLON or "Defense:" -- :917 (the shared
-- _SetDefense's label; only
-- the pet page has the row)
ARMOR_TOOLTIP = ARMOR_TOOLTIP or
"Decreases the amount of damage taken from physical attacks. The amount of reduction is influenced by the level of the attacker.\nDamage reduction against a level %d attacker: %.1f%%" -- :65
ATTACK_POWER_COLON = ATTACK_POWER_COLON or "Power:" -- :71
@@ -231,9 +242,13 @@
-- The pages that exist so far (this file's header comment; the 0138 merchant-tab
-- precedent — tabs arrive with their pages). Benilla-prefixed, unlike the ref's bare
-- "PaperDollFrame"/"SkillFrame". 1:1 with the tab row below (index N is tab N's page) —
-- decision 0437 phase 4 adds "BenillaSkillFrame" as tab 2; Pet/Reputation/Honor (the
-- real client's tabs 2/3/5) are still OMITTED.
BENILLA_CHARACTERFRAME_SUBFRAMES = { "BenillaPaperDollFrame", "BenillaSkillFrame" }
-- decision 0437 phase 4 added the Skills page, decision 1057 the Pet one, which takes the
-- real client's own slot 2 and pushes Skills to 3. Reputation/Honor (the real client's
-- tabs 3/5) are still OMITTED, which is the whole reason our Skills tab renders 3rd where
-- the reference's is 4th.
BENILLA_CHARACTERFRAME_SUBFRAMES = {
"BenillaPaperDollFrame", "BenillaPetPaperDollFrame", "BenillaSkillFrame",
}
-- ── The window: toggle / tab dispatch (ref CharacterFrame.lua:1-48) ─────────────────────
-- Kept BARE (not Benilla-prefixed) — the addon-facing entry point, matching this codebase's
@@ -252,6 +267,14 @@
function ToggleCharacter(tab)
local subFrame = getglobal(tab)
if not subFrame then return end
-- ref CharacterFrame.lua:4-6, the pet guard: asking for the pet page with no pet does
-- NOTHING — not even close an open window. The `not IsVisible()` half is what still
-- lets the page CLOSE itself when the pet vanishes underneath it (the pet page's own
-- OnEvent calls straight back in here to do exactly that). Hoisted below the getglobal
-- (the ref tests it above) purely so the visibility read has a frame to ask.
if tab == "BenillaPetPaperDollFrame" and not HasPetUI() and not subFrame:IsVisible() then
return
end
local frame = getglobal("BenillaCharacterFrame")
if frame:IsVisible() then
if subFrame:IsVisible() then
@@ -285,10 +308,12 @@
function BenillaCharacterFrame_OnLoad(frame)
-- GetID()/SetID() DO exist (landed with slice 2's BagFrame, which relies on them for
-- its own per-window bag id) — the STALE claim this comment used to make is corrected
-- here (0208 phase 1b touched this function anyway). 2 tabs ship as of decision 0437
-- phase 4 (Character + Skills); the window opens on tab 1 by default, matching the
-- real client.
PanelTemplates_SetNumTabs(frame, 2)
-- here (0208 phase 1b touched this function anyway). 3 tabs ship as of decision 1057
-- (Character + Pet + Skills); the window opens on tab 1 by default, matching the real
-- client. Tab 2 raises and hides itself with the pet — BenillaPetTab_Update, which the
-- pet page runs at its own OnLoad and on every pet transition — so the COUNT here is
-- the full row, not the currently-visible one, exactly as the ref's SetNumTabs(5) is.
PanelTemplates_SetNumTabs(frame, 3)
PanelTemplates_SetTab(frame, 1)
end
@@ -517,6 +542,27 @@
frame.tooltipSubtext = format(ARMOR_TOOLTIP, playerLevel, armorReduction)
end
-- ref PaperDollFrame_SetDefense (l.252-272). Shared like every setter around it, but the
-- CHARACTER sheet has no defense row — the real client's doesn't either, and this exists
-- solely for the pet page (decision 1057), which is why it arrives now and not in 0208.
-- `UnitDefense`'s second return is a single signed modifier, not the pos/neg pair the other
-- setters get, so the split into posBuff/negBuff happens here.
function BenillaPaperDollFrame_SetDefense(unit, prefix)
unit = unit or "player"
prefix = prefix or "BenillaCharacter"
local base, modifier = UnitDefense(unit)
local frame = getglobal(prefix .. "DefenseFrame")
local text = getglobal(prefix .. "DefenseFrameStatText")
local posBuff = 0
local negBuff = 0
if modifier > 0 then
posBuff = modifier
elseif modifier < 0 then
negBuff = modifier
end
BenillaPaperDollFormatStat(DEFENSE_COLON, base, posBuff, negBuff, frame, text)
end
-- ref PaperDollFrame_SetDamage (l.274-372).
function BenillaPaperDollFrame_SetDamage(unit, prefix)
unit = unit or "player"
@@ -787,11 +833,17 @@
-- Bag/QuestLog all thread `self` explicitly rather than lean on the engine's `this`
-- global, which DOES exist — `benilla-ui/src/script/event.rs` — but isn't this codebase's
-- idiom).
function BenillaPaperDollStatTooltip(button, stat)
-- `unit` joins the signature for the pet page (decision 1057): the ref's own
-- PaperDollStatTooltip(unit, stat) always took it — its pet rows pass "pet" (ref
-- PetPaperDollFrame.xml:345) — and phase 1a simply had no second unit to pass, so it was
-- inlined as "player". Restored, defaulted, and third rather than first because `button`
-- leads by house convention (above).
function BenillaPaperDollStatTooltip(button, stat, unit)
-- classFileName nil until the first player snapshot lands (this file's SetLevel comment
-- on the same engine timing gap) — "" just misses every class lookup, falling through to
-- DEFAULT_*_TOOLTIP below exactly like an unrecognized class would.
local _, classFileName = UnitClass("player")
-- DEFAULT_*_TOOLTIP below exactly like an unrecognized class would. A pet has no class
-- either, so the pet rows land on the same DEFAULT_* fallback the ref gives them.
local _, classFileName = UnitClass(unit or "player")
classFileName = classFileName or ""
local classStatText = getglobal(strupper(classFileName) .. "_" .. stat .. "_TOOLTIP")
if not classStatText then
@@ -882,17 +934,23 @@
BenillaPaperDollSlot_Update(button)
end
-- Left: shift/ctrl are named deferrals (chat-link insert / DressUpItemLink, header
-- comment) — recognized and done, not fallen through to a pickup a real modified click
-- would never trigger. Plain click: PickupInventoryItem (pick up / place / swap / cancel,
-- Left: the reference's modifier fork verbatim (ref l.647-660) — CTRL previews the worn
-- item in the dressing room (decision 1060), SHIFT posts its link into an open chat edit
-- box (decision 1059), and neither falls through to a pickup a real modified click would
-- never trigger. Plain click: PickupInventoryItem (pick up / place / swap / cancel,
-- ref l.656). Right: UseInventoryItem (ref l.659). `ignoreModifiers` is the drag-route
-- calls' own suppression, the bag slots' precedent.
function BenillaPaperDollSlot_OnClick(button, mouseButton, ignoreModifiers)
if mouseButton == "LeftButton" then
if IsControlKeyDown() and not ignoreModifiers then
return -- DressUpItemLink: named deferral, no dressing room yet
DressUpItemLink(GetInventoryItemLink("player", button.invSlotId))
return
elseif IsShiftKeyDown() and not ignoreModifiers then
return -- chat-link insert: named deferral
-- Both of the ref's guards (l.652: only when the edit box is up) plus our own
-- nil-link one live in the shared helper (UIParent.xml). A shift-click with chat
-- closed does nothing here — a worn item has no stack to split, unlike a bag slot.
BenillaChatEdit_InsertLink(GetInventoryItemLink("player", button.invSlotId))
return
end
PickupInventoryItem(button.invSlotId)
GameTooltip:Hide() -- the item just left the slot for the cursor
@@ -1047,9 +1105,16 @@
-- ── The model pane (ref UIParent.lua:1420-1462) ─────────────────────────────────────────
-- This file's header comment: a booth-texture stand-in, not a live widget. `frame` here is
-- BenillaCharacterModelFrame; `frame.facing` stands in for the ref's `model.rotation`.
-- These four are the ref's SHARED Model_* helpers (UIParent.lua), and they are shared here
-- too: the pet page's pane (decision 1057) drives the very same code. The one thing that
-- differs per pane is WHERE the yaw goes — the engine has no `Model:SetFacing` widget
-- method, so each pane has its own booth-yaw binding — so the pane names its own in
-- `frame.setFacing` at OnLoad and these read it. (The inspect window still carries its own
-- copy of this quartet, predating the generalization; folding it in is a follow-up, not
-- this slice's to churn.)
function BenillaPaperDollModel_OnLoad(frame)
frame.facing = 0.61 -- ref UIParent.lua:1422, the literal default facing
BenillaPaperDollModel_SetFacing(frame.facing)
frame.setFacing(frame.facing)
end
-- ref Model_RotateLeft/Right (UIParent.lua:1426-1442) — the ref's OWN left/right sign
@@ -1057,13 +1122,13 @@
-- re-derived, just quoted.
function BenillaPaperDollModel_RotateLeft(frame)
frame.facing = frame.facing - 0.03
BenillaPaperDollModel_SetFacing(frame.facing)
frame.setFacing(frame.facing)
PlaySound("igInventoryRotateCharacter")
end
function BenillaPaperDollModel_RotateRight(frame)
frame.facing = frame.facing + 0.03
BenillaPaperDollModel_SetFacing(frame.facing)
frame.setFacing(frame.facing)
PlaySound("igInventoryRotateCharacter")
end
@@ -1082,14 +1147,14 @@
if frame.facing < 0 then
frame.facing = frame.facing + (2 * PI)
end
BenillaPaperDollModel_SetFacing(frame.facing)
frame.setFacing(frame.facing)
end
if getglobal(frame:GetName() .. "RotateRightButton"):GetButtonState() == "PUSHED" then
frame.facing = frame.facing - step
if frame.facing > (2 * PI) then
frame.facing = frame.facing - (2 * PI)
end
BenillaPaperDollModel_SetFacing(frame.facing)
frame.setFacing(frame.facing)
end
end
@@ -1270,22 +1335,35 @@
<OnClick>HideUIPanel(getglobal("BenillaCharacterFrame"))</OnClick>
</Scripts>
</Button>
<!-- The tab row (ref l.79-96 — CENTER of BOTTOMLEFT +60,+62 — tab 2 chained off tab 1,
MerchantFrame.xml's own Merchant/Buyback LEFT-of-RIGHT -16 offset). DEVIATION
flagged (decision 0437 phase 4): the real client's Skills tab is 4th of five
(Character/Pet/Reputation/Skills/Honor, ref l.79-168); ours renders 2nd until
Pet/Reputation/Honor land (the "tabs arrive with their pages" precedent, 0138/0208).
<!-- The tab row (ref l.79-96 — CENTER of BOTTOMLEFT +60,+62 — each tab chained off the
last, MerchantFrame.xml's own Merchant/Buyback LEFT-of-RIGHT -16 offset). The Pet
tab takes the real client's own slot 2 (decision 1057); DEVIATION still flagged for
the third: the real client's Skills tab is 4th of five
(Character/Pet/Reputation/Skills/Honor, ref l.79-168) and ours is 3rd until
Reputation/Honor land (the "tabs arrive with their pages" precedent, 0138/0208).
Tab 2 starts HIDDEN — it is raised only for a unit with a pet UI, by the pet page's
BenillaPetTab_Update, which also re-anchors tab 3 onto tab 2's LEFT while it is
down so the row has no gap (ref PetPaperDollFrame.lua:189-198). Tab 3's XML anchor
is the WITH-pet one; the update owns it from load onward.
No hover tooltip (MicroButtonTooltipText/TOGGLECHARACTER0 have no engine surface,
and each tab's own visible label already says what it does — Merchant's own tab
pair carries no hover tooltip either). -->
<Button name="BenillaCharacterFrameTab1" inherits="CharacterFrameTabButtonTemplate" text="Character">
<!-- The three labels are the ref's own GlobalStrings KEYS (l.79/97/…), not literals —
the loader resolves a key-shaped `text=` through the shipped strings, so the row is
localized like the reference's. (Tabs 1/3 carried the enUS literals from 0437;
same words either way in enUS, and now the same mechanism.) -->
<Button name="BenillaCharacterFrameTab1" inherits="CharacterFrameTabButtonTemplate" text="CHARACTER">
<Anchors><Anchor point="CENTER" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="60" y="62"/></Offset></Anchor></Anchors>
<Scripts><OnClick>BenillaCharacterFrameTab_OnClick(1)</OnClick></Scripts>
</Button>
<Button name="BenillaCharacterFrameTab2" inherits="CharacterFrameTabButtonTemplate" text="Skills">
<Button name="BenillaCharacterFrameTab2" inherits="CharacterFrameTabButtonTemplate" text="PET" hidden="true">
<Anchors><Anchor point="LEFT" relativeTo="BenillaCharacterFrameTab1" relativePoint="RIGHT"><Offset><AbsDimension x="-16" y="0"/></Offset></Anchor></Anchors>
<Scripts><OnClick>BenillaCharacterFrameTab_OnClick(2)</OnClick></Scripts>
</Button>
<Button name="BenillaCharacterFrameTab3" inherits="CharacterFrameTabButtonTemplate" text="SKILLS">
<Anchors><Anchor point="LEFT" relativeTo="BenillaCharacterFrameTab2" relativePoint="RIGHT"><Offset><AbsDimension x="-16" y="0"/></Offset></Anchor></Anchors>
<Scripts><OnClick>BenillaCharacterFrameTab_OnClick(3)</OnClick></Scripts>
</Button>
<!-- The paper-doll page (ref PaperDollFrame, ref-PaperDollFrame.xml:124-1059). Sized to
exactly fill the parent instead of the ref's setAllPoints="true" — the same effect
@@ -1373,6 +1451,7 @@
-- header comment) — needed THIS phase (1b) so the pane hit-tests
-- as an OnMouseUp/OnReceiveDrag drop target (ref l.226-231).
self:EnableMouse(true)
self.setFacing = BenillaPaperDollModel_SetFacing
BenillaPaperDollModel_OnLoad(self)
BenillaSetBoothTexture(getglobal(self:GetName() .. "Texture"), "paperdoll")
</OnLoad>
@@ -0,0 +1,308 @@
<Ui>
<!--
The dressing room (decision 1060) — ref `DressUpFrame.xml` / `DressUpFrame.lua`, quoted.
CTRL-left-click an item anywhere (bags, the paper doll, a chat link, the bank) and it lands
on your own character here, over the gear you are actually wearing. The window's own
instruction line says exactly that, in Blizzard's words:
`DRESSUP_FRAME_INSTRUCTIONS` = "CTRL-Left Click additional items to display them on your
character" (ref-GlobalStrings.lua:944) — which is also where the modifier is settled: CTRL
previews, SHIFT posts the link to chat (`ContainerFrame.lua:565-571`).
THE MODEL PANE is a booth-texture stand-in, not the ref's live `<DressUpModel>` widget —
the settled doctrine (0105/0118) and exactly what the character window's own pane does
(`CharacterFrame.xml`'s header). So the ref's three model METHODS have no widget to live on
and become the engine globals `BenillaDressUpModel_Dress/TryOn/Close/SetFacing`
(`benilla-ui/src/script/dressup.rs` has the why); `DressUpItem`/`DressUpItemLink` keep their
real global names, because every ctrl-click site in the UI calls those.
Departures, each named:
- No drag (the ref is `movable` with StartMoving/StopMovingOrSizing): benilla has no
user-moved-frame affordance yet — the window sits in the left UIPanel slot, which the ref
registers it into too (`UIParent.lua:41`, transcribed in UiPanels.xml).
- `DressUpModel:SetUnit("player")` and `:Dress()` are ONE verb here
(`BenillaDressUpModel_Dress`): our model is always the player's, so "point it at the
player" and "put the player's own gear back on it" are the same effect.
- The ref's `scale="2.0"` on the model widget is a 3D-widget zoom with no meaning for a
baked pane — the booth frames the body from its own bounds (`framing::body_frame`), the
same law the paper doll is framed by.
- The pane animates in the ref (a live widget playing Stand); ours is the frozen bake the
paper doll beside it is (decision 0822 — the same look call, taken the same way).
-->
<Script><![CDATA[
-- GlobalStrings this window uses (ref-GlobalStrings.lua, cited per line; the
-- `X = X or "..."` idiom is this codebase's convention — CharacterFrame.xml/InspectFrame.xml).
DRESSUP_FRAME = DRESSUP_FRAME or "Dressing Room" -- :943
DRESSUP_FRAME_INSTRUCTIONS = DRESSUP_FRAME_INSTRUCTIONS
or "CTRL-Left Click additional items to display them on your character" -- :944
RESET = RESET or "Reset" -- :3340
CLOSE = CLOSE or "Close" -- :760
-- ref DressUpFrame.lua:2-8. The `not IsVisible()` gate is the reference's own state reset:
-- opening the room fresh re-dresses the model in the player's real gear, and only then
-- tries the clicked item on. Both halves ride ONE ordered intent queue app-side, so the
-- reset can never overtake the try-on (dressup.rs's module doc).
function DressUpItem(item)
if not BenillaDressUpFrame:IsVisible() then
ShowUIPanel(BenillaDressUpFrame)
BenillaDressUpModel_Dress() -- ref: DressUpModel:SetUnit("player")
end
BenillaDressUpModel_TryOn(item) -- ref: DressUpModel:TryOn(item)
end
-- ref DressUpFrame.lua:10-16, verbatim: pull the bare item id out of the `|Hitem:<id>:…`
-- payload and hand THAT to TryOn. Every ctrl-click site in the UI calls this one function,
-- which is why it keeps the reference's global name.
function DressUpItemLink(link)
if not link then
return
end
local item = gsub(link, ".*item:(%d+).*", "%1", 1)
DressUpItem(tonumber(item))
end
-- ref DressUpFrame.lua:18-32 including its own "-- HACK!!!" comment: gnomes borrow the
-- dwarf backdrop and trolls the orc one (there is no Gnome/Troll art), and an unknown race
-- falls back to Orc. Quoted, not corrected.
function DressUpTexturePath()
local race, fileName = UnitRace("player")
if fileName == "Gnome" or fileName == "GNOME" then
fileName = "Dwarf"
elseif fileName == "Troll" or fileName == "TROLL" then
fileName = "Orc"
end
if not fileName then
fileName = "Orc"
end
return "Interface\\DressUpFrame\\DressUpBackground-" .. fileName
end
-- ref DressUpFrame.lua:34-40. Called on OnShow as well as OnLoad: at load time the app has
-- usually not pushed the player's unit snapshot yet, so `UnitRace` answers nil and the ref's
-- own fallback would freeze an Orc backdrop onto every character (the same one-tick nil the
-- character window's level line documents).
function SetDressUpBackground()
local texture = DressUpTexturePath()
BenillaDressUpBackgroundTopLeft:SetTexture(texture .. 1)
BenillaDressUpBackgroundTopRight:SetTexture(texture .. 2)
BenillaDressUpBackgroundBotLeft:SetTexture(texture .. 3)
BenillaDressUpBackgroundBotRight:SetTexture(texture .. 4)
end
-- The pane's facing — the paper doll's pair of handlers, over this room's own booth yaw
-- (ref Model_OnLoad/Model_RotateLeft/Model_RotateRight/Model_OnUpdate, UIParent.lua:1421-1462;
-- CharacterFrame.xml carries the full note on the ref's sign quirk, quoted here too).
function BenillaDressUpModel_OnLoad(frame)
frame.facing = 0.61 -- ref UIParent.lua:1422, the literal default facing
BenillaDressUpModel_SetFacing(frame.facing)
end
function BenillaDressUpModel_RotateLeft(frame)
frame.facing = frame.facing - 0.03
BenillaDressUpModel_SetFacing(frame.facing)
PlaySound("igInventoryRotateCharacter")
end
function BenillaDressUpModel_RotateRight(frame)
frame.facing = frame.facing + 0.03
BenillaDressUpModel_SetFacing(frame.facing)
PlaySound("igInventoryRotateCharacter")
end
-- The held-button spin (ref Model_OnUpdate) — half a turn per second while a rotate button
-- reads PUSHED, the ref's own opposite-sign held branches included.
function BenillaDressUpModel_OnUpdate(frame, elapsed)
local step = elapsed * 2 * PI * ROTATIONS_PER_SECOND
if getglobal(frame:GetName() .. "RotateLeftButton"):GetButtonState() == "PUSHED" then
frame.facing = frame.facing + step
if frame.facing < 0 then
frame.facing = frame.facing + (2 * PI)
end
BenillaDressUpModel_SetFacing(frame.facing)
end
if getglobal(frame:GetName() .. "RotateRightButton"):GetButtonState() == "PUSHED" then
frame.facing = frame.facing - step
if frame.facing > (2 * PI) then
frame.facing = frame.facing - (2 * PI)
end
BenillaDressUpModel_SetFacing(frame.facing)
end
end
]]></Script>
<!-- A panel action button (Reset/Close): the ref's UIPanelButtonTemplate face, locally
reconstructed — this house dialect ships no such template, and each window keeps its own
copy (CraftFrame.xml's identical note). -->
<Button name="BenillaDressUpPanelButtonTemplate" virtual="true">
<Size><AbsDimension x="80" y="22"/></Size>
<NormalFont inherits="GameFontNormal"/>
<HighlightFont inherits="GameFontHighlight"/>
<DisabledFont inherits="GameFontDisable"/>
<NormalTexture file="Interface\Buttons\UI-Panel-Button-Up"><TexCoords left="0" right="0.625" top="0" bottom="0.6875"/></NormalTexture>
<PushedTexture file="Interface\Buttons\UI-Panel-Button-Down"><TexCoords left="0" right="0.625" top="0" bottom="0.6875"/></PushedTexture>
<DisabledTexture file="Interface\Buttons\UI-Panel-Button-Disabled"><TexCoords left="0" right="0.625" top="0" bottom="0.6875"/></DisabledTexture>
<HighlightTexture file="Interface\Buttons\UI-Panel-Button-Highlight" alphaMode="ADD"><TexCoords left="0" right="0.625" top="0" bottom="0.6875"/></HighlightTexture>
</Button>
<Frame name="BenillaDressUpFrame" toplevel="true" hidden="true">
<!-- The ref's 384×512 window. No <Anchors>: ShowUIPanel seats it in the left slot, this
house's convention for every panel window (MerchantFrame.xml/CraftFrame.xml). -->
<Size><AbsDimension x="384" y="512"/></Size>
<HitRectInsets><AbsInset left="0" right="30" top="0" bottom="45"/></HitRectInsets>
<Layers>
<!-- The "player" portrait, 60×60 at TOPLEFT (7,-6) (ref l.20-33) — BACKGROUND so the
window art's ring frames it; bound in OnShow, like every other portrait here. -->
<Layer level="BACKGROUND">
<Texture name="BenillaDressUpFramePortrait">
<Size><AbsDimension x="60" y="60"/></Size>
<Anchors><Anchor point="TOPLEFT"><Offset><AbsDimension x="7" y="-6"/></Offset></Anchor></Anchors>
</Texture>
</Layer>
<!-- The window art: the general-panel top pair + the skill-frame bottom pair, the ref's
own four slabs (l.34-72). -->
<Layer level="ARTWORK">
<Texture file="Interface\PaperDollInfoFrame\UI-Character-General-TopLeft">
<Size><AbsDimension x="256" y="256"/></Size>
<Anchors><Anchor point="TOPLEFT"/></Anchors>
</Texture>
<Texture file="Interface\PaperDollInfoFrame\UI-Character-General-TopRight">
<Size><AbsDimension x="128" y="256"/></Size>
<Anchors><Anchor point="TOPLEFT"><Offset><AbsDimension x="256" y="0"/></Offset></Anchor></Anchors>
</Texture>
<Texture file="Interface\PaperDollInfoFrame\SkillFrame-BotLeft">
<Size><AbsDimension x="256" y="256"/></Size>
<Anchors><Anchor point="TOPLEFT"><Offset><AbsDimension x="0" y="-256"/></Offset></Anchor></Anchors>
</Texture>
<Texture file="Interface\PaperDollInfoFrame\SkillFrame-BotRight">
<Size><AbsDimension x="128" y="256"/></Size>
<Anchors><Anchor point="TOPLEFT"><Offset><AbsDimension x="256" y="-256"/></Offset></Anchor></Anchors>
</Texture>
<FontString name="BenillaDressUpFrameTitleText" inherits="GameFontHighlight" text="DRESSUP_FRAME">
<Size><AbsDimension x="300" y="14"/></Size>
<Anchors><Anchor point="TOP"><Offset><AbsDimension x="0" y="-17"/></Offset></Anchor></Anchors>
</FontString>
<FontString name="BenillaDressUpFrameDescriptionText" inherits="GameFontNormalSmall" text="DRESSUP_FRAME_INSTRUCTIONS">
<Size><AbsDimension x="260" y="36"/></Size>
<Anchors>
<Anchor point="CENTER" relativeTo="BenillaDressUpFrameTitleText" relativePoint="BOTTOM"><Offset><AbsDimension x="10" y="-22"/></Offset></Anchor>
</Anchors>
</FontString>
</Layer>
<!-- The race backdrop behind the model, four quadrants (ref l.73-118); the files are
chosen per race by SetDressUpBackground above. -->
<Layer level="OVERLAY">
<Texture name="BenillaDressUpBackgroundTopLeft">
<Size><AbsDimension x="256" y="255"/></Size>
<Anchors><Anchor point="TOPLEFT"><Offset><AbsDimension x="22" y="-76"/></Offset></Anchor></Anchors>
</Texture>
<Texture name="BenillaDressUpBackgroundTopRight">
<Size><AbsDimension x="62" y="255"/></Size>
<Anchors><Anchor point="TOPLEFT" relativeTo="BenillaDressUpBackgroundTopLeft" relativePoint="TOPRIGHT"/></Anchors>
</Texture>
<Texture name="BenillaDressUpBackgroundBotLeft">
<Size><AbsDimension x="256" y="128"/></Size>
<Anchors><Anchor point="TOPLEFT" relativeTo="BenillaDressUpBackgroundTopLeft" relativePoint="BOTTOMLEFT"/></Anchors>
</Texture>
<Texture name="BenillaDressUpBackgroundBotRight">
<Size><AbsDimension x="62" y="128"/></Size>
<Anchors><Anchor point="TOPLEFT" relativeTo="BenillaDressUpBackgroundTopLeft" relativePoint="BOTTOMRIGHT"/></Anchors>
</Texture>
</Layer>
</Layers>
<Frames>
<!-- The corner close X: the ref's UIPanelCloseButton seat (CENTER of TOPRIGHT, -46,-25 —
ref l.120-127) on the minimize-button art every close button in this house uses. -->
<Button name="BenillaDressUpFrameCloseButton">
<Size><AbsDimension x="32" y="32"/></Size>
<Anchors><Anchor point="CENTER" relativePoint="TOPRIGHT"><Offset><AbsDimension x="-46" y="-25"/></Offset></Anchor></Anchors>
<NormalTexture file="Interface\Buttons\UI-Panel-MinimizeButton-Up"/>
<PushedTexture file="Interface\Buttons\UI-Panel-MinimizeButton-Down"/>
<HighlightTexture file="Interface\Buttons\UI-Panel-MinimizeButton-Highlight" alphaMode="ADD"/>
<Scripts>
<OnClick>HideUIPanel(BenillaDressUpFrame)</OnClick>
</Scripts>
</Button>
<!-- Close (ref l.128-146) and Reset (l.147-165, chained off Close's LEFT). Reset is the
ref's `DressUpModel:Dress()` + its gsTitleOptionOK click. -->
<Button name="BenillaDressUpFrameCancelButton" inherits="BenillaDressUpPanelButtonTemplate" text="CLOSE">
<Anchors><Anchor point="CENTER" relativePoint="TOPLEFT"><Offset><AbsDimension x="305" y="-422"/></Offset></Anchor></Anchors>
<Scripts>
<OnClick>HideUIPanel(BenillaDressUpFrame)</OnClick>
</Scripts>
</Button>
<Button name="BenillaDressUpFrameResetButton" inherits="BenillaDressUpPanelButtonTemplate" text="RESET">
<Anchors><Anchor point="RIGHT" relativeTo="BenillaDressUpFrameCancelButton" relativePoint="LEFT"/></Anchors>
<Scripts>
<OnClick>
BenillaDressUpModel_Dress()
PlaySound("gsTitleOptionOK")
</OnClick>
</Scripts>
</Button>
<!-- The model pane (ref the <DressUpModel> widget, l.166-207): 316×351 anchored BOTTOM
(-11,+85). A plain Frame sampling the "dressup" booth bake (header). -->
<Frame name="BenillaDressUpModelFrame">
<Size><AbsDimension x="316" y="351"/></Size>
<Anchors><Anchor point="BOTTOM"><Offset><AbsDimension x="-11" y="85"/></Offset></Anchor></Anchors>
<Layers>
<Layer level="ARTWORK">
<Texture name="$parentTexture">
<Size><AbsDimension x="316" y="351"/></Size>
<Anchors><Anchor point="TOPLEFT"/></Anchors>
</Texture>
</Layer>
</Layers>
<Frames>
<!-- Rotate buttons (ref l.183-206): 35×35 at the WINDOW's TOPLEFT (21,-75), left
then right chained off its TOPRIGHT. Both edges registered, so one tap fires
OnClick twice and holding hands the spin to the pane's OnUpdate — the ref's
own tap-and-hold (decision 0638 §3). -->
<Button name="BenillaDressUpModelFrameRotateLeftButton">
<Size><AbsDimension x="35" y="35"/></Size>
<Anchors><Anchor point="TOPLEFT" relativeTo="BenillaDressUpFrame" relativePoint="TOPLEFT"><Offset><AbsDimension x="21" y="-75"/></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>BenillaDressUpModel_RotateLeft(getglobal("BenillaDressUpModelFrame"))</OnClick>
</Scripts>
</Button>
<Button name="BenillaDressUpModelFrameRotateRightButton">
<Size><AbsDimension x="35" y="35"/></Size>
<Anchors><Anchor point="TOPLEFT" relativeTo="BenillaDressUpModelFrameRotateLeftButton" 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>BenillaDressUpModel_RotateRight(getglobal("BenillaDressUpModelFrame"))</OnClick>
</Scripts>
</Button>
</Frames>
<Scripts>
<OnLoad>
BenillaDressUpModel_OnLoad(self)
BenillaSetBoothTexture(getglobal(self:GetName() .. "Texture"), "dressup")
</OnLoad>
<OnUpdate>BenillaDressUpModel_OnUpdate(self, arg1)</OnUpdate>
</Scripts>
</Frame>
</Frames>
<Scripts>
<OnLoad>SetDressUpBackground()</OnLoad>
<OnShow>
SetDressUpBackground()
SetPortraitTexture(BenillaDressUpFramePortrait, "player")
PlaySound("igCharacterInfoOpen")
</OnShow>
<!-- Closing empties the booth (dressup.rs's `Close` intent): the ref's widget keeps its
state while hidden, but its next DressUpItem re-issues SetUnit("player") *because*
the frame wasn't visible, so that state is never observable. -->
<OnHide>
BenillaDressUpModel_Close()
PlaySound("igCharacterInfoClose")
</OnHide>
</Scripts>
</Frame>
</Ui>
+47 -13
View File
@@ -44,20 +44,23 @@
`BenillaGroupLootFrame`s byte-identical instances of the same template (each registers only
`CANCEL_LOOT_ROLL` on itself, ref l.487-489/493-495, verbatim).
NO DRESSING-ROOM / CHAT-LINK INSERT (CraftFrame.xml's/TradeSkillFrame.xml's own identical
deviation). The ref icon button's Ctrl-click `DressUpItemLink`/Shift-click
`ChatFrameEditBox:Insert` (ref l.353-360) and its `this.hasItem = 1` OnLoad flag (ref l.339,
read only by UIParent.lua's own generic Ctrl-click dressing-room handler) are dropped — no
dressing room exists; a real, now-inert vestige, not a benilla omission. Likewise the ref's
`CursorUpdate()`/`ResetCursor()`/`CursorOnUpdate()` hover-cursor calls (ref l.344,348,351) have
no engine counterpart and are omitted — LootFrame.xml's own row template
(`BenillaLootButtonTemplate`) already established this same omission for item-row hover.
THE DRESSING-ROOM / CHAT-LINK CLICK LANDED (decisions 1059/1060 — this note used to read "no
dressing room exists", which was true until the room did). The ref icon button's Ctrl-click
`DressUpItemLink`/Shift-click `ChatFrameEditBox:Insert` (ref l.353-361) are transcribed in
`BenillaGroupLootFrame_Icon_OnClick` below, over the `GetLootRollItemLink(rollID)` binding the
same arc added to `loot_roll.rs`. Still dropped: the ref's `this.hasItem = 1` OnLoad flag
(ref l.338-340) and its `CursorUpdate()`/`ResetCursor()`/`CursorOnUpdate()` hover-cursor calls
(ref l.344,348,351) — the flag exists only to feed those, and they are the Ctrl-hover
MAGNIFIER, a separate affordance (`ShowInspectCursor`, which the bags and the vendor rows do
wire) that this button has never had. LootFrame.xml's own row template
(`BenillaLootButtonTemplate`) carries the same hover-cursor omission.
THE ITEM TOOLTIP uses `GameTooltip:SetItemById(itemId, name, quality)` (LootFrame.xml's own
`BenillaLootItem_OnEnter` idiom), not the reference's `SetLootRollItem`/`GetLootRollItemLink`
(neither exists here) — `itemId` is the benilla-extension 6th return of `GetLootRollItemInfo`
`BenillaLootItem_OnEnter` idiom), not the reference's `SetLootRollItem` (no such tooltip
method here) — `itemId` is the benilla-extension 6th return of `GetLootRollItemInfo`
(see `loot_roll.rs`), always populated once a roll is known (unlike name/texture/quality, which
may be `nil` while the item template is still in flight).
may be `nil` while the item template is still in flight). `GetLootRollItemLink` DOES exist now
(decision 1059) but is the click's, not the hover's: the tooltip wants the id, not the markup.
`toplevel="true"` (ref l.243) IS OMITTED — `SetToplevel` isn't in the v1 object model yet
(`loader/geometry.rs`'s `apply_attrs`: a warn-once "gap", same as `movable`/`resizable`); other
@@ -78,6 +81,7 @@
The Era API this drives (`benilla-ui/src/script/loot_roll.rs`, decision 0591):
`GetLootRollItemInfo(rollID)` -> texture,name,count,quality,bindOnPickUp,itemID;
`GetLootRollItemLink(rollID)` -> the item's link (the icon click's two arms, decision 1059);
`GetLootRollTimeLeft(rollID)` -> ms remaining; `RollOnLoot(rollID, rollType)` (0 Pass / 1 Need /
2 Greed). Events: `START_LOOT_ROLL(rollID, rollTime)` claims a frame; `CANCEL_LOOT_ROLL(rollID)`
hides the one that owns it (`crates/benilla/src/ui_loot_roll.rs` fires both).
@@ -248,6 +252,34 @@
function BenillaGroupLootFrame_Icon_OnLeave()
GameTooltip:Hide()
end
-- The item icon's click (ref $parentIconFrame OnClick, LootFrame.xml l.353-361, transcribed
-- whole): CTRL previews the rolled item in the dressing room (decision 1060), SHIFT posts
-- its link into an open chat edit box (decision 1059) — "link me that" being most of what a
-- roll popup gets asked. Both read `GetLootRollItemLink(rollID)`, the binding this arc added
-- (loot_roll.rs); it answers nil while the item template is still in flight, and both arms
-- drop that nil — the ctrl one inside DressUpItemLink (DressUpFrame.lua l.10-16), the shift
-- one inside BenillaChatEdit_InsertLink.
--
-- Nothing else happens on this button, exactly as in the reference: the vote lives on the
-- Pass/Need/Greed buttons and the icon is a preview/link affordance only. The ref's
-- `this.hasItem = 1` OnLoad flag (l.338-340) stays dropped — it exists solely for the
-- CursorUpdate/CursorOnUpdate ctrl-hover magnifier (l.344/351), which this button still does
-- not wire (see the header). No RegisterForClicks either, also as in the reference: a Button
-- is registered for LeftButtonUp by default (widget/kinds/mod.rs), which is the whole set
-- this handler wants.
function BenillaGroupLootFrame_Icon_OnClick(button)
local rollId = button:GetParent().rollID
if IsControlKeyDown() then
DressUpItemLink(GetLootRollItemLink(rollId)) -- ref l.354-355
elseif IsShiftKeyDown() then
-- ref l.356-359, through the shared helper (UIParent.xml): it carries the ref's own
-- edit-box-visible guard AND our nil-link one, which this popup needs routinely —
-- the frame is up the instant START_LOOT_ROLL fires, well before the item template
-- answers, and until it does there is no link to post.
BenillaChatEdit_InsertLink(GetLootRollItemLink(rollId))
end
end
]]></Script>
<!--
@@ -305,8 +337,9 @@
</Layer>
</Layers>
<Frames>
<!-- The item icon (ref $parentIconFrame, l.314-363): hover-tooltip only here — the ref's
Ctrl/Shift dressing-room/chat-link branch and hasItem OnLoad flag are dropped (header). -->
<!-- The item icon (ref $parentIconFrame, l.314-363): hover tooltip + the ref's own
Ctrl/Shift dressing-room/chat-link click (l.353-361, landed — decision 1059/1060);
only the hasItem OnLoad flag stays dropped (header). -->
<Button name="$parentIconFrame" enableMouse="true">
<Size><AbsDimension x="34" y="34"/></Size>
<Anchors>
@@ -325,6 +358,7 @@
<Scripts>
<OnEnter>BenillaGroupLootFrame_Icon_OnEnter(self)</OnEnter>
<OnLeave>BenillaGroupLootFrame_Icon_OnLeave()</OnLeave>
<OnClick>BenillaGroupLootFrame_Icon_OnClick(self)</OnClick>
</Scripts>
</Button>
<!-- Pass (ref $parentPassButton, l.364-384): the ref inherits UIPanelCloseButton, which
+19 -7
View File
@@ -38,8 +38,9 @@
- `InspectTitleText` / `InspectGuildText` — both `hidden="true"` in the ref itself, filled
only from PVP-rank / guild-query data (0208's own two named gaps). Not transcribed.
- Ctrl-click `DressUpItemLink` and shift-click chat-link insert (ref
`InspectPaperDollFrame.lua:104-112`) — 0208's named deferrals; the click recognizes the
modifiers and does nothing rather than falling through to a verb the ref wouldn't run.
`InspectPaperDollFrame.lua:104-112`) — 0208's named deferrals, both LANDED (decisions
1059/1060): ctrl previews the inspected item on your own body, shift posts its link.
Nothing else happens on a click, which is still the reference's own shape.
- `UnitHasRelicSlot` (ref l.123, l.22) — vanilla-always-false, so the relic branch can
never fire. Dropped outright, as CharacterFrame.xml drops it.
- The held-button continuous spin (ref `InspectModelFrame_OnUpdate`, l.7-22) needs
@@ -248,14 +249,25 @@
end
end
-- ref InspectPaperDollItemSlotButton_OnClick (l.104-112): both branches are named deferrals
-- (header), so this recognizes the modifiers and does nothing — deliberately inert rather
-- than falling through to a verb the reference would never have run on an inspected item.
-- ref InspectPaperDollItemSlotButton_OnClick (l.104-112): CTRL previews the inspected
-- player's item on YOUR character in the dressing room (decision 1060), SHIFT posts its
-- link to chat (decision 1059) — "link me that" being most of why anyone inspects. Both
-- read through the unit-keyed `GetInventoryItemLink(unit, slot)`, the same binding the
-- paper doll uses: a foreign player's worn items are PUBLIC descriptor data, so their
-- links are as readable as our own (decision 0631's whole premise). Nothing else happens on
-- a click here — the reference has no third branch, and an inspected item is not ours to
-- pick up or use.
function BenillaInspectSlot_OnClick(button, mouseButton)
local unit = getglobal("BenillaInspectFrame").unit
if IsControlKeyDown() then
return -- DressUpItemLink: no dressing room yet
-- DressUpItemLink carries the reference's own nil guard (DressUpFrame.lua:11-13).
DressUpItemLink(GetInventoryItemLink(unit, button.invSlotId))
elseif IsShiftKeyDown() then
return -- chat-link insert: named deferral
-- The visible-and-non-nil guards both live in the shared helper (UIParent.xml).
-- Waiting on a template is the COMMON case at this window — an inspected player's
-- gear is usually gear we have never seen — so the nil arm is a live path here,
-- not a theoretical one.
BenillaChatEdit_InsertLink(GetInventoryItemLink(unit, button.invSlotId))
end
end
+6 -2
View File
@@ -14,7 +14,8 @@
right-click opens the name-only FRIEND dropdown (FriendsFrame_ShowDropdown, the
minimal FriendsFrame stand-in below), shift-click inserts into the chat editbox. The
ref's shift-click friends/ignore popup fills + SendWho("n-"..name) wait on the
friends/who arc (both guarded). The ITEM branch's Ctrl-DressUp needs a dressing room.
friends/who arc (both guarded). The ITEM branch's Ctrl-DressUp landed with the dressing
room itself (decision 1060).
- No UIPanelWindows row (the ref has none either): ShowUIPanel's unregistered branch is
a plain Show, and SetHyperlink's own empty-gate shows/hides the plate.
-->
@@ -49,7 +50,10 @@
return
end
if IsControlKeyDown() then
-- ref: DressUpItemLink(text) - no dressing room exists.
-- ref l.50: ctrl-clicking an item link in chat previews it on your own character
-- (decision 1060). `text` is the FULL escaped link, not the bare payload —
-- DressUpItemLink pulls the id out of it itself.
DressUpItemLink(text)
return
end
if IsShiftKeyDown() then
+60 -5
View File
@@ -33,9 +33,10 @@
The Era loot API this drives (all app-fed via the loot seam, benilla-ui `script/loot.rs`):
GetNumLootItems(), GetLootSlotInfo(slot) -> texture,item,quantity,quality, LootSlotIsItem/
LootSlotIsCoin(slot), LootSlot(slot) (the row click's action), CloseLoot() (from OnHide). The
synthesized coin pile is row 1 when the loot carries gold; a row click sends the money or the
item's wire slot (the app maps it).
LootSlotIsCoin(slot), GetLootSlotLink(slot) (the row's item link, read by the click's
ctrl/shift arms — decision 1059), LootSlot(slot) (the row click's action), CloseLoot() (from
OnHide). The synthesized coin pile is row 1 when the loot carries gold; a row click sends the
money or the item's wire slot (the app maps it).
Paging (LootFrame.lua l.27-31,70-73,105-118): 4 rows/page; > 4 items spends one slot on the
pager, showing 3 rows + Up/Down. We keep the current page across content-change repaints
@@ -70,12 +71,66 @@
button:RegisterForClicks("LeftButtonUp", "RightButtonUp")
end
-- ref LootFrameItem_OnClick (LootFrame.lua l.147-162): CTRL previews the row's item in the
-- dressing room (decision 1060), SHIFT posts its link into an open chat edit box (decision
-- 1059) — both through GetLootSlotLink, the row-link binding this arc added (loot.rs).
--
-- WHERE THE TAKE LIVES, and why the gate below reads three modifiers where FrameXML names
-- two (VERIFIED at the 1.12.1 bytes — wow-re `ui/scratch/loot-click-modifier-gate.md`,
-- decision 1067). The reference's Lua handler suppresses NOTHING, because the take is the C
-- `CLootButton`'s own click behaviour (`LootFrame.lua:94`'s `button:SetSlot(slot)` hands the
-- widget the slot). `CLootButton::OnClick 0x4c1820` runs the Lua `<OnClick>` FIRST and
-- unconditionally, then gates itself on `0x41f8f0(0/1/2)` — byte-identically the predicates
-- behind `IsShiftKeyDown`/`IsControlKeyDown`/**`IsAltKeyDown`** — and only then sends
-- (`0x4c2790` → `0x5e1ad0` → `push 0x108`, CMSG_AUTOSTORE_LOOT_ITEM).
--
-- Three consequences, all of them this handler's shape:
-- · **ALT suppresses the take too**, though no FrameXML file mentions alt. Its gate is C.
-- · The gate is on ANY modifier being HELD, not on "the arm that handled it": a shift-click
-- with the chat box closed does nothing in Lua and still must not loot.
-- · The button code is never read (`0x4c1820` doesn't look at it), so a RIGHT-click loots
-- exactly like a left one — which is why this handler takes no `mouseButton`.
--
-- benilla folds that C action into this Lua handler (`LootSlot(button.slot)`, this file's
-- model since 0084), so the gate has to be an early return here. Named divergence, not a
-- transcription slip: 1.12 exposes no plain "take slot N" binding at all — the real
-- `LootSlot 0x4c2e70` is the LOOT_BIND *confirmation continuation* (flag 1, gated on the
-- stashed slot at `[0x847cec]`; its only FrameXML caller is `StaticPopup.lua:606`). Moving
-- the take host-side onto the widget, and shrinking `LootSlot()` to that continuation, is
-- 1067's named follow-up.
--
-- The reference's tail (StaticPopup_Hide("CONFIRM_LOOT_DISTRIBUTION") and the four
-- LootFrame.selected* fields, l.155-161) is the MASTER-LOOT dropdown's bookkeeping —
-- GroupLootDropDown reads exactly those four (l.164+). benilla ships no master loot
-- (benilla-protocol's loot module: CMSG_LOOT_MASTER_GIVE/SMSG_LOOT_MASTER_LIST out of
-- scope), so returning early skips nothing that exists.
--
-- The coin row has no link (it is synthesized, with no item behind it — ui_loot.rs), so a
-- modified click on it finds nil: DressUpItemLink returns on a nil link (DressUpFrame.lua
-- l.10-16) and the insert is skipped. It does not fall through to looting the money either,
-- which is the same law the item rows follow.
function BenillaLootItem_OnClick(button)
-- The row remembers its resolved loot slot from the last Update; act on it (LootFrame.lua
-- l.94-95 stores button.slot, the click calls LootSlot on it).
if button.slot then
LootSlot(button.slot)
if not button.slot then return end
if IsControlKeyDown() then
DressUpItemLink(GetLootSlotLink(button.slot)) -- ref l.148-149
return
elseif IsShiftKeyDown() then
-- ref l.150-153, through the shared helper (UIParent.xml): it carries the ref's own
-- "only when the edit box is up" guard AND our nil-link one, which this row needs
-- more than most — the coin row never has a link, and an item row has none until
-- its template lands. A shift-click with chat closed does nothing here: a loot row
-- has no stack to split, unlike a bag slot.
BenillaChatEdit_InsertLink(GetLootSlotLink(button.slot))
return
elseif IsAltKeyDown() then
-- The C gate's third modifier (`0x41f8f0(2)` @ `0x4c1841`). Nothing is bound to
-- alt in FrameXML, so this arm does nothing at all — its entire job is to NOT
-- loot, which is what the real client does and what we did not.
return
end
LootSlot(button.slot)
end
-- Hover: the item name coloured by quality (loot rows carry it — GetLootSlotInfo's 4th
+43 -5
View File
@@ -20,7 +20,11 @@
repair pair, the page turners. We keep the real geometry of everything we DO ship.
Clicks (decision 0081 v1): RIGHT-click a row → BuyMerchantItem. LEFT-click is PickupMerchantItem
in the real client (a drag-buy) — a no-op here until the cursor-item arc.
in the real client (a drag-buy) — a no-op here until the cursor-item arc. CTRL-left-click
previews the item in the dressing room and SHIFT-left-click posts its link to an open chat
edit box (the ref's own fork, MerchantFrame.lua l.301-306 — decisions 1059/1060, over the
GetMerchantItemLink feed); the shift-with-chat-closed stack-split half of that arm waits on a
GetMerchantItemMaxStack binding (named at the handler).
UIPanel slot system (decision 0084 §2): carries NO hardcoded <Anchors> — ShowUIPanel/SetLeftFrame
SetPoints it to the left slot (TOPLEFT UIParent, 0, -104) on open; every show goes through
@@ -200,13 +204,47 @@
function BenillaMerchantItem_OnClick(button, mouseButton)
-- Buyback tab: either click buys the slot back (ref MerchantItemButton_OnClick's else
-- arm, l.358-361). Merchant tab: RIGHT-click = BuyMerchantItem (the real right-click
-- branch); LEFT-click is PickupMerchantItem (a drag-buy onto the cursor) in the real
-- client — a no-op here until the cursor-item arc lands.
-- arm, l.358-361) — and, as there, with NO modifier branch of any kind: 1.12 ships no
-- GetBuybackItemLink, so a buyback row carries no link at all (ui_merchant.rs).
if BenillaMerchantFrame.selectedTab == 2 then
BuybackItem(button.merchantIndex)
return
end
local index = BenillaMerchantFrame_ItemIndex(button)
if mouseButton == "LeftButton" then
-- The reference's LEFT-button modifier fork (ref l.301-306): CTRL previews the
-- vendor's item in the dressing room (decision 1060) — which is what the Ctrl-hover
-- magnifier this window already arms has been promising — and SHIFT posts its link
-- into an open chat edit box (decision 1059). Both return: a modified click must
-- never also buy.
if IsControlKeyDown() then
DressUpItemLink(GetMerchantItemLink(index))
return
elseif IsShiftKeyDown() then
-- ref l.304-306, through the shared helper (UIParent.xml): it carries the ref's
-- own edit-box-visible guard AND our nil-link one (a row whose item template is
-- still in flight has no link yet).
BenillaChatEdit_InsertLink(GetMerchantItemLink(index))
-- DEFERRED, in the ref's own words: the `else` half of that shift arm is
-- `local maxStack = GetMerchantItemMaxStack(this:GetID())` →
-- `OpenStackSplitFrame(maxStack, ...)`, buying up to what you can afford
-- (ref l.307-327). `GetMerchantItemMaxStack` has no binding here, so a
-- shift-click with chat CLOSED does nothing rather than guess a max stack.
return
end
-- Plain LEFT-click is PickupMerchantItem, a drag-buy onto the cursor (ref l.329) —
-- still a no-op here until the cursor-item arc lands.
elseif mouseButton == "RightButton" then
BuyMerchantItem(BenillaMerchantFrame_ItemIndex(button))
-- The ref's right-button leg opens with `if IsControlKeyDown() ... then return`
-- (l.332-333): a Ctrl-held right-click does nothing at all, and in particular must
-- not buy while the player is Ctrl-clicking around to preview. Its shift arm is the
-- same GetMerchantItemMaxStack stack-split deferred above (l.334-353) — and the
-- ref's own `maxStack <= 1` case re-enters to buy a single unit (l.341-343), which
-- is exactly what falling through to BuyMerchantItem does here.
if IsControlKeyDown() then
return
end
BuyMerchantItem(index)
end
end
+120 -19
View File
@@ -131,8 +131,15 @@
whose click jumps to the page (the era redirect law), capped at the static pool's size.
Both bars wear the shared kit's trough (the recessed channel — 1.12 authors that art
per window, so it is opt-in there and declared beside each bar here).
- **Setting-page cuts (0957 Audio, 0959 Graphics)**: setting-row tooltips (the era
OPTION_TOOLTIP_* hovers) are not wired; Defaults resets the VISIBLE page only (the
- **Row tooltips are 1.12's own strings on the era's seat (B223, decision 1054)**: every
row whose setting exists in a 1.12 panel raises that panel's `OPTION_TOOLTIP_*` text —
the description ALONE, wrapped, in the engine's default gold (the 1.12/1.14 option-row
plate, and this window's own since 1008's Character Specific row), seated the era way
(ANCHOR_RIGHT off the label half, x -10). The era's name-line-then-description pair
(`Settings.InitTooltip` → `GameTooltip_AddHighlightLine(name)`) is cut: its title would
repeat the label the cursor is already on. **Auto Loot** is the one row with no 1.12
string — no 1.12 row exists for it (its label is era's too) — and raises no plate.
- **Setting-page cuts (0957 Audio, 0959 Graphics)**: Defaults resets the VISIBLE page only (the
era confirm dialog is not modeled); era rows with no 1.12 CVar +
benilla knob behind them (output device, Dialog volume, loop music, channels, cache;
the whole era Graphics dropdown zoo) don't ship. A slider shows the 1.12 panel's
@@ -609,8 +616,13 @@
Nameplates = { "RowPlayerNames", "RowNpcNames", "RowOwnName" },
}
function OptionsRow_OnLoad(row, cvar, label, child)
-- `tip` is the GlobalStrings KEY of the row's description (B223, decision 1054) — the key,
-- not the text: it resolves at HOVER, the way the 1.12 Interface panel resolves its own
-- (UIOptionsFrame.lua l.265, `getglobal("OPTION_TOOLTIP_"..key)`), so the row is wired
-- before GlobalStrings has to exist. nil = no 1.12 string for this setting (Auto Loot).
function OptionsRow_OnLoad(row, cvar, label, tip, child)
row.cvar = cvar
row.tip = tip
-- The search tag: the era's default is exactly the setting's display name
-- (SettingControls.lua l.773 AddSearchTags(name)), uppercased at registration
-- (Blizzard_Settings.lua l.60).
@@ -725,9 +737,47 @@
-- The era row-wide hover wash (HoverBackgroundTemplate, white a=0.1): the row and every
-- control on it show the SAME texture, so crossing from label to slider doesn't blink.
-- ONE seam carries the wash AND the plate (B223): every surface that lights the row also
-- raises its tooltip, so the two can never disagree — and the crossing is still seamless,
-- since the engine fires OnLeave before OnEnter within a move (pointer.rs), leaving the
-- hide/re-show inside a single frame.
function OptionsRow_Hover(row, on)
local hover = getglobal(row:GetName() .. "Hover")
if on then hover:Show() else hover:Hide() end
if on then
hover:Show()
OptionsRow_ShowTooltip(row)
else
hover:Hide()
GameTooltip:Hide()
end
end
-- The row's descriptive plate (B223, decision 1054).
-- STRING: 1.12's own per-option text, by GlobalStrings key (see OptionsRow_OnLoad). A row
-- with no 1.12 string raises NOTHING — a plate whose only line echoes the label the cursor
-- is on is noise, and inventing a description would be inventing fidelity.
-- SHAPE: the 1.12/1.14 option-row plate — the description alone, wrapped, in the tooltip's
-- default gold (OptionsFrameTemplates.xml l.52-64: SetOwner + SetText(text, …, 1)); this
-- window already wears it on the Keybindings page's Character Specific row (1008). The era
-- pair (a white name line over the description) is cut — see the SCOPE note.
-- SEAT: the era's (DefaultTooltipMixin.OnEnter — ANCHOR_RIGHT, x -10, off the row's LABEL
-- half), carried by the $parentTip region, so the plate opens at the same column on every
-- row whichever surface the cursor crossed.
function OptionsRow_ShowTooltip(row)
local text = row.tip and getglobal(row.tip)
if text then
GameTooltip:SetOwner(getglobal(row:GetName() .. "Tip"), "ANCHOR_RIGHT", -10, 0)
GameTooltip:SetText(text, nil, nil, nil, nil, 1)
else
-- A mute row PUTS AWAY whatever the last row raised. Entering a row asserts "this
-- row's plate is what shows", so an early return isn't enough: the ordinary cursor
-- path is saved by the outgoing row's own OnLeave, but any caller that enters
-- without one (a driven hover, a re-layout under the cursor) would leave the
-- neighbour's description standing over a row it doesn't describe — a plate saying
-- the wrong thing is worse than a row saying nothing. 1.12's own handler has the
-- bare early-out; this is deliberate, and it is why the mute case has a test.
GameTooltip:Hide()
end
end
function OptionsCheckbox_OnClick(box)
@@ -1076,6 +1126,18 @@
</Layer>
</Layers>
<Frames>
<!-- The tooltip's SEAT (B223): the era's own anchor region, verbatim geometry — the
row's LABEL half, TOPLEFT out to (BOTTOM, -80), i.e. the control column
(SettingsListElementTemplate's $parentTooltip, Blizzard_SettingControls.xml
l.57-61). The era hangs its hover wash AND its mouse scripts here; ours borrows
the RECT only — the row lights and raises as one surface (OptionsRow_Hover) — so
this frame stays mouse-less and can never steal the row's own hover. -->
<Frame name="$parentTip">
<Anchors>
<Anchor point="TOPLEFT"/>
<Anchor point="BOTTOMRIGHT" relativePoint="BOTTOM"><Offset><AbsDimension x="-80" y="0"/></Offset></Anchor>
</Anchors>
</Frame>
<CheckButton name="$parentCheck">
<Size><AbsDimension x="30" y="29"/></Size>
<Anchors><Anchor point="LEFT" relativePoint="CENTER"><Offset><AbsDimension x="-80" y="0"/></Offset></Anchor></Anchors>
@@ -1135,6 +1197,16 @@
</Layer>
</Layers>
<Frames>
<!-- The tooltip's seat — the era label-half region (the checkbox template's comment is
the recipe). The era anchors a SLIDER row's plate off the slider itself instead
(SettingControls.lua l.556, ANCHOR_RIGHT +20); ours keeps the one column, so a
walk down a page doesn't make the plate hop 250 units per row. -->
<Frame name="$parentTip">
<Anchors>
<Anchor point="TOPLEFT"/>
<Anchor point="BOTTOMRIGHT" relativePoint="BOTTOM"><Offset><AbsDimension x="-80" y="0"/></Offset></Anchor>
</Anchors>
</Frame>
<Frame name="$parentControl">
<Size><AbsDimension x="250" y="40"/></Size>
<Anchors><Anchor point="LEFT" relativePoint="CENTER"><Offset><AbsDimension x="-80" y="3"/></Offset></Anchor></Anchors>
@@ -1218,6 +1290,14 @@
</Layer>
</Layers>
<Frames>
<!-- The tooltip's seat — the era label-half region (the checkbox template's comment is
the recipe). -->
<Frame name="$parentTip">
<Anchors>
<Anchor point="TOPLEFT"/>
<Anchor point="BOTTOMRIGHT" relativePoint="BOTTOM"><Offset><AbsDimension x="-80" y="0"/></Offset></Anchor>
</Anchors>
</Frame>
<Frame name="$parentDropdown" inherits="UIDropDownMenuTemplate">
<Anchors><Anchor point="LEFT" relativePoint="CENTER"><Offset><AbsDimension x="-71" y="-2"/></Offset></Anchor></Anchors>
</Frame>
@@ -1599,7 +1679,10 @@
deselectOnClick=="0"; era wraps the CVar in NegateBoolean);
labels are 1.12 GlobalStrings where the row exists there
(GAMEFIELD_DESELECT_TEXT, INVERT_MOUSE), era's for Auto Loot
(no 1.12 row exists). -->
(no 1.12 row exists) — and the tooltip keys are those same
1.12 keys through the panel's own `_TEXT`-stripping lookup
(UIOptionsFrame.lua l.265), which is why Sticky Targeting's
description is GAMEFIELD_DESELECT's. -->
<Frame name="$parentControls" hidden="true">
<Anchors>
<Anchor point="TOPLEFT"/>
@@ -1612,7 +1695,8 @@
<Anchor point="TOPRIGHT"><Offset><AbsDimension x="-20" y="-12"/></Offset></Anchor>
</Anchors>
<Scripts><OnLoad>
OptionsRow_OnLoad(self, "deselectOnClick", "Sticky Targeting")
OptionsRow_OnLoad(self, "deselectOnClick", "Sticky Targeting",
"OPTION_TOOLTIP_GAMEFIELD_DESELECT")
self.invert = 1
</OnLoad></Scripts>
</Frame>
@@ -1621,6 +1705,10 @@
<Anchor point="TOPLEFT" relativeTo="$parentRowStickyTarget" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
<Anchor point="TOPRIGHT" relativeTo="$parentRowStickyTarget" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
</Anchors>
<!-- No tip: 1.12 has no Auto Loot row, so no
OPTION_TOOLTIP_* for it either (verified — the
5875 GlobalStrings has no AUTO_LOOT key at all,
and no 1.12 panel names the CVar). -->
<Scripts><OnLoad>OptionsRow_OnLoad(self, "autoLootDefault", "Auto Loot")</OnLoad></Scripts>
</Frame>
<Frame name="$parentRowInvertMouse" inherits="OptionsCheckboxRowTemplate">
@@ -1628,7 +1716,8 @@
<Anchor point="TOPLEFT" relativeTo="$parentRowAutoLoot" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
<Anchor point="TOPRIGHT" relativeTo="$parentRowAutoLoot" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
</Anchors>
<Scripts><OnLoad>OptionsRow_OnLoad(self, "mouseInvertPitch", "Invert Mouse")</OnLoad></Scripts>
<Scripts><OnLoad>OptionsRow_OnLoad(self, "mouseInvertPitch", "Invert Mouse",
"OPTION_TOOLTIP_INVERT_MOUSE")</OnLoad></Scripts>
</Frame>
</Frames>
</Frame>
@@ -1653,7 +1742,8 @@
<Anchor point="TOPRIGHT"><Offset><AbsDimension x="-20" y="-12"/></Offset></Anchor>
</Anchors>
<Scripts><OnLoad>
OptionsRow_OnLoad(self, "MasterSoundEffects", "Enable All Sound")
OptionsRow_OnLoad(self, "MasterSoundEffects", "Enable All Sound",
"OPTION_TOOLTIP_ENABLE_ALL_SOUND")
self.soundQuirk = 1
</OnLoad></Scripts>
</Frame>
@@ -1662,7 +1752,8 @@
<Anchor point="TOPLEFT" relativeTo="$parentRowEnableAll" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
<Anchor point="TOPRIGHT" relativeTo="$parentRowEnableAll" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
</Anchors>
<Scripts><OnLoad>OptionsRow_OnLoad(self, "MasterVolume", "Master Volume")</OnLoad></Scripts>
<Scripts><OnLoad>OptionsRow_OnLoad(self, "MasterVolume", "Master Volume",
"OPTION_TOOLTIP_MASTER_VOLUME")</OnLoad></Scripts>
</Frame>
<Frame name="$parentRowSound" inherits="OptionsSliderRowTemplate">
<Anchors>
@@ -1670,7 +1761,8 @@
<Anchor point="TOPRIGHT" relativeTo="$parentRowMaster" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
</Anchors>
<Scripts><OnLoad>
OptionsRow_OnLoad(self, "SoundVolume", "Sound Volume", 1)
OptionsRow_OnLoad(self, "SoundVolume", "Sound Volume",
"OPTION_TOOLTIP_SOUND_VOLUME", 1)
-- era parentInitializer (search pulls Master in
-- above a matched child, SettingsPanel.lua l.665)
self.parentRow = "RowMaster"
@@ -1682,7 +1774,8 @@
<Anchor point="TOPRIGHT" relativeTo="$parentRowSound" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
</Anchors>
<Scripts><OnLoad>
OptionsRow_OnLoad(self, "MusicVolume", "Music Volume", 1)
OptionsRow_OnLoad(self, "MusicVolume", "Music Volume",
"OPTION_TOOLTIP_MUSIC_VOLUME", 1)
self.parentRow = "RowMaster"
</OnLoad></Scripts>
</Frame>
@@ -1692,7 +1785,8 @@
<Anchor point="TOPRIGHT" relativeTo="$parentRowMusic" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
</Anchors>
<Scripts><OnLoad>
OptionsRow_OnLoad(self, "AmbienceVolume", "Ambience Volume", 1)
OptionsRow_OnLoad(self, "AmbienceVolume", "Ambience Volume",
"OPTION_TOOLTIP_AMBIENCE_VOLUME", 1)
self.parentRow = "RowMaster"
</OnLoad></Scripts>
</Frame>
@@ -1702,7 +1796,8 @@
<Anchor point="TOPRIGHT" relativeTo="$parentRowAmbience" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
</Anchors>
<Scripts><OnLoad>
OptionsRow_OnLoad(self, "EnableMusic", "Enable Music")
OptionsRow_OnLoad(self, "EnableMusic", "Enable Music",
"OPTION_TOOLTIP_ENABLE_MUSIC")
self.soundQuirk = 1
</OnLoad></Scripts>
</Frame>
@@ -1712,7 +1807,8 @@
<Anchor point="TOPRIGHT" relativeTo="$parentRowEnableMusic" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
</Anchors>
<Scripts><OnLoad>
OptionsRow_OnLoad(self, "EnableAmbience", "Enable Ambience")
OptionsRow_OnLoad(self, "EnableAmbience", "Enable Ambience",
"OPTION_TOOLTIP_ENABLE_AMBIENCE")
self.soundQuirk = 1
</OnLoad></Scripts>
</Frame>
@@ -1742,7 +1838,8 @@
<Anchor point="TOPRIGHT"><Offset><AbsDimension x="-20" y="-12"/></Offset></Anchor>
</Anchors>
<Scripts><OnLoad>
OptionsRow_OnLoad(self, "uiScale", "UI Scale")
OptionsRow_OnLoad(self, "uiScale", "UI Scale",
"OPTION_TOOLTIP_UI_SCALE")
OptionsSliderRow_Setup(self, 0.64, 1.0, 0.01)
-- era registers its uiscale row CommitFlag.Apply
-- (Graphics.lua l.684): the one row whose live
@@ -1757,7 +1854,8 @@
<Anchor point="TOPRIGHT" relativeTo="$parentRowUiScale" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
</Anchors>
<Scripts><OnLoad>
OptionsRow_OnLoad(self, "WorldDetail", "Environment Detail")
OptionsRow_OnLoad(self, "WorldDetail", "Environment Detail",
"OPTION_TOOLTIP_ENVIRONMENT_DETAIL")
-- The stop names are the era preset ladder's (its
-- quality dropdowns say Low/Medium/High); 1.12's
-- slider had no stop labels to quote.
@@ -1787,21 +1885,24 @@
<Anchor point="TOPLEFT"><Offset><AbsDimension x="10" y="-12"/></Offset></Anchor>
<Anchor point="TOPRIGHT"><Offset><AbsDimension x="-20" y="-12"/></Offset></Anchor>
</Anchors>
<Scripts><OnLoad>OptionsRow_OnLoad(self, "UnitNamePlayer", "Player Names")</OnLoad></Scripts>
<Scripts><OnLoad>OptionsRow_OnLoad(self, "UnitNamePlayer", "Player Names",
"OPTION_TOOLTIP_SHOW_PLAYER_NAMES")</OnLoad></Scripts>
</Frame>
<Frame name="$parentRowNpcNames" inherits="OptionsCheckboxRowTemplate">
<Anchors>
<Anchor point="TOPLEFT" relativeTo="$parentRowPlayerNames" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
<Anchor point="TOPRIGHT" relativeTo="$parentRowPlayerNames" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
</Anchors>
<Scripts><OnLoad>OptionsRow_OnLoad(self, "UnitNameNPC", "NPC Names")</OnLoad></Scripts>
<Scripts><OnLoad>OptionsRow_OnLoad(self, "UnitNameNPC", "NPC Names",
"OPTION_TOOLTIP_SHOW_NPC_NAMES")</OnLoad></Scripts>
</Frame>
<Frame name="$parentRowOwnName" inherits="OptionsCheckboxRowTemplate">
<Anchors>
<Anchor point="TOPLEFT" relativeTo="$parentRowNpcNames" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
<Anchor point="TOPRIGHT" relativeTo="$parentRowNpcNames" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
</Anchors>
<Scripts><OnLoad>OptionsRow_OnLoad(self, "UnitNameOwn", "Show Own Name")</OnLoad></Scripts>
<Scripts><OnLoad>OptionsRow_OnLoad(self, "UnitNameOwn", "Show Own Name",
"OPTION_TOOLTIP_SHOW_OWN_NAME")</OnLoad></Scripts>
</Frame>
</Frames>
</Frame>
+81 -2
View File
@@ -245,8 +245,15 @@
-- Note what the key path does NOT do — it is a bare CastPetAction, with none of the click
-- path's forks: no shift pick-up, no right-button autocast, and no IsPetAttackActive
-- call-off. That is the reference's split, not a simplification of ours: the fork lives in
-- PetActionButton_OnClick, which a key press never reaches. So CTRL-1 on a pet already
-- attacking re-issues Attack where a left click would call it off.
-- PetActionButton_OnClick, which a key press never reaches.
--
-- On a PET bar none of that is visible, because the call-off fork is DEAD CODE there:
-- IsPetAttackActive needs the [0xb714b0] latch, and `0x4bd420` raises it only when the
-- bar's unit is the one you are POSSESSING (ui_pet.rs's `possessing` carries the carve —
-- UNIT_FLAG_POSSESSED, never a hunter's or warlock's pet). A pet's Attack button can never
-- latch lit, so click and key both simply order the attack. The split is visible on a
-- POSSESS bar (Mind Control, Eye of Kilrogg, Eyes of the Beast): there a click sends
-- PetStopAttack and CTRL-1 re-issues the order instead.
function BenillaPetActionButtonDown(id)
local button = getglobal("BenillaPetActionButton" .. id)
if ( button:GetButtonState() == "NORMAL" ) then
@@ -390,6 +397,78 @@
BenillaPetAutocast_Update(getglobal("BenillaPetActionButton" .. i), frame.clock)
end
end
-- ── The pet menu's two dialogs (ref StaticPopup.lua l.738-748 + l.1069-1102 + l.365-374,
-- decision 1066) ────────────────────────────────────────────────────────────────────
-- They live here rather than with the rows that raise them (UnitPopup.xml) for the same
-- reason every other dialog in this codebase lives with its arc's frame file: registering
-- into StaticPopupDialogs is a LOAD-TIME dependency on UiPanels.xml's engine, and
-- UnitPopup.xml is a shared engine that eight isolation prefixes load without it. Raising
-- them is not a dependency — StaticPopup_Show resolves at click time.
--
-- ABANDON_PET is the plain confirm. RENAME_PET is a CHAIN of two, which is the reference's
-- own shape and not a flourish: the first collects the name, the second reads it back
-- ("Name your pet 'Bruce'?") before anything is sent — because a rename is one-shot, so a
-- typo is permanent in exactly the way the abandon is. That second Show happens while the
-- first dialog is still up, which is why the engine's two instances are both needed.
StaticPopupDialogs["ABANDON_PET"] = {
text = ABANDON_PET,
button1 = OKAY,
button2 = CANCEL,
OnAccept = function()
PetAbandon();
end,
timeout = 0,
exclusive = 1,
hideOnEscape = 1,
}
StaticPopupDialogs["RENAME_PET"] = {
text = PET_RENAME_LABEL,
button1 = ACCEPT,
button2 = CANCEL,
hasEditBox = 1,
-- The 12-letter cap is the reference's, and it is the client's only name check: the
-- server re-validates with CheckPetName and refuses with SMSG_PET_NAME_INVALID.
maxLetters = 12,
OnAccept = function()
-- `this` is the clicked button, so its parent is THIS dialog — never
-- StaticPopup1 by name, which would read the wrong box the moment this dialog
-- lands in the second instance (the chain below is exactly that case).
local text = getglobal(this:GetParent():GetName() .. "EditBox"):GetText()
local dialog = StaticPopup_Show("PETRENAMECONFIRM", text)
if dialog then
dialog.data = text
end
end,
EditBoxOnEnterPressed = function()
local text = getglobal(this:GetParent():GetName() .. "EditBox"):GetText()
local dialog = StaticPopup_Show("PETRENAMECONFIRM", text)
if dialog then
dialog.data = text
end
this:GetParent():Hide()
end,
OnShow = function()
getglobal(this:GetName() .. "EditBox"):SetFocus()
end,
timeout = 0,
exclusive = 1,
hideOnEscape = 1,
}
StaticPopupDialogs["PETRENAMECONFIRM"] = {
-- "Name your pet '%s'?" — the name arrives as text_arg1 AND as `data`; the first
-- fills the question, the second is what actually gets sent.
text = PET_RENAME_CONFIRMATION,
button1 = YES,
button2 = NO,
OnAccept = function(data)
PetRename(data);
end,
timeout = 0,
hideOnEscape = 1,
}
]]></Script>
<!--
@@ -0,0 +1,735 @@
<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: this engine reads no XML `parent=` attribute, so a page ships as a sibling pinned
to `BenillaCharacterFrame`'s TOPLEFT by name, 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 `"BenillaPet"`, so `prefix.."ArmorFrame"` resolves `BenillaPetArmorFrame`,
exactly as `"BenillaCharacter"` resolves the sibling page's.
**The tab is conditional, and `BenillaPetTab_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. Ours is the
ref's arithmetic unchanged; only WHICH page tab 3 is differs (Skills here, Reputation there —
the "tabs arrive with their pages" precedent, 0138/0208/0437, and the reason our Skills tab
renders 3rd of three where the real client's is 4th of five).
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 transcribed 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, transcribed 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.
- `BenillaPetPaperDollFrame_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
BENILLA_NUM_PET_STATS = 5 -- NUM_PET_STATS (ref l.2)
BENILLA_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 BenillaPetPaperDollFrame_OnLoad(frame)
getglobal("BenillaPetAttackFrameLabel"):SetText(ATTACK_COLON)
getglobal("BenillaPetDamageFrameLabel"):SetText(DAMAGE_COLON)
getglobal("BenillaPetAttackPowerFrameLabel"):SetText(ATTACK_POWER_COLON)
getglobal("BenillaPetDefenseFrameLabel"):SetText(DEFENSE_COLON)
getglobal("BenillaPetArmorFrameLabel"):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")
BenillaPetTab_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 BenillaPetPaperDollFrame_OnEvent(frame)
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("BenillaPetPaperDollFrame")
end
BenillaPetTab_Update()
BenillaPetPaperDollFrame_Update()
elseif event == "PET_UI_CLOSE" then
if frame:IsVisible() then
ToggleCharacter("BenillaPetPaperDollFrame")
end
BenillaPetTab_Update()
elseif event == "UNIT_PET_EXPERIENCE" then
BenillaPetExpBar_Update()
elseif arg1 == "pet" then
BenillaPetPaperDollFrame_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 BenillaPetPaperDollFrame_OnShow()
getglobal("BenillaCharacterNameText"):Hide()
local petName = getglobal("BenillaPetNameText")
petName:Show()
petName:SetText(UnitName("pet"))
BenillaPetPaperDollFrame_Update()
end
function BenillaPetPaperDollFrame_OnHide()
getglobal("BenillaCharacterNameText"):Show()
getglobal("BenillaPetNameText"):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 BenillaPetPaperDollFrame_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("BenillaPetLevelText"):SetText(
format(UNIT_LEVEL_TEMPLATE, UnitLevel("pet")) .. " " .. family)
end
getglobal("BenillaPetLoyaltyText"):SetText(GetPetLoyalty())
BenillaPetExpBar_Update()
BenillaPetPaperDollFrame_SetResistances()
BenillaPetPaperDollFrame_SetStats()
BenillaPaperDollFrame_SetDamage("pet", "BenillaPet")
BenillaPaperDollFrame_SetRangedDamage("pet", "BenillaPet")
BenillaPaperDollFrame_SetAttackPower("pet", "BenillaPet")
BenillaPaperDollFrame_SetRangedAttackPower("pet", "BenillaPet")
BenillaPaperDollFrame_SetArmor("pet", "BenillaPet")
BenillaPaperDollFrame_SetAttackBothHands("pet", "BenillaPet")
BenillaPaperDollFrame_SetDefense("pet", "BenillaPet")
-- 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("BenillaPetPaperDollPetInfo"):Show()
local totalPoints, spent = GetPetTrainingPoints()
local text = getglobal("BenillaPetTrainingPointText")
text:SetText(totalPoints - spent)
text:Show()
getglobal("BenillaPetTrainingPointLabel"):Show()
else
getglobal("BenillaPetPaperDollPetInfo"):Hide()
getglobal("BenillaPetTrainingPointText"):Hide()
getglobal("BenillaPetTrainingPointLabel"):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, transcribed rather than unified.
function BenillaPetPaperDollFrame_SetResistances()
for i = 1, BENILLA_NUM_PET_RESISTANCE_TYPES do
local resistId = BENILLA_MAGICRES_IDS[i]
local text = getglobal("BenillaPetMagicResText" .. i)
local frame = getglobal("BenillaPetMagicResFrame" .. 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 BenillaPetPaperDollFrame_SetStats()
for i = 1, BENILLA_NUM_PET_STATS do
local label = getglobal("BenillaPetStatFrame" .. i .. "Label")
local text = getglobal("BenillaPetStatFrame" .. i .. "StatText")
local frame = getglobal("BenillaPetStatFrame" .. 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 BenillaPetExpBar_Update()
local currXP, nextXP = GetPetExperience()
local bar = getglobal("BenillaPetPaperDollFrameExpBar")
bar:SetMinMaxValues(math.min(0, currXP), nextXP)
bar:SetValue(currXP)
end
-- ref PetTab_Update (l.189-198) — the conditional tab, whole. 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.
function BenillaPetTab_Update()
local tab2 = getglobal("BenillaCharacterFrameTab2")
local tab3 = getglobal("BenillaCharacterFrameTab3")
-- 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", "BenillaCharacterFrameTab2", "LEFT", 0, 0)
else
tab2:Show()
tab3:SetPoint("LEFT", "BenillaCharacterFrameTab2", "RIGHT", -16, 0)
end
end
]]></Script>
<Frame name="BenillaPetPaperDollFrame" enableMouse="true" parent="BenillaCharacterFrame" hidden="true" id="2">
<!-- The real 384×512 page (ref l.4, `setAllPoints="true"`); pinned to the window's TOPLEFT
by name, the SkillFrame.xml precedent (no cross-file `parent=` in this engine). -->
<Size><AbsDimension x="384" y="512"/></Size>
<Anchors><Anchor point="TOPLEFT" relativeTo="BenillaCharacterFrame" 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="BenillaPetNameText" 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="BenillaPetLevelText" inherits="GameFontNormalSmall">
<Anchors>
<Anchor point="TOP" relativeTo="BenillaPetNameText" relativePoint="BOTTOM"><Offset><AbsDimension x="0" y="-6"/></Offset></Anchor>
</Anchors>
</FontString>
<FontString name="BenillaPetLoyaltyText" inherits="GameFontNormalSmall">
<Anchors>
<Anchor point="TOP" relativeTo="BenillaPetLevelText" 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="BenillaPetTrainingPointText" inherits="GameFontHighlightSmall">
<Anchors><Anchor point="BOTTOMRIGHT"><Offset><AbsDimension x="-135" y="86"/></Offset></Anchor></Anchors>
</FontString>
<FontString name="BenillaPetTrainingPointLabel" inherits="GameFontNormalSmall" text="TRAINING_POINTS">
<Anchors>
<Anchor point="RIGHT" relativeTo="BenillaPetTrainingPointText" 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="BenillaPetPaperDollFrameExpBar">
<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="BenillaPetPaperDollXPBar1" 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="BenillaPetPaperDollXPBar1" 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>BenillaPetExpBar_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="BenillaPetModelFrame">
<Size><AbsDimension x="318" y="224"/></Size>
<Anchors><Anchor point="TOPLEFT"><Offset><AbsDimension x="25" y="-78"/></Offset></Anchor></Anchors>
<Layers>
<Layer level="ARTWORK">
<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="BenillaPetModelFrameRotateLeftButton">
<Size><AbsDimension x="35" y="35"/></Size>
<Anchors><Anchor point="TOPLEFT" relativeTo="BenillaPetPaperDollFrame" 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("BenillaPetModelFrame"))</OnClick>
</Scripts>
</Button>
<Button name="BenillaPetModelFrameRotateRightButton">
<Size><AbsDimension x="35" y="35"/></Size>
<Anchors><Anchor point="TOPLEFT" relativeTo="BenillaPetModelFrameRotateLeftButton" 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("BenillaPetModelFrame"))</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="BenillaPetPaperDollPetInfo">
<Size><AbsDimension x="24" y="23"/></Size>
<Anchors>
<Anchor point="TOPLEFT" relativeTo="BenillaPetModelFrameRotateLeftButton" 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, transcribed 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="BenillaPetPaperDollCloseButton" 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("BenillaCharacterFrame"))</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="BenillaPetAttributesFrame">
<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="BenillaPetStatFrame1" inherits="BenillaStatRowTemplate">
<Anchors><Anchor point="TOPLEFT"><Offset><AbsDimension x="6" y="-5"/></Offset></Anchor></Anchors>
<Scripts>
<OnLoad>self:EnableMouse(true)</OnLoad>
<OnEnter>BenillaPaperDollStatTooltip(self, "STRENGTH", "pet")</OnEnter>
<OnLeave>GameTooltip:Hide()</OnLeave>
</Scripts>
</Frame>
<Frame name="BenillaPetStatFrame2" inherits="BenillaStatRowTemplate">
<Anchors><Anchor point="TOPLEFT" relativeTo="BenillaPetStatFrame1" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-1"/></Offset></Anchor></Anchors>
<Scripts>
<OnLoad>self:EnableMouse(true)</OnLoad>
<OnEnter>BenillaPaperDollStatTooltip(self, "AGILITY", "pet")</OnEnter>
<OnLeave>GameTooltip:Hide()</OnLeave>
</Scripts>
</Frame>
<Frame name="BenillaPetStatFrame3" inherits="BenillaStatRowTemplate">
<Anchors><Anchor point="TOPLEFT" relativeTo="BenillaPetStatFrame2" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-1"/></Offset></Anchor></Anchors>
<Scripts>
<OnLoad>self:EnableMouse(true)</OnLoad>
<OnEnter>BenillaPaperDollStatTooltip(self, "STAMINA", "pet")</OnEnter>
<OnLeave>GameTooltip:Hide()</OnLeave>
</Scripts>
</Frame>
<Frame name="BenillaPetStatFrame4" inherits="BenillaStatRowTemplate">
<Anchors><Anchor point="TOPLEFT" relativeTo="BenillaPetStatFrame3" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-1"/></Offset></Anchor></Anchors>
<Scripts>
<OnLoad>self:EnableMouse(true)</OnLoad>
<OnEnter>BenillaPaperDollStatTooltip(self, "INTELLECT", "pet")</OnEnter>
<OnLeave>GameTooltip:Hide()</OnLeave>
</Scripts>
</Frame>
<Frame name="BenillaPetStatFrame5" inherits="BenillaStatRowTemplate">
<Anchors><Anchor point="TOPLEFT" relativeTo="BenillaPetStatFrame4" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-1"/></Offset></Anchor></Anchors>
<Scripts>
<OnLoad>self:EnableMouse(true)</OnLoad>
<OnEnter>BenillaPaperDollStatTooltip(self, "SPIRIT", "pet")</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="BenillaPetAttackFrame" inherits="BenillaStatRowTemplate">
<Anchors><Anchor point="TOPLEFT" relativeTo="BenillaPetAttributesFrame" 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.
Transcribed 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="BenillaPetAttackPowerFrame" inherits="BenillaStatRowTemplate">
<Anchors><Anchor point="TOPLEFT" relativeTo="BenillaPetAttackFrame" 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="BenillaPetDamageFrame" inherits="BenillaStatRowTemplate">
<Anchors><Anchor point="TOPLEFT" relativeTo="BenillaPetAttackPowerFrame" 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>BenillaCharacterDamageFrame_OnEnter(self)</OnEnter>
<OnLeave>GameTooltip:Hide()</OnLeave>
</Scripts>
</Frame>
<Frame name="BenillaPetDefenseFrame" inherits="BenillaStatRowTemplate">
<Anchors><Anchor point="TOPLEFT" relativeTo="BenillaPetDamageFrame" 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="BenillaPetArmorFrame" inherits="BenillaStatRowTemplate">
<Anchors><Anchor point="TOPLEFT" relativeTo="BenillaPetDefenseFrame" 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="BenillaPetResistanceFrame">
<Size><AbsDimension x="32" y="160"/></Size>
<Anchors>
<Anchor point="TOPRIGHT" relativeTo="BenillaPetPaperDollFrame" relativePoint="TOPLEFT"><Offset><AbsDimension x="347" y="-77"/></Offset></Anchor>
</Anchors>
<Frames>
<Frame name="BenillaPetMagicResFrame1" inherits="BenillaMagicResistanceFrameTemplate">
<Anchors><Anchor point="TOP" relativeTo="BenillaPetResistanceFrame" 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="BenillaPetMagicResText1" inherits="GameFontHighlightSmall" text="X">
<Anchors><Anchor point="BOTTOM"><Offset><AbsDimension x="0" y="3"/></Offset></Anchor></Anchors>
</FontString>
</Layer>
</Layers>
</Frame>
<Frame name="BenillaPetMagicResFrame2" inherits="BenillaMagicResistanceFrameTemplate">
<Anchors><Anchor point="TOP" relativeTo="BenillaPetMagicResFrame1" 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="BenillaPetMagicResText2" inherits="GameFontHighlightSmall" text="X">
<Anchors><Anchor point="BOTTOM"><Offset><AbsDimension x="0" y="3"/></Offset></Anchor></Anchors>
</FontString>
</Layer>
</Layers>
</Frame>
<Frame name="BenillaPetMagicResFrame3" inherits="BenillaMagicResistanceFrameTemplate">
<Anchors><Anchor point="TOP" relativeTo="BenillaPetMagicResFrame2" 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="BenillaPetMagicResText3" inherits="GameFontHighlightSmall" text="X">
<Anchors><Anchor point="BOTTOM"><Offset><AbsDimension x="0" y="3"/></Offset></Anchor></Anchors>
</FontString>
</Layer>
</Layers>
</Frame>
<Frame name="BenillaPetMagicResFrame4" inherits="BenillaMagicResistanceFrameTemplate">
<Anchors><Anchor point="TOP" relativeTo="BenillaPetMagicResFrame3" 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="BenillaPetMagicResText4" inherits="GameFontHighlightSmall" text="X">
<Anchors><Anchor point="BOTTOM"><Offset><AbsDimension x="0" y="3"/></Offset></Anchor></Anchors>
</FontString>
</Layer>
</Layers>
</Frame>
<Frame name="BenillaPetMagicResFrame5" inherits="BenillaMagicResistanceFrameTemplate">
<Anchors><Anchor point="TOP" relativeTo="BenillaPetMagicResFrame4" 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="BenillaPetMagicResText5" 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>BenillaPetPaperDollFrame_OnLoad(self)</OnLoad>
<OnEvent>BenillaPetPaperDollFrame_OnEvent(self)</OnEvent>
<OnShow>BenillaPetPaperDollFrame_OnShow()</OnShow>
<OnHide>BenillaPetPaperDollFrame_OnHide()</OnHide>
</Scripts>
</Frame>
</Ui>
+75 -6
View File
@@ -104,6 +104,13 @@
button.kind = kind
button.index = index
button.itemId = itemId
-- ref l.388/490 ("-- For the tooltip"): every laid-out reward/choice row is stamped
-- rewardType = "item"; the ONE row that ever reads "spell" is the reward-spell row
-- (l.445), which v1 doesn't build (GetRewardSpell() is nil, decision 0088). The click
-- arms below guard on it exactly as the ref does, so the guard is live the day that
-- row lands. (Deviation, harmless: the ref leaves required rows' rewardType unset —
-- l.197-200 stamps only `.type` — which reads the same `~= "spell"` either way.)
button.rewardType = "item"
button.quality = quality
button.icon:SetTexture(texture or BENILLA_FALLBACK_ICON)
button.name:SetText(name or "...")
@@ -559,9 +566,65 @@
end
end
-- ref QuestRewardItem_OnClick (l.127-141): only a REWARD-panel choice row is selectable (the
-- detail panel's rows, and both panels' FIXED reward rows, are informational only — no
-- OnClick wired to them, matching the ref's separate QuestRewardItemTemplate vs QuestItemTemplate).
-- ── The item-row modifier fork, shared by every quest-item row in this window ─────────────
-- ref QuestItem_OnClick (QuestFrame.lua:115-125), the OnClick QuestItemTemplate itself
-- carries (QuestFrameTemplates.xml:162-164) — so it covers the DETAIL panel's choice/reward
-- rows, the PROGRESS panel's required rows, and the reward panel's FIXED reward rows.
-- CTRL previews the item in the dressing room (decision 1060), SHIFT posts its link into an
-- open chat edit box (decision 1059); a plain click does nothing here, which is why these
-- rows carried no OnClick before this arc.
--
-- Two guards the ref's own code has, transcribed: the ctrl arm is gated on
-- `this.rewardType ~= "spell"` (l.117 — a spell reward has no item to wear), and the shift
-- arm additionally requires the edit box to be VISIBLE (l.121; the `and rewardType ~=
-- "spell"` rides the same line there). Ours adds `ChatFrameEditBox and` — ChatFrame.xml is
-- a separate file a window test need not load, exactly as BenillaPaperDollSlot_OnClick does.
--
-- One benilla-only guard: `link` is nil until the ask-once item template lands (a state the
-- reference's synchronous item cache never has), and EditBox:Insert takes a string. The ref
-- already carries the twin of this guard inside DressUpItemLink (ref DressUpFrame.lua:11-13).
function BenillaQuestItem_OnClick(button)
if IsControlKeyDown() then
if button.rewardType ~= "spell" then
DressUpItemLink(GetQuestItemLink(button.kind, button.index))
end
elseif IsShiftKeyDown() then
-- The ref's spell guard rides this arm (l.121); the "edit box up" and "link not
-- nil" pair is the shared helper's (UIParent.xml — one law, one place).
if button.rewardType ~= "spell" then
BenillaChatEdit_InsertLink(GetQuestItemLink(button.kind, button.index))
end
end
end
-- ref QuestRewardItem_OnClick (l.127-141), the OnClick the REWARD panel's choice rows carry
-- through QuestRewardItemTemplate (QuestFrameTemplates.xml:169-171). The first two arms are
-- QuestItem_OnClick's verbatim (only the shift arm's spell guard differs — the ref drops it
-- here, l.133); the choice SELECT is the THIRD arm, so a ctrl- or shift-click must NOT also
-- pick the reward. That ordering is the whole point of the fork.
function BenillaQuestRewardItem_OnClick(button)
if IsControlKeyDown() then
if button.rewardType ~= "spell" then
DressUpItemLink(GetQuestItemLink(button.kind, button.index))
end
elseif IsShiftKeyDown() then
-- No spell guard on this arm — the ref drops it here (l.133). The visible/non-nil
-- pair is the shared helper's (UIParent.xml).
BenillaChatEdit_InsertLink(GetQuestItemLink(button.kind, button.index))
elseif button.kind == "choice" then
-- ref l.136's own `this.type == "choice"` test. The ref needs it because its reward
-- panel packs choices AND fixed rewards into ONE QuestRewardItem1..10 pool; ours
-- keeps two pools and hangs this template only on BenillaQuestRewardChoice*, so the
-- test is belt-and-braces — kept because it is the ref's, and because it is what
-- makes the template safe to reuse if the pools are ever merged.
BenillaQuestRewardChoice_OnClick(button)
end
end
-- The third arm alone (ref l.136-139): only a REWARD-panel choice row is selectable — the
-- detail panel's rows and both panels' FIXED reward rows ride the plain
-- `BenillaQuestItemTemplate`, whose OnClick has no select arm at all, matching the ref's
-- separate QuestRewardItemTemplate vs QuestItemTemplate.
function BenillaQuestRewardChoice_OnClick(button)
getglobal("BenillaQuestFrame").itemChoice = button.index
-- Ref QuestRewardItem_OnClick l.137-138 verbatim: the glow frame seats at (-8,7) on
@@ -702,14 +765,20 @@
GameTooltip:Show()
</OnEnter>
<OnLeave>GameTooltip:Hide()</OnLeave>
<!-- ref QuestItemTemplate's own OnClick (QuestFrameTemplates.xml:162-164): the CTRL
dressing-room preview + the SHIFT chat-link post, and nothing else — a plain click
on one of these rows is inert (decisions 1059/1060). -->
<OnClick>BenillaQuestItem_OnClick(self)</OnClick>
</Scripts>
</Button>
<!-- A REWARD-panel choice row: the same visual, plus the ref's `QuestRewardItemTemplate` click
(choose highlight) — the detail panel's rows (and both panels' FIXED reward rows) stay plain
`BenillaQuestItemTemplate` with no OnClick (ref: only reward-panel CHOICE rows select). -->
(QuestFrameTemplates.xml:167-173) — the ctrl/shift arms shared with the base template, then
the choose highlight as the THIRD arm. The detail panel's rows and both panels' FIXED reward
rows stay on plain `BenillaQuestItemTemplate`, whose OnClick has no select arm (ref: only
reward-panel CHOICE rows select). -->
<Button name="BenillaQuestRewardItemTemplate" inherits="BenillaQuestItemTemplate" virtual="true" hidden="true">
<Scripts><OnClick>BenillaQuestRewardChoice_OnClick(self)</OnClick></Scripts>
<Scripts><OnClick>BenillaQuestRewardItem_OnClick(self)</OnClick></Scripts>
</Button>
<!-- A greeting quest-title button (ref QuestTitleButtonTemplate, QuestFrameTemplates.xml l.174-213):
+66 -7
View File
@@ -176,8 +176,23 @@
end
return
end
-- pin §5, QuestLogTitleButton_OnClick (QuestLogFrame.lua:469-501): shift-click toggles
-- the watch. Guard order matches the ref exactly: already watched → unwatch,
-- pin §5, QuestLogTitleButton_OnClick (QuestLogFrame.lua:469-501): shift-click either
-- posts the quest's NAME into an open chat edit box (l.478-480) or, with chat closed,
-- toggles the watch (l.481-500 — the ref's own `else`). Chat wins; the watch is the
-- fallback, not the other way round (decision 1059).
--
-- The insert (l.480) is `gsub(this:GetText(), " *(.*)", "%1")` — the row LABEL with its
-- leading indent trimmed, i.e. the bare quest name; note it is plain text, NOT a
-- hyperlink (1.12 has no |Hquest: link). We read `GetQuestLogTitle(index)` instead of
-- the label, because our label is not the ref's: this window appends the Complete/Failed
-- tag onto the ONE title string (the v1 simplification in BenillaQuestLogList_Update,
-- where the ref keeps a separate right-justified $parentTag FontString), so the ref's
-- gsub over our label would post "Quest 1 (Complete)". The API title is exactly the
-- string the ref's own trim yields — same result, different expression. `ChatFrameEditBox
-- and` guards a window test that hasn't loaded ChatFrame.xml (BenillaPaperDollSlot_OnClick's
-- precedent).
--
-- Guard order inside the watch arm matches the ref exactly: already watched → unwatch,
-- unconditionally; else zero objectives (QUEST_WATCH_NO_OBJECTIVES) or the watch list
-- full (QUEST_WATCH_TOO_MANY) put the ref's red line on the errors frame
-- (BenillaErrorsFrame_AddMessage — the ref's UIErrorsFrame:AddMessage call sites,
@@ -187,7 +202,12 @@
-- fires an event off a plain Add/RemoveQuestWatch call; we mirror that exactly.
if mouseButton == "LeftButton" and IsShiftKeyDown() then
local title = GetQuestLogTitle(index)
if IsQuestWatched(index) then
if ChatFrameEditBox and ChatFrameEditBox:IsVisible() then
-- The visibility test stays HERE, not in the helper: it is the ref's own fork
-- (l.474/478) — with chat open this click never watches, and the helper's
-- return would conflate "chat closed" with "nothing to post".
BenillaChatEdit_InsertLink(title)
elseif IsQuestWatched(index) then
-- Clearing the timer too: a lingering timed entry would later expire and
-- silently unwatch a re-added MANUAL watch.
if title then BENILLA_QUEST_WATCH_TIMERS[title] = nil end
@@ -531,11 +551,20 @@
-- GetQuestLogRewardInfo (pin §6): both answer the shared 5-tuple `name, texture, count,
-- quality, usable` — the same shape QuestFrame.xml's GetQuestItemInfo(kind, index) answers
-- for the questgiver panels, just under quest-log-specific getter names instead of one
-- kind-dispatched function.
function BenillaQuestLogItem_Set(button, getter, index)
-- kind-dispatched function. `kind` ("choice"/"reward") rides alongside because the LINK
-- getter IS kind-dispatched — GetQuestLogItemLink(this.type, this:GetID()), ref
-- QuestLogFrame.lua:545/549 — and because it is the ref's own `questItem.type`
-- (QuestFrame.lua:369/476, the shared layout routine both windows run).
function BenillaQuestLogItem_Set(button, getter, index, kind)
local name, texture, count, quality, usable, itemId = getter(index)
button.index = index
button.kind = kind
button.itemId = itemId
-- ref QuestFrame.lua:388/490 ("-- For the tooltip"), the same stamp the giver window's
-- BenillaQuestItem_Set makes: only the reward-SPELL row ever reads "spell" (l.445), and
-- v1 builds none (GetQuestLogRewardSpell is nil — this file's header). The click arms
-- below guard on it exactly as the ref does, so the guard is live when that row lands.
button.rewardType = "item"
button.quality = quality
button.icon:SetTexture(texture or BENILLA_FALLBACK_ICON)
button.name:SetText(name or "...")
@@ -546,6 +575,32 @@
button:Show()
end
-- ref QuestLogRewardItem_OnClick (QuestLogFrame.lua:542-552), the OnClick
-- QuestLogRewardItemTemplate carries (ref QuestLogFrame.xml:118-120): CTRL previews the
-- reward in the dressing room (decision 1060), SHIFT posts its link into an open chat edit
-- box (decision 1059). There is no third arm here — a quest-log reward row is never
-- selectable, so a plain click is inert, which is why these rows carried no OnClick before
-- this arc.
--
-- Guard-for-guard with the ref: ctrl is gated on `this.rewardType ~= "spell"` (l.544), and
-- so is shift — but on the ELSEIF line itself (l.547), where the giver window's twin puts it
-- inside (QuestFrame.lua:121). Same outcome, and transcribed where the ref puts it. Ours
-- adds `ChatFrameEditBox and` (ChatFrame.xml is a separate file a window test need not
-- load — BenillaPaperDollSlot_OnClick's precedent) and a nil-link guard: `link` is nil until
-- the ask-once item template lands, a state the reference's synchronous item cache never
-- has, and EditBox:Insert takes a string (DressUpItemLink carries the same guard itself —
-- ref DressUpFrame.lua:11-13).
function BenillaQuestLogRewardItem_OnClick(button)
if IsControlKeyDown() then
if button.rewardType ~= "spell" then
DressUpItemLink(GetQuestLogItemLink(button.kind, button.index))
end
elseif IsShiftKeyDown() and button.rewardType ~= "spell" then
-- The visible/non-nil pair is the shared helper's (UIParent.xml).
BenillaChatEdit_InsertLink(GetQuestLogItemLink(button.kind, button.index))
end
end
-- The reward-panel layout, ported verbatim from the ref's shared `QuestFrameItems_Update`
-- (QuestFrame.lua:311-522, the same routine the live QuestFrame reward panel uses) — this
-- window's v1 subset of it: no spell-reward row (GetQuestLogRewardSpell has no v1 caller,
@@ -589,7 +644,7 @@
for i = 1, BENILLA_QUESTLOG_MAX_ITEMS do
local choice = getglobal("BenillaQuestLogChoice" .. i)
if i <= lastChoice then
BenillaQuestLogItem_Set(choice, GetQuestLogChoiceInfo, i)
BenillaQuestLogItem_Set(choice, GetQuestLogChoiceInfo, i, "choice")
if i == 1 then
choice:SetPoint("TOPLEFT", chooseText, "BOTTOMLEFT", -3, -5)
elseif i % 2 == 1 then
@@ -628,7 +683,7 @@
for i = 1, BENILLA_QUESTLOG_MAX_ITEMS do
local reward = getglobal("BenillaQuestLogReward" .. i)
if i <= lastReward then
BenillaQuestLogItem_Set(reward, GetQuestLogRewardInfo, i)
BenillaQuestLogItem_Set(reward, GetQuestLogRewardInfo, i, "reward")
if i == 1 then
reward:SetPoint("TOPLEFT", receiveText, "BOTTOMLEFT", -3, -5)
elseif i % 2 == 1 then
@@ -1076,6 +1131,10 @@
GameTooltip:Show()
</OnEnter>
<OnLeave>GameTooltip:Hide()</OnLeave>
<!-- ref QuestLogRewardItemTemplate's own OnClick (ref QuestLogFrame.xml:118-120): the
CTRL dressing-room preview + the SHIFT chat-link post, and nothing else — a plain
click on a quest-log reward row is inert (decisions 1059/1060). -->
<OnClick>BenillaQuestLogRewardItem_OnClick(self)</OnClick>
</Scripts>
</Button>
+61
View File
@@ -341,5 +341,66 @@
end
return modKeys..localizedName;
end
-- ref UIParent.lua:1051-1057, transcribed 1:1 (decision 1062). The reference's own
-- list joiner, and it is exactly as plain as it looks: a bare ", " between every item,
-- with NO "and" before the last one and NO localization string of any kind — 1.12 has
-- no LIST_DELIMITER/AND global for it to reach for, and it does not build one. (The
-- richer `BuildColoredListString` right below it in the reference — the per-item
-- met/unmet red — already lives in TradeSkillFrame.xml as a guarded global, 0437 phase
-- 3; folding that one up here beside this is a follow-up, not this slice's churn.)
--
-- Callers: the pet paper doll's diet tooltip and PetStable.lua's, both as
-- `format(PET_DIET_TEMPLATE, BuildListString(GetPetFoodTypes()))`.
--
-- ZERO arguments returns NIL, not "" — the ref reads `arg[1]` off an empty vararg and
-- returns it unchanged. That is load-bearing: `format("%s", nil)` errors, so a caller
-- that can pass nothing (a warlock minion's empty diet) must be gated by its own UI, and
-- it is — the diet icon is hunter-only. Quietly returning "" here would hide that.
--
-- Lua-5.0 `arg`/`arg.n` has no equivalent (LUA_COMPAT_VARARG isn't shipped) —
-- `select("#", ...)`/`select(i, ...)`, TradeSkillFrame.xml's own substitution.
function BuildListString(...)
local n = select("#", ...);
if ( n == 0 ) then
return nil;
end
local list = select(1, ...);
for i = 2, n do
local item = select(i, ...);
list = list..", "..item;
end
return list;
end
-- BenillaChatEdit_InsertLink(link) → true if it went in, else nil (decision 1059).
--
-- Every shift-click site in this UI does the same two things before inserting: check the
-- chat edit box is up, and — ours alone — check the link is not nil. That nil case is
-- **benilla's, not the reference's**: our item templates resolve asynchronously
-- (`CMSG_ITEM_QUERY_SINGLE`, answered a frame or many later), so a `Get…Link` getter
-- legitimately answers nil for a slot whose name and quality have not landed, where the real
-- client's synchronous item cache always has one. `EditBox:Insert` takes a string (the
-- byte-modelled `0x77bee0`), so an unguarded nil is a script error — which is exactly the
-- bug this function exists to make impossible to reintroduce at the next site. A dozen call
-- sites were growing a dozen copies of the same two-line guard, and three of them shipped
-- without it.
--
-- The RETURN is what lets a caller fork on "did chat take it?" — a bag slot's shift-click
-- posts the link when chat is open and opens the stack splitter when it is not (ref
-- ContainerFrame.lua:567-577). Blizzard themselves eventually factored this exact helper out
-- for this exact reason: 2.0's `ChatEdit_InsertLink(text)`, returning true/false, called from
-- every one of these sites. 1.12 has no such function, so ours carries a benilla name rather
-- than pretending to be reference API.
function BenillaChatEdit_InsertLink(link)
if not link then
return nil
end
if not ChatFrameEditBox or not ChatFrameEditBox:IsVisible() then
return nil
end
ChatFrameEditBox:Insert(link)
return true
end
]]></Script>
</Ui>
@@ -45,6 +45,11 @@
-- registration line (`UIPanelWindows["TaxiFrame"] = { area = "left", pushable = 0 }`),
-- quoted verbatim — same left-slot competition as Gossip/Merchant/Trainer above.
UIPanelWindows["BenillaTaxiFrame"] = { area = "left", pushable = 0 }
-- The dressing room (decision 1060, DressUpFrame.xml): the ref's own UIParent.lua l.41
-- registration (`UIPanelWindows["DressUpFrame"] = { area = "left", pushable = 2 }`), quoted
-- verbatim — pushable=2 lets the NPC-session windows (pushable=0) shove it aside, which is
-- what makes "ctrl-click a merchant's item" show both.
UIPanelWindows["BenillaDressUpFrame"] = { area = "left", pushable = 2 }
UIPanelWindows["BenillaQuestFrame"] = { area = "left", pushable = 0 }
UIPanelWindows["BenillaQuestLogFrame"] = { area = "left", pushable = 0 }
UIPanelWindows["BenillaLootFrame"] = { area = "left", pushable = 7 }
+79 -21
View File
@@ -223,8 +223,19 @@
-- flag to build one from — UnitIsPlayer is the nearest honest read, so a charmed mob or
-- another player's pet falls through to the unit tooltip where the reference would explain
-- the menu. The player case, which is the one anybody hovers, is exact.
-- ref UnitFrame_OnEnter l.55-57, ahead of everything else: the ONLY lit/grey cursor split
-- over a UI element in 1.12, and it is authored here in Lua rather than in the engine
-- (wow-re item-target-cursor-and-dropitemonunit.md 4.3). Bag and paper-doll slots have no
-- such branch, which is why they show the ordinary pointer.
function BenillaUnitFrame_OnEnter(frame)
local unit = frame.unit
if SpellIsTargeting() then
if SpellCanTargetUnit(unit) then
SetCursor("CAST_CURSOR")
else
SetCursor("CAST_ERROR_CURSOR")
end
end
if not UnitExists(unit) then return end
GameTooltip_SetDefaultAnchor(GameTooltip, frame)
if SHOW_NEWBIE_TIPS == "1" then
@@ -249,6 +260,10 @@
-- lingers on a ramp reads as a stuck tooltip, where a unit readout fading is the client's
-- signature. (Items Hide, units fade — that split still holds for the world mouseover.)
function BenillaUnitFrame_OnLeave(frame)
-- ref UnitFrame_OnLeave l.86: leaving a unit frame while still targeting goes to the
-- GREY cast cursor, not to the base one — you have left the only thing the armed spell
-- could have bound.
if SpellIsTargeting() then SetCursor("CAST_ERROR_CURSOR") end
if SHOW_NEWBIE_TIPS == "1" then
GameTooltip:Hide()
else
@@ -708,6 +723,11 @@
local name
if UnitIsUnit("target", "player") then
menu = "SELF"
elseif UnitIsUnit("target", "pet") then
-- Selecting your own pet and right-clicking the TARGET frame gives you the pet
-- menu — the ref's own second branch (TargetFrame.lua l.440-441), which sat as a
-- comment here until the "pet" token (0990) and then the menu itself (1066).
menu = "PET"
elseif UnitIsPlayer("target") then
if UnitInParty("target") then
menu = "PARTY"
@@ -715,8 +735,6 @@
menu = "PLAYER"
end
else
-- The ref checks UnitIsUnit("target", "pet") → PET before this branch; no "pet"
-- token exists until the pet slice (phase 4).
menu = "RAID_TARGET_ICON"
name = RAID_TARGET_ICON
end
@@ -1169,16 +1187,12 @@
0988's warning was answered rather than dodged. Its three siblings (`GetPetLoyalty`,
`GetPetTrainingPoints`, `GetPetExperience`) landed with it.
STILL DEFERRED, each with its reason:
- The PET right-click menu. The reference opens it from PetFrame_OnClick's else arm
(`ToggleDropDownMenu(1, nil, PetFrameDropDown)` — PetFrame.lua:127-129), and its four
entries (UnitPopup.xml's PET_PAPERDOLL / PET_RENAME / PET_ABANDON / PET_DISMISS) each
need a binding that is its own build — the pet paper doll most of all. Two more are
needed just to decide what the menu SHOWS: `PetCanBeAbandoned` forks it, hiding
paperdoll/rename/abandon for a warlock pet and hiding dismiss for a hunter's, so the
same menu reads "Abandon" (permanent, behind the ABANDON_PET confirm) on a hunter pet
and "Dismiss" on a demon. They stay suppressed in UnitPopup.xml; the frame takes no
right click rather than opening a menu of dead rows.
The PET RIGHT-CLICK MENU is no longer deferred either — decision 1066 built the last of
the five things it was waiting on (the pet paper doll, the fifth, landed as 1057). The
frame's else arm opens PetFrameDropDown below; the rows, their fork and their dialogs are
UnitPopup.xml's.
STILL DEFERRED, with its reason:
- PartyMemberBuffTooltip on hover (the ref's OnEnter panel): it belongs to the party
frames' own deferred buff panel, not here.
-->
@@ -1348,20 +1362,52 @@
BenillaPetFrameAttackMode:SetVertexColor(1.0, 1.0, 1.0, alpha)
end
-- ref PetFrame_OnClick's left branch, all three legs in the ref's own order. The right
-- branch (the PET menu) is the named deferral in the header.
-- ref PetFrame_OnClick, now whole (decision 1066 built the right branch). Its FIRST leg is
-- the one that is easy to drop: with a spell armed, a RIGHT click disarms rather than
-- opening the menu, and it precedes both branches.
function BenillaPetFrame_OnClick(button)
if button ~= "LeftButton" then return end
if SpellIsTargeting() then
SpellTargetUnit("pet")
elseif CursorHasItem() then
DropItemOnUnit("pet")
if SpellIsTargeting() and button == "RightButton" then
SpellStopTargeting()
return
end
if button == "LeftButton" then
if SpellIsTargeting() then
SpellTargetUnit("pet")
elseif CursorHasItem() then
DropItemOnUnit("pet")
else
TargetUnit("pet")
end
else
TargetUnit("pet")
-- No anchor arguments, unlike the player and target frames: the ref opens this one
-- at the CURSOR (PetFrame.lua l.128).
ToggleDropDownMenu(1, nil, PetFrameDropDown)
end
end
-- The PET menu's owner (ref PetFrame.lua l.158-166). The UnitExists gate is the ref's own,
-- and it is not redundant with the frame being hidden: this dropdown is also what the
-- TARGET frame's menu falls through to when you have your own pet selected.
function PetFrameDropDown_OnLoad()
UIDropDownMenu_Initialize(this, PetFrameDropDown_Initialize, "MENU")
end
function PetFrameDropDown_Initialize()
if UnitExists("pet") then
UnitPopup_ShowMenu(PetFrameDropDown, "PET", "pet")
end
end
function BenillaPetFrame_OnEnter(frame)
-- The pet frame is a unit frame in the reference, and takes the same cursor split:
-- hovering it with a spell armed lights or greys exactly as the target frame does.
if SpellIsTargeting() then
if SpellCanTargetUnit("pet") then
SetCursor("CAST_CURSOR")
else
SetCursor("CAST_ERROR_CURSOR")
end
end
if not UnitExists("pet") then return end
GameTooltip_SetDefaultAnchor(GameTooltip, frame)
if GameTooltip:SetUnit("pet") then
@@ -1410,6 +1456,18 @@
<Anchor point="TOPLEFT"><Offset><AbsDimension x="80" y="-60"/></Offset></Anchor>
</Anchors>
<Frames>
<!-- The PET-menu dropdown host (ref $parentDropDown, PetFrame.xml l.35-51): a hidden
state-holder, like the player and target frames'. It carries the ref's own id=1
and its 10×10 size; nothing anchors to it, because this menu opens at the cursor.
It lives INSIDE the pet frame, which means it is only ever initialized once the
pet frame exists — the same nesting the reference uses. -->
<Frame name="PetFrameDropDown" inherits="UIDropDownMenuTemplate" id="1" hidden="true">
<Size><AbsDimension x="10" y="10"/></Size>
<Anchors><Anchor point="TOP"><Offset><AbsDimension x="-20" y="-20"/></Offset></Anchor></Anchors>
<Scripts>
<OnLoad>PetFrameDropDown_OnLoad()</OnLoad>
</Scripts>
</Frame>
<!-- Bars first, pushed one frame level under the TextureFrame at load (see OnLoad). -->
<StatusBar name="$parentHealthBar">
<Size><AbsDimension x="70" y="8"/></Size>
@@ -1512,7 +1570,7 @@
<OnEvent>BenillaPetFrame_OnEvent(this)</OnEvent>
<OnUpdate>BenillaPetFrame_OnUpdate(this, arg1)</OnUpdate>
<OnEnter>BenillaPetFrame_OnEnter(this)</OnEnter>
<OnLeave>GameTooltip:FadeOut()</OnLeave>
<OnLeave>if SpellIsTargeting() then SetCursor("CAST_ERROR_CURSOR") end GameTooltip:FadeOut()</OnLeave>
<OnClick>BenillaPetFrame_OnClick(arg1)</OnClick>
</Scripts>
</Frame>
+43 -11
View File
@@ -22,9 +22,12 @@
that last entry's stated reason ("its CMSG family is its own slice") was simply WRONG: 0890
verified auto-follow sends nothing at all, it is pure client-side movement, so the row was
waiting on a wire slice that does not exist. It was the motion law it really needed.
Everything a menu SHOWS works. The PET menu and the RAID_* rank rows stay
in the tables but are unreachable (no "pet" token, no raid roster) — their OnClick branches
are trimmed with the rest.
Everything a menu SHOWS works. The PET menu opened with decision 1066: its four rows and the
one predicate that forks them are below, its opener is UnitFrames.xml's PetFrameDropDown, and
the two dialogs behind Abandon and Rename are registered in PetActionBar.xml (with the pet
arc, like every other dialog in this codebase — this file must stay loadable without the
popup engine). The RAID_* rank rows stay in the tables but are unreachable (no raid roster) —
their OnClick branches are trimmed with the rest.
- Lua 5.1: the ref's 5.0 `for i, v in t do` iteration is ipairs/pairs here; strsub → the
TEXT stub below keeps the table lines verbatim (the ref's TEXT() is the localization
identity).
@@ -126,6 +129,7 @@
UnitPopupFrames = {
"PlayerFrameDropDown",
"TargetFrameDropDown",
"PetFrameDropDown",
"PartyMemberFrame1DropDown",
"PartyMemberFrame2DropDown",
"PartyMemberFrame3DropDown",
@@ -404,13 +408,27 @@
elseif ( value == "RAID_LEADER" or value == "RAID_PROMOTE" or value == "RAID_DEMOTE" or value == "RAID_REMOVE" ) then
-- The raid roster (GetRaidRosterInfo) is a later arc — no raid menus open yet.
UnitPopupShown[index] = 0;
elseif ( value == "PET_PAPERDOLL" or value == "PET_RENAME" or value == "PET_ABANDON" or value == "PET_DISMISS" ) then
-- The "pet" token exists now (0990), but none of these four rows does: each
-- wants its own binding (paperdoll a whole panel), and `PetCanBeAbandoned`
-- besides, which is what forks abandon-vs-dismiss. So the PET menu stays shut
-- at its opener (UnitFrames.xml's pet frame takes no right click) and these
-- stay suppressed here — the two halves of one deferral, not two.
UnitPopupShown[index] = 0;
-- The four PET rows, ref UnitPopup.lua l.402-417 verbatim (decision 1066). ONE
-- predicate decides all four, and Dismiss reads it INVERTED: a hunter's pet is
-- abandonable, so it offers paperdoll/rename/abandon and hides dismiss; a
-- warlock's demon is not, so it offers only dismiss. Rename wants both bits,
-- because its own is one-shot — the server clears it after the first rename.
elseif ( value == "PET_PAPERDOLL" ) then
if ( not PetCanBeAbandoned() ) then
UnitPopupShown[index] = 0;
end
elseif ( value == "PET_RENAME" ) then
if ( not PetCanBeAbandoned() or not PetCanBeRenamed() ) then
UnitPopupShown[index] = 0;
end
elseif ( value == "PET_ABANDON" ) then
if ( not PetCanBeAbandoned() ) then
UnitPopupShown[index] = 0;
end
elseif ( value == "PET_DISMISS" ) then
if ( PetCanBeAbandoned() ) then
UnitPopupShown[index] = 0;
end
elseif ( string.sub(value, 1, 12) == "RAID_TARGET_" ) then
if ( (inParty == 0) or ((isLeader == 0) and (isAssistant == 0)) ) then
UnitPopupShown[index] = 0;
@@ -596,11 +614,25 @@
-- already knows the unit's name to the letter, so unlike `/follow rag` it must not
-- prefix-match its way onto a bystander. Client-side movement only, no wire.
FollowByName(name, 1);
-- The four PET arms, ref UnitPopup_OnClick l.590-597 verbatim (decision 1066). The
-- asymmetry is the reference's and it is the right one: DISMISS runs immediately,
-- ABANDON goes behind a confirm. Sending a summon away costs nothing; giving up a
-- tamed pet is permanent, and the server deletes it (`PET_SAVE_AS_DELETED`).
elseif ( button == "PET_DISMISS" ) then
PetDismiss();
elseif ( button == "PET_ABANDON" ) then
StaticPopup_Show("ABANDON_PET");
elseif ( button == "PET_PAPERDOLL" ) then
ToggleCharacter("BenillaPetPaperDollFrame");
elseif ( button == "PET_RENAME" ) then
StaticPopup_Show("RENAME_PET");
end
-- DEFERRED branches trimmed with their rows (header): TARGET,
-- GUILD_*, RESET_INSTANCES, PET_*, RAID_LEADER/PROMOTE/DEMOTE/REMOVE.
-- GUILD_*, RESET_INSTANCES, RAID_LEADER/PROMOTE/DEMOTE/REMOVE.
PlaySound("UChatScrollButton");
end
]]></Script>
<!-- The OnUpdate driver (ref UIParent.xml l.21 runs UnitPopup_OnUpdate from UIParent's own
+89 -21
View File
@@ -91,18 +91,91 @@ pub(crate) fn normalize_path(path: &str) -> String {
path.replace('/', "\\").to_ascii_lowercase()
}
/// The archive key a UI **sprite** reference resolves to: [`normalize_path`] plus the FrameXML
/// extensionless→`.blp` rule (`Interface\Buttons\UI-Quickslot2` → `interface\buttons\ui-quickslot2.blp`;
/// a path that already carries an extension passes through). One function because every sprite
/// entry point needs it — the five decoders below and, deliberately, the shipped-XML sweep that
/// asserts each `file=` in our own UI actually resolves (`ui_script::shipped_xml_tests`): a sweep
/// re-implementing this rule could agree with itself while disagreeing with the renderer.
/// The **cache key** a UI sprite reference folds to: [`normalize_path`], then an extension of
/// **exactly three characters** stripped — a dot at `len - 4`.
///
/// That is the reference's own rule (`0x449590`, which strips a 3-char extension and returns which
/// of `{".tga", ".blp"}` it was), and its texture cache is keyed on the resulting stem — so
/// `Foo`, `Foo.blp` and `Foo.tga` are **one** entry, not three. Ours keys the same way, which is
/// why this is separate from [`sprite_candidates`]: the key identifies the texture, the candidates
/// are the files that might hold it.
///
/// Note a 3-char extension is stripped whatever it is: `Foo.bmp` keys as `Foo`. A longer or shorter
/// one is not an extension to the client at all — `Foo.jpeg` keys as `Foo.jpeg` and looks for
/// `Foo.jpeg.blp`.
pub(crate) fn sprite_key(path: &str) -> String {
let mut key = normalize_path(path);
if !key.rsplit('\\').next().is_some_and(|f| f.contains('.')) {
key.push_str(".blp");
let key = normalize_path(path);
match key.len().checked_sub(4) {
Some(dot) if key.as_bytes()[dot] == b'.' => key[..dot].to_string(),
_ => key,
}
key
}
/// The two archive names a UI sprite reference may live under, **in the order the reference tries
/// them** — `TextureCreate`'s fixed two-candidate chain (`0x449d90`, extension table `0x835248 =
/// {".tga", ".blp"}`). The supplied extension's pipeline goes first, the other second; both open
/// through the same Storm primitive, and a miss on the first is silent, so the second is a genuine
/// fallback rather than an error path.
///
/// | reference | first | second |
/// |---|---|---|
/// | `Foo` (no 3-char extension) | `Foo.blp` | `Foo.tga` |
/// | `Foo.blp` | `Foo.blp` | `Foo.tga` |
/// | `Foo.tga` | `Foo.tga` | `Foo.blp` |
/// | `Foo.bmp` (any other 3-char) | `Foo.blp` | `Foo.tga` |
/// | `Foo.jpeg` (not 3 chars) | `Foo.jpeg.blp` | `Foo.jpeg.tga` |
///
/// This chain is what makes `Interface\Icons\Ability_Druid_Mangle.tga` — a real macro-chooser entry,
/// since `Ability_Druid_Mangle.tga.blp` ships and the chooser stores names extension-stripped —
/// resolve to `…Mangle.blp` on the second try instead of drawing a white square (bug B221). The old
/// rule here passed an extensioned path through untouched and had no second try at all.
///
/// One function, used by the decoders below *and* by the sweeps that assert our own paths resolve
/// (`ui_script::shipped_xml_tests`, `ui_macro::tests`): a sweep re-implementing this could agree
/// with itself while disagreeing with what actually draws.
pub(crate) fn sprite_candidates(path: &str) -> [String; 2] {
let stem = sprite_key(path);
let normalized = normalize_path(path);
// `normalize_path` has already folded case, so a plain compare is the case-insensitive one.
if normalized.len() >= 4 && normalized[normalized.len() - 4..] == *".tga" {
[format!("{stem}.tga"), format!("{stem}.blp")]
} else {
[format!("{stem}.blp"), format!("{stem}.tga")]
}
}
/// Decode a UI sprite key to RGBA8, **warning once per key that fails to resolve**.
///
/// Every sprite cache below stores misses as well as hits, and each calls this on the miss path
/// *before* inserting — so the warning is self-limiting by construction (one line per distinct key
/// for the process's life), with no separate "already warned" set to keep.
///
/// It exists because the renderer's fallback for an unresolvable path is **silent and looks like
/// art**: a `Texture` region whose file can't be found still pushes a quad, which `ui_pass` draws
/// with the shared 1×1 white image tinted white — an opaque WHITE RECTANGLE at the region's exact
/// rect. That fallback can't itself be made loud (it is what lets flat-shaded quads batch into one
/// texture run), so a miss has to be reported here instead. Bug B221 — macro-chooser icons
/// rendering as white squares — was invisible to every layer of the client until this line existed:
/// `shipped_xml_tests` sweeps only static XML `file=` attributes, never a path that arrives at
/// runtime from a DBC.
///
/// Walks [`sprite_candidates`] in order and takes the first that both reads and decodes; only when
/// **both** fail is it a miss. A candidate that reads but won't decode falls through exactly like
/// one that isn't there — the reference's two loaders both fail silently, so a bad first candidate
/// can't poison the second.
fn decode_sprite(chain: &Mutex<Chain>, path: &str) -> Option<(u32, u32, Vec<u8>)> {
let candidates = sprite_candidates(path);
let mut chain = chain.lock_recover();
for candidate in &candidates {
if let Ok(decoded) = read_texture_rgba(&mut chain, candidate) {
return Some(decoded);
}
}
warn!(
"texture miss: '{path}' does not resolve in the patch chain (tried {})",
candidates.join(", ")
);
None
}
/// Mutate an asset only when `differs` says its current state doesn't already match.
@@ -202,8 +275,7 @@ impl WorldAssets {
if let Some(cached) = self.sprites.get(&key) {
return cached.clone();
}
let loaded = read_texture_rgba(&mut self.chain.lock_recover(), &key)
.ok()
let loaded = decode_sprite(&self.chain, path)
.map(|(w, h, rgba)| images.add(sprite_image(w, h, rgba)));
self.sprites.insert(key, loaded.clone());
loaded
@@ -214,8 +286,7 @@ impl WorldAssets {
/// which resizes the 128×32 frame to the plate's exact size). Same extensionless→`.blp` resolve
/// as [`Self::sprite_texture`]; not cached (a one-shot decode at load, not a per-frame ask).
pub(crate) fn decode_rgba(&mut self, path: &str) -> Option<(u32, u32, Vec<u8>)> {
let key = sprite_key(path);
read_texture_rgba(&mut self.chain.lock_recover(), &key).ok()
decode_sprite(&self.chain, path)
}
/// A UI sprite decoded with **repeat** (wrap) addressing — the frame `Backdrop` tiled pieces
@@ -232,8 +303,7 @@ impl WorldAssets {
if let Some(cached) = self.tiled_sprites.get(&key) {
return cached.clone();
}
let loaded = read_texture_rgba(&mut self.chain.lock_recover(), &key)
.ok()
let loaded = decode_sprite(&self.chain, path)
.map(|(w, h, rgba)| images.add(sprite_image_tiled(w, h, rgba)));
self.tiled_sprites.insert(key, loaded.clone());
loaded
@@ -252,8 +322,7 @@ impl WorldAssets {
if let Some(cached) = self.portraits.get(&key) {
return cached.clone();
}
let loaded = read_texture_rgba(&mut self.chain.lock_recover(), &key)
.ok()
let loaded = decode_sprite(&self.chain, path)
.map(|(w, h, rgba)| images.add(portrait_image(w, h, rgba)));
self.portraits.insert(key, loaded.clone());
loaded
@@ -271,9 +340,8 @@ impl WorldAssets {
if let Some(cached) = self.masks.get(&key) {
return cached.clone();
}
let loaded = read_texture_rgba(&mut self.chain.lock_recover(), &key)
.ok()
.map(|(w, h, rgba)| images.add(mask_image(w, h, rgba)));
let loaded =
decode_sprite(&self.chain, path).map(|(w, h, rgba)| images.add(mask_image(w, h, rgba)));
self.masks.insert(key, loaded.clone());
loaded
}
+19 -3
View File
@@ -91,9 +91,12 @@ pub(crate) mod cmd {
pub(crate) const FRIEND_NAMEPLATES: Cmd = Cmd(63);
pub(crate) const ALL_NAMEPLATES: Cmd = Cmd(64);
pub(crate) const ATTACK_TARGET: Cmd = Cmd(65);
pub(crate) const TOGGLE_UI: Cmd = Cmd(86);
pub(crate) const CAMERA_ZOOM_IN: Cmd = Cmd(87);
pub(crate) const CAMERA_ZOOM_OUT: Cmd = Cmd(88);
// These three shift whenever a row lands ahead of them in SPECS (1057's `TOGGLECHARACTER3`
// moved them 86/87/88 → 87/88/89). `the_cmd_handles_index_their_rows` is what makes that
// loud and cheap instead of silent: it names the handle and the row it actually points at.
pub(crate) const TOGGLE_UI: Cmd = Cmd(87);
pub(crate) const CAMERA_ZOOM_IN: Cmd = Cmd(88);
pub(crate) const CAMERA_ZOOM_OUT: Cmd = Cmd(89);
}
/// The registry, 1.12 `Bindings.xml` order. Sub-tables (action buttons, shapeshift, raid
@@ -581,6 +584,19 @@ pub(crate) static SPECS: &[Spec] = &[
Some("K"),
None
),
// The pet paper doll is TOGGLECHARACTER**3**, not 2 — 1.12's `Bindings.xml` numbers these by
// page, not by tab (0 = PaperDoll, 1 = Skill, 2 = Reputation, 3 = PetPaperDoll, 4 = Honor), so
// the name is the reference's and has nothing to do with our tab index. `SHIFT-P` is byte-real
// from the client's own `bindings-cache.wtf`, and identical in two independent accounts (ONE
// and WINUSER) — which is what rules out a player rebind (the `TOGGLEUI` trap, 0870).
// Decision 1057.
spec!(
"TOGGLECHARACTER3",
INTERFACE,
Kind::Edge(r#"ToggleCharacter("BenillaPetPaperDollFrame")"#),
Some("SHIFT-P"),
None
),
spec!(
"TOGGLESPELLBOOK",
INTERFACE,
@@ -138,6 +138,7 @@ pub(super) fn seed_ui_fixture(
name: "Godric Rothgar".into(),
subname: None,
creature_type: 0,
pet_family: 0,
rank: 0,
type_flags: 0,
civilian: false,
@@ -201,6 +202,7 @@ pub(super) fn seed_ui_fixture(
name: "Marshal McBride".into(),
subname: None,
creature_type: 0,
pet_family: 0,
rank: 0,
type_flags: 0,
civilian: false,
@@ -265,6 +267,7 @@ pub(super) fn seed_ui_fixture(
name: "Soleil Stonemantle".into(),
subname: Some("Banker".into()),
creature_type: 7,
pet_family: 0,
rank: 0,
type_flags: 0,
civilian: true,
@@ -357,6 +360,7 @@ pub(super) fn seed_ui_fixture(
name: "Marshal McBride".into(),
subname: None,
creature_type: 0,
pet_family: 0,
rank: 0,
type_flags: 0,
civilian: false,
@@ -400,6 +404,7 @@ pub(super) fn seed_ui_fixture(
name: "Marshal McBride".into(),
subname: None,
creature_type: 0,
pet_family: 0,
rank: 0,
type_flags: 0,
civilian: false,
@@ -445,6 +450,7 @@ pub(super) fn seed_ui_fixture(
name: "Kobold Vermin".into(),
subname: None,
creature_type: 0,
pet_family: 0,
rank: 0,
type_flags: 0,
civilian: false,
@@ -884,6 +890,7 @@ pub(super) fn seed_ui_fixture(
name: "Timber Wolf".into(),
subname: None,
creature_type: 1,
pet_family: 0,
rank: 0,
type_flags: 0,
civilian: false,
@@ -1167,6 +1174,7 @@ pub(super) fn seed_ui_fixture(
name: "Timber Wolf".into(),
subname: None,
creature_type: 1,
pet_family: 0,
rank: 0,
type_flags: 0,
civilian: false,
+286 -39
View File
@@ -145,52 +145,135 @@ fn cursor_path(stem: &str) -> String {
format!("Interface\\Cursor\\{stem}.blp")
}
/// The **displayed** cursor — the world classifier's base [`crate::target::WorldCursor`] with the
/// UI overlays applied, mirroring the client's displayed-vs-base mode pair (`0xbe2c2c`/`0xbe2c4c`,
/// wow-re cursor-system.md §7): a world classification always wins; over plain UI, the repair-mode
/// latch shows Repair(17) (the locked base `ShowRepairCursor` sets), else the bag hover's sell
/// latch shows Buy(3) (armed only while the base is Point — `ShowContainerSellCursor 0x4fa460`).
/// Read by the platform drivers instead of `WorldCursor` directly.
/// The **displayed** cursor — the client's single sticky mode global `0xbe2c2c` (wow-re
/// cursor-system.md §1/§7). Read by the platform drivers instead of [`crate::target::WorldCursor`],
/// which is only ever *one* of its two writers.
#[derive(Resource, Clone, Copy, PartialEq, Eq, Debug, Default)]
pub(crate) struct DisplayedCursor(pub(crate) crate::target::WorldCursor);
fn overlay_ui_cursor(
base: Res<crate::target::WorldCursor>,
script: Option<bevy::ecs::system::NonSend<benilla_ui::script::UiScript>>,
/// Resolve this frame's displayed cursor — **one sticky mode with two writers** (decision 1064).
///
/// The reference has exactly one cursor-mode cell and everything that wants a cursor calls
/// `CursorSetMode` on it. Two things do:
///
/// - **the world**, from the WorldFrame's hover handler `0x481790` — which runs *only while the
/// WorldFrame is the frame manager's mouse-focus frame* (`4817ae: cmp [[this+0xa0]+0x7c], this;
/// jne`). Over a UI frame it does not run, and writes nothing.
/// - **FrameXML**, from a hover handler — `ShowContainerSellCursor`, `ShowInspectCursor`,
/// `SetCursor`, `ResetCursor`.
///
/// **Between those two, nothing writes it, so the last value simply stands.** That is the whole
/// correction here, and B208's regression was getting it wrong in each direction at once: the old
/// code recomputed the mode from the world every frame (so a UI hover could never keep a cursor),
/// and 1055 then made the world *skip* over UI while the classifier still wrote Point underneath
/// (so every UI element force-reset the cursor). Either way an armed spell lost its cast cursor the
/// instant the mouse touched a spellbook button — which is what the director saw.
///
/// So: a FrameXML write wins the frame it happens; otherwise the world's verdict applies while the
/// pointer is over the world; otherwise the mode is left exactly as it was. The bag's `ResetCursor()`
/// still resets over a bag slot, because it is a *write* — and a spellbook button, which calls no
/// cursor function at all, correctly changes nothing.
fn drive_displayed_cursor(
world: Res<crate::target::WorldCursor>,
over_ui: Res<crate::ui_script::PointerOverUi>,
targeting: Res<crate::ui_action::targeting::SpellTargeting>,
script: Option<bevy::ecs::system::NonSendMut<benilla_ui::script::UiScript>>,
// The base last applied by the UI-entry restore below — `None` while the pointer is over the
// world, so re-entering the UI always restores once.
mut last: Local<Option<crate::target::WorldCursor>>,
mut displayed: ResMut<DisplayedCursor>,
) {
use crate::target::{CursorKind, WorldCursor};
use benilla_ui::script::UiCursorMode;
let mut out = *base;
// Spell-targeting pre-empts the WHOLE classifier — the real dispatcher's step 2 runs before
// any object resolve (wow-re cursor-system.md §5, VERIFIED; the 0446/0452 named deferral).
// There is no branch for it HERE any more (decision 0923): the one targeting state writes the
// BASE cursor itself (`ui_action::targeting::drive_targeting_cursor`, which runs late in the
// target chain), so a live targeting word already reads as Cast — and, being non-Point, it
// falls past every overlay below by construction. That is the pre-emption, structurally.
if out == WorldCursor::default() {
if let Some(script) = script.as_ref() {
// Repair is the locked base-mode override (`ShowRepairCursor`) and wins over the
// displayed-cursor family — the real client parks the base at Repair, so the sell/
// inspect gate (base == Point) bails while it holds (wow-re cursor-system.md §7).
if script.repair_mode() {
out.kind = CursorKind::Repair;
} else {
match script.ui_cursor() {
Some(UiCursorMode::Buy) => out.kind = CursorKind::Buy,
Some(UiCursorMode::UnableBuy) => {
out = WorldCursor {
kind: CursorKind::Buy,
unable: true,
}
}
Some(UiCursorMode::Inspect) => out.kind = CursorKind::Inspect,
None => {}
}
}
// **The BASE mode is not a constant** (`0xbe2c4c`, wow-re cursor-system.md §7: *"it is
// independently mutable — e.g. a spell-cancel flow parks it at Cast(2)"*), and that is the
// piece B208 kept missing. `ResetCursor` restores *the value of this cell*, not a hardcoded
// Point — so what a bag slot's `ResetCursor()` shows depends entirely on what is parked here.
//
// While a spell awaits its click, the base is **Cast(2)**. The director watched the reference
// do exactly this: with Feed Pet armed the cursor is blue over *"pretty much anything UI
// related"* and grey only out in the world. Both halves fall out of one cell — over the world
// the classifier writes the per-seam verdict (grey, for a word with no world handler), and over
// UI nothing overrides the base, so the blue shows.
//
// The corroborating gate is `ShowContainerSellCursor 0x4fa460`'s second test, *"base mode is
// Point(1), else bail"*: a test that is only ever meaningful because the base is routinely
// **not** Point while targeting.
//
// Note what this blue does NOT mean: it is not a validity verdict. It says "a spell is armed",
// not "this item is a legal target" — no hover-time item verdict exists anywhere in 1.12
// (decision 1055), so valid and invalid food look identical here, exactly as they do in the
// reference.
let repair = script.as_ref().is_some_and(|s| s.repair_mode());
let base = if repair {
// `ShowRepairCursor` parks the base at Repair for as long as it holds (wow-re
// repair-machinery.md); it is the explicit modal, so it wins the cell.
WorldCursor {
kind: CursorKind::Repair,
unable: false,
}
} else if targeting.active() {
WorldCursor {
kind: CursorKind::Cast,
unable: false,
}
} else {
WorldCursor::default()
};
if let Some(mut script) = script {
if let Some(write) = script.take_cursor_write() {
displayed.0 = match write {
Some(UiCursorMode::Buy) => WorldCursor {
kind: CursorKind::Buy,
unable: false,
},
Some(UiCursorMode::UnableBuy) => WorldCursor {
kind: CursorKind::Buy,
unable: true,
},
Some(UiCursorMode::Inspect) => WorldCursor {
kind: CursorKind::Inspect,
unable: false,
},
Some(UiCursorMode::Cast) => WorldCursor {
kind: CursorKind::Cast,
unable: false,
},
Some(UiCursorMode::CastError) => WorldCursor {
kind: CursorKind::Cast,
unable: true,
},
Some(UiCursorMode::Point) => WorldCursor::default(),
// `ResetCursor` — displayed goes back to the base mode, Repair included.
None => base,
};
*last = Some(base);
return;
}
}
displayed.0 = out;
if !over_ui.0 {
displayed.0 = *world;
*last = None;
return;
}
// Over UI with no FrameXML write this frame. The mode is still sticky between writes — a UI
// element that calls no cursor function must not disturb it — but **crossing into the UI, and
// any later change of the base, restores the base**, which is what makes an armed spell read
// blue over the interface at large rather than dragging the world's grey in behind the mouse.
//
// In the reference that restore is spread across the UI rather than centralised: nearly every
// hover handler ends in `ResetCursor()` (`ContainerFrameItemButton_OnEnter`'s else branch,
// `CursorUpdate`/`CursorOnUpdate` in `UIParent.lua`, every `OnLeave`), and the world
// classifier's own no-hover path is the same `0x523d30` restore. Modelling it as one edge here
// gets the same observable without requiring each of our frames to have grown its
// `ResetCursor()` call yet — and a frame that DOES write still wins, so the unit-frame
// lit/grey split survives.
if *last != Some(base) {
*last = Some(base);
displayed.0 = base;
}
}
pub(crate) struct CursorPlugin;
@@ -200,11 +283,11 @@ impl Plugin for CursorPlugin {
app.init_resource::<DisplayedCursor>();
#[cfg(target_os = "macos")]
app.add_systems(Startup, macos::setup.after(AssetSet::Open))
.add_systems(Update, (overlay_ui_cursor, macos::drive).chain());
.add_systems(Update, (drive_displayed_cursor, macos::drive).chain());
#[cfg(not(target_os = "macos"))]
app.init_resource::<other::PayloadCursorImages>()
.add_systems(Startup, other::setup.after(AssetSet::Open))
.add_systems(Update, (overlay_ui_cursor, other::drive).chain());
.add_systems(Update, (drive_displayed_cursor, other::drive).chain());
}
}
@@ -538,3 +621,167 @@ mod macos {
))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::target::{CursorKind, WorldCursor};
use bevy::ecs::system::RunSystemOnce;
const CAST_GREY: WorldCursor = WorldCursor {
kind: CursorKind::Cast,
unable: true,
};
const SWORD: WorldCursor = WorldCursor {
kind: CursorKind::Attack,
unable: false,
};
/// Run one frame of [`drive_displayed_cursor`] with the displayed mode pre-set to `standing`,
/// and return what it becomes. `lua` is whatever FrameXML did this frame.
fn frame(standing: WorldCursor, world: WorldCursor, over_ui: bool, lua: &str) -> WorldCursor {
frame_armed(standing, world, over_ui, lua, false)
}
/// One frame, with `armed` deciding whether a spell awaits its click — which is what parks the
/// BASE mode at Cast.
fn frame_armed(
standing: WorldCursor,
world: WorldCursor,
over_ui: bool,
lua: &str,
armed: bool,
) -> WorldCursor {
let mut app = App::new();
let script = benilla_ui::script::UiScript::new().unwrap();
script.run(lua).unwrap();
app.insert_non_send_resource(script);
app.insert_resource(world);
app.insert_resource(crate::ui_script::PointerOverUi(over_ui));
app.insert_resource(DisplayedCursor(standing));
let mut targeting = crate::ui_action::SpellTargeting::default();
if armed {
// Feed Pet's own bare ITEM word.
targeting.enter(6991, crate::ui_action::CastCommit::Spell, 0x0010);
}
app.insert_resource(targeting);
app.world_mut()
.run_system_once(drive_displayed_cursor)
.expect("the displayed cursor drives");
app.world().resource::<DisplayedCursor>().0
}
/// **The base mode is Cast while a spell awaits its click** (wow-re cursor-system.md §7: the
/// base cell `0xbe2c4c` "is independently mutable — e.g. a spell-cancel flow parks it at
/// Cast(2)"), and `ResetCursor` restores *that value*, never a hardcoded Point.
///
/// This is what the director watched the reference do with Feed Pet armed: **blue over the
/// interface at large, grey only out in the world**. Both fall out of the one cell — the world
/// classifier writes the per-seam verdict (grey, for a word with no world handler), and over UI
/// nothing overrides the base.
///
/// It is emphatically NOT a validity verdict: no hover-time item verdict exists in 1.12, so
/// good food and bad food are the same blue. The last assertion pins that, because reading the
/// blue as "this food is correct" is exactly the misreading that sent B208 round twice.
#[test]
fn an_armed_spell_parks_the_base_at_cast_so_the_ui_reads_blue() {
const GREY: WorldCursor = CAST_GREY;
const BLUE: WorldCursor = WorldCursor {
kind: CursorKind::Cast,
unable: false,
};
// Out in the world the classifier's verdict stands: an item-only word greys.
assert_eq!(frame_armed(BLUE, GREY, false, "", true), GREY);
// Crossing into the UI restores the base — blue — rather than dragging the world's grey in.
assert_eq!(frame_armed(GREY, GREY, true, "", true), BLUE);
// A bag slot's real `ResetCursor()` resolves to the same base, so the food reads blue…
assert_eq!(frame_armed(GREY, GREY, true, "ResetCursor()", true), BLUE);
// …and so does a slot holding something the pet would never eat. The blue says "a spell is
// armed", not "this item is a legal target".
assert_eq!(frame_armed(GREY, GREY, true, "ResetCursor()", true), BLUE);
// Nothing armed: the base is Point again, and the UI reads as the ordinary pointer.
assert_eq!(
frame_armed(GREY, WorldCursor::default(), true, "ResetCursor()", false),
WorldCursor::default()
);
}
/// **A UI element that writes no cursor does not get to invent one.** The mode has two
/// writers and only two — the world (while the pointer is over it) and a FrameXML cursor call.
/// Crossing into the UI restores the BASE, which is the reference's own behaviour spread across
/// dozens of `ResetCursor()` calls; what must never happen is a *third* party recomputing a
/// value each frame, which is what made B208 go round twice.
#[test]
fn only_the_world_and_framexml_write_the_cursor() {
// Over the world the classifier's verdict applies, every frame.
assert_eq!(frame(CAST_GREY, SWORD, false, ""), SWORD);
// Over the UI with nothing armed the base is Point — the ordinary pointer, which is what a
// bag slot shows in the reference.
assert_eq!(frame(SWORD, SWORD, true, ""), WorldCursor::default());
assert_eq!(
frame(SWORD, SWORD, true, "ResetCursor()"),
WorldCursor::default()
);
// An explicit FrameXML write wins and then STAYS — the unit-frame lit/grey split would be
// pointless if the next frame's base restore stamped over it.
let lit = WorldCursor {
kind: CursorKind::Cast,
unable: false,
};
assert_eq!(
frame(
WorldCursor::default(),
SWORD,
true,
"SetCursor(\"CAST_CURSOR\")"
),
lit
);
assert_eq!(
frame(lit, SWORD, true, "SetCursor(\"CAST_ERROR_CURSOR\")"),
CAST_GREY
);
}
/// `ShowContainerSellCursor` bails on `IsTargeting` at its first instruction, so an armed spell
/// keeps its cast cursor over a vendor-open bag instead of getting the coin. With a sticky mode
/// this gate is load-bearing: a Buy write would stamp over the cast cursor and no per-frame
/// world write would put it back.
#[test]
fn the_sell_cursor_does_not_paint_over_an_armed_spell() {
// The gate reads the app-fed targeting flag, so drive it the way the app does.
let mut app = App::new();
let mut script = benilla_ui::script::UiScript::new().unwrap();
script.set_spell_targeting(true);
script.set_container(
0,
Some(benilla_ui::script::ContainerState {
name: None,
num_slots: 16,
slots: std::collections::HashMap::from([(
1,
benilla_ui::script::ContainerSlot::default(),
)]),
}),
);
script.run("ShowContainerSellCursor(0, 1)").unwrap();
app.insert_non_send_resource(script);
app.insert_resource(CAST_GREY);
app.insert_resource(crate::ui_script::PointerOverUi(true));
app.insert_resource(DisplayedCursor(CAST_GREY));
let mut targeting = crate::ui_action::SpellTargeting::default();
targeting.enter(6991, crate::ui_action::CastCommit::Spell, 0x0010);
app.insert_resource(targeting);
app.world_mut()
.run_system_once(drive_displayed_cursor)
.expect("the displayed cursor drives");
assert_eq!(
app.world().resource::<DisplayedCursor>().0,
WorldCursor {
kind: CursorKind::Cast,
unable: false
},
"an armed spell suppresses the sell cursor entirely — the armed base shows instead"
);
}
}
+32 -1
View File
@@ -46,7 +46,7 @@ use display::{
/// Attaching a visual to each net entity (the back half of this subsystem) — kept in its own file as it
/// carries the bulk of the per-entity spawn logic (skeleton/animation, character geoset + skin, fade).
mod attach;
use attach::{attach_entity_visuals, build_glue_preview};
use attach::{attach_entity_visuals, build_dressup_preview, build_glue_preview};
/// The dynamic point lights an entity's own model carries into the world — the held torch above all:
/// decision 0016's law applied to the *entity* half of the scene, not just the placed half.
@@ -117,6 +117,9 @@ pub(crate) use chain_beam::ChainHops;
use chain_beam::{simulate_chain_beams, spawn_chain_beams};
// The container feed reads the icon column off the same catalog resource (one DBC parse).
pub(crate) use attach::spawn_joints;
// The one InventoryType → equipment-slot table (`attach::preview`): the dressing-room feed places a
// tried-on item by the very same map the preview it feeds dresses by (decision 1060).
pub(crate) use attach::equip_slot;
pub(crate) use equipment::ItemDisplays;
pub(crate) use equipment::{BoneAttach, Equipment};
@@ -459,6 +462,11 @@ impl Plugin for EntitiesPlugin {
// the freshly-built display model, for the create booth to bake. After
// `update_display_models` (its want-list built the body) — server-less, at char select.
build_glue_preview,
// The dressing room's preview (decision 1060): the same tuple-driven assembly,
// for the item nobody in the world is wearing. Beside the glue one — same
// dependency (the display cache built by `update_display_models` above), same
// retry-until-ready latch.
build_dressup_preview,
attach_entity_visuals,
// WMO-gameobject doodad props (the ship's sails): resolve the MODD list the
// frame after the WMO visual attaches, then spawn each prop as its M2 lands
@@ -747,6 +755,29 @@ fn setup_entities(
p.err().or(l.err()).expect("at least one of the two failed")
),
}
// The family pair (decision 1062) — its own resource, so it degrades independently of the
// happiness tables above rather than taking them down with it.
match (
benilla_formats::load_creature_families(&mut chain),
benilla_formats::load_pet_food_names(&mut chain),
) {
(Ok(families), Ok(foods)) => {
info!(
"pet family tables: {} families, {} food types",
families.len(),
foods.len()
);
commands.insert_resource(crate::ui_pet_stats::PetFamilyTables { families, foods });
}
// No family tables ⇒ `UnitCreatureFamily("pet")` answers nil, which the reference's own
// guard turns into a BLANK level line on the pet page (ref `PetPaperDollFrame.lua:68-70`),
// and the diet tooltip shows nothing at all. That is exactly the state 1057 shipped in —
// degraded to the faithful fallback, not wrong.
(f, p) => warn!(
"pet family tables unavailable, the pet's level line stays blank: {:#}",
f.err().or(p.err()).expect("at least one of the two failed")
),
}
match CharacterGeosets::load(&mut chain) {
Ok(geosets) => commands.insert_resource(Characters(geosets)),
Err(e) => warn!("character geosets unavailable, players show every geoset: {e:#}"),
@@ -35,8 +35,9 @@ mod char_skin;
use char_skin::{build_char_skin_materials, equip_geosets, resolve_char_look, resolve_worn_equip};
mod dress;
use dress::{spawn_part, PartDress};
mod glue_preview;
pub(super) use glue_preview::build_glue_preview;
mod preview;
pub(crate) use preview::equip_slot;
pub(super) use preview::{build_dressup_preview, build_glue_preview};
mod redress;
pub(super) use redress::redress_player_looks;
@@ -1,22 +1,33 @@
//! The glue-preview part builder (decisions 0423 + 0465) — the entities-side half of the glue
//! booth. It lives under `attach` so it can reuse the character body pipeline directly
//! The **tuple-driven character preview** builder (decisions 0423 + 0465, widened by 1060) — the
//! entities-side half of every booth that shows a character nobody is standing in. It lives under
//! `attach` so it can reuse the character body pipeline directly
//! (`char_skin::build_char_skin_materials`, the geoset filter, the [`super::super::EntityPart`]
//! source, the item-display models) without widening any of it.
//!
//! When a glue screen's [`GluePreview`] look changes, this assembles the character the world's own
//! way — displayId → the shared [`Creatures`] display cache → geoset filter → the composited body
//! over a hand-built [`CharLook`] — into a flat list of [`PreviewPart`]s in [`GluePreviewBake`].
//! Both looks dress from the **same** enum-shaped `[CharEnumItem; 19]`: a **Select** look carries
//! its roster record verbatim; a **Create** look resolves the (race, class, sex) starting outfit
//! (CharStartOutfit, decision 0527) into that array — so the create preview is geared exactly like a
//! select body (the ref's create screen dresses the model, not the underwear-only body decision 0423
//! assumed). Both then get the armor-region composite + real equipment geosets, the cloak, and the
//! attach riders — helm (per-race/sex model, hide-helm honored), shoulders, and the weapons **held
//! in the hands** (the byte-verified select build — wow-re `glue-select-model.md`, folded back over
//! 0465's sheathed INTERIM). The portrait module's `sync_glue_booth` then re-lights and bakes
//! them (it owns the *booth*; this owns the *assembly*). The body displayId is added to
//! Two callers, **one assembly** ([`assemble`]). Each reduces its own look to the same
//! [`PreviewSpec`] — body displayId + the five appearance dials + an enum-shaped
//! `[CharEnumItem; 19]` — and gets back the flat part/rider/effect lists its booth bakes:
//!
//! - **The glue screens** ([`build_glue_preview`]): a **Select** look carries its roster record
//! verbatim; a **Create** look resolves the (race, class, sex) starting outfit (CharStartOutfit,
//! decision 0527) into that array — so the create preview is geared exactly like a select body
//! (the ref's create screen dresses the model, not the underwear-only body decision 0423 assumed).
//! - **The dressing room** ([`build_dressup_preview`], decision 1060): the *live player's* own body
//! and appearance, wearing their own visible items with the tried-on ones substituted in. That
//! substitution is the whole feature, and it is why the dressing room cannot mirror a world
//! entity's spawned children the way the paper doll does (`portrait::sync_body_booth`) — nobody
//! in the world is wearing the item being previewed.
//!
//! The assembly is the world's own way — displayId → the shared [`Creatures`] display cache →
//! geoset filter → the composited body over a hand-built [`CharLook`] — plus the armor-region
//! composite + real equipment geosets, the cloak, and the attach riders: helm (per-race/sex model,
//! hide-helm honored), shoulders, and the weapons **held in the hands** (the byte-verified select
//! build — wow-re `glue-select-model.md`, folded back over 0465's sheathed INTERIM). The portrait
//! module's `sync_glue_booth` / `sync_dressup_booth` then re-light and bake them (they own the
//! *booth*; this owns the *assembly*). A glue body's displayId is added to
//! `update_display_models`'s want-list (in [`super::super`]) so its model builds with no wire
//! entity; item models register through the same [`ItemDisplays`] want the world path uses.
//! entity — the dressing room's is the player's own, already built; item models register through
//! the same [`ItemDisplays`] want the world path uses.
use benilla_formats::CharSkinSlot;
use benilla_protocol::{CharEnumItem, CHARACTER_FLAG_HIDE_CLOAK, CHARACTER_FLAG_HIDE_HELM};
@@ -25,8 +36,8 @@ use bevy::prelude::*;
use crate::assets::WorldAssets;
use crate::lighting::SharedLightBuffer;
use crate::portrait::{
GlueLook, GluePreview, GluePreviewBake, PreviewBillboard, PreviewEffects, PreviewPart,
PreviewRider,
DressUpBake, DressUpLook, DressUpPreview, GlueLook, GluePreview, GluePreviewBake,
PreviewBillboard, PreviewEffects, PreviewPart, PreviewRider,
};
use crate::terrain::WowModelMaterial;
@@ -55,7 +66,7 @@ const ENUM_HELD: [usize; 3] = [15, 16, 17];
/// waist 5 · legs 6 · feet 7 · wrist 8 · hands 9 · back 14 · tabard 18, wow-re `glue-select-model.md`)
/// plus the held triple (main 15 · off 16 · ranged 17). `None` for a non-worn / non-rendered type
/// (bags, ammo, quiver, relic, and NON_EQUIP consumables — the outfit's food + hearthstone).
fn equip_slot(inv_type: u8) -> Option<usize> {
pub(crate) fn equip_slot(inv_type: u8) -> Option<usize> {
Some(match inv_type {
1 => 0, // HEAD
2 => 1, // NECK (not rendered; its slot for completeness)
@@ -76,6 +87,59 @@ fn equip_slot(inv_type: u8) -> Option<usize> {
})
}
/// One preview's complete input tuple — the body model, its five appearance dials, and the worn
/// equipment in the `SMSG_CHAR_ENUM` slot shape. Both callers reduce their own look to this, so
/// [`assemble`] has exactly one shape to build from and the two previews can never dress by
/// different laws.
pub(in crate::entities) struct PreviewSpec {
/// The **body** display id, already resolved by the caller: the race/sex body for a glue look
/// (`CharCreateCatalog::body_display`), the live player's own display for the dressing room.
/// Its model must be in the display cache (or its want-list) or the assembly waits for it.
pub(in crate::entities) display_id: u32,
pub(in crate::entities) race: u8,
pub(in crate::entities) sex: u8,
pub(in crate::entities) skin: u8,
pub(in crate::entities) face: u8,
pub(in crate::entities) hair_style: u8,
pub(in crate::entities) hair_color: u8,
pub(in crate::entities) facial_hair: u8,
/// Worn **ItemDisplayInfo** ids by equipment slot (`EQUIPMENT_SLOT_*`) — no template hop; the
/// caller has already resolved entries to displays.
pub(in crate::entities) equipment: [CharEnumItem; 19],
/// `CHARACTER_FLAG_*` bits; only hide-helm / hide-cloak are read here.
pub(in crate::entities) flags: u32,
}
/// What one [`assemble`] produced — the content half of a preview bake, in the booth's own shape.
pub(in crate::entities) struct Assembled {
pub(in crate::entities) parts: Vec<PreviewPart>,
pub(in crate::entities) riders: Vec<PreviewRider>,
pub(in crate::entities) effects: Vec<PreviewEffects>,
pub(in crate::entities) billboards: Vec<PreviewBillboard>,
pub(in crate::entities) grip: [bool; 2],
}
/// The resource borrows [`assemble`] needs — the display caches it reads and registers wants with,
/// the character/skin data the composite is built from, and the asset stores it writes into. A
/// plain borrow struct rather than a `SystemParam`: each driver already holds these as its own
/// `Res`/`ResMut` params and simply lends them for the call.
pub(in crate::entities) struct PreviewCtx<'a> {
pub(in crate::entities) creatures: &'a Creatures,
pub(in crate::entities) characters: Option<&'a Characters>,
pub(in crate::entities) displays: Option<&'a mut ItemDisplays>,
/// The item/enchant glow chain (decision 0805): both previews resolve it themselves — the
/// world's `resolve_equipment` never runs pre-world, and never runs for a look nobody wears.
pub(in crate::entities) glows: Option<&'a mut ItemGlows>,
pub(in crate::entities) sections: Option<&'a SkinSections>,
pub(in crate::entities) world_assets: Option<&'a WorldAssets>,
pub(in crate::entities) shared_light: Option<&'a SharedLightBuffer>,
pub(in crate::entities) images: &'a mut Assets<Image>,
pub(in crate::entities) skin_composites: &'a mut SkinComposites,
pub(in crate::entities) asset_server: &'a AssetServer,
pub(in crate::entities) materials: &'a mut Assets<WowModelMaterial>,
pub(in crate::entities) entity_mats: &'a mut EntityMaterials,
}
/// Assemble the preview part (+ rider) lists when the glue-screen look changes. Runs in the
/// entity-visuals chain (after `update_display_models` has built the body model from the
/// want-list), so a fresh look's models are ready within a frame or two; until then it leaves the
@@ -125,7 +189,8 @@ pub(in crate::entities) fn build_glue_preview(
};
// displayId → the shared display cache. The want-list in `update_display_models` has already
// asked for this display; wait (retry, `built` stays false) until its model + parts are built.
// asked for this display; the assembly waits (retry, `built` stays false) until its model +
// parts are built.
let (Some(creatures), Some(char_create)) = (creatures.as_deref(), char_create.as_deref())
else {
return;
@@ -135,27 +200,12 @@ pub(in crate::entities) fn build_glue_preview(
state.built = true; // a non-playable race can't resolve — nothing to show, don't spin
return;
};
let Some(dm) = creatures.models.get(&display_id) else {
return; // model not built yet — retry next frame
};
let Some(parts) = dm.parts.as_deref() else {
return; // asset still loading
};
// The body appearance is identical in shape for both looks (only its source struct differs).
let (skin, face, hair_style, hair_color, facial_hair) = match look {
GlueLook::Create(l) => (l.skin, l.face, l.hair_style, l.hair_color, l.facial_hair),
GlueLook::Select(l) => (l.skin, l.face, l.hair_style, l.hair_color, l.facial_hair),
};
let char_look = CharLook {
race,
sex,
skin,
hair_style,
hair_color,
facial_hair,
body: BodySkin::Composite { face },
};
// The equipment, unified as an enum-shaped `[CharEnumItem; 19]` + display flags: a Select look
// carries its roster record verbatim; a Create look resolves the (race, class, sex) starting
@@ -178,6 +228,199 @@ pub(in crate::entities) fn build_glue_preview(
}
GlueLook::Select(l) => (l.equipment, l.flags),
};
let spec = PreviewSpec {
display_id,
race,
sex,
skin,
face,
hair_style,
hair_color,
facial_hair,
equipment,
flags,
};
let Some(a) = assemble(
&spec,
&mut PreviewCtx {
creatures,
characters: characters.as_deref(),
displays: displays.as_deref_mut(),
glows: glows.as_deref_mut(),
sections: sections.as_deref(),
world_assets: world_assets.as_deref(),
shared_light: shared_light.as_deref(),
images: &mut images,
skin_composites: &mut skin_composites,
asset_server: &asset_server,
materials: &mut materials,
entity_mats: &mut entity_mats,
},
) else {
return; // a model (body, item, or glow) is still loading — retry next frame
};
debug!(
"glue preview: race {race} sex {sex} display {display_id} → {} parts, {} riders, \
{} camera-facing, {} effect model(s) / {} emitter(s) ({})",
a.parts.len(),
a.riders.len(),
a.billboards.len(),
a.effects.len(),
a.effects
.iter()
.map(|e: &PreviewEffects| e.emitters.len())
.sum::<usize>(),
match look {
GlueLook::Create(_) => "create",
GlueLook::Select(_) => "select",
},
);
*bake = GluePreviewBake {
look: Some(look),
display_id,
parts: a.parts,
riders: a.riders,
effects: a.effects,
billboards: a.billboards,
grip: a.grip,
revision: bake.revision + 1,
};
state.built = true;
}
/// Assemble the **dressing room**'s look (decision 1060) — the same law as the glue driver above,
/// over a look whose equipment [`crate::ui_dressup`] composed from the player's own visible items
/// plus the tried-on substitutions. The body display is the player's own, so it is already built
/// (they are standing in the world); an ITEM model still has to load, which is what the retry latch
/// is for.
#[allow(clippy::too_many_arguments)]
pub(in crate::entities) fn build_dressup_preview(
preview: Res<DressUpPreview>,
mut bake: ResMut<DressUpBake>,
creatures: Option<Res<Creatures>>,
characters: Option<Res<Characters>>,
mut displays: Option<ResMut<ItemDisplays>>,
mut glows: Option<ResMut<ItemGlows>>,
sections: Option<Res<SkinSections>>,
world_assets: Option<Res<WorldAssets>>,
shared_light: Option<Res<SharedLightBuffer>>,
mut images: ResMut<Assets<Image>>,
mut skin_composites: ResMut<SkinComposites>,
asset_server: Res<AssetServer>,
mut materials: ResMut<Assets<WowModelMaterial>>,
mut entity_mats: ResMut<EntityMaterials>,
mut state: Local<DressUpState>,
) {
// A new look resets the retry latch (the glue driver's own shape).
if state.last_look != preview.look {
state.last_look = preview.look;
state.built = false;
}
if state.built {
return;
}
// No look → clear the booth once (the window closed, or nothing to show).
let Some(look) = preview.look else {
if bake.look.is_some() {
*bake = DressUpBake {
revision: bake.revision + 1,
..default()
};
}
state.built = true;
return;
};
let Some(creatures) = creatures.as_deref() else {
return;
};
let spec = PreviewSpec {
display_id: look.display_id,
race: look.race,
sex: look.sex,
skin: look.skin,
face: look.face,
hair_style: look.hair_style,
hair_color: look.hair_color,
facial_hair: look.facial_hair,
equipment: look.equipment,
// The world path honours no hide-helm/hide-cloak flag for a live player either (the helm
// comes straight off the visible-item slot), so the preview of that player carries none.
flags: 0,
};
let Some(a) = assemble(
&spec,
&mut PreviewCtx {
creatures,
characters: characters.as_deref(),
displays: displays.as_deref_mut(),
glows: glows.as_deref_mut(),
sections: sections.as_deref(),
world_assets: world_assets.as_deref(),
shared_light: shared_light.as_deref(),
images: &mut images,
skin_composites: &mut skin_composites,
asset_server: &asset_server,
materials: &mut materials,
entity_mats: &mut entity_mats,
},
) else {
return; // an item model is still loading — retry next frame
};
debug!(
"dressup preview: race {} sex {} display {} → {} parts, {} riders, {} camera-facing, \
{} effect model(s)",
look.race,
look.sex,
look.display_id,
a.parts.len(),
a.riders.len(),
a.billboards.len(),
a.effects.len(),
);
*bake = DressUpBake {
look: Some(look),
display_id: look.display_id,
parts: a.parts,
riders: a.riders,
effects: a.effects,
billboards: a.billboards,
grip: a.grip,
revision: bake.revision + 1,
};
state.built = true;
}
/// The one assembly both previews run — [`PreviewSpec`] in, the booth's flat lists out.
///
/// `None` means **not ready**: the body model, an item model, or a glow model is still loading.
/// A caller must treat that as "retry next frame" and leave its bake untouched, never as an empty
/// look — a geared character popping in piecewise would flicker on every change.
fn assemble(spec: &PreviewSpec, ctx: &mut PreviewCtx) -> Option<Assembled> {
let PreviewSpec {
display_id,
race,
sex,
equipment,
flags,
..
} = *spec;
let dm = ctx.creatures.models.get(&display_id)?; // model not built yet — retry next frame
let parts = dm.parts.as_deref()?; // asset still loading
let char_look = CharLook {
race,
sex,
skin: spec.skin,
hair_style: spec.hair_style,
hair_color: spec.hair_color,
facial_hair: spec.facial_hair,
body: BodySkin::Composite { face: spec.face },
};
let bodyslots = ENUM_BODYSLOTS.map(|slot| equipment[slot].display_id);
let cloak = if flags & CHARACTER_FLAG_HIDE_CLOAK != 0 {
0
@@ -202,21 +445,21 @@ pub(in crate::entities) fn build_glue_preview(
// The rider models: register each want with [`ItemDisplays`] (built by
// `update_display_models` once the M2 loads) and gate the bake on ALL of them being ready —
// a geared character popping in piecewise would flicker on every selection change.
if let Some(d) = displays.as_deref_mut() {
if let Some(d) = ctx.displays.as_deref_mut() {
for w in &held {
ensure_item_model(d, w.display, w.kind, &asset_server);
ensure_item_model(d, w.display, w.kind, ctx.asset_server);
}
// The glow ids (decision 0805), and the models they imply: **held weapons/shields only**
// — the reference passes a display's visual from the hand attach and a literal `0` from
// the helm/shoulder ones (`crate::entities::item_glow`). The world lane resolves this in
// `resolve_equipment`, which never runs pre-world.
if let Some(g) = glows.as_deref_mut() {
if let Some(g) = ctx.glows.as_deref_mut() {
for w in held
.iter_mut()
.filter(|w| matches!(w.kind, ItemModelKind::Weapon | ItemModelKind::Shield))
{
w.visual = d.catalog.get(w.display).map_or(0, |c| c.item_visual);
item_glow::ensure_glow_models(g, w.visual, &asset_server);
item_glow::ensure_glow_models(g, w.visual, ctx.asset_server);
}
}
if !held.iter().all(|w| {
@@ -225,42 +468,42 @@ pub(in crate::entities) fn build_glue_preview(
.and_then(|m| m.parts.as_ref())
.is_some()
}) {
return; // an item model is still loading — retry next frame
return None; // an item model is still loading — retry next frame
}
// Same gate for the glow models: they are tiny beside the weapon that carries them, and a
// weapon that pops in ahead of its glow would flicker on every selection change.
if let Some(g) = glows.as_deref() {
if let Some(g) = ctx.glows.as_deref() {
let pending = held
.iter()
.filter_map(|w| g.effects(w.visual))
.flat_map(|paths| paths.iter().flatten())
.any(|p| g.models.get(p).is_none_or(|m| m.parts.is_none()));
if pending {
return;
return None;
}
}
}
// The dressed geoset set: the worn armor's geoset groups + the cloak + the helm's hide-masks
// (RF-0083), exactly the world path's selection (the shared helper).
let eg = equip_geosets(displays.as_deref(), &bodyslots, cloak, helm);
let visible = characters.as_deref().map(|c| {
let eg = equip_geosets(ctx.displays.as_deref(), &bodyslots, cloak, helm);
let visible = ctx.characters.map(|c| {
c.0.visible_geosets(race, sex, char_look.hair_style, char_look.facial_hair, &eg)
});
let char_mats = super::char_skin::build_char_skin_materials(
&char_look,
bodyslots,
cloak,
displays.as_deref(),
sections.as_deref(),
world_assets.as_deref(),
shared_light.as_deref(),
ctx.displays.as_deref(),
ctx.sections,
ctx.world_assets,
ctx.shared_light,
parts,
&mut images,
&mut skin_composites.0,
&asset_server,
&mut materials,
&mut entity_mats.0,
ctx.images,
&mut ctx.skin_composites.0,
ctx.asset_server,
ctx.materials,
&mut ctx.entity_mats.0,
);
// Whether this look shows the part's geoset (billboard or body alike).
@@ -320,7 +563,7 @@ pub(in crate::entities) fn build_glue_preview(
let attach_point = |id: u16| dm.attachments.iter().find(|a| a.id == id);
let mut riders = Vec::new();
let mut preview_effects = Vec::new();
if let Some(d) = displays.as_deref() {
if let Some(d) = ctx.displays.as_deref() {
for w in &held {
let Some(point) = attach_point(w.attach) else {
continue; // the body has no such attach point — hold nothing (world-path rule)
@@ -364,7 +607,7 @@ pub(in crate::entities) fn build_glue_preview(
emitters: item.emitters.clone(),
});
}
let Some(paths) = glows.as_deref().and_then(|g| g.effects(w.visual)) else {
let Some(paths) = ctx.glows.as_deref().and_then(|g| g.effects(w.visual)) else {
continue;
};
for (slot, path) in paths
@@ -381,7 +624,7 @@ pub(in crate::entities) fn build_glue_preview(
slot as u16,
);
let (Some(at), Some(glow)) =
(at, glows.as_deref().and_then(|g| g.models.get(path)))
(at, ctx.glows.as_deref().and_then(|g| g.models.get(path)))
else {
continue;
};
@@ -419,33 +662,13 @@ pub(in crate::entities) fn build_glue_preview(
}
}
debug!(
"glue preview: race {race} sex {sex} display {display_id} → {} parts, {} riders, \
{} camera-facing, {} effect model(s) / {} emitter(s) ({})",
preview_parts.len(),
riders.len(),
preview_billboards.len(),
preview_effects.len(),
preview_effects
.iter()
.map(|e: &PreviewEffects| e.emitters.len())
.sum::<usize>(),
match look {
GlueLook::Create(_) => "create",
GlueLook::Select(_) => "select",
},
);
*bake = GluePreviewBake {
look: Some(look),
display_id,
Some(Assembled {
parts: preview_parts,
riders,
effects: preview_effects,
billboards: preview_billboards,
grip,
revision: bake.revision + 1,
};
state.built = true;
})
}
/// One wanted rider model: which display + model kind, the body attach point it seats on, and —
@@ -551,6 +774,13 @@ pub(in crate::entities) struct PreviewState {
built: bool,
}
/// [`build_dressup_preview`]'s per-run memory — the same latch over the dressing room's look.
#[derive(Default)]
pub(in crate::entities) struct DressUpState {
last_look: Option<DressUpLook>,
built: bool,
}
#[cfg(test)]
mod tests {
use super::*;
+11
View File
@@ -113,6 +113,7 @@ mod ui_cast;
mod ui_char;
mod ui_chat;
mod ui_craft;
mod ui_dressup;
mod ui_duel;
mod ui_follow;
mod ui_gamma;
@@ -133,6 +134,7 @@ mod ui_party;
mod ui_pass;
mod ui_pet;
mod ui_pet_book;
mod ui_pet_doll;
mod ui_pet_stats;
mod ui_quest;
mod ui_quest_log;
@@ -220,6 +222,7 @@ use ui_party::UiPartyPlugin;
use ui_pass::PlayerUiPlugin;
use ui_pet::UiPetPlugin;
use ui_pet_book::UiPetBookPlugin;
use ui_pet_doll::UiPetDollPlugin;
use ui_pet_stats::UiPetStatsPlugin;
use ui_quest::UiQuestPlugin;
use ui_quest_log::UiQuestLogPlugin;
@@ -689,6 +692,10 @@ pub fn run(build: BuildId) -> AppExit {
// The inspect feed (decision 0631): another player's equipment off their PUBLIC visible-item
// entries, plus the "inspect" booth's unit + yaw. Right after the character feed it mirrors.
.add_plugins(ui_inspect::InspectUiPlugin)
// The dressing-room feed (decision 1060): the window's try-on intents → the player's own look
// with the tried-on items substituted in, plus the "dressup" booth's yaw. Beside the inspect
// feed, whose shape it shares (intents in, a booth look out).
.add_plugins(ui_dressup::DressUpUiPlugin)
.add_plugins(UiActionPlugin)
// The aura feed (decisions 0255/0257): the player's insertion-ordered buff/debuff cache + the
// self-only durations, pushed as the data the `UnitAura` bindings read; fires UNIT_AURA and
@@ -723,6 +730,10 @@ pub fn run(build: BuildId) -> AppExit {
// The pet's paper-doll stat block (happiness/loyalty/XP/training points). Its own plugin
// because it runs off descriptor fields and two DBC tables rather than off `SMSG_PET_SPELLS`.
.add_plugins(UiPetStatsPlugin)
// The pet paper doll's SHARED surface (decision 1057) — the combat-stats snapshot under the
// `"pet"` token and the page's model booth. Apart from the block above because these values
// pass through the character sheet's own bindings and events, with no hunter gate.
.add_plugins(UiPetDollPlugin)
// The connection-telemetry feed: the averaged ping RTT behind `GetNetStats()`, which the main
// bar's performance meter polls (decision 0658).
.add_plugins(UiNetPlugin)
+20
View File
@@ -54,6 +54,11 @@ pub(crate) struct CreatureRecord {
pub(crate) name: String,
pub(crate) subname: Option<String>,
pub(crate) creature_type: u32,
/// `CreatureFamily.dbc` id — the pet paper doll's family word and, through the row's food
/// mask, its diet tooltip (decision 1062). `0` on everything that is neither a tameable beast
/// nor a warlock minion, which is most of the table; the family table has no row 0, so a `0`
/// resolves to nil without needing a sentinel of its own.
pub(crate) pet_family: u32,
/// Elite rank 0..4 **as the template declares it** — read it through [`gated_rank`], never
/// directly, unless you specifically want the ungated template value.
pub(crate) rank: u32,
@@ -213,6 +218,20 @@ impl NameCache {
self.pets.insert(pet_number, name);
}
/// Forget a pet's cached name so the next [`Self::resolve`] asks for it again (decision 1066).
///
/// **The one hole in the ask-once discipline, and the one place it has to have one.** A player's
/// or a creature's name cannot change under a cache entry; a pet's can — its owner renames
/// it — and nothing on the wire pushes the new one, because a pet's name does not ride its
/// descriptor. What *does* arrive is a bumped `UNIT_FIELD_PET_NAME_TIMESTAMP`, and observing
/// that is what calls this (`crate::ui_pet::unit`). Clearing the pending marker matters too: a
/// rename racing an in-flight query would otherwise have its re-ask deduped away and leave the
/// stale name in place forever.
pub(crate) fn forget_pet(&mut self, pet_number: u32) {
self.pending_pets.remove(&pet_number);
self.pets.remove(&pet_number);
}
/// Record a creature-name answer (`SMSG_CREATURE_QUERY_RESPONSE`); `None` = unknown entry.
pub(crate) fn insert_creature(&mut self, entry: u32, record: Option<CreatureRecord>) {
self.pending_creatures.remove(&entry);
@@ -323,6 +342,7 @@ mod tests {
name: "Young Wolf".into(),
subname: None,
creature_type: 0,
pet_family: 0,
rank: 0,
type_flags: 0,
civilian: false,
+14
View File
@@ -717,6 +717,20 @@ pub(crate) enum ClientCommand {
spell_id: u32,
enabled: bool,
},
/// Give the pet up permanently (`CMSG_PET_ABANDON`, decision 1066) — the right-click menu's
/// **Abandon**, and only that row.
///
/// **Its Dismiss is not this**, however alike they read: `PetDismiss 0x4be4d0` opens no packet
/// and goes down the pet bar's ordinary [`Self::PetAction`] path with the word `0x07000003`
/// (wow-re §11c). Both would have worked against vmangos, which is why it is written here.
///
/// No reply: the answer is `SMSG_PET_SPELLS` with a zero guid, and the pet object leaving.
PetAbandon { pet_guid: u64 },
/// Rename the pet (`CMSG_PET_RENAME`, decision 1066) — the `PETRENAMECONFIRM` popup's accept.
///
/// The server may refuse the name outright, so nothing is applied locally; success arrives as a
/// bumped `UNIT_FIELD_PET_NAME_TIMESTAMP` on the pet, which is what re-asks the name cache.
PetRename { pet_guid: u64, name: String },
/// Start melee auto-attack on `guid` (`CMSG_ATTACKSWING`); echoed as `SMSG_ATTACKSTART`.
AttackSwing { guid: u64 },
/// Stop melee auto-attack (`CMSG_ATTACKSTOP`); echoed as `SMSG_ATTACKSTOP`, whose receive path
+2
View File
@@ -610,6 +610,7 @@ pub(super) fn apply_net_updates(
name,
subname,
creature_type,
pet_family,
rank,
type_flags,
civilian,
@@ -619,6 +620,7 @@ pub(super) fn apply_net_updates(
name,
subname,
creature_type,
pet_family,
rank,
type_flags,
civilian,
@@ -33,6 +33,7 @@ pub(super) fn creature_name(
name: Option<String>,
subname: Option<String>,
creature_type: Option<u32>,
pet_family: u32,
rank: u32,
type_flags: u32,
civilian: bool,
@@ -45,6 +46,7 @@ pub(super) fn creature_name(
name: n,
subname,
creature_type: creature_type.unwrap_or(0),
pet_family,
rank,
type_flags,
civilian,
+2
View File
@@ -714,6 +714,8 @@ fn writer_loop(
spell_id,
enabled,
} => w.pet_spell_autocast(pet_guid, spell_id, enabled),
ClientCommand::PetAbandon { pet_guid } => w.pet_abandon(pet_guid),
ClientCommand::PetRename { pet_guid, name } => w.pet_rename(pet_guid, &name),
ClientCommand::AttackSwing { guid } => w.attack_swing(guid),
ClientCommand::AttackStop => w.attack_stop(),
ClientCommand::SetSheathed { state } => w.set_sheathed(state),
+326
View File
@@ -0,0 +1,326 @@
//! The **dressing room** booth (decision 1060) — the ref's `DressUpFrame`/`DressUpModel`: the
//! player's own character wearing an item they do *not* own, spun by the window's rotate buttons.
//!
//! ## Why this booth cannot be the paper doll's
//!
//! The paper doll and the inspect pane mirror a *live entity's* spawned children
//! ([`super::sync_body_booth`]) — which is exactly right for "show me what is standing in the
//! world", and exactly wrong here: nobody in the world is wearing the previewed item. So the
//! dressing room takes the **tuple-driven** path the glue screens already use — the shared
//! assembly in [`crate::entities::attach`] builds the parts from a spec
//! (body displayId + appearance dials + a 19-slot equipment array), and this module bakes them.
//!
//! The spec's equipment is the player's own visible items with the tried-on ones substituted in
//! ([`crate::ui_dressup`] composes it). One consequence worth naming: the preview dresses by the
//! **select-screen** law (weapons drawn in the hands, wow-re `glue-select-model.md`), not by the
//! world's sheath state — the reference's `DressUpModel` is a `<PlayerModel>` subclass showing the
//! character posed for inspection, and that is the pose the shared assembly produces.
//!
//! ## The light is the reference's own
//!
//! `DressUpModel` (`0x495c00`) subclasses the very `CharacterModelBase` ctor (`0x505680`) the
//! character window's `<PlayerModel>` uses — same single directional light, same ambient
//! (decision 0638). So this bake lights through [`BoothLight::pane`], the same rig as the paper
//! doll, with no glow ([`crate::ffx_glow::FfxGlow::UI_PANE`]) for the same reason.
use benilla_protocol::CharEnumItem;
use bevy::camera::visibility::RenderLayers;
use bevy::camera::PerspectiveProjection;
use bevy::prelude::*;
use crate::entities::Creatures;
use crate::terrain::WowModelMaterial;
use super::light::BoothLight;
use super::{
aim, body_frame, booth_anchors, new_target_image, spawn_booth_effects, spawn_booth_model,
wake_booth, Booth, BoothBillboardSpec, BoothCam, BoothEffects, BoothMotion, BoothPart,
BoothRider, Booths, PortraitImages, PortraitSource, PreviewBillboard, PreviewEffects,
PreviewPart, PreviewRider, BOOTH_SETTLE_FRAMES, DRESSUP_LAYER, PAPERDOLL_SIZE,
};
/// The dressing-room booth slot token (its key in [`PortraitImages`] / [`Booths`]).
pub(crate) const DRESSUP_SLOT: &str = "dressup";
/// The character the dressing room shows: the player's own body + appearance, wearing the
/// equipment array [`crate::ui_dressup`] composed (their visible items, with each tried-on item
/// substituted into its slot).
///
/// Its `PartialEq` is the re-assembly trigger — a try-on, a reset, or the player equipping
/// something while the window is open all show up as a different look.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub(crate) struct DressUpLook {
/// The player's own body display id (the ref's `SetUnit("player")`).
pub(crate) display_id: u32,
pub(crate) race: u8,
pub(crate) sex: u8,
pub(crate) skin: u8,
pub(crate) face: u8,
pub(crate) hair_style: u8,
pub(crate) hair_color: u8,
pub(crate) facial_hair: u8,
/// Worn **ItemDisplayInfo** ids by equipment slot, the `SMSG_CHAR_ENUM` shape the shared
/// assembly reads (helm 0 · shoulder 2 · … · main hand 15 · off hand 16 · ranged 17 · tabard 18).
pub(crate) equipment: [CharEnumItem; 19],
}
/// The dressing room's live input: who is standing in it (`None` = the window is closed / has
/// nothing to show, which empties the booth) and the pane's bake **yaw** in radians — the ref's
/// `Model:SetRotation`, written by the window's rotate buttons exactly as the paper doll's is.
#[derive(Resource)]
pub(crate) struct DressUpPreview {
pub(crate) look: Option<DressUpLook>,
pub(crate) yaw: f32,
}
impl Default for DressUpPreview {
fn default() -> Self {
Self {
look: None,
// The ref's `Model_OnLoad` default facing (`UIParent.lua:1422`), same as the paper doll's.
yaw: 0.61,
}
}
}
/// The assembled dressing-room look — the entities-side builder's output, the [`super::GluePreviewBake`]
/// twin. `revision` bumps on every real change (a fresh assembly, or a clear to `look: None`); the
/// booth re-bakes only when it moves, and a bare yaw change never touches it.
#[derive(Resource, Default)]
pub(crate) struct DressUpBake {
pub(crate) look: Option<DressUpLook>,
pub(crate) display_id: u32,
pub(crate) parts: Vec<PreviewPart>,
pub(crate) riders: Vec<PreviewRider>,
pub(crate) effects: Vec<PreviewEffects>,
pub(crate) billboards: Vec<PreviewBillboard>,
pub(crate) grip: [bool; 2],
pub(crate) revision: u64,
}
/// Stand the dressing-room booth up beside the two body panes (called from [`super::setup_booths`]).
/// Same off-screen pipeline as those — [`PAPERDOLL_SIZE`]² target, near-black backdrop, decode but
/// no glow (decision 0638) — on its own layer, framed per-bake.
pub(super) fn spawn_dressup_booth(
commands: &mut Commands,
images: &mut Assets<Image>,
portraits: &mut PortraitImages,
booths: &mut Booths,
) {
let image = images.add(new_target_image(PAPERDOLL_SIZE));
portraits.0.insert(
DRESSUP_SLOT.to_string(),
PortraitSource::Live(image.clone()),
);
let layer = RenderLayers::layer(DRESSUP_LAYER);
let root = commands
.spawn((Transform::IDENTITY, Visibility::Visible, layer.clone()))
.id();
commands.spawn((
super::booth_view_shape(),
Camera {
order: -100 + DRESSUP_LAYER as isize,
clear_color: ClearColorConfig::Custom(Color::srgb(0.055, 0.045, 0.04)),
..default()
},
bevy::camera::RenderTarget::Image(image.clone().into()),
crate::ffx_glow::FfxGlow::UI_PANE,
// Placeholder — `sync_dressup_booth` overwrites transform + projection from the body's
// bounds on the first bake, exactly as the paper doll's does.
Projection::from(PerspectiveProjection {
fov: super::PORTRAIT_FOV,
near: 0.02,
far: 100.0,
..default()
}),
layer.clone(),
BoothCam(DRESSUP_SLOT.to_string()),
));
booths.0.insert(
DRESSUP_SLOT.to_string(),
Booth {
layer,
root,
target: image,
baked: None,
wake: 0,
live: false,
pending: Vec::new(),
},
);
}
/// Bake the assembled dressing-room look, and spin it to the pane's yaw.
///
/// The [`super::sync_body_booth`] law over tuple-driven parts: re-light onto the pane rig, pose a
/// fresh instance, seat the riders and effects, frame it full-body, arm the wake window. What
/// differs from the paper doll is only where the parts come from (a bake resource, not a live
/// unit's children) and the hand grip — the assembly holds the weapons, so the hands close on them
/// (wow-re `hand-grip-mechanism.md`).
#[allow(clippy::too_many_arguments)]
pub(super) fn sync_dressup_booth(
mut commands: Commands,
preview: Res<DressUpPreview>,
bake: Res<DressUpBake>,
mut booths: ResMut<Booths>,
mut booth_light: ResMut<BoothLight>,
mut materials: ResMut<Assets<WowModelMaterial>>,
creatures: Option<Res<Creatures>>,
anim_data: Option<Res<crate::creature_anim::AnimData>>,
mut cams: Query<(&BoothCam, &mut Transform, &mut Projection)>,
mut palettes: ResMut<crate::rig_palette::RigPalettes>,
mut env_cache: Local<Option<bool>>,
mut last: Local<Option<(u64, f32)>>,
// Whether a bake is currently STANDING on the stage — the empty arm's "is there anything to
// tear down?" gate. `Booth::baked` can't answer it here: that field keys the mirrored booths'
// look, and this one is revision-keyed.
mut staged: Local<bool>,
) {
if super::test_mode(&mut env_cache) {
return; // the test bake owns the booths
}
let Some(booth) = booths.0.get_mut(DRESSUP_SLOT) else {
return;
};
let (last_rev, last_yaw) = last.unwrap_or((u64::MAX, f32::NAN));
let rebake = last_rev != bake.revision;
if !rebake && last_yaw == preview.yaw {
return;
}
if rebake {
// Nothing to show (the window is closed, or the assembly cleared) → empty the stage and
// let the camera sleep once it has rendered the emptied frame (decision 0540). Only when
// something WAS standing here: at startup this arm runs with an empty stage already, and
// arming the wake there paid four full booth passes to re-render nothing (caught in the
// 1060 probe's own `[booth] t=0.00 dressup active=true wake=4` line).
if bake.parts.is_empty() {
if *staged {
commands.entity(booth.root).despawn_related::<Children>();
booth.baked = None;
booth.wake = BOOTH_SETTLE_FRAMES;
booth.live = false;
booth.pending.clear();
*staged = false;
}
*last = Some((bake.revision, preview.yaw));
return;
}
// The rig + framing come from the display cache — the same readiness the assembly gated
// on, so both are ready together. If somehow not, leave `last` alone and retry next frame.
let Some(creatures) = creatures.as_deref() else {
return;
};
let (Some(rig), Some(anchors)) = (
creatures.display_rig(bake.display_id),
booth_anchors(Some(creatures), Some(bake.display_id)),
) else {
booth.wake = booth.wake.max(BOOTH_SETTLE_FRAMES);
return;
};
let mut relight =
|m: &Handle<WowModelMaterial>| booth_light.pane.variant(m, &mut materials);
let booth_parts: Vec<BoothPart> = bake
.parts
.iter()
.map(|p| BoothPart {
skinned: p.skinned_mesh.clone(),
static_mesh: p.static_mesh.clone(),
material: relight(&p.material),
// `None` — the same known gap as the glue preview's (decision 0807).
alpha_anim: None,
})
.collect();
let booth_riders: Vec<BoothRider> = bake
.riders
.iter()
.map(|r| BoothRider {
mesh: r.mesh.clone(),
material: relight(&r.material),
bone: r.bone,
offset: r.offset,
})
.collect();
let booth_billboards: Vec<BoothBillboardSpec> = bake
.billboards
.iter()
.map(|b| BoothBillboardSpec {
mesh: b.mesh.clone(),
material: relight(&b.material),
bone: b.bone,
offset: b.offset,
kind: b.kind,
})
.collect();
// Never latch a world-lane material into a pane (the [`super::light`] law): retry instead.
if booth_light.pane.take_unready() {
booth.wake = booth.wake.max(BOOTH_SETTLE_FRAMES);
return;
}
commands.entity(booth.root).despawn_related::<Children>();
let joints = spawn_booth_model(
&mut commands,
&mut palettes,
booth.root,
booth.layer.clone(),
&booth_parts,
&booth_riders,
rig.inverse_bindposes
.as_ref()
.map(|ibp| (rig.skeleton, ibp, rig.animations)),
anim_data.as_deref().map(|a| &a.0),
// Frozen at Stand, like the paper doll beside it — matching the reference widget's live
// animation is the same look call there and here (decision 0822).
BoothMotion::Frozen,
bake.grip,
&booth_billboards,
);
let (fx_emitters, _) = spawn_booth_effects(
&mut commands,
&joints,
&booth.layer,
booth_light.pane.buffer.as_ref(),
&bake
.effects
.iter()
.map(|fx| BoothEffects {
bone: fx.bone,
offset: fx.offset,
emitters: fx.emitters.clone(),
})
.collect::<Vec<_>>(),
);
booth.live = fx_emitters > 0;
*staged = true;
aim(&mut cams, DRESSUP_SLOT, &body_frame(&anchors));
// `WOW_BOOTH_LOG=1` — one line per committed bake, the same instrument the mirrored booths
// carry (`super::log_bake`, whose signature is the mirrored part types'). It is what
// separates "the pane is black because nothing baked" from "…because the camera is wrong".
if super::booth_log() {
eprintln!(
"[booth] dressup bake parts={} riders={} billboards={} fx={} rev={}",
booth_parts.len(),
booth_riders.len(),
booth_billboards.len(),
fx_emitters,
bake.revision,
);
}
wake_booth(
booth,
&materials,
booth_parts
.iter()
.map(|p| &p.material)
.chain(booth_riders.iter().map(|r| &r.material))
.chain(booth_billboards.iter().map(|b| &b.material)),
);
}
// The yaw (the ref's `Model:SetRotation`) — applied on a fresh bake and on every spin, never on
// an idle frame. A spin is a content edge too (decision 0540).
commands
.entity(booth.root)
.insert(Transform::from_rotation(Quat::from_rotation_y(preview.yaw)));
booth.wake = booth.wake.max(BOOTH_SETTLE_FRAMES);
*last = Some((bake.revision, preview.yaw));
}
+101 -14
View File
@@ -84,6 +84,8 @@ use booth::{
spawn_booth_effects, spawn_booth_model, BoothBillboardSpec, BoothEffects, BoothMotion,
BoothPart, BoothRider,
};
mod dressup;
pub(crate) use dressup::{DressUpBake, DressUpLook, DressUpPreview};
mod glue_booth;
pub(crate) use glue_booth::{
CreateLook, GlueLook, GluePreview, GluePreviewBake, GlueScene, PreviewBillboard,
@@ -110,6 +112,12 @@ const PAPERDOLL_SLOT: &str = "paperdoll";
/// slots already do separately: body framing like the paper doll, an arbitrary unit like
/// `"target"`. Both run through [`sync_body_booth`]; only the unit and the yaw differ.
const INSPECT_SLOT: &str = "inspect";
/// The **pet paper doll**'s model pane (decision 1057) — the character window's tab 2. Composed
/// exactly like [`INSPECT_SLOT`] (body framing + an arbitrary unit + a live yaw), pointed at the
/// pet instead of at another player; it too runs through [`sync_body_booth`]. It gets its own booth
/// rather than sharing the `"pet"` portrait slot because that one is a 256² *bust* for the pet unit
/// frame — different subject framing, different resolution, and a yaw the portrait must never have.
const PETDOLL_SLOT: &str = "petdoll";
/// 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;
@@ -117,6 +125,8 @@ const PORTRAIT_LAYER_BASE: usize = 2;
const PAPERDOLL_LAYER: usize = PORTRAIT_LAYER_BASE + SLOTS.len();
/// The inspect booth's render layer — the next one past the paper doll's.
const INSPECT_LAYER: usize = PAPERDOLL_LAYER + 1;
/// The pet-doll booth's render layer — the next one past inspect's (the one ladder below).
const PETDOLL_LAYER: usize = INSPECT_LAYER + 1;
/// The glue booth's render layer — the next one past inspect's. **Every booth layer is computed
/// HERE**, in one ladder, because they were not: the glue booth (`930b327c`) and the inspect booth
/// (`ead3b0c9`) each defined "the next layer past the paper doll's" in a different file and landed
@@ -124,19 +134,26 @@ const INSPECT_LAYER: usize = PAPERDOLL_LAYER + 1;
/// resolves an emitter's booth camera by *finding the first camera whose layers intersect*, so the
/// glue scene's 28 emitters addressed the INSPECT camera, which is off at the glue screens, and the
/// login screen's braziers simulated forever without ever being drawn (decision 0775).
pub(super) const GLUE_LAYER: usize = INSPECT_LAYER + 1;
/// The pipe_warm **twin booth**'s render layer — the next one past the glue booth's (same ladder
pub(super) const GLUE_LAYER: usize = PETDOLL_LAYER + 1;
/// The **dressing room**'s render layer (decision 1060) — the next one past the glue booth's, by
/// the same ladder rule.
pub(super) const DRESSUP_LAYER: usize = GLUE_LAYER + 1;
/// The pipe_warm **twin booth**'s render layer — the next one past the dressing room's (same ladder
/// rule as [`GLUE_LAYER`]). This camera exists only while the warm pass runs
/// ([`spawn_warm_booth`]); nothing but menagerie rigs ever rides its layer.
pub(crate) const WARM_BOOTH_LAYER: usize = GLUE_LAYER + 1;
pub(crate) const WARM_BOOTH_LAYER: usize = DRESSUP_LAYER + 1;
// The ladder must stay collision-free: a booth camera's layer is its identity for both rendering
// and the emitter→camera match, and the failure above was silent in both.
const _: () = assert!(
PAPERDOLL_LAYER != INSPECT_LAYER
&& INSPECT_LAYER != GLUE_LAYER
&& INSPECT_LAYER != PETDOLL_LAYER
&& PETDOLL_LAYER != GLUE_LAYER
&& PAPERDOLL_LAYER != PETDOLL_LAYER
&& PAPERDOLL_LAYER != GLUE_LAYER
&& WARM_BOOTH_LAYER > GLUE_LAYER,
&& INSPECT_LAYER != GLUE_LAYER
&& DRESSUP_LAYER > GLUE_LAYER
&& WARM_BOOTH_LAYER > DRESSUP_LAYER,
"booth render layers must be distinct — see GLUE_LAYER"
);
const _: () = assert!(
@@ -316,6 +333,25 @@ impl Default for InspectBooth {
}
}
/// The pet paper doll's model pane input (decision 1057) — the [`InspectBooth`] shape exactly:
/// a yaw the pane's rotate buttons write, and the unit [`crate::ui_pet_doll`] resolved the pet
/// token to this frame (`None` with no pet out, or while its object hasn't streamed — which empties
/// the booth, the same "no pet" the rest of the page shows).
#[derive(Resource)]
pub(crate) struct PetDollBooth {
pub(crate) yaw: f32,
pub(crate) unit: Option<Entity>,
}
impl Default for PetDollBooth {
fn default() -> Self {
Self {
yaw: 0.61,
unit: None,
}
}
}
/// The key identifying what a booth currently has baked. Any change in the unit's dressed look
/// (gear swap, appearance refresh, different unit) changes it → re-bake.
#[derive(PartialEq)]
@@ -503,8 +539,11 @@ impl Plugin for PortraitPlugin {
app.init_resource::<PortraitImages>()
.init_resource::<PaperDollBooth>()
.init_resource::<InspectBooth>()
.init_resource::<PetDollBooth>()
.init_resource::<glue_booth::GluePreview>()
.init_resource::<glue_booth::GluePreviewBake>()
.init_resource::<dressup::DressUpPreview>()
.init_resource::<dressup::DressUpBake>()
.init_resource::<Booths>()
.init_resource::<BoothLight>()
.init_resource::<crate::ui_session::InteractNpc>()
@@ -522,8 +561,10 @@ impl Plugin for PortraitPlugin {
sync_portraits,
sync_paperdoll,
sync_inspect_booth,
sync_petdoll_booth,
glue_booth::sync_glue_booth,
glue_booth::sync_glue_scene,
dressup::sync_dressup_booth,
// Last: it reads the wake/pending state every sync above may have armed.
gate_booth_cameras,
)
@@ -738,15 +779,17 @@ fn setup_booths(
);
}
// The two **body** booths — the character window's paper doll (decision 0208 §5) and the
// inspect window's pane (decision 0631 §4). Same off-screen pipeline as the portrait slots
// (transparent target, HDR + the FFXGlow node, negative order so the bake is ready before
// the world/UI cameras), but their own 512² targets, their own layers, and a body-framing
// projection (aimed per-bake by `sync_body_booth`). Kept a separate spawn from the portrait
// loop above so those two cameras stay byte-for-byte what the director approved.
// The three **body** booths — the character window's paper doll (decision 0208 §5), the
// inspect window's pane (decision 0631 §4) and the pet paper doll's (decision 1057). Same
// off-screen pipeline as the portrait slots (transparent target, HDR + the FFXGlow node,
// negative order so the bake is ready before the world/UI cameras), but their own 512²
// targets, their own layers, and a body-framing projection (aimed per-bake by
// `sync_body_booth`). Kept a separate spawn from the portrait loop above so these cameras
// stay byte-for-byte what the director approved.
for (i, (slot, layer_index)) in [
(PAPERDOLL_SLOT, PAPERDOLL_LAYER),
(INSPECT_SLOT, INSPECT_LAYER),
(PETDOLL_SLOT, PETDOLL_LAYER),
]
.into_iter()
.enumerate()
@@ -801,6 +844,9 @@ fn setup_booths(
// The glue booth (decisions 0423 + 0465): its own slot/layer/target, framed per-bake.
glue_booth::spawn_glue_booth(&mut commands, &mut images, &mut portraits, &mut booths);
// The dressing room (decision 1060): a third body pane, tuple-driven like the glue booth but
// lit and framed like the paper doll.
dressup::spawn_dressup_booth(&mut commands, &mut images, &mut portraits, &mut booths);
}
/// `true` while the `WOW_PORTRAIT_TEST` debug bake owns the booths (checked once — env vars don't
@@ -1188,9 +1234,50 @@ fn sync_inspect_booth(
);
}
/// Bake `unit`'s full-body dressed look into the `slot` booth at `yaw` — the shared body of both
/// body booths (decision 0208 §5 for the paper doll, 0631 §4 for inspect). `unit` is `None` when
/// there is nothing to show, which empties the booth.
/// The pet paper doll's model pane (decision 1057) — the inspect pane's exact twin, pointed at the
/// pet [`crate::ui_pet_doll`] resolved this frame.
#[allow(clippy::too_many_arguments)]
fn sync_petdoll_booth(
mut commands: Commands,
mut booths: ResMut<Booths>,
mut portraits: ResMut<PortraitImages>,
mut booth_light: ResMut<BoothLight>,
creatures: Option<Res<Creatures>>,
ent_q: Query<&NetEntity>,
look: DressedLook,
petdoll: Res<PetDollBooth>,
mut palettes: ResMut<crate::rig_palette::RigPalettes>,
mut wow_mats: ResMut<Assets<WowModelMaterial>>,
mut env_cache: Local<Option<bool>>,
mut last_yaw: Local<Option<f32>>,
mut cams: Query<(&BoothCam, &mut Transform, &mut Projection)>,
anim_data: Option<Res<crate::creature_anim::AnimData>>,
) {
if test_mode(&mut env_cache) {
return;
}
sync_body_booth(
&mut palettes,
PETDOLL_SLOT,
petdoll.unit,
petdoll.yaw,
&mut last_yaw,
&mut commands,
&mut booths,
&mut portraits,
&mut booth_light,
creatures.as_deref(),
&ent_q,
&look,
&mut wow_mats,
&mut cams,
anim_data.as_deref(),
);
}
/// Bake `unit`'s full-body dressed look into the `slot` booth at `yaw` — the shared body of all
/// three body booths (decision 0208 §5 for the paper doll, 0631 §4 for inspect, 1057 for the pet
/// doll). `unit` is `None` when there is nothing to show, which empties the booth.
#[allow(clippy::too_many_arguments)]
fn sync_body_booth(
palettes: &mut crate::rig_palette::RigPalettes,
+8 -1
View File
@@ -244,7 +244,14 @@ impl Plugin for TargetPlugin {
crate::ui_action::targeting::commit_object_cast_on_click,
click::act_on_right_click,
click::clear_target_requests,
click::target_unit_requests,
// The two unit-token drains, grouped as one element (the outer chain is at
// Bevy's 20-tuple limit): `TargetUnit`'s selection commit, and
// `DropItemOnUnit`'s pet-leg feed (decision 1055). Independent of each other —
// one writes `Selection`, the other sends a cast — so they need no order.
(
click::target_unit_requests,
crate::ui_action::drop_item::drop_item_on_unit,
),
// The chat layer's by-name asks (decision 0886), beside the UI's token asks:
// both are non-mouse selection writers committing through `scan::commit`.
// The chat layer's by-name asks, grouped as one chained element (the outer
@@ -411,6 +411,17 @@ mod tests {
cast_fail_text(0x4D, Some(&rage), &g).unwrap(),
"Not enough rage"
);
// The environment gate's pair (decision 1056) — both are plain passthroughs, so what the
// player reads IS the GlobalStrings value. A typo'd key here would degrade a real refusal
// to a dead-looking button, which is what this test exists to catch.
assert_eq!(
cast_fail_text(0x50, None, &g).unwrap(),
"Cannot use while swimming"
);
assert_eq!(
cast_fail_text(0x58, None, &g).unwrap(),
"Can only use while swimming"
);
// The data-suppression face on the real file: the absent keys show nothing.
assert_eq!(cast_fail_text(0x08, None, &g), None);
assert_eq!(cast_fail_text(0x21, None, &g), None);
@@ -454,6 +454,18 @@ fn send_spell_cast(
cast_errors.0.push((spell_id, 0x39));
return;
}
// The ENVIRONMENT leg of the SAME requirement validator (`0x609d39–0x609d7b`, between the
// mounted block above and the moving block below; decision 1056): a spell whose aura cannot
// survive the caster's current side of the water surface refuses locally — 0x50 "Cannot use
// while swimming" for the mount/Travel-Form/food set, 0x58 "Can only use while swimming" for
// Aquatic Form. The gate must be local: vmangos's CheckCast has no shapeshift or food water
// arm at all, so without this the server grants a druid aquatic form standing on dry land
// (ledger B176) and lets you eat mid-swim (B155's eating half). The condition is [`state`]'s.
if let Some(reason) = state::cast_water_refusal(ctx.self_move_flags, def) {
debug!("ui_action: cast {spell_id} refused locally — water side ({reason:#x})");
cast_errors.0.push((spell_id, reason));
return;
}
// The moving leg of the SAME requirement validator (`0x609de3`, after the mounted/posture/
// environment blocks, before the form leg — wow-re `moving-cast-gate.md`, decision 0862): a
// cast-time (or movement-sensitive) press while already moving refuses locally with the
@@ -0,0 +1,173 @@
//! **`DropItemOnUnit 0x48d960`** — the cursor's held item dropped onto a unit, which in 1.12 is
//! how you feed your pet (decision 1055; wow-re `ui/scratch/item-target-cursor-and-dropitemonunit.md`,
//! VERIFIED).
//!
//! The reference's binding forks on which unit it was given:
//!
//! - **PET** — the unit guid equals `[0xb714a0]/[0xb714a4]`, the charm-else-summon pet global →
//! `0x6ea1e0`, the validator whose three gates are transcribed in [`feedable_pet`] → `CastSpell`
//! with the learned Feed Pet spell and **`castItem = NULL`**, then `BindTarget(the food)`.
//! - **PLAYER** — a trade (`CMSG_INITIATE_TRADE 0x116` via `0x5d3fb0`, else a trade-slot
//! placement). Not modelled here: the trade window owns it, and no node does yet. A drop on a
//! player is silently refused, which is the same shape every other refusal on this path takes.
//!
//! **The wire is `CMSG_CAST_SPELL (0x12E)`, not `CMSG_USE_ITEM`,** and that falls out of the null
//! cast item: the sender's discriminator (`0x6e5513..0x6e55a7`) takes its no-item arm
//! (`je 0x6e5808`), skipping the `push 0xab` block outright. The block that goes out is
//! `{ spellId, flags = TARGET_FLAG_ITEM (0x0010), the FOOD's guid }` — **the pet's guid never
//! reaches the wire at all**, which is why this is [`TargetedBind::Item`] and not a unit bind.
//!
//! Two behaviours worth stating because they are easy to get backwards:
//!
//! - **`ClearCursor 0x495190(1,1)` runs on SUCCESS only.** Every refusal is silent and leaves the
//! payload on the cursor — no error line, no drop. So a mis-drop costs nothing and you are still
//! holding the food.
//! - **There is no range test on this path.** Neither function contains a floating-point
//! instruction, and the one reachable range helper sits behind a `test [0xceac5c],0x8202` mask
//! disjoint from `0x0010`. The server judges distance.
use bevy::prelude::*;
use benilla_ui::script::{CursorPayload, UiScript};
use super::cast_send::{CastCommit, TargetedBind};
/// `0x6ea1e0` — is this unit a pet *I* can feed? All three gates, in the reference's order. Any
/// miss is a silent refusal (see the module docs), so this returns a plain bool rather than a
/// reason.
///
/// The gates are about **ownership and provenance**, not about the food: what may be fed to a pet
/// is the server's call (vmangos checks the item's food type against the pet's diet and answers
/// `SPELL_FAILED_WRONG_PET_FOOD`), and the client never looks at the item here at all.
fn feedable_pet(
pet: &benilla_protocol::ObjectFields,
self_guid: u64,
feed_pet_known: bool,
) -> bool {
// 1 · `UNIT_CREATED_BY_SPELL` non-zero — something summoned it.
pet.unit_created_by_spell().is_some()
// 2 · `UNIT_FIELD_CREATEDBY == my guid` — and that something was me.
&& pet.unit_created_by() == Some(self_guid)
// 3 · the learn-time latch `[0xcecad8]` — I actually know Feed Pet. A warlock's imp is a
// summon owned by its master and passes both field gates; this is the leg that stops
// it, because only a hunter ever learns a `SPELL_EFFECT_FEED_PET` spell.
&& feed_pet_known
}
/// Drain the feed asks and run the pet leg — **both** of the reference's entries into `0x6ea1e0`:
///
/// - the **frame**: `PetFrame_OnClick`'s `DropItemOnUnit("pet")`, queued by the VM;
/// - the **world**: a left click on the pet's own model with an item held. `0x6ea1e0`'s second
/// caller is `0x4927e8`, inside the world left-click worker `0x4925d0`'s object leg, so dropping
/// food on the body feeds it identically. The generic object-leg rule still stands around it
/// (`cursor::world_drop_click`: an `Object` pick drops nothing and keeps the payload, and the
/// click still selects) — the feed is an extra arm inside that leg, not a replacement for it.
///
/// One law, one place: whichever entry it came from, the ask runs the same three gates and the same
/// commit.
#[allow(clippy::too_many_arguments)]
pub(crate) fn drop_item_on_unit(
script: Option<NonSendMut<UiScript>>,
self_q: Query<(&crate::net::Guid, &crate::net::ObjectStore), With<crate::net::SelfPlayer>>,
stores: Query<&crate::net::ObjectStore>,
index: Res<crate::net::GuidIndex>,
pet_bar: Res<crate::ui_pet::PetBar>,
learned: Res<super::LearnedAbilities>,
hovered: Res<crate::target::Hovered>,
mut clicks: MessageReader<crate::interact::WorldClick>,
mut ladder: super::CastLadder,
) {
let Some(mut script) = script else {
clicks.clear();
return;
};
let mut tokens = script.take_drop_item_on_unit();
// The world entry: a left click whose pick IS the pet. `Hovered` is the same nearest-object
// pick the click itself selects from, so cursor and click agree by construction.
let clicked_pet = clicks.read().count() > 0
&& pet_bar.spells.pet_guid != 0
&& hovered.guid == Some(pet_bar.spells.pet_guid);
if clicked_pet {
tokens.push("pet".to_string());
}
if tokens.is_empty() {
return;
}
let Some((self_guid, self_store)) = self_q.iter().next() else {
return;
};
for token in tokens {
// The reference compares the resolved unit's guid against the pet global; our `"pet"` token
// resolves off the same cached guid the pet frame and `UNIT_PET` read (decision 0990), so
// any other token simply is not the pet and takes the unmodelled trade leg.
if token != "pet" {
debug!("DropItemOnUnit({token}) — only the pet leg is modelled; refused, payload kept");
continue;
}
let Some(pet_fields) = (pet_bar.spells.pet_guid != 0)
.then(|| index.0.get(&pet_bar.spells.pet_guid))
.flatten()
.and_then(|&e| stores.get(e).ok())
else {
continue; // no pet, or it hasn't streamed in — silent, payload kept
};
let Some(spell_id) = learned.feed_pet else {
continue; // gate 3 fails outright: we never learned Feed Pet
};
if !feedable_pet(&pet_fields.0, self_guid.0, true) {
debug!("DropItemOnUnit(\"pet\") — not a pet I summoned; refused, payload kept");
continue;
}
// The food itself: the cursor's held item, resolved to the live guid the same way the bag
// click seam resolves a clicked slot. A Spell/Action/Macro payload is not an item and
// cannot be dropped on a unit.
let Some(CursorPayload::Item(held)) = script.cursor_payload() else {
continue;
};
let slot0 = u8::try_from(held.slot.saturating_sub(1)).unwrap_or(0);
let Some(item_guid) =
crate::ui_items::slot_guid(&self_store.0, held.bag, slot0, &ladder.items)
else {
continue; // the slot emptied under us — silent, as every refusal here is
};
debug!("DropItemOnUnit(\"pet\") — Feed Pet {spell_id} at item {item_guid:#x}");
ladder.commit_targeted(spell_id, CastCommit::Spell, TargetedBind::Item(item_guid));
// `ClearCursor(1,1)` — success only.
script.clear_cursor_payload();
}
}
#[cfg(test)]
mod tests {
use super::*;
/// `0x6ea1e0`'s three gates, each failed alone (decision 1055). The warlock's-imp row is the
/// one that earns gate 3: it passes both field gates and must still be refused.
#[test]
fn the_feed_gates_are_ownership_and_provenance() {
use benilla_protocol::messages::ObjectFields;
const ME: u64 = 0xDEAD_BEEF;
// Field 146 is `UNIT_CREATED_BY_SPELL`; 14/15 are `UNIT_FIELD_CREATEDBY`'s guid pair.
let pet = |created_by_spell: Option<u32>, created_by: Option<u64>| {
let mut pairs: Vec<(u16, u32)> = Vec::new();
if let Some(s) = created_by_spell {
pairs.push((146, s));
}
if let Some(g) = created_by {
pairs.push((14, g as u32));
pairs.push((15, (g >> 32) as u32));
}
ObjectFields::from_pairs(&pairs)
};
let mine = pet(Some(883), Some(ME));
assert!(feedable_pet(&mine, ME, true), "my own summoned pet feeds");
// 1 · nothing summoned it — a plain creature that happens to name me.
assert!(!feedable_pet(&pet(None, Some(ME)), ME, true));
// 2 · summoned, but by somebody else.
assert!(!feedable_pet(&pet(Some(883), Some(1)), ME, true));
// 3 · a warlock's imp: BOTH field gates pass, and it is still not feedable, because only a
// hunter ever learns a SPELL_EFFECT_FEED_PET spell.
assert!(!feedable_pet(&mine, ME, false));
}
}
+22 -2
View File
@@ -39,6 +39,7 @@ pub(crate) mod cast_target;
mod drain;
#[cfg(test)]
mod drain_tests;
pub(crate) mod drop_item;
mod errors;
mod feed;
#[cfg(test)]
@@ -195,12 +196,23 @@ pub(crate) struct LearnedAbilities {
/// `[0xb700e8]` — our known `SPELL_EFFECT_SKIN_PLAYER_CORPSE` spell (the PvP insignia). Kept
/// for symmetry with the reference's pair; the insignia arm of the cursor isn't modelled yet.
pub(crate) skin_player_corpse: Option<u32>,
/// `[0xcecad8]` — our known `SPELL_EFFECT_FEED_PET` spell (Feed Pet 6991), `None` if we never
/// learned one. The reference latches it the same way, at learn time: `0x6ea1d0` (←
/// `0x5e9e49`) stores the spell whose `Effect[0] == 0x65`. It is the **third gate** on
/// [`targeting::drop_item_on_unit`]'s pet leg, and the spell that leg casts — so, like
/// `skinning`, one lookup here rather than a second scan at the drop.
pub(crate) feed_pet: Option<u32>,
}
/// `SpellEffects` value `0x74` — `SPELL_EFFECT_SKIN_PLAYER_CORPSE` (the "Remove Insignia" family),
/// the second of the two effects `0x4b25e0` latches.
const SPELL_EFFECT_SKIN_PLAYER_CORPSE: u32 = 0x74;
/// `SpellEffects` value `0x65` (101) — `SPELL_EFFECT_FEED_PET`, the effect the reference tests at
/// learn time to latch `[0xcecad8]` (wow-re `ui/scratch/item-target-cursor-and-dropitemonunit.md`).
/// Feed Pet 6991 is the only shipped row carrying it.
const SPELL_EFFECT_FEED_PET: u32 = 0x65;
/// Re-derive [`LearnedAbilities`] whenever the spell book changes — our stand-in for the
/// reference's learn/unlearn write sites. Change-detected, so it is a no-op on almost every frame;
/// rescanning the book beats threading a hook through every spell-arrival path, and it cannot
@@ -221,14 +233,22 @@ fn track_learned_abilities(
.copied()
.find(|&id| spells.catalog.get(id).is_some_and(|d| d.effect_1 == effect))
};
let (skinning, skin_player_corpse) = (
let (skinning, skin_player_corpse, feed_pet) = (
first_with(benilla_formats::SPELL_EFFECT_SKINNING),
first_with(SPELL_EFFECT_SKIN_PLAYER_CORPSE),
first_with(SPELL_EFFECT_FEED_PET),
);
if (skinning, skin_player_corpse) != (learned.skinning, learned.skin_player_corpse) {
if (skinning, skin_player_corpse, feed_pet)
!= (
learned.skinning,
learned.skin_player_corpse,
learned.feed_pet,
)
{
*learned = LearnedAbilities {
skinning,
skin_player_corpse,
feed_pet,
};
}
}
+232 -2
View File
@@ -97,12 +97,87 @@ pub(super) fn cast_range_refusal(
/// test at `0x609c6f`, the exact vmangos `SPELL_ATTR_ALLOW_WHILE_MOUNTED` mirror). A spell
/// with no loaded record has no exemption to claim — the gate holds (the ref always has the
/// record; refusing without data errs toward the ref's visible behavior). The sibling
/// mount-REQUIRED gate (reason 0x53 off `SpellRec+0x5c & 0x40`, `0x609c05`) is recorded but
/// unbuilt — no 1.12 player spell exercises it.
/// mount-REQUIRED gate (reason 0x53, `0x609c05`) is recorded but unbuilt — no 1.12 player spell
/// exercises it. It is **two-armed** like the water pair below, not the single `+0x5c & 0x40`
/// read this comment used to claim: arm A is `AuraInterruptFlags & 0x40` (the `cl` at
/// `0x609c05` is the untouched low byte of `ecx = [esi+0x58]`, loaded 0x104 bytes earlier for
/// the unsheathed leg), arm B is `ChannelInterruptFlags & 0x40` at `0x609c3a` — corrected by
/// the 1063 §5 in wow-re `mounted-action-gate.md`. Note the plain mounted gate above is NOT in
/// that family: it is single-armed on `Attributes`.
pub(super) fn cast_mounted_refusal(mounted: bool, spell: Option<&SpellDisplay>) -> bool {
mounted && spell.is_none_or(|d| d.attributes & 0x0100_0000 == 0)
}
/// The interrupt-flag water pair — the two bits that say *which side of the surface this aura
/// can live on*: `0x80` cancels it on ENTERING water, `0x100` on LEAVING it (vmangos
/// `AURA_INTERRUPT_UNDER_WATER_CANCELS` / `AURA_INTERRUPT_ABOVE_WATER_CANCELS`, bits 7/8). The
/// requirement validator reads the same pair to refuse the *cast* whose aura could not survive
/// the caster's current side.
const INTERRUPT_UNDER_WATER: u32 = 0x80;
const INTERRUPT_ABOVE_WATER: u32 = 0x100;
/// `AttributesEx & (IS_CHANNELED 0x4 | IS_SELF_CHANNELED 0x40)` — the gate on **arm B** of every
/// leg in this family (byte-verified `0x609d6a`/`0x609db1`). A channeled spell's requirement
/// bits live in its CHANNEL column, so the validator reads that column too — but only for a
/// spell that actually channels, which is what keeps Summon Baby Shark 25849 (`Channel 0x100`,
/// `AttributesEx 0`) out of the gate.
const ATTR_EX_CHANNELED: u32 = 0x44;
/// `SPELL_FAILED_ONLY_ABOVEWATER` — "Cannot use while swimming".
pub(super) const ERR_ONLY_ABOVEWATER: u8 = 0x50;
/// `SPELL_FAILED_ONLY_UNDERWATER` — "Can only use while swimming".
pub(super) const ERR_ONLY_UNDERWATER: u8 = 0x58;
/// The **pre-send** water refusal (decisions 1056 + 1063) — the requirement validator
/// `0x6094f0`'s environment block `0x609d33–0x609de2`, byte-carved by the wow-re §5 trio
/// (`system/spell/scratch/water-cast-gate.md`). It sits after the mounted/posture/day/night
/// legs and before the moving gate (`0x609de3`), which is where the ladder runs it — so a druid
/// standing on land is refused **before** the form gate `0x612480` ever evaluates.
///
/// One gate, two faces, and each face has **two arms** reading the same bit in two columns:
///
/// | reason | bit | arm A — `AuraInterruptFlags` (+0x58) | arm B — `ChannelInterruptFlags` (+0x5c), if channeled |
/// |---|---|---|---|
/// | [`ERR_ONLY_UNDERWATER`] `0x58` | `0x100` | `0x609d36` / swim `0x609d46` | `0x609d6f` / swim `0x609d7b` |
/// | [`ERR_ONLY_ABOVEWATER`] `0x50` | `0x80` | `0x609da4` / swim `0x609dac` | `0x609db5` / swim `0x609dc2` |
///
/// The swimming state is `[[caster+0x118]+0x40] & 0x200000` — the same wire-layout movement word
/// the moving gate reads, four times over, twice in each face.
///
/// **Arm B is not dead code, and leaving it out is visibly wrong**: Fishing rank 1 (7620)
/// carries `AuraInterruptFlags 0x80`, but ranks 2–4 (7731/7732/18248) carry **zero** and reach
/// the gate only through their `ChannelInterruptFlags 0x3cac`. Arm A alone would refuse rank 1
/// mid-swim and let its own upgrades through.
///
/// What the two bits actually select in the 5875 data: `0x80` is 245 rows — every mount, Travel
/// Form, the campfires, all of Food/Drink (`0x40080`), Fishing — and `0x100` is exactly five:
/// Aquatic Form 1066, the Lava/Slime swim auras 16455/16456, Master Angler 24346/24347.
///
/// **No exemption skips this block** (every jump into the run was enumerated) — unlike the
/// mounted gate's `Attributes` bit 24. And the gate must be LOCAL: vmangos's `CheckCast` gates
/// water only on `SPELL_AURA_MOUNTED` (`Spell.cpp:6379`), so it happily grants a druid aquatic
/// form on dry cobblestone (ledger B176, with the screenshot to prove it). An uncataloged spell
/// passes, like every other data-driven rung on this ladder.
///
/// The legs are evaluated `0x58` before `0x50`, the binary's own order; they are mutually
/// exclusive on the swim bit, so the order is fidelity, not behaviour.
pub(super) fn cast_water_refusal(move_flags_word: u32, spell: Option<&SpellDisplay>) -> Option<u8> {
let d = spell?;
let swimming = move_flags_word & crate::creature_anim::move_flags::SWIMMING != 0;
// Arm A always; arm B only for a spell that actually channels.
let requires = |bit: u32| {
d.aura_interrupt_flags & bit != 0
|| (d.attributes_ex & ATTR_EX_CHANNELED != 0 && d.channel_interrupt_flags & bit != 0)
};
if !swimming && requires(INTERRUPT_ABOVE_WATER) {
return Some(ERR_ONLY_UNDERWATER);
}
if swimming && requires(INTERRUPT_UNDER_WATER) {
return Some(ERR_ONLY_ABOVEWATER);
}
None
}
/// The AuraInterruptFlags-space MOVING|TURNING pair (`0x18`) — the moving gate's
/// "movement would matter anyway" arms test it on BOTH `AuraInterruptFlags` (+0x58) and
/// `ChannelInterruptFlags` (+0x5c), byte-verified at `0x609e0e`/`0x609e1c`.
@@ -714,6 +789,161 @@ mod tests {
assert!(cast_mounted_refusal(true, None), "no record, no exemption");
}
/// The water refusal (`0x609d33–0x609de2`) — both faces of the environment gate and both
/// arms of each face, on the real 5875 columns: Aquatic Form's `0x100` needs the SWIMMING
/// bit set, the mount/Travel-Form/food `0x80` needs it clear, Fishing's ranks reach it
/// through the CHANNEL column, and a spell carrying neither bit passes on both sides.
#[test]
fn cast_water_refusal_reads_both_sides_of_the_surface() {
use crate::creature_anim::move_flags as mf;
// Aquatic Form 1066: AuraInterruptFlags 0x100 — above-water cancels it, so the cast
// needs water. This is ledger B176: on land it must refuse, and it must not send.
let aquatic = SpellDisplay {
aura_interrupt_flags: 0x100,
..Default::default()
};
assert_eq!(
cast_water_refusal(0, Some(&aquatic)),
Some(ERR_ONLY_UNDERWATER)
);
assert_eq!(cast_water_refusal(mf::SWIMMING, Some(&aquatic)), None);
// Travel Form 783 / every mount: 0x80 — entering water cancels it, so it refuses the
// other way round.
let travel = SpellDisplay {
aura_interrupt_flags: 0x80,
..Default::default()
};
assert_eq!(
cast_water_refusal(mf::SWIMMING, Some(&travel)),
Some(ERR_ONLY_ABOVEWATER)
);
assert_eq!(cast_water_refusal(0, Some(&travel)), None);
// Food 433's shape (0x40080 = STANDING_CANCELS | UNDER_WATER_CANCELS) — the eating half
// of ledger B155 rides the same 0x80 arm.
let food = SpellDisplay {
aura_interrupt_flags: 0x4_0080,
..Default::default()
};
assert_eq!(
cast_water_refusal(mf::SWIMMING, Some(&food)),
Some(ERR_ONLY_ABOVEWATER)
);
assert_eq!(cast_water_refusal(0, Some(&food)), None);
// ARM B (decision 1063): a CHANNELED spell's requirement bits live in its channel
// column. Fishing ranks 2–4's shape — AuraInterruptFlags zero, ChannelInterruptFlags
// 0x3cac (which carries 0x80), AttributesEx 0x21004004 (IS_CHANNELED).
let fishing_r2 = SpellDisplay {
aura_interrupt_flags: 0,
channel_interrupt_flags: 0x3cac,
attributes_ex: 0x2100_4004,
..Default::default()
};
assert_eq!(
cast_water_refusal(mf::SWIMMING, Some(&fishing_r2)),
Some(ERR_ONLY_ABOVEWATER),
"arm A is empty here — only the channel column refuses it"
);
assert_eq!(cast_water_refusal(0, Some(&fishing_r2)), None);
// …and the `AttributesEx & 0x44` gate on arm B is load-bearing: Summon Baby Shark
// 25849's shape carries the channel bit but does not channel, so it is NOT gated.
let not_channeled = SpellDisplay {
channel_interrupt_flags: 0x100,
attributes_ex: 0,
..Default::default()
};
assert_eq!(cast_water_refusal(0, Some(&not_channeled)), None);
assert_eq!(cast_water_refusal(mf::SWIMMING, Some(&not_channeled)), None);
// Cat Form 768 / Bear Form 5487 carry neither bit: usable on both sides, always.
let cat = SpellDisplay::default();
assert_eq!(cast_water_refusal(0, Some(&cat)), None);
assert_eq!(cast_water_refusal(mf::SWIMMING, Some(&cat)), None);
// No record, nothing to read — the press passes, like every other data-driven rung.
assert_eq!(cast_water_refusal(0, None), None);
assert_eq!(cast_water_refusal(mf::SWIMMING, None), None);
}
/// The water gate against the **real 5875 Spell.dbc** — the census the whole gate rests on.
/// The leg↔bit assignment is not a naming choice we could get backwards: exactly five rows
/// in the shipped data carry the water-REQUIRED bit, and Aquatic Form is one of them, while
/// the water-FORBIDDEN bit is a broad set led by the mounts, Travel Form and food/drink.
/// Skips without client data.
#[test]
fn the_water_bits_split_the_5875_data_the_way_the_gate_assumes() {
let data = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data");
if !data.is_dir() {
eprintln!("skipping: vanilla client not present at {}", data.display());
return;
}
let mut chain = benilla_formats::open_chain(&data).expect("open chain");
let catalog = benilla_formats::load_spell_catalog(&mut chain).expect("Spell.dbc");
// Every spell the client would refuse OUT of water, by id.
let mut needs_water: Vec<u32> = catalog
.iter()
.filter(|(_, d)| d.aura_interrupt_flags & INTERRUPT_ABOVE_WATER != 0)
.map(|(id, _)| id)
.collect();
needs_water.sort_unstable();
assert_eq!(
needs_water,
vec![1066, 16455, 16456, 24346, 24347],
"the water-required set: Aquatic Form, the Lava/Slime swim auras, Master Angler"
);
// The other face: broad, and led by exactly the things you cannot do mid-swim.
let forbids_water = |id: u32| {
catalog
.get(id)
.is_some_and(|d| d.aura_interrupt_flags & INTERRUPT_UNDER_WATER != 0)
};
assert!(forbids_water(783), "Travel Form");
assert!(forbids_water(458), "Brown Horse");
assert!(forbids_water(433), "Food");
assert!(forbids_water(430), "Drink");
assert!(forbids_water(818), "Basic Campfire");
// ARM B's reason to exist, on the real rows (decision 1063). Fishing rank 1 carries the
// bit in the AURA column; its own upgrades carry NOTHING there and reach the gate only
// through the CHANNEL column. Arm A alone would refuse rank 1 mid-swim and let 2–4 fish.
let swim = crate::creature_anim::move_flags::SWIMMING;
assert!(forbids_water(7620), "Fishing rank 1 — arm A");
for rank in [7731, 7732, 18248] {
let d = catalog
.get(rank)
.unwrap_or_else(|| panic!("Fishing {rank} missing"));
assert_eq!(d.aura_interrupt_flags, 0, "Fishing {rank}: arm A is empty");
assert!(d.attributes_ex & ATTR_EX_CHANNELED != 0, "and it channels");
assert_eq!(
cast_water_refusal(swim, Some(d)),
Some(ERR_ONLY_ABOVEWATER),
"Fishing {rank} still refuses mid-swim, through the channel column"
);
assert_eq!(cast_water_refusal(0, Some(d)), None, "and fishes on shore");
}
// The forms that carry neither bit — usable on both sides, and the control that says the
// gate is reading a real per-spell column and not a class-wide accident.
for (id, name) in [(768, "Cat Form"), (5487, "Bear Form"), (2645, "Ghost Wolf")] {
let d = catalog.get(id).unwrap_or_else(|| panic!("{name} missing"));
assert_eq!(d.aura_interrupt_flags & 0x180, 0, "{name} is side-agnostic");
assert_eq!(cast_water_refusal(0, Some(d)), None, "{name} on land");
assert_eq!(cast_water_refusal(swim, Some(d)), None, "{name} swimming");
}
// And end to end, on the real rows: B176's exact press.
let aquatic = catalog.get(1066).expect("Aquatic Form");
assert_eq!(
cast_water_refusal(0, Some(aquatic)),
Some(ERR_ONLY_UNDERWATER),
"on land, Aquatic Form refuses — the B176 report"
);
assert_eq!(
cast_water_refusal(crate::creature_anim::move_flags::SWIMMING, Some(aquatic)),
None,
"swimming, it goes through"
);
}
/// The moving refusal (`0x609de3`) — every leg of the byte-verified condition: the
/// `InterruptFlags & 0x1` entry, the `0x200f` wire mask (turn/FALLINGFAR outside it,
/// JUMPING inside), the auto-repeat exemption, and the "would movement matter" arms (cast
@@ -69,10 +69,14 @@ pub(crate) fn ground_cast_radius(spells: Option<&Spells>, spell_id: u32, level:
}
/// While targeting, the world cursor is the classifier's pre-empt (cursor-system §5). Runs right
/// after [`crate::target`]'s classifier in the target chain and overwrites its verdict. Because it
/// writes the *base* [`WorldCursor`], it also pre-empts every UI overlay downstream
/// ([`crate::cursor`]'s repair/sell latches only arm while the base is Point) — the same total
/// pre-emption the reference's step 2 has.
/// after [`crate::target`]'s classifier in the target chain and overwrites its verdict.
///
/// **That pre-emption is the WORLD's, and only the world's** (decisions 1055, 1061). This computes
/// the world's verdict every frame — [`crate::target::reticle`] reads `WorldCursor.unable` as the
/// AoE ring's colour, so it must stay live even while the mouse is parked on a bag. What the
/// reference gates is the *display*: its hover handler `0x481790` runs only while the WorldFrame is
/// the frame manager's mouse-focus frame, so over a UI frame the cursor simply keeps its last
/// value. That gate lives in [`crate::cursor`], over the one sticky mode.
///
/// **The verdict is per-seam, and the default is grey** (decision 0949). The reference reaches a
/// cursor through the pick, and while targeting the pick flags come from the word alone
@@ -106,6 +106,12 @@ pub(crate) enum TargetingWants {
GameObject,
}
/// The unit-shaped bits of the flag_word — what a UNIT candidate could ever satisfy, and so what
/// `SpellCanTargetUnit` answers for. None of them can appear in a word that reaches the targeting
/// cursor today (the resolver binds or refuses a unit word before it gets here), which is exactly
/// why this is a mask test rather than a `false`.
const UNIT_WORD_BITS: u16 = 0x0002 | 0x0004 | 0x0008 | 0x0080 | 0x0100 | 0x0200 | 0x0400 | 0x8000;
impl TargetingWants {
/// The seam's own mask test, verbatim.
fn matches(self, word: u16) -> bool {
@@ -246,6 +252,15 @@ pub(crate) fn feed_targeting_to_vm(
if let Some(mut script) = script {
script.set_spell_targeting(targeting.active());
script.set_item_pick_armed(targeting.wants(TargetingWants::Item));
// `SpellCanTargetUnit`'s answer (`0x6e6460`'s unit leg). Derived from the word rather than
// hardcoded false: none of the three seams benilla models can be satisfied by a unit, so it
// is false today — and it becomes right on its own if the residual unit-word machine lands.
script.set_spell_can_target_unit(
targeting
.0
.as_ref()
.is_some_and(|t| t.word & UNIT_WORD_BITS != 0),
);
if *last != targeting.spell() {
*last = targeting.spell();
script.fire_event("CURRENT_SPELL_CAST_CHANGED", vec![]);
+17
View File
@@ -236,6 +236,16 @@ impl QueuedMeleeSpell {
/// in `0xcecaa8`. `AbortCast` cancels the cast and pops the strike back up — so the cast dies on
/// the first Esc and the strike on the second. Reading `PendingCast` first reproduces that
/// without our needing the push/pop pair.
///
/// One knowing divergence there, pinned by wow-re's `spell/scratch/esc-queued-strike.md` §Q(c)
/// and weighed in decision 1058: the ref's pop *reads* `0xcecaa8` and never writes it, so a
/// second Esc re-asserts the same strike locally and only the server echo converges it. We have
/// no separate save slot to go stale, so ours clears. The two agree everywhere it shows: in the
/// un-nested case the ref's pop reads a zero save and clears too, and in the nested case vmangos
/// drops the melee slot on press **1** regardless (`HandleCancelCastOpcode` is spellId-blind
/// about `CURRENT_MELEE_SPELL`), so both clients converge off that echo. Reproducing the
/// re-assert would mean modelling `0xcecaa8` as a third slot to hold a value that is stale by
/// construction, for a window one localhost round trip wide.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum Inflight {
/// An ordinary cast — ours to send-guard, to paint a bar for, and to reap.
@@ -1137,6 +1147,13 @@ mod tests {
/// pushes it down (`PushPopNestedCast 0x6e4ad0`), so `0xceca88` holds the CAST. One Esc
/// takes the cast; `AbortCast`'s pop restores the strike, and the next Esc takes that.
/// One press, one thing — the ladder's law all the way down.
///
/// This is the **local** law, which is all a harness with no server can show, and it is
/// the ref's local law too (wow-re `esc-queued-strike.md` §Q(c)) — with the one knowing
/// difference `Inflight`'s doc names: the ref's press 2 re-asserts the strike instead of
/// clearing it. On a live wire neither client gets this far, because vmangos drops the
/// melee slot on press **1** regardless of the id in the packet, and both converge off
/// that echo. So what this pins is the ordering, not a two-press ritual a player sees.
#[test]
fn esc_takes_the_cast_first_and_the_strike_on_the_next_press() {
let (mut app, rx) = harness(HashMap::new());
+168 -124
View File
@@ -5,11 +5,17 @@
//!
//! Three jobs, each frame, before the VM ticks ([`UiInput`]):
//!
//! - **[`PlayerCombatStats`]** from the self player's [`ObjectStore`] — the stat/resistance/
//! damage/attack-power fields (all OWNER_ONLY, verified streamed in 0208) plus the two resolved
//! weapon-skill pairs: the app picks WHICH skill line the equipped main-hand/ranged item uses
//! (`weapon_subclass_skill` — vmangos `Item.cpp`'s table; no weapon = unarmed 162) and reads its
//! `PLAYER_SKILL_INFO` triplet, so the engine-free binding just serves the pair.
//! - **[`UnitCombatStats`]** from the self player's [`ObjectStore`] — the stat/resistance/
//! damage/attack-power fields (all OWNER_ONLY, verified streamed in 0208) plus the three resolved
//! skill pairs: the app picks WHICH skill line the equipped main-hand/ranged item uses
//! (`weapon_subclass_skill` — vmangos `Item.cpp`'s table; no weapon = unarmed 162), reads its
//! `PLAYER_SKILL_INFO` triplet and the Defense line's ([`SKILL_DEFENSE`]), so the engine-free
//! bindings just serve the pairs.
//!
//! The descriptor half is [`unit_combat_stats`], which any unit's store goes through — the pet
//! paper doll's feed ([`crate::ui_pet_doll`], decision 1057) is the second caller. What
//! [`combat_stats`] adds on top is exactly the player-only half: the four values derived from the
//! equipped items, and the skill pairs, neither of which a creature has.
//! - **`InventorySlots`** from the inv-slot guids → [`Items`] objects → templates → the
//! [`ItemDisplays`] icon — index 0 the ammo slot (`PLAYER_AMMO_ID`, its count summed across the
//! backpack + the four equipped bags), 1..=19 the equipment slots, 20..=23 the four equipped-bag
@@ -28,15 +34,16 @@
//! 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`,
//! `UNIT_ATTACK_SPEED`, `UNIT_ATTACK_POWER`, `UNIT_RANGED_ATTACK_POWER`, `UNIT_RANGEDDAMAGE`,
//! `UNIT_ATTACK` (the weapon-skill pairs), and `UNIT_INVENTORY_CHANGED` on any slot-view change.
//! A first snapshot counts as a transition of every group (the `ui_unit` rule).
//! `UNIT_ATTACK` (the weapon-skill pairs) — [`fire_stat_transitions`], shared with the pet feed —
//! and `UNIT_INVENTORY_CHANGED` on any slot-view change. A first snapshot counts as a transition of
//! every group (the `ui_unit` rule).
use bevy::prelude::*;
use benilla_protocol::messages::PLAYER_SKILL_SLOTS;
use benilla_ui::script::{
weapon_subclass_skill, InvSlotView, InventorySlots, PlayerCombatStats, ScriptValue, SkillEntry,
SkillsState, UiScript, EQUIPMENT_BAG, SKILL_UNARMED,
weapon_subclass_skill, InvSlotView, InventorySlots, ScriptValue, SkillEntry, SkillsState,
UiScript, UnitCombatStats, EQUIPMENT_BAG, SKILL_DEFENSE, SKILL_UNARMED,
};
use crate::entities::ItemDisplays;
@@ -67,7 +74,7 @@ fn fixed(v: f32) -> i64 {
/// snapshots pushed, so events fire on transitions only.
#[derive(Resource, Default)]
struct CharFeedState {
last_stats: Option<PlayerCombatStats>,
last_stats: Option<UnitCombatStats>,
last_inv: Option<InventorySlots>,
}
@@ -306,13 +313,15 @@ fn skill_pair(store: &ObjectStore, skill_id: u32) -> (u32, i32) {
(0, 0)
}
/// Build the combat-stats snapshot from the self player's descriptor (field mappings pinned in
/// decision 0208; the absent shapes are the descriptor's zero defaults, `percent` 1.0).
fn combat_stats(
store: &ObjectStore,
items: &mut Items,
commands: &NetCommands,
) -> PlayerCombatStats {
/// Build the **descriptor-only** combat-stats snapshot for ANY unit's store (field mappings pinned
/// in decision 0208; the absent shapes are the descriptor's zero defaults, `percent` 1.0).
///
/// Nothing here reads inventory, templates or skill lines — so it is exactly the part a pet can go
/// through ([`crate::ui_pet_doll`], decision 1057). The `player_*` reads are deliberately left in:
/// a creature has no PLAYER block, so they read absent and fall to their defaults, which is the
/// right pet answer (no buff decomposition, `damage_percent` the divide-safe 1.0) rather than a
/// second unit-only copy of the same twenty lines.
pub(crate) fn unit_combat_stats(store: &ObjectStore) -> UnitCombatStats {
let f = &store.0;
let round = |v: Option<f32>| v.unwrap_or(0.0).round() as i32;
@@ -336,23 +345,7 @@ fn combat_stats(
let (ap_pos, ap_neg) = f.unit_attack_power_mods();
let (rap_pos, rap_neg) = f.unit_ranged_attack_power_mods();
// The offhand-speed gate is an offhand *weapon* (a shield doesn't swing); the wand check is
// the ranged item's subclass. Both read the equipped item's template (ask-once in flight →
// false this frame, refined when it lands).
let has_offhand = slot_entry(store, items, SLOT_OFF_HAND)
.and_then(|e| items.template(e, 0, commands))
.is_some_and(|t| t.class == ITEM_CLASS_WEAPON);
let has_wand = slot_entry(store, items, SLOT_RANGED)
.and_then(|e| items.template(e, 0, commands))
.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 ranged_skill_id = slot_entry(store, items, SLOT_RANGED)
.and_then(|e| items.template(e, 0, commands))
.filter(|t| t.class == ITEM_CLASS_WEAPON)
.and_then(|t| weapon_subclass_skill(t.subclass));
PlayerCombatStats {
UnitCombatStats {
stats,
stat_pos,
stat_neg,
@@ -370,7 +363,6 @@ fn combat_stats(
// damage/speed division finite on the first frames.
main_attack_time_ms: f.unit_base_attack_time(0).unwrap_or(2000),
offhand_attack_time_ms: f.unit_base_attack_time(1).unwrap_or(2000),
has_offhand,
attack_power: f.unit_attack_power().unwrap_or(0),
attack_power_pos: i32::from(ap_pos),
attack_power_neg: i32::from(ap_neg),
@@ -380,9 +372,45 @@ fn combat_stats(
ranged_attack_time_ms: f.unit_ranged_attack_time().unwrap_or(2000),
ranged_min_damage: f.unit_min_ranged_damage().unwrap_or(0.0),
ranged_max_damage: f.unit_max_ranged_damage().unwrap_or(0.0),
// The equipment- and skill-derived half is the PLAYER feed's ([`combat_stats`]); a unit
// with neither keeps these defaults.
has_offhand: false,
has_wand: false,
main_weapon_skill: (0, 0),
ranged_weapon_skill: (0, 0),
defense_skill: (0, 0),
}
}
/// The player's snapshot: [`unit_combat_stats`] plus the half only *we* have — the two values that
/// need the equipped items' templates, and the three `PLAYER_SKILL_INFO` pairs.
fn combat_stats(store: &ObjectStore, items: &mut Items, commands: &NetCommands) -> UnitCombatStats {
// The offhand-speed gate is an offhand *weapon* (a shield doesn't swing); the wand check is
// the ranged item's subclass. Both read the equipped item's template (ask-once in flight →
// false this frame, refined when it lands).
let has_offhand = slot_entry(store, items, SLOT_OFF_HAND)
.and_then(|e| items.template(e, 0, commands))
.is_some_and(|t| t.class == ITEM_CLASS_WEAPON);
let has_wand = slot_entry(store, items, SLOT_RANGED)
.and_then(|e| items.template(e, 0, commands))
.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 ranged_skill_id = slot_entry(store, items, SLOT_RANGED)
.and_then(|e| items.template(e, 0, commands))
.filter(|t| t.class == ITEM_CLASS_WEAPON)
.and_then(|t| weapon_subclass_skill(t.subclass));
UnitCombatStats {
has_offhand,
has_wand,
main_weapon_skill: skill_pair(store, main_skill),
ranged_weapon_skill: ranged_skill_id.map_or((0, 0), |id| skill_pair(store, id)),
has_wand,
// `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
// `UNIT_DEFENSE` — the character sheet never registers one.
defense_skill: skill_pair(store, SKILL_DEFENSE),
..unit_combat_stats(store)
}
}
@@ -604,6 +632,109 @@ fn inventory_slots(
inv
}
/// Fire the paper doll's per-group repaint events for one unit's snapshot transition, `arg1 =
/// token` — the [`crate::ui_unit::fire_transitions`] shape, for the combat-stats surface.
///
/// The groups ARE the ref's own event registrations (`PaperDollFrame.lua:14-28`, which
/// `PetPaperDollFrame.lua:12-20` repeats verbatim for the pet page), so each event fires exactly
/// when a value some handler of it reads has moved; `prev == None` (a first snapshot) counts as a
/// transition of every group, the `ui_unit` rule.
///
/// Shared by both feeds rather than transcribed twice (decision 1057): the pet page calls the same
/// `PaperDollFrame_Set*` helpers off the same events, so two copies of this grouping could only
/// ever drift into one page repainting on an edge the other misses.
pub(crate) fn fire_stat_transitions(
script: &mut UiScript,
token: &str,
prev: Option<&UnitCombatStats>,
stats: &UnitCombatStats,
) {
let tok = || ScriptValue::Str(token.to_string());
let changed = |sel: fn(&UnitCombatStats) -> Vec<i64>| prev.is_none_or(|p| sel(p) != sel(stats));
if changed(|s| {
let mut v: Vec<i64> = s.stats.iter().map(|&x| i64::from(x)).collect();
v.extend(
s.stat_pos
.iter()
.chain(s.stat_neg.iter())
.map(|&x| i64::from(x)),
);
v
}) {
script.fire_event("UNIT_STATS", vec![tok()]);
}
if changed(|s| {
s.resistances
.iter()
.chain(s.resistance_pos.iter())
.chain(s.resistance_neg.iter())
.map(|&x| i64::from(x))
.collect()
}) {
script.fire_event("UNIT_RESISTANCES", vec![tok()]);
}
if changed(|s| {
vec![
fixed(s.min_damage),
fixed(s.max_damage),
fixed(s.min_offhand_damage),
fixed(s.max_offhand_damage),
i64::from(s.physical_bonus_pos),
i64::from(s.physical_bonus_neg),
fixed(s.damage_percent),
]
}) {
script.fire_event("UNIT_DAMAGE", vec![tok()]);
}
if changed(|s| {
vec![
i64::from(s.main_attack_time_ms),
i64::from(s.offhand_attack_time_ms),
i64::from(s.has_offhand),
]
}) {
script.fire_event("UNIT_ATTACK_SPEED", vec![tok()]);
}
if changed(|s| {
vec![
i64::from(s.attack_power),
i64::from(s.attack_power_pos),
i64::from(s.attack_power_neg),
]
}) {
script.fire_event("UNIT_ATTACK_POWER", vec![tok()]);
}
if changed(|s| {
vec![
i64::from(s.ranged_attack_power),
i64::from(s.ranged_attack_power_pos),
i64::from(s.ranged_attack_power_neg),
i64::from(s.has_wand),
]
}) {
script.fire_event("UNIT_RANGED_ATTACK_POWER", vec![tok()]);
}
if changed(|s| {
vec![
fixed(s.ranged_min_damage),
fixed(s.ranged_max_damage),
i64::from(s.ranged_attack_time_ms),
]
}) {
script.fire_event("UNIT_RANGEDDAMAGE", vec![tok()]);
}
if changed(|s| {
vec![
i64::from(s.main_weapon_skill.0),
i64::from(s.main_weapon_skill.1),
i64::from(s.ranged_weapon_skill.0),
i64::from(s.ranged_weapon_skill.1),
]
}) {
script.fire_event("UNIT_ATTACK", vec![tok()]);
}
}
#[allow(clippy::too_many_arguments)]
fn feed_char(
script: Option<NonSendMut<UiScript>>,
@@ -641,94 +772,7 @@ fn feed_char(
// paints the OLD values and, being transition-gated, never corrects itself).
let prev = feed.last_stats.take();
script.set_player_combat_stats(Some(stats.clone()));
let tok = || ScriptValue::Str("player".to_string());
// Group the transitions by the ref's own event registrations (PaperDollFrame.lua:14-28);
// a first snapshot fires every group whose data is present (the ui_unit rule).
let changed = |sel: fn(&PlayerCombatStats) -> Vec<i64>| {
prev.as_ref().is_none_or(|p| sel(p) != sel(&stats))
};
if changed(|s| {
let mut v: Vec<i64> = s.stats.iter().map(|&x| i64::from(x)).collect();
v.extend(
s.stat_pos
.iter()
.chain(s.stat_neg.iter())
.map(|&x| i64::from(x)),
);
v
}) {
script.fire_event("UNIT_STATS", vec![tok()]);
}
if changed(|s| {
s.resistances
.iter()
.chain(s.resistance_pos.iter())
.chain(s.resistance_neg.iter())
.map(|&x| i64::from(x))
.collect()
}) {
script.fire_event("UNIT_RESISTANCES", vec![tok()]);
}
if changed(|s| {
vec![
fixed(s.min_damage),
fixed(s.max_damage),
fixed(s.min_offhand_damage),
fixed(s.max_offhand_damage),
i64::from(s.physical_bonus_pos),
i64::from(s.physical_bonus_neg),
fixed(s.damage_percent),
]
}) {
script.fire_event("UNIT_DAMAGE", vec![tok()]);
}
if changed(|s| {
vec![
i64::from(s.main_attack_time_ms),
i64::from(s.offhand_attack_time_ms),
i64::from(s.has_offhand),
]
}) {
script.fire_event("UNIT_ATTACK_SPEED", vec![tok()]);
}
if changed(|s| {
vec![
i64::from(s.attack_power),
i64::from(s.attack_power_pos),
i64::from(s.attack_power_neg),
]
}) {
script.fire_event("UNIT_ATTACK_POWER", vec![tok()]);
}
if changed(|s| {
vec![
i64::from(s.ranged_attack_power),
i64::from(s.ranged_attack_power_pos),
i64::from(s.ranged_attack_power_neg),
i64::from(s.has_wand),
]
}) {
script.fire_event("UNIT_RANGED_ATTACK_POWER", vec![tok()]);
}
if changed(|s| {
vec![
fixed(s.ranged_min_damage),
fixed(s.ranged_max_damage),
i64::from(s.ranged_attack_time_ms),
]
}) {
script.fire_event("UNIT_RANGEDDAMAGE", vec![tok()]);
}
if changed(|s| {
vec![
i64::from(s.main_weapon_skill.0),
i64::from(s.main_weapon_skill.1),
i64::from(s.ranged_weapon_skill.0),
i64::from(s.ranged_weapon_skill.1),
]
}) {
script.fire_event("UNIT_ATTACK", vec![tok()]);
}
fire_stat_transitions(&mut script, "player", prev.as_ref(), &stats);
feed.last_stats = Some(stats);
}
+350
View File
@@ -0,0 +1,350 @@
//! The app-side **dressing-room feed** (decision 1060): the bridge between the window's intents
//! (`BenillaDressUpModel_Dress/TryOn/Close`, [`DressUpIntent`]) and the booth's look
//! ([`DressUpPreview`]).
//!
//! Three jobs, each frame, before the VM ticks ([`UiInput`]):
//!
//! - **Apply the intents, in order.** `Dress` drops every substitution (the ref's
//! `SetUnit("player")` on open and `Dress()` on Reset); `TryOn(id)` records one; `Close` empties
//! the room. Order matters — `DressUpItem` resets *then* tries on in the same breath.
//! - **Resolve each tried-on item to a display.** An item id is all a `|Hitem:` link carries, so the
//! display id / inventory type come from the ask-once template cache
//! ([`Items::template`] — `CMSG_ITEM_QUERY_SINGLE` on a miss, exactly as the reference's
//! ItemCache does). A substitution whose answer is still in flight stays **pending** and lands on
//! a later frame: that is the normal case for an item the player has never seen, e.g. one linked
//! in chat by someone else.
//! - **Compose the look.** The player's own body + appearance + their `PLAYER_VISIBLE_ITEM_*`
//! displays, with each resolved substitution written into the slot its `InventoryType` maps to
//! (the shared [`equip_slot`] table — the same one the glue/select preview dresses by, so a
//! robe lands on the chest and a wand in the ranged hand here exactly as it does there).
//!
//! **What is NOT here:** any notion of *fit*. The reference's dressing room previews whatever it is
//! handed — a plate helm on a mage, a mail chest on a rogue — because it is a look, not an equip:
//! `DressUpItemLink` reaches `TryOn` with no class/level/proficiency check anywhere in the path
//! (`DressUpFrame.lua:2-16`). Ours does the same.
use benilla_protocol::CharEnumItem;
use benilla_ui::script::{DressUpIntent, UiScript};
use bevy::prelude::*;
use crate::entities::equip_slot;
use crate::items::Items;
use crate::net::{NetCommands, NetEntity, ObjectStore, SelfPlayer};
use crate::portrait::{DressUpLook, DressUpPreview};
use crate::ui_script::UiInput;
/// The equipment slots a dressing-room look reads off the player — every rendered slot
/// (`EQUIPMENT_SLOT_*`), which is exactly the set [`equip_slot`] can map an item into.
const LOOK_SLOTS: [u8; 14] = [0, 2, 3, 4, 5, 6, 7, 8, 9, 14, 15, 16, 17, 18];
/// What the room is currently showing: the substitutions applied on top of the player's own gear,
/// and the ones still waiting on a template answer.
#[derive(Resource, Default)]
pub(crate) struct DressUpRoom {
/// Open? `false` = the window is closed and the booth stays empty (the `Close` intent).
open: bool,
/// Resolved substitutions by equipment slot — `(display id, inventory type)`, the same pair the
/// enum-shaped array carries.
worn: [Option<CharEnumItem>; 19],
/// Item ids whose template has not answered yet, oldest first. Retried every frame; a
/// substitution only becomes visible once its display id is known.
pending: Vec<u32>,
}
impl DressUpRoom {
/// Apply one intent (see [`DressUpIntent`]).
fn apply(&mut self, intent: DressUpIntent) {
match intent {
DressUpIntent::Dress => {
self.open = true;
self.worn = Default::default();
self.pending.clear();
}
DressUpIntent::TryOn(item) => {
self.open = true;
self.pending.push(item);
}
DressUpIntent::Close => {
self.open = false;
self.worn = Default::default();
self.pending.clear();
}
}
}
/// Resolve whatever is still waiting on a template answer. `Items::template` asks once and
/// answers on a later frame; an id the server never answers for simply stays pending, showing
/// the player's own gear in that slot rather than a hole.
fn resolve_pending(&mut self, items: &mut Items, commands: &NetCommands) {
let mut pending = std::mem::take(&mut self.pending);
pending.retain(|item| {
let Some(t) = items.template(*item, 0, commands) else {
return true; // still in flight — keep asking
};
let (display_id, inventory_type) = (t.display_info_id, t.inventory_type as u8);
if let Some(slot) = equip_slot(inventory_type) {
self.worn[slot] = Some(CharEnumItem {
display_id,
inventory_type,
});
}
// A non-worn item (a potion, a bag) resolves to no slot and simply previews nothing —
// the reference's `TryOn` is equally happy to be handed one.
false
});
self.pending = pending;
}
}
pub(crate) struct DressUpUiPlugin;
impl Plugin for DressUpUiPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<DressUpRoom>()
.add_systems(Update, feed_dressup.in_set(UiInput));
}
}
fn feed_dressup(
script: Option<NonSendMut<UiScript>>,
mut room: ResMut<DressUpRoom>,
mut preview: ResMut<DressUpPreview>,
mut items: ResMut<Items>,
commands: Res<NetCommands>,
self_q: Query<(&ObjectStore, &NetEntity), With<SelfPlayer>>,
) {
let Some(mut script) = script else {
return;
};
for intent in script.take_dressup_intents() {
room.apply(intent);
}
// The pane's rotate buttons own the yaw; the booth mirrors it (the paper doll's own law, 0208 §5).
preview.yaw = script.dressup_yaw();
room.resolve_pending(&mut items, &commands);
let look = match (room.open, self_q.single().ok()) {
(true, Some((store, net))) => player_look(store, net, &room, &mut items, &commands),
_ => None,
};
if preview.look != look {
preview.look = look;
}
}
/// The player's own dressed look with the room's substitutions written in — `None` while the
/// descriptor cannot answer race/sex, or before the body display is known (the frame or two right
/// after entering the world).
fn player_look(
store: &ObjectStore,
net: &NetEntity,
room: &DressUpRoom,
items: &mut Items,
commands: &NetCommands,
) -> Option<DressUpLook> {
let s = &store.0;
let mut equipment = [CharEnumItem::default(); 19];
for slot in LOOK_SLOTS {
let idx = slot as usize;
// A substitution wins over what the player is actually wearing — that IS the preview.
if let Some(worn) = room.worn[idx] {
equipment[idx] = worn;
continue;
}
let Some(entry) = s.player_visible_item_entry(slot).filter(|e| *e != 0) else {
continue;
};
// Template-only ask, like the inspect feed's: the visible-item field carries an item
// ENTRY, and the display id lives on its template.
if let Some(t) = items.template(entry, 0, commands) {
equipment[idx] = CharEnumItem {
display_id: t.display_info_id,
inventory_type: t.inventory_type as u8,
};
}
}
Some(DressUpLook {
display_id: net.display_id?,
race: s.unit_race()?,
sex: s.unit_gender()?,
skin: s.player_skin().unwrap_or(0),
face: s.player_face().unwrap_or(0),
hair_style: s.player_hair_style().unwrap_or(0),
hair_color: s.player_hair_color().unwrap_or(0),
facial_hair: s.player_facial_hair().unwrap_or(0),
equipment,
})
}
#[cfg(test)]
mod tests {
use super::*;
use benilla_protocol::{EntityKind, ItemInfo, ObjectFields};
use crate::items::test_template;
use crate::net::ClientCommand;
/// A self-player descriptor: a human male wearing `entries` (equipment slot → item entry).
/// The raw field indices are the wire's own (`UNIT_FIELD_BYTES_0` 36, `PLAYER_BYTES` 193,
/// `PLAYER_VISIBLE_ITEM_1_CREATOR` 258 + 12 per slot, entry at +2) — the same literal-index
/// idiom the descriptor fixtures elsewhere use, since the constants are crate-private to
/// benilla-protocol.
fn player(entries: &[(u8, u32)]) -> ObjectStore {
let mut pairs = vec![
(36u16, 1 | 1 << 8), // race 1 (human), class 1, gender 0 (male)
(193u16, 3 | 4 << 8), // skin 3, face 4, hair 0, hair colour 0
];
for (slot, entry) in entries {
pairs.push((258 + 2 + 12 * u16::from(*slot), *entry));
}
ObjectStore(ObjectFields::from_pairs(&pairs))
}
fn net() -> NetEntity {
NetEntity {
kind: EntityKind::Player,
display_id: Some(49),
scale: 1.0,
}
}
fn commands() -> (NetCommands, crossbeam_channel::Receiver<ClientCommand>) {
let (tx, rx) = crossbeam_channel::unbounded();
(NetCommands(tx), rx)
}
/// An item template with a known display + inventory type.
fn worn(name: &str, display_info_id: u32, inventory_type: u32) -> ItemInfo {
ItemInfo {
display_info_id,
inventory_type,
..test_template(name)
}
}
/// A try-on replaces exactly its own slot and leaves the rest of the player's gear standing —
/// the whole point of the room: you see YOUR character in the item, not a mannequin.
#[test]
fn a_try_on_substitutes_only_its_own_slot() {
let (cmds, _rx) = commands();
let mut items = Items::default();
// Worn: a chest (slot 4) and a sword in the main hand (slot 15).
items.insert_template(1000, Some(worn("Worn Chest", 5000, 5)));
items.insert_template(1500, Some(worn("Worn Sword", 5500, 21)));
// Tried on: a different chest.
items.insert_template(2000, Some(worn("Shiny Chest", 7000, 5)));
let store = player(&[(4, 1000), (15, 1500)]);
let mut room = DressUpRoom::default();
room.apply(DressUpIntent::Dress);
room.apply(DressUpIntent::TryOn(2000));
room.resolve_pending(&mut items, &cmds);
let look = player_look(&store, &net(), &room, &mut items, &cmds).expect("a look");
assert_eq!(
look.equipment[4].display_id, 7000,
"the tried-on chest shows"
);
assert_eq!(
look.equipment[15].display_id, 5500,
"the sword the player is actually holding is untouched"
);
assert_eq!(look.race, 1);
assert_eq!(look.sex, 0);
assert_eq!((look.skin, look.face), (3, 4));
assert_eq!(look.display_id, 49, "the player's own body");
}
/// Reset (`DressUpModel:Dress()`) drops every substitution — the player's own gear comes back —
/// and closing the window empties the room entirely.
#[test]
fn reset_drops_substitutions_and_close_empties_the_room() {
let (cmds, _rx) = commands();
let mut items = Items::default();
items.insert_template(1000, Some(worn("Worn Chest", 5000, 5)));
items.insert_template(2000, Some(worn("Shiny Chest", 7000, 5)));
let store = player(&[(4, 1000)]);
let mut room = DressUpRoom::default();
room.apply(DressUpIntent::TryOn(2000));
room.resolve_pending(&mut items, &cmds);
assert_eq!(
player_look(&store, &net(), &room, &mut items, &cmds)
.unwrap()
.equipment[4]
.display_id,
7000
);
room.apply(DressUpIntent::Dress);
assert_eq!(
player_look(&store, &net(), &room, &mut items, &cmds)
.unwrap()
.equipment[4]
.display_id,
5000,
"Reset puts the player's own chest back on"
);
room.apply(DressUpIntent::Close);
assert!(!room.open, "closing empties the room (the booth goes dark)");
}
/// An item whose template has not answered yet stays PENDING rather than previewing nothing:
/// linked-in-chat items are the normal case here, and the first click on one always misses the
/// cache. It lands on the frame the answer does — and the ask goes out exactly once.
#[test]
fn an_unknown_item_waits_for_its_template_then_lands() {
let (cmds, rx) = commands();
let mut items = Items::default();
items.insert_template(1000, Some(worn("Worn Chest", 5000, 5)));
let store = player(&[(4, 1000)]);
let mut room = DressUpRoom::default();
room.apply(DressUpIntent::TryOn(2000));
room.resolve_pending(&mut items, &cmds);
assert_eq!(room.pending, vec![2000], "still waiting on the answer");
assert_eq!(
player_look(&store, &net(), &room, &mut items, &cmds)
.unwrap()
.equipment[4]
.display_id,
5000,
"until it lands, the player's own gear is what shows"
);
// Exactly one query for the unknown entry (`Items` asks once).
let asks = rx
.try_iter()
.filter(|c| matches!(c, ClientCommand::ItemQuery { entry: 2000, .. }))
.count();
assert_eq!(asks, 1);
items.insert_template(2000, Some(worn("Shiny Chest", 7000, 5)));
room.resolve_pending(&mut items, &cmds);
assert!(room.pending.is_empty());
assert_eq!(
player_look(&store, &net(), &room, &mut items, &cmds)
.unwrap()
.equipment[4]
.display_id,
7000,
"the answer landing is what makes it show"
);
}
/// A non-worn item (a potion) is handed to `TryOn` by any ctrl-click on one, and previews
/// nothing — it maps to no equipment slot. It must not stay pending forever either.
#[test]
fn a_non_worn_item_previews_nothing_and_does_not_linger() {
let (cmds, _rx) = commands();
let mut items = Items::default();
items.insert_template(3000, Some(worn("Healing Potion", 9000, 0)));
let mut room = DressUpRoom::default();
room.apply(DressUpIntent::TryOn(3000));
room.resolve_pending(&mut items, &cmds);
assert!(room.pending.is_empty(), "resolved, just not worn anywhere");
assert!(room.worn.iter().all(Option::is_none));
}
}
+26 -3
View File
@@ -389,15 +389,34 @@ fn coin_icon(copper: u32) -> &'static str {
/// Resolve one wire [`LootItem`] into the Lua-facing [`LootRow`]: the icon comes straight from the
/// wire `display_info_id` (no template wait), name + quality from the ask-once template cache (`None`
/// while in flight — the row shows a placeholder and fills in when the answer lands).
///
/// The row's **link** (`GetLootSlotLink`, decision 1059) comes off that same one template answer, out
/// of the one shared builder [`receive_line`] uses ([`crate::ui_items::item_link_full`], our
/// transcription of `0x52adb0`) and with the same arguments: enchant `0`, the wire's own
/// `randomPropertyId`, and suffix factor `0` — `SMSG_LOOT_RESPONSE`'s `randomSuffix` is a literal `0`
/// server-side (`LootMgr.cpp:841`, which is why [`LootItem`] doesn't even carry it), so there is
/// nothing else to pass. One builder, no drifting twins — the reason the zeros live in `item_link`
/// rather than at each call site.
fn resolve_item(
item: &LootItem,
items: &mut Items,
icons: Option<&ItemDisplays>,
commands: &NetCommands,
) -> LootRow {
let (name, quality) = match items.template(item.item_id, 0, commands) {
Some(t) => (Some(t.name.clone()), Some(t.quality)),
None => (None, None),
let (name, quality, link) = match items.template(item.item_id, 0, commands) {
Some(t) => (
Some(t.name.clone()),
Some(t.quality),
Some(crate::ui_items::item_link_full(
item.item_id,
0,
item.random_property_id,
0,
&t.name,
t.quality,
)),
),
None => (None, None, None),
};
let texture = icons
.and_then(|i| i.catalog.get(item.display_info_id))
@@ -409,6 +428,7 @@ fn resolve_item(
quality,
is_coin: false,
item_id: item.item_id,
link,
}
}
@@ -430,6 +450,9 @@ fn snapshot(
quality: Some(COIN_QUALITY),
is_coin: true,
item_id: 0,
// No link: the coin pile is a synthesized row with no item behind it, so a modified
// click on it finds nil and does nothing (decision 1059).
link: None,
});
}
for it in &loot.items {
+7
View File
@@ -401,6 +401,11 @@ fn snapshot(
bind_on_pickup: t.is_some_and(|t| t.bonding == BIND_WHEN_PICKED_UP),
time_left_ms: r.remaining_ms,
item_id: r.item_id,
// The icon button's ctrl/shift arms read this (`GetLootRollItemLink`, decision
// 1059). Same builder and same arguments as the announcement lines' own link a few
// functions up ([`render`]) — one `item_link` call site per resolved roll, `None`
// until the template answers, exactly like `name`/`quality` beside it.
link: t.map(|t| item_link(r.item_id, &t.name, t.quality)),
}
})
.collect();
@@ -832,6 +837,8 @@ mod tests {
bind_on_pickup: name.is_some(),
time_left_ms,
item_id: 17182,
// Lands with the name — one template answer fills both (decision 1059).
link: name.map(|n| item_link(17182, n, 4)),
}
}
+67
View File
@@ -213,3 +213,70 @@ fn either_line_ending_splits_a_body() {
"\\r alone splits; \\r\\n does not leave an empty token"
);
}
/// **Every icon the chooser offers RESOLVES to real art, and the catalog is the archive's** — the
/// tripwire for B221, where four pages of the picker each showed a solid WHITE cell.
///
/// This is `ui_script::shipped_xml_tests`' resolve sweep for the paths that sweep structurally
/// cannot see. That one walks static `file=` attributes in our own XML; a macro icon never appears
/// in XML — it arrives at runtime as `SetTexture(GetMacroIconInfo(i))`. Nothing checked that those
/// resolve, and an unresolvable path drew as an opaque white rectangle, so the picker shipped with
/// white squares in it and every gate green.
///
/// The catalog is now the archive enumeration the reference itself does
/// ([`benilla_formats::load_macro_icons`]), not a `SpellIcon.dbc` scan, so a name with no file
/// behind it can no longer enter the list at all — what this guards is the other half: that the
/// **resolution rule** still finds every enumerated name. `Ability_Druid_Mangle.tga` is the entry
/// that matters (the chooser stores names extension-stripped, and `…Mangle.tga.blp` ships): it only
/// resolves via the reference's second `.blp` candidate, and the old rule had no second candidate.
///
/// Resolution goes through the renderer's own [`crate::assets::sprite_candidates`], never a copy of
/// it: a sweep re-implementing the rule could agree with itself while disagreeing with what draws.
///
/// Needs client data; skips without it, like the XML sweep.
#[test]
fn every_macro_chooser_icon_resolves_in_the_client_archives() {
/// Icons on a stock 5875 install: `patch.MPQ` 77 + `interface.MPQ` 443 = 520 raw names under
/// `Interface\Icons\` matching `Spell_`/`Ability_`, less 3 that differ only by case or
/// extension — independently counted off the binary's own enumeration by the wow-re note
/// `system/ui/scratch/macro-icon-chooser.md`. The DBC scan this replaced served 521, a
/// different set: it included five names with no file and missed art the archive has.
const CHOOSER_ICONS_5875: usize = 517;
let data = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data");
if !data.is_dir() {
eprintln!("skipping: no client data at {}", data.display());
return;
}
let mut chain = benilla_formats::open_chain(&data).expect("open chain");
let icons = benilla_formats::load_macro_icons(&mut chain).expect("load chooser catalog");
assert_eq!(
icons.len(),
CHOOSER_ICONS_5875,
"chooser catalog size moved — the enumeration or its filter changed"
);
let missing: Vec<String> = icons
.iter()
.enumerate()
.filter(|(_, p)| {
!crate::assets::sprite_candidates(p)
.iter()
.any(|c| chain.contains(c))
})
.map(|(i, p)| format!("#{} {p}", i + 1))
.collect();
assert!(
missing.is_empty(),
"chooser icons that resolve to nothing (each draws as a white square): {missing:#?}"
);
// Sorted, not archive order: the reference `qsort`s case-insensitively before deduping, so the
// order the player scrolls is alphabetical.
let mut sorted = icons.clone();
sorted.sort_by_key(|p| p.to_ascii_lowercase());
assert_eq!(
icons, sorted,
"the chooser list must be case-insensitively sorted"
);
}
+12
View File
@@ -29,6 +29,7 @@ use crate::entities::ItemDisplays;
use crate::items::Items;
use crate::names::NameCache;
use crate::net::{ClientCommand, NetCommands, ObjectStore, SelfPlayer};
use crate::ui_items::item_link;
use crate::ui_script::UiInput;
use crate::ui_session::{close_npc_session_out_of_range, npc_switched, NpcSession};
@@ -206,6 +207,12 @@ fn resolve_item(
} else {
item.current_count as i32
};
// The row's link (`GetMerchantItemLink`, decision 1059) — what the row click's ctrl/shift arms
// hand on. Off the SAME one template answer as `name`/`stats`, through the one shared builder
// ([`crate::ui_items::item_link`], our transcription of the client's own `0x52adb0`): a vendor
// row carries no enchant and no random property on the wire, so the no-ids form is the right
// one here.
let link = template.map(|t| item_link(item.entry, &t.name, t.quality));
MerchantItem {
name,
texture,
@@ -214,6 +221,7 @@ fn resolve_item(
num_available,
item_id: item.entry,
stats,
link,
}
}
@@ -274,6 +282,10 @@ fn resolve_buyback(
num_available: 0,
item_id: entry,
stats,
// No link on a buyback row: 1.12 has no `GetBuybackItemLink`, and the reference's buyback
// click carries no ctrl/shift branch at all — it is a bare `BuybackItem(this:GetID())`
// (`MerchantFrame.lua:358-361`). Nothing reads it, so nothing builds it (decision 1059).
link: None,
}
}
File diff suppressed because it is too large Load Diff
+338
View File
@@ -0,0 +1,338 @@
//! The pet action bar's **feed** — ten packed words in, ten `PetActionView`s out, once a frame.
//!
//! **The shape of this system is the stance bar's mirror image, and that is the thing to hold on
//! to.** `crate::ui_shapeshift` derives its whole bar locally, from the known-spell set × the
//! `Spell.dbc` catalog, and the server never has an opinion about it. Here the server owns the
//! bar's CONTENTS — which slots exist and what is in them — and hands them over whole in
//! `SMSG_PET_SPELLS`. So the feed does no admission and no ordering: it *renders* the ten words
//! the last packet delivered. The slot law it renders them by is [`super`]'s.
use std::time::Instant;
use bevy::prelude::*;
use benilla_protocol::messages::{
PetActionEntry, PetSpells, PET_COMMAND_ATTACK, PET_COMMAND_DISMISS, PET_COMMAND_FOLLOW,
PET_COMMAND_STAY, PET_REACT_AGGRESSIVE, PET_REACT_DEFENSIVE, PET_REACT_PASSIVE,
};
use benilla_ui::script::{PetActionView, UiScript};
use crate::net::{GuidIndex, ObjectStore};
use crate::ui_action::Spells;
use super::drain::UNIT_FLAG_POSSESSED;
use super::PetBar;
/// `GetPetActionsUsable()` — may the bar be used at all (wow-re §4, the predicate `0x4bcf70`).
///
/// benilla's earlier reading — "the enabled-flags byte's `0x8`" — was REFUTED by the RE as the
/// *whole* answer, but it survives as one of the seven steps: the client tests bit 27 of the state
/// dword, which is that same byte's `0x8` (see `PET_STATE_BAR_DISABLED`). The step it was missing
/// is the pet's own crowd-control state — a **stunned, confused or feared** pet cannot be ordered,
/// and its bar greys until it recovers.
///
/// The remaining four steps are ownership identity (the player resolves, the pet resolves, the pet
/// is ours, we are not ourselves charmed). Holding a bar at all already means the server named us
/// this pet's controller, so they are structurally true here; the two that can actually change
/// while a bar is on screen are the two we test.
///
/// This is the same predicate that gates whether a press does anything, so a false answer greys
/// the icons *and* is the honest reason a click would be ignored.
pub(super) fn actions_usable(bar: &PetBar, pet_flags: Option<u32>) -> bool {
!bar.spells.bar_disabled()
&& pet_flags.is_none_or(|f| f & benilla_protocol::messages::PET_UNUSABLE_UNIT_FLAGS == 0)
}
/// What the feed last pushed, so `PET_BAR_UPDATE` fires on a real change rather than every frame
/// (the [`crate::ui_shapeshift`] memory pattern).
///
/// The leading `u32` is [`PetBar::bar_signals`], and it is what keeps the dedup from swallowing a
/// press that changes nothing — see that field for why the reference cannot afford to skip one.
#[derive(Default)]
pub(super) struct PetBarMemory {
pushed: Option<(u32, bool, bool, bool, Vec<PetActionView>)>,
}
/// The command tokens' `(GlobalStrings key, texture-global name)` pair.
///
/// Both are returned as **the names of globals**, not values — the reference's token convention.
/// The name keys ship in `GlobalStrings.lua` (`PET_ACTION_ATTACK` = "Attack" at l.3029-3032); the
/// texture globals are declared by `PetActionBar.xml`, quoting the reference's
/// `PetActionBarFrame.lua:6-12` verbatim.
pub(super) fn command_token(action: u32) -> Option<(&'static str, &'static str)> {
Some(match action {
PET_COMMAND_STAY => ("PET_ACTION_WAIT", "PET_WAIT_TEXTURE"),
PET_COMMAND_FOLLOW => ("PET_ACTION_FOLLOW", "PET_FOLLOW_TEXTURE"),
PET_COMMAND_ATTACK => ("PET_ACTION_ATTACK", "PET_ATTACK_TEXTURE"),
PET_COMMAND_DISMISS => ("PET_ACTION_DISMISS", "PET_DISMISS_TEXTURE"),
_ => return None,
})
}
/// The reaction tokens' pair, same convention. `PET_MODE_*` ship in `GlobalStrings.lua`
/// (l.3045-3047) — deliberately the `PET_MODE_*` keys, which name the state the pet is IN, not the
/// `PET_AGGRESSIVE`/`PET_DEFENSIVE`/`PET_PASSIVE` keys, which are the right-click menu's
/// imperatives. Both read identically in enUS ("Aggressive"), so only a localized client can tell
/// them apart — which is exactly why the key matters rather than the string.
pub(super) fn reaction_token(action: u32) -> Option<(&'static str, &'static str)> {
Some(match action {
PET_REACT_PASSIVE => ("PET_MODE_PASSIVE", "PET_PASSIVE_TEXTURE"),
PET_REACT_DEFENSIVE => ("PET_MODE_DEFENSIVE", "PET_DEFENSIVE_TEXTURE"),
PET_REACT_AGGRESSIVE => ("PET_MODE_AGGRESSIVE", "PET_AGGRESSIVE_TEXTURE"),
_ => return None,
})
}
/// Resolve one packed slot word into what the bar draws.
///
/// `cooldown` and `showing_active` are passed in rather than looked up here so the whole function
/// stays a pure (state, word) → view mapping, which is what the tests below exercise.
/// `showing_active` is [`active_aura_press`]'s predicate — the *same* answer that decides whether
/// a click cancels, because in the reference it is literally the same call (`0x4bcea0`, reached
/// from `GetPetActionInfo` at `0x4bdd2f` and from `CastPetAction` at `0x4bd24a`). Computing it
/// once and handing it to both is what keeps the icon honest: the button that shows the active art
/// is exactly the button whose next press takes the aura off.
pub(super) fn slot_view(
entry: PetActionEntry,
bar: &PetSpells,
spell: Option<&benilla_formats::SpellDisplay>,
cooldown: Option<(i64, u32, bool)>,
pet_attacking: bool,
showing_active: bool,
) -> PetActionView {
PetActionView {
// The raw word rides EVERY slot, including the ones that draw as empty — decision 1010's
// drag is word arithmetic and reads it. Zeroing an "empty" slot here would be wrong on the
// wire *and* wrong in the drop core: vmangos fills its unused slots with `ACT_DISABLED` +
// spell id 0, and that shape (type 1, low 16 zero) is precisely the relocation candidate
// the core hunts for.
packed: entry.packed,
// Only a resolved spell can be passive; a token has no record and an unresolvable id has
// no answer, and `false` is the honest reading of both.
passive: spell.is_some_and(|s| s.passive),
..slot_paint(entry, bar, spell, cooldown, pet_attacking, showing_active)
}
}
/// [`slot_view`]'s painted half — everything the button draws, with no wire word in it. Split so
/// the drag's two raw fields are stamped in exactly one place rather than on each of four returns.
pub(super) fn slot_paint(
entry: PetActionEntry,
bar: &PetSpells,
spell: Option<&benilla_formats::SpellDisplay>,
cooldown: Option<(i64, u32, bool)>,
pet_attacking: bool,
showing_active: bool,
) -> PetActionView {
let kind = entry.kind();
let action = entry.action();
if let Some((name, texture)) = (kind == benilla_protocol::messages::PET_ACT_COMMAND)
.then(|| command_token(action))
.flatten()
{
// A command token lights on `(state >> 8) == action` **or** on the attack latch — read at
// the bytes at `0x4bdf01`-`0x4bdf22`, and both halves matter:
//
// - the compare is against the UNMASKED `state >> 8` (`PetSpells::command_state`'s own
// note), so a disabled bar puts every command button out;
// - ATTACK gets the extra clause — and it is the *only* thing that can light ATTACK,
// because the command byte is never written for it (§10.1).
//
// So whether Attack ever appears lit is entirely a question about [`PetBar::attacking`],
// and the answer for a pet bar is **never**: that latch is the possess bar's, gated on
// `0x5ee5a0` ([`possessing`]). This expression stays faithful rather than hard-coding the
// `false`, because under Mind Control or Eyes of the Beast the same button does light.
//
// `attack_active` — `IsPetAttackActive`, the click fork — is the same latch narrowed to
// this slot (`0x4be138`-`0x4be153`: type 7, action 2, and the flag). Same input, so the
// button that lights is exactly the button whose next press calls the unit off.
let attacking = pet_attacking && action == PET_COMMAND_ATTACK;
return PetActionView {
name: Some(name.to_string()),
texture: Some(texture.to_string()),
is_token: true,
active: bar.command_state() == action || attacking,
attack_active: attacking,
..Default::default()
};
}
if let Some((name, texture)) = (kind == benilla_protocol::messages::PET_ACT_REACTION)
.then(|| reaction_token(action))
.flatten()
{
// The reaction compare's left side is forced to Passive when the bar is disabled
// (wow-re §2.2, `0x4bde3c`): a pet that cannot be ordered reads as Passive rather than
// keeping the mode light it had, which is the honest thing for it to say.
let showing = if bar.bar_disabled() {
benilla_protocol::messages::PET_REACT_PASSIVE
} else {
bar.react_state()
};
return PetActionView {
name: Some(name.to_string()),
texture: Some(texture.to_string()),
is_token: true,
active: showing == action,
..Default::default()
};
}
// A spell slot. `is_empty` is the zero WORD (the client tests the dword); vmangos's own unused
// middle slots are not zero and arrive here instead, where their spell id 0 misses the catalog
// and takes the same exit — the client's own route to the same empty button.
if !entry.is_spell() || entry.is_empty() {
return PetActionView::default();
}
let Some(spell) = spell else {
// The catalog failed to load, or the server named a spell 5875's DBC does not have. Draw
// the slot as occupied but nameless rather than inventing a name: the button then hides,
// which is honest, and the alternative (a "?" with no tooltip) has fooled nobody.
return PetActionView::default();
};
PetActionView {
name: Some(spell.name.clone()),
subtext: spell.rank.clone(),
// THE ICON SWAP (decision 1007, wow-re §2.1 `0x4bdd2f`/`0x4bdd38`/`0x4bdd77`): a spell the
// pet is currently running draws its record's `ActiveIconID` instead of its `SpellIconID`.
// Falling back to `icon` here would be wrong — the reference looks up whichever id the
// predicate chose and pushes **nil** if that lookup fails (`0x4bdd50`), so an unresolvable
// active icon hides the button rather than showing the inactive art on an active spell.
// `active_icon` is `None` on exactly that failure, so `.clone()` already says it.
texture: if showing_active {
spell.active_icon.clone()
} else {
spell.icon.clone()
},
is_token: false,
spell_id: Some(action),
// A spell slot NEVER reports isActive — VERIFIED nil on every path (wow-re §2.1, pushed
// at `0x4bdd5e`), which retires 0982's INTERIM. `isActive` is exclusively a token
// concept and `autoCast*` exclusively a spell one; the two halves of the signature never
// overlap (§2.5 quirk 3).
//
// "The pet is running this spell" is expressed by the icon above, not by this flag — which
// is why 0988's hole closes without this line changing.
active: false,
// Autocast is bits 31/30 of the word, not the type byte (wow-re §2.1) — and both are
// additionally gated on the spell resolving in `Spell.dbc`, which the early return above
// has already enforced by the time we get here.
autocast_allowed: entry.autocast_allowed(),
autocast_enabled: entry.autocast_on(),
attack_active: false,
cooldown,
// `packed`/`passive` are [`slot_view`]'s to stamp — this half paints, it does not encode.
..Default::default()
}
}
/// The pet spell slot that is **showing active** — the reference's `0x4bcea0` (wow-re
/// `ui/scratch/pet-action-bar-api.md` §2.1), returning the spell id when it holds so the one
/// answer can drive both of its consumers. Decision 1007.
///
/// It is not a new predicate: `0x4bcea0` is the *pet-side compiled twin* of the player's
/// `0x4e55f0`, which we already carry as [`crate::ui_action::toggle::active_action_toggle`] — same
/// three tests (nonzero raw `ActiveIconID`, the spell's own id in a live `UNIT_FIELD_AURA` slot,
/// that slot's `AURAFLAGS` nibble bit 0), different unit. So this reaches for it rather than
/// restating it, and the pet's store goes in where the player's does.
///
/// **The `ActiveIconID != 0` gate is load-bearing on the send, not just the icon.** Because the
/// binary tests it first (`0x4bcefd`) and `CastPetAction` takes its cancel arm on the whole
/// predicate, a pet spell whose record carries no active icon can never be clicked off — it
/// re-casts instead. That reads like an oversight and isn't ours to fix: the same `0` is what
/// tells the bar there is no "active" art to show, so the two halves are consistent.
pub(super) fn active_aura_press(
entry: PetActionEntry,
pet: Option<&ObjectStore>,
spell: Option<&benilla_formats::SpellDisplay>,
) -> Option<u32> {
if !entry.is_spell() || entry.is_empty() {
return None;
}
let spell_id = entry.action();
crate::ui_action::toggle::active_action_toggle(spell_id, spell?, pet?).then_some(spell_id)
}
/// Rebuild the ten slot views each frame and diff-push them, firing `PET_BAR_UPDATE` on a change.
///
/// One event where the reference has four (`PET_BAR_UPDATE`, `PET_BAR_UPDATE_COOLDOWN`,
/// `UNIT_PET`, and the `UNIT_FLAGS`/`UNIT_AURA` pair its bar filters for `arg1 == "pet"`) — the
/// deliberate collapse `crate::ui_shapeshift` already makes for the stance bar, and for the same
/// reason: we diff the whole pushed state, so one event carries every change there can be.
#[allow(clippy::too_many_arguments)]
pub(super) fn feed_pet_bar(
script: Option<NonSendMut<UiScript>>,
bar: Res<PetBar>,
spells: Option<Res<Spells>>,
clock: Res<crate::ui_script::UiClock>,
index: Res<GuidIndex>,
stores: Query<&ObjectStore>,
mut memory: Local<PetBarMemory>,
) {
let Some(mut script) = script else {
return;
};
let now = Instant::now();
let (anchor, ui_now) = (clock.anchor, clock.ui_now);
let has_bar = bar.has_bar();
// The pet's own descriptor. `None` = we hold a bar for a unit whose descriptor has not arrived
// (or has left); the usability predicate then rests on bit 27 alone rather than greying a bar
// on missing data, and no slot can read as showing-active.
let pet_store = index
.0
.get(&bar.spells.pet_guid)
.and_then(|&e| stores.get(e).ok());
let pet_flags = pet_store.map(|s| s.0.unit_flags());
let usable = actions_usable(&bar, pet_flags);
// `PickupPetAction`'s own gate, and nobody else's (`0x4be1c1`): a POSSESSED unit's bar cannot
// be rearranged. Deliberately not folded into `usable` — the reference keeps possession out of
// the flags that grey the bar, because a possessed unit is exactly when the buttons must work.
// Absent flags read as not-possessed, matching `usable`'s own missing-data posture.
let pickup_allowed = pet_flags.unwrap_or(0) & UNIT_FLAG_POSSESSED == 0;
let pet_attacking = bar.attacking;
let fresh: Vec<PetActionView> = if has_bar {
bar.spells
.bar
.iter()
.map(|&entry| {
let display = entry
.is_spell()
.then(|| spells.as_ref().and_then(|s| s.catalog.get(entry.action())))
.flatten();
let cooldown = display.and_then(|d| {
bar.cooldowns
.info(entry.action(), 0, Some(d), now)
.ui_triple(anchor, ui_now)
});
slot_view(
entry,
&bar.spells,
display,
cooldown,
pet_attacking,
active_aura_press(entry, pet_store, display).is_some(),
)
})
.collect()
} else {
Vec::new()
};
// `bar.bar_signals` rides the key so a press the state does not move still repaints — the
// `0x4bc940`/`0x4bc960` signal, which the button's own `SetChecked(0)` makes mandatory.
let key = (bar.bar_signals, has_bar, usable, pickup_allowed, fresh);
if memory.pushed.as_ref() != Some(&key) {
debug!(
"ui_pet: bar {} ({} occupied slot(s), {}{})",
if key.1 { "shown" } else { "hidden" },
key.4.iter().filter(|s| s.name.is_some()).count(),
if key.2 { "usable" } else { "disabled" },
if key.3 { "" } else { ", possessed" },
);
memory.pushed = Some(key.clone());
script.set_pet_actions(key.1, key.2, key.3, key.4);
script.fire_event("PET_BAR_UPDATE", vec![]);
}
}
+435
View File
@@ -0,0 +1,435 @@
//! The bar's click **intents** → the wire, and the state a press latches locally.
//!
//! **The bar's STATE — the lit command, the lit reaction, the autocast bit — is applied LOCALLY,
//! and that is forced rather than chosen.** The server answers none of those three presses:
//! `HandlePetAction`'s command and reaction arms send nothing back,
//! `HandlePetSpellAutocastOpcode` sets its bits and returns, and `SMSG_PET_MODE` is emitted from
//! exactly one place in the whole server (`Pet::SetEnabled` — the enabled flag, nothing else). A
//! client that waited for confirmation would show a bar whose reaction buttons never light and
//! whose autocast ring never appears. Verified live against vmangos, 2026-08-05: a
//! `TogglePetAutocast` and a Follow press drew no reply packet at all. [`drain_pet_actions`]
//! carries that half, and the next `SMSG_PET_SPELLS` re-authorities it.
use bevy::prelude::*;
use benilla_protocol::messages::{
PetActionEntry, PET_COMMAND_ATTACK, PET_COMMAND_FOLLOW, PET_COMMAND_STAY,
};
use benilla_ui::script::UiScript;
use crate::net::{ClientCommand, NetCommands, ObjectStore};
use crate::target::Selection;
use crate::ui_action::Spells;
use super::bar::active_aura_press;
use super::{PetBar, PetUnit};
/// `UNIT_FIELD_FLAGS` bit 24 — `UNIT_FLAG_POSSESSED` (vmangos `UnitDefines.h:515`), read by the
/// reference as the descriptor byte `[[pet+0x110]+0xA3] & 1`. Gates the pet bar's **drag** and
/// nothing else (decision 1010): a possessed unit's buttons still work, its layout is just not
/// yours to rearrange.
pub(super) const UNIT_FLAG_POSSESSED: u32 = 0x0100_0000;
/// Drain the bar's three intents onto the wire.
///
/// Every one carries the pet's guid from [`PetBar`] — never a guid the VM supplied — so an intent
/// queued in the frame a pet was dismissed dies here rather than naming a unit that is gone.
///
/// `CMSG_PET_ACTION` echoes the slot's **own packed word**: the server re-splits it and dispatches
/// on the type byte, so command, reaction and cast all leave through this one send. The target is
/// our current selection, which is what makes "select a mob, press Attack" work; a slot that wants
/// no target simply has the server ignore it (`HandlePetAction`'s `explicitlySelectedTarget` fork
/// drops a target the spell does not want).
///
/// **This drain is also where the bar's STATE moves, and that is not an optimisation — it is
/// forced.** The server answers none of these three: `HandlePetAction`'s command and reaction arms
/// send nothing back, and `HandlePetSpellAutocastOpcode` sets its bits and returns
/// (`PetHandler.cpp:451-478`). `SMSG_PET_MODE` is emitted from exactly ONE place in the whole
/// server — `Pet::SetEnabled`, i.e. only when the bar is enabled or disabled
/// (`Pet.cpp:2362-2377`). So a client that waited for confirmation would show a pet bar whose
/// reaction buttons never light and whose autocast ring never appears; the reference plainly does
/// not, so it must apply all three locally, and so do we. Verified live, 2026-08-05: a
/// `TogglePetAutocast` + a Follow press against vmangos drew no packet whatsoever in reply.
///
/// The optimism is bounded by the same packet that owns everything else — the next
/// `SMSG_PET_SPELLS` (a re-summon, a learn, a stable swap) replaces state and contents together.
#[allow(clippy::too_many_arguments)] // one Bevy system's full input set
pub(super) fn drain_pet_actions(
script: Option<NonSendMut<UiScript>>,
mut bar: ResMut<PetBar>,
mut selection: ResMut<Selection>,
commands: Res<NetCommands>,
pet: PetUnit,
spells: Option<Res<Spells>>,
mut ui_errors: ResMut<crate::ui_action::UiErrorKeys>,
scan: crate::target::EnemyScan,
mut seam: crate::creature_anim::AttackSeam,
) {
let Some(mut script) = script else {
return;
};
let pressed = script.take_pet_actions();
let toggles = script.take_pet_autocast_toggles();
let stops = script.take_pet_stop_attacks();
let writes = script.take_pet_set_actions();
if pressed.is_empty() && toggles.is_empty() && stops == 0 && writes.is_empty() {
return;
}
let pet_guid = bar.spells.pet_guid;
if pet_guid == 0 {
debug!("ui_pet: dropping queued pet intents — the bar is gone");
return;
}
// NOTE, so it is not "fixed" again (decision 1030 reverting 1027): the repaint is
// [`latch_press`]'s alone, and it is deliberately NOT unconditional on the click.
// `PetActionButton_OnClick`'s `this:SetChecked(0)` runs for every click, but the reference only
// signals `PET_BAR_UPDATE` from the writes that changed something — so a **refused**
// `TogglePetAutocast` (`0x4bcbf7`: a token is not autocastable, which is every right-click on
// Follow, Stay or a reaction) leaves that button's ring off until the next repaint from
// anywhere. That is the reference's own behaviour, checked there by the director, and
// reproduced.
//
// The pet's own descriptor — the ATTACK arm's validator reads it, and so does the spell arm's
// showing-active test. Neither is about the bar; both are about the pet.
let pet_store = pet.store(pet_guid);
// `0x4bd420`'s compare, hoisted once: is the bar's unit the one we are *driving*? Only then may
// an ATTACK press latch (see [`possessing`]) — which for an ordinary pet is never.
let possessing = possessing(pet_store, pet.self_guid.0);
for slot in pressed {
let Some(entry) = slot_entry(&bar, slot) else {
continue;
};
// The spell arm's early exit (wow-re §10.1, `0x4bd240`–`0x4bd2ad`): a press on a spell the
// pet is already running takes the aura OFF and **returns** — `CMSG_PET_ACTION` never
// leaves, so it is a cancel, not a re-cast. Nothing is latched locally either: the icon
// goes back when the pet's `UNIT_FIELD_AURA` says the aura is gone, which is the honest
// order (the server can refuse — a dead pet gets `FEEDBACK_PET_DEAD` instead).
let display = entry
.is_spell()
.then(|| spells.as_ref().and_then(|s| s.catalog.get(entry.action())))
.flatten();
if let Some(spell_id) = active_aura_press(entry, pet_store, display) {
debug!("ui_pet: slot {slot} cancels its own aura (spell {spell_id}) — no PetAction");
let _ = commands
.0
.send(ClientCommand::PetCancelAura { pet_guid, spell_id });
continue;
}
// `0x612df0`, the ATTACK order's validator, and its actor is the **pet**. Phase A is the
// pet's own eligibility; Phase B is the target, and it can move your selection — see
// [`crate::target::attack_order_target`], which is the whole of "press Attack at nothing
// and the pet goes for whatever is in front of you".
//
// Every other arm sends the selection untouched: `CastPetAction` substitutes it for a nil
// Lua argument at `0x4bd212` and only the ATTACK arm ever calls the validator, so a Follow
// or a spell carries whatever you happened to have.
let mut target_guid = selection.guid.unwrap_or(0);
let refused = if is_attack_order(entry) {
crate::ui_action::attack_actor_refusal(pet_store, pet.self_guid.0, &mut ui_errors)
|| match crate::target::attack_order_target(
&scan,
&mut selection,
&mut seam,
&mut ui_errors,
) {
Some(guid) => {
target_guid = guid;
false
}
None => true,
}
} else {
false
};
if !commit_press(&mut bar, entry, refused, possessing) {
debug!("ui_pet: slot {slot} refused by the attack validator — no packet");
continue;
}
debug!(
"ui_pet: press slot {slot} (action {} kind {:#04x}) at {target_guid:#x}",
entry.action(),
entry.kind()
);
let _ = commands.0.send(ClientCommand::PetAction {
pet_guid,
packed: entry.packed,
target_guid,
});
}
for slot in toggles {
let Some(entry) = slot_entry(&bar, slot).filter(|e| e.autocast_allowed()) else {
continue;
};
// The client flips bit 30 in the slot word IN PLACE and sends the whole new word — it is
// not a "set autocast to X for spell Y" verb (wow-re §10.2, `0x4bcbff`/`0x4bcc17`). The
// server reads the direction back out of the type byte it arrives in.
let flipped = entry.with_autocast(!entry.autocast_on());
debug!(
"ui_pet: autocast {} for spell {} (slot {slot})",
flipped.autocast_on(),
entry.action()
);
let _ = commands.0.send(ClientCommand::PetSetAction {
pet_guid,
// The wire's slot positions are 0-based (vmangos bounds them `< 10`); the Lua's are
// 1-based. This subtraction is the whole of the conversion and cannot underflow —
// `slot_entry` already rejected 0.
entries: vec![(slot - 1, flipped.packed)],
});
if let Some(e) = slot_entry_mut(&mut bar, slot) {
*e = flipped;
}
}
for _ in 0..stops {
if stop_pet_attack(&mut bar, &commands) {
debug!("ui_pet: stop attack");
}
}
// The drag's writes (decision 1010). The engine ran the assign core against its own mirror and
// handed back the `(0-based position, word)` pairs; the authoritative ten words live *here*, so
// the app's whole job is to mirror each pair and put the batch on the wire **whole** — the
// server tells the one-pair form from the two-pair form by body size, so a relocation and its
// write must not be split into two sends.
for entries in writes {
for &(position, packed) in &entries {
if let Some(e) = bar.spells.bar.get_mut(position as usize) {
*e = PetActionEntry::from(packed);
}
}
debug!("ui_pet: bar write {entries:?}");
let _ = commands
.0
.send(ClientCommand::PetSetAction { pet_guid, entries });
}
}
/// `PetStopAttack`'s **core**, `0x4bd650` — call the pet off, and the only thing besides a new pet
/// that puts the Attack button out.
///
/// It **no-ops entirely when the latch is down** (`0x4bd65e`): no packet, no repaint. That gate
/// lives here rather than in the VM because the latch does. Returns whether it actually fired,
/// which is what its two callers log.
///
/// Split out of the drain because the drain is *not* its only caller: [`pet_stop_on_old_target_clear`]
/// is the second, exactly as `0x4bd650` has a second call site of its own.
pub(super) fn stop_pet_attack(bar: &mut PetBar, commands: &NetCommands) -> bool {
let pet_guid = bar.spells.pet_guid;
if !bar.attacking || pet_guid == 0 {
return false;
}
let _ = commands.0.send(ClientCommand::PetStopAttack { pet_guid });
bar.attacking = false;
true
}
/// `0x493910`'s entry gate, as a predicate on the selection transition.
///
/// The old-target clear returns immediately unless there **is** a current selection
/// (`0x493937 or edx,ecx; je epilogue`) and the guid it was handed is either zero or that same
/// selection (`0x493949`/`0x493951`) — and its two callers only ever hand it one of those two.
/// So it runs on exactly the transitions below: a selection that existed is being replaced or
/// dropped. `None → Some` is *not* one of them.
pub(super) fn old_target_cleared(previous: Option<u64>, now: Option<u64>) -> bool {
previous.is_some() && previous != now
}
/// **Clearing the old target calls `PetStopAttack`'s core** — the third and last way the attack
/// latch goes down, and live only on a **possess** bar (see [`possessing`]; a hunter's pet never
/// raises the latch in the first place, so [`stop_pet_attack`]'s own gate makes this a no-op for
/// it, exactly as `0x4bd65e` does in the reference).
///
/// `0x493910` — the old-target clear, run by *both* selection writers (`0x493540`'s switch calls
/// it with the outgoing guid at `ecx = 0`, the explicit clear with `{0,0}` at `ecx = 1`) — does
/// three things past its entry gate, of which benilla previously carried only the first:
///
/// ```text
/// 0x493a08 0x5ecac0(player) ; StopAttack -> CMSG_ATTACKSTOP (target/scan.rs `commit`)
/// 0x493a0f 0x5ee5a0(player) ; the unit I am POSSESSING, or null
/// 0x493a18 0x4bd650() ; PetStopAttack's core, iff there is one <- THIS
/// 0x493a1d if (notifyServer) ... ; CMSG_SET_SELECTION
/// ```
///
/// The `0x5ee5a0` at `0x493a0f` is the same possession resolver `0x4bd420` uses to gate the latch,
/// which is the tidy proof that the whole latch arm belongs to one bar: the thing that raises it
/// and the thing that clears it ask the identical question. The call also sits **above** the
/// notify-server branch, so it runs on the silent switch-clear too, not only on the explicit one.
///
/// Modelled as a transition on [`Selection`] rather than a hook in the selection writers because
/// that is what the reference gets for free by routing every writer through one clear: a `Local`
/// mirror sees `/target`, a click, TAB, ESC, the death-teardown clear and the acquire alike. The
/// dedup is the reference's own (`0x493540` bails when the guid is already current, so no clear
/// runs) and falls out of comparing against the mirror.
pub(super) fn pet_stop_on_old_target_clear(
selection: Res<Selection>,
mut previous: Local<Option<u64>>,
mut bar: ResMut<PetBar>,
commands: Res<NetCommands>,
) {
let now = selection.guid;
if *previous == now {
return;
}
let cleared = old_target_cleared(*previous, now);
*previous = now;
if cleared && stop_pet_attack(&mut bar, &commands) {
debug!("ui_pet: the old-target clear called the pet off (0x493a18)");
}
}
/// `0x5ee5a0` — **"the unit I am directly possessing"**, and the gate that decides whether an
/// ATTACK press latches anything at all.
///
/// This is the fact the pet bar was missing, and it is the answer to "why can the reference's
/// Attack button never stay lit". `0x4bd420` calls this on the *player* and compares the result
/// with the bar's own unit; only on a match does `0x4bd42e` raise `[0xb714b0]`. And the function
/// is not "resolve my pet" despite the shape — read at the bytes it is four tests in a row:
///
/// ```text
/// 0x5ee5bc [player + 0x1c70] & 1 ; "I am driving something that is not me" — else null
/// 0x5ee5d5 the passed unit IS the local player ; else guid 0
/// 0x5ee5e9 [[player + 0xe68] + 0x830] ; the guid I am driving -> object lookup (type 8)
/// 0x5ee626 [fields + 0xa3] & 1 ; UNIT_FLAG_POSSESSED on that unit — else null
/// 0x5ee62f charmedBy (else createdBy) == me ; else null
/// ```
///
/// `[player + 0x1c70]`'s bit 0 is set and cleared by the control-transfer handler `0x5ee2xx`
/// against the active-mover guid, firing event `0x27a` with a bool — the client's own
/// "control lost / control gained". So the whole thing means *possession*: Mind Control, Eye of
/// Kilrogg, Eyes of the Beast. vmangos agrees from the other side — `UNIT_FLAG_POSSESSED` is
/// written in exactly three places (`Player::SummonPossessedMinion`, `SPELL_AURA_MOD_POSSESS`,
/// `Player::ModPossessPet`), each of them paired with `SetMover` + `UpdateControl`, and **never**
/// for an ordinary hunter or warlock pet.
///
/// Which is why `PetActionButton_OnClick`'s `IsPetAttackActive` fork and `PetStopAttack` are dead
/// code for a hunter, the way `PetActionButton_StartFlash` is dead code for everyone: the shipped
/// `PetActionBarFrame.lua` is written for both bars and a hunter only ever takes one branch.
///
/// **What benilla checks, and the gap:** the flag and the ownership, which are the two halves that
/// live in the descriptor we already stream. The active-mover halves (`+0x1c70`, the driven guid)
/// need client-control state benilla does not model yet; they only ever *narrow* this further, and
/// the server sets the flag and the mover together, so the flag standing in for both is faithful
/// until possession itself is built.
pub(super) fn possessing(store: Option<&ObjectStore>, self_guid: Option<u64>) -> bool {
store.is_some_and(|s| {
s.0.unit_flags() & UNIT_FLAG_POSSESSED != 0
&& s.0.unit_owner(benilla_protocol::OwnerFallback::CreatedBy) == self_guid
})
}
/// Is this press the ATTACK **order** — the one slot word that runs a validator before it sends?
///
/// Type 7 action 2 and nothing else. The reference's type-7 arm branches exactly twice
/// (`cmp ecx,1; jle` then `cmp ecx,2; jne`), so DISMISS, every `action >= 4`, and both mode
/// commands leave down paths that send unconditionally.
pub(super) fn is_attack_order(entry: PetActionEntry) -> bool {
entry.kind() == benilla_protocol::messages::PET_ACT_COMMAND
&& entry.action() == PET_COMMAND_ATTACK
}
/// Commit one press against the bar and answer **whether it goes on the wire** — the composition
/// of the gate and the latch, in one place because that composition is where the last bug lived.
///
/// `refused` is the shared attack-start validator's verdict on the **pet** as actor (`0x4bd40d`
/// passes `ecx = edi`, the pet object resolved four instructions earlier). A veto costs
/// everything: `0x4bd414 je 0x4bd4c6` jumps to the **function epilogue, not the send**, so there
/// is no packet, no `[0xb714b0] = 1`, and no `PET_BAR_UPDATE`. The click is as if it never
/// happened, except for the red line the validator itself raised.
///
/// This closes the residual decision 0998 named in its own text — *"a refused attack currently
/// lights the button where the real client would not"* — and it is deliberately the one function
/// that both decides and records, because 0998's bug was invisible to thirteen tests that checked
/// the read side and the write side separately and never their join.
///
/// `possessing` is [`possessing`]'s answer, threaded rather than re-derived because it is the
/// *second* gate on the same press and it belongs beside the first: `0x4bd420` runs immediately
/// after `0x4bd40d`'s validator, and unlike it, failing costs only the latch — `0x4bd427 jne
/// 0x4bd444` lands on the **send**, so the order still goes out. That asymmetry is the whole shape
/// of the Attack button: the pet is always dispatched, the button never lights for it.
pub(super) fn commit_press(
bar: &mut PetBar,
entry: PetActionEntry,
refused: bool,
possessing: bool,
) -> bool {
if refused {
return false;
}
latch_press(bar, entry, possessing);
true
}
/// Latch a pressed slot's state locally — the half the server never confirms (the module doc's
/// "forced rather than chosen"), now with the client's own masks rather than byte assignments.
///
/// Both writes are exact transcriptions, and the masks are the point:
///
/// - **reaction** (`0x4bc94c`): `state = state & 0xFFFFFF00 | action` — byte 0 only.
/// - **command** (`0x4bc96f`): `state = state & 0x080000FF | action << 8` — bits 8–15, keeping
/// byte 0 and **bit 27**. The client deliberately preserves the disabled bit while rewriting the
/// command, which is also the proof that bit 27 is server-owned: no client path writes it.
///
/// **Only STAY and FOLLOW reach the command write.** The binary's type-7 arm gates it on
/// `action <= 1` (§10.1): DISMISS (3) falls straight through, and ATTACK (2) leaves down the
/// validation chain that ends at `[0xb714b0] = 1` (`0x4bd42e`) — the attack latch, never the
/// command byte. The distinction is the difference between a mode and an order: Stay and Follow
/// are what the pet is *doing until told otherwise*, and they stay lit because `isActive`'s state
/// compare keeps matching the command byte. Attack is a thing you tell it once.
///
/// Getting this wrong is visible within one click: latching ATTACK into the command byte leaves
/// the Attack button lit forever and blanks Follow and Stay.
///
/// **The ATTACK arm's latch is gated on possession** — [`possessing`], the `0x5ee5a0` compare at
/// `0x4bd420`, which no ordinary pet passes. So on a hunter's bar this match arm never fires and
/// the Attack button never checks, which is what the reference does and what the director sees.
/// The arm is still written out rather than deleted because the possess bar is the same bar: Mind
/// Control and Eyes of the Beast reach it, and everything downstream (`IsPetAttackActive`,
/// `PetStopAttack`, the old-target clear) is live for them.
///
/// The other gate on this press, `0x612df0`, ran back in [`drain_pet_actions`] and is *not*
/// symmetric with it: a validator veto costs the whole press (`0x4bd414` jumps to the epilogue,
/// no packet), while failing the possession compare costs only the latch (`0x4bd427` jumps to the
/// send). [`commit_press`] holds that asymmetry.
pub(super) fn latch_press(bar: &mut PetBar, entry: PetActionEntry, possessing: bool) {
let action = entry.action();
match entry.kind() {
benilla_protocol::messages::PET_ACT_COMMAND
if action == PET_COMMAND_STAY || action == PET_COMMAND_FOLLOW =>
{
// `0x4bc960` — the write, then `ecx = 0x161; jmp SignalEvent`. Both halves, and the
// signal is unconditional: writing the SAME command still repaints the bar, which is
// the only thing that puts back the light `OnClick`'s `SetChecked(0)` just took off.
bar.spells.state = (bar.spells.state & 0x0800_00FF) | (action << 8);
bar.bar_signals = bar.bar_signals.wrapping_add(1);
}
benilla_protocol::messages::PET_ACT_COMMAND
if action == PET_COMMAND_ATTACK && possessing =>
{
// `0x4bd42e` signals too (`mov ecx,0x161` immediately before the write), and it is the
// one signal a pet bar never gets: the possession gate above it is what skips both.
bar.attacking = true;
bar.bar_signals = bar.bar_signals.wrapping_add(1);
}
benilla_protocol::messages::PET_ACT_REACTION => {
// `0x4bc940`, the same shape one byte-mask over.
bar.spells.state = (bar.spells.state & 0xFFFF_FF00) | action;
bar.bar_signals = bar.bar_signals.wrapping_add(1);
}
// DISMISS, spells and every unknown type reach `0x4bd444` without signalling. A spell's
// button can be checked (its active-aura ring), and it comes back the honest way: the
// pet's aura field moves and the feed sees a real change.
_ => {}
}
}
/// The 1-based Lua slot index → the packed word we still hold for it.
pub(super) fn slot_entry(bar: &PetBar, slot: u32) -> Option<PetActionEntry> {
let index = usize::try_from(slot.checked_sub(1)?).ok()?;
bar.spells.bar.get(index).copied()
}
/// [`slot_entry`]'s mutable twin, for the autocast flip's write-back.
pub(super) fn slot_entry_mut(bar: &mut PetBar, slot: u32) -> Option<&mut PetActionEntry> {
let index = usize::try_from(slot.checked_sub(1)?).ok()?;
bar.spells.bar.get_mut(index)
}
+171
View File
@@ -0,0 +1,171 @@
//! The pet **right-click menu** — its two predicates and its three verbs (decision 1066).
//!
//! `PetFrame_OnClick`'s else arm opens `PetFrameDropDown` on `UnitPopupMenus["PET"]`, whose four
//! rows are decided by `PetCanBeAbandoned()` alone (`UnitPopup.lua:402-416`): paperdoll and abandon
//! show when it is true, dismiss when it is false, rename when it and `PetCanBeRenamed()` both are.
//! So one predicate makes the same menu read *Abandon* on a hunter's pet and *Dismiss* on a
//! warlock's demon — and the row a player sees is the whole of the difference the client draws
//! between "give this pet up forever" and "send this summon away".
//!
//! **The predicates are VERIFIED at the bytes** (wow-re `ui/scratch/pet-action-bar-api.md` §11c,
//! carved for this build). `0x4be500` and `0x4be580` are byte-identical but for three dwords:
//! resolve `[0xb714a0]`, check the pet's `UNIT_FIELD_SUMMONEDBY` against the local player, then test
//! **one byte of `UNIT_FIELD_FLAGS`** — `[pet+0x110]+0xa0`, dword 46 — with mask `0x20` for abandon
//! (`0x4be544`) and `0x10` for rename (`0x4be5c4`). Each returns exactly one value on every path: a
//! double `1.0`, or nil.
//!
//! **The `UNIT_FIELD_BYTES_2` byte-2 layout everybody remembers is REFUTED here** — that is the
//! TBC+ home of these bits, its record is dword 164, and neither predicate touches it. This is the
//! shape decision 0988 warns about, and it was one carve away from being built wrong: benilla's own
//! queue had recorded the TBC guess, off a field that is always zero on this server.
//!
//! **The two give-up verbs are NOT one opcode**, which is the correction that made carving them
//! worth it (the seam's two counts, kept apart on principle, are what made the fold a two-line
//! change):
//!
//! - `PetAbandon 0x4be4c0` → `0x4bd740` sends **`CMSG_PET_ABANDON` 0x176** `{u64 petGuid}`.
//! - `PetDismiss 0x4be4d0` → `0x4bd6e0` **opens no packet at all**. It stages the packed slot word
//! `0x07000003` — command type 7, action 3, `PET_COMMAND_DISMISS` — and hands it to `0x4bd1d0`,
//! the *same dispatcher every pet-bar click uses*, which sends **`CMSG_PET_ACTION` 0x175**
//! `{u64 petGuid, u32 0x07000003, u64 0}`. Dismiss is a bar press with no button.
//!
//! Both would have worked against vmangos — `HandlePetAbandon` unsummons a non-hunter pet, and so
//! does `COMMAND_DISMISS` — which is exactly why the wrong one would never have been noticed.
use bevy::prelude::*;
use benilla_protocol::messages::{PET_ACT_COMMAND, PET_COMMAND_DISMISS};
use benilla_ui::script::UiScript;
use crate::net::{ClientCommand, NetCommands};
use crate::ui_action::{UiError, UiErrorKeys};
use super::{PetBar, PetUnit};
/// `UNIT_FIELD_FLAGS` bit 4 — `UNIT_FLAG_PET_RENAME`, `0x4be5c4`'s mask. Set on a tamed hunter pet
/// that has never been renamed.
///
/// **One-shot, and server-side only.** wow-re's census of field-view reads of `[+0x110]+0xa0` found
/// exactly three sites testing these two masks — the two predicates and the rename sender's own
/// re-check — and **no writer anywhere in `.text`**. Nothing client-side clears this; the row
/// disappears because the server re-streams the field and `UnitPopup` re-evaluates when the menu is
/// next built. There is no event to listen for and none is needed.
const UNIT_FLAG_PET_RENAME: u32 = 0x0000_0010;
/// `UNIT_FIELD_FLAGS` bit 5 — `UNIT_FLAG_PET_ABANDON`, `0x4be544`'s mask. Set on a hunter pet and
/// clear on a summon, which is what forks the whole menu.
const UNIT_FLAG_PET_ABANDON: u32 = 0x0000_0020;
/// The client's own silent cap on a pet name — `0x64a7f0(dst, 0x50, "%s", name)` in the rename
/// sender, i.e. 79 characters plus the terminator (wow-re §11c).
///
/// It is **not** the 12 everyone knows: that is `RENAME_PET`'s `maxLetters` in FrameXML, a
/// different layer with a different job. This one is the last thing between the Lua and the wire,
/// it truncates rather than refusing, and it exists because the binding is reachable from any
/// script, not only from the dialog.
const PET_NAME_MAX: usize = 79;
/// `(PetCanBeAbandoned, PetCanBeRenamed)` from a pet's `UNIT_FIELD_FLAGS` — the whole fork, in one
/// place, so the one mistake that matters here has somewhere to be tested: swapping the two masks
/// would offer a hunter *Dismiss* and a warlock *Abandon*, which is quest-breaking in one direction
/// and pet-losing in the other.
pub(super) fn menu_predicates(pet_flags: u32) -> (bool, bool) {
(
pet_flags & UNIT_FLAG_PET_ABANDON != 0,
pet_flags & UNIT_FLAG_PET_RENAME != 0,
)
}
/// Push the menu's two predicates off the pet's descriptor.
///
/// **The ownership test is the predicates' own**, and it is `UNIT_FIELD_SUMMONEDBY` **alone** —
/// `0x4be500` compares `[pet+0x110]+0x18/0x1c` against the local player before either bit is read,
/// with no `CHARMEDBY` leg. That distinction is the third one in this folder (`0x5ff580` falls back
/// from charmed to summoned, `0x5ee5a0` from charmed to created, this reads summoned flat), and it
/// is exactly the case it decides: a **charmed** unit has the bar and the cached guid, and is not
/// somebody's summon — so its menu is empty, which is right, because it is not yours to abandon.
///
/// No pet, an unstreamed pet, or a pet that is not our summon pushes `(false, false)` — every row
/// off. That is the honest answer and also the useful one: `UnitPopup_ShowMenu` counts the shown
/// rows and **refuses to open a menu with nothing but CANCEL in it**, so an unresolved pet takes no
/// menu rather than an empty one.
pub(super) fn feed_pet_menu(script: Option<NonSendMut<UiScript>>, bar: Res<PetBar>, pet: PetUnit) {
let Some(mut script) = script else {
return;
};
let flags = (bar.spells.pet_guid != 0)
.then(|| pet.store(bar.spells.pet_guid))
.flatten()
.filter(|store| store.0.unit_summoned_by() == pet.self_guid.0)
.map_or(0, |store| store.0.unit_flags());
let (can_be_abandoned, can_be_renamed) = menu_predicates(flags);
script.set_pet_menu(can_be_abandoned, can_be_renamed);
}
/// Drain the menu's three verbs onto the wire.
///
/// Each carries [`PetBar`]'s cached pet guid rather than anything the VM supplied — the drain's own
/// rule, and here it also means a verb queued in the frame the pet left dies rather than naming a
/// unit that is gone. Nothing is applied locally: unlike the bar's presses, all three ask the
/// *server* for something it may refuse, and it answers by removing the pet or by bumping its name
/// timestamp.
pub(super) fn drain_pet_menu(
script: Option<NonSendMut<UiScript>>,
bar: Res<PetBar>,
commands: Res<NetCommands>,
mut ui_errors: ResMut<UiErrorKeys>,
) {
let Some(mut script) = script else {
return;
};
let (abandons, dismisses) = script.take_pet_gives_up();
let renames = script.take_pet_renames();
if abandons == 0 && dismisses == 0 && renames.is_empty() {
return;
}
let pet_guid = bar.spells.pet_guid;
if pet_guid == 0 {
debug!("ui_pet: dropping a queued menu verb — there is no pet");
return;
}
// Counted rather than deduped: a double click on the confirm is two presses, and the reference
// sends two.
for _ in 0..abandons {
debug!("ui_pet: abandoning pet {pet_guid:#x}");
let _ = commands.0.send(ClientCommand::PetAbandon { pet_guid });
}
// Dismiss is a bar press with no button (the header): the word `0x07000003` down the ordinary
// `CMSG_PET_ACTION` path, target 0. Built from the constants rather than written as the literal
// so it stays the same word `PET_COMMAND_DISMISS` means everywhere else in this folder.
let dismiss_word = PET_COMMAND_DISMISS | (u32::from(PET_ACT_COMMAND) << 24);
for _ in 0..dismisses {
debug!("ui_pet: dismissing pet {pet_guid:#x} ({dismiss_word:#010x})");
let _ = commands.0.send(ClientCommand::PetAction {
pet_guid,
packed: dismiss_word,
target_guid: 0,
});
}
for name in renames {
// The sender's own two steps, in its order (`0x4bd840`): an empty name is refused to the
// error line and sends nothing, then the rest is truncated silently. Neither is the
// dialog's 12-letter cap — this is the layer under it.
if name.is_empty() {
ui_errors.0.push(UiError::key("ERR_NULL_PETNAME"));
continue;
}
let name = clamp_pet_name(&name);
debug!("ui_pet: renaming pet {pet_guid:#x} to {name:?}");
let _ = commands.0.send(ClientCommand::PetRename { pet_guid, name });
}
}
/// [`PET_NAME_MAX`]'s truncation, on **character** boundaries.
///
/// The reference counts bytes into a fixed buffer; we cannot, because a mid-character cut would not
/// be a shorter name but an invalid string. Every name a 1.12 client can produce is well inside the
/// cap either way — the clamp is here so a scripted `PetRename` cannot hand the wire something the
/// server will read differently than we meant.
fn clamp_pet_name(name: &str) -> String {
name.chars().take(PET_NAME_MAX).collect()
}
+193
View File
@@ -0,0 +1,193 @@
//! The pet system — the app side of `benilla_ui::script::pet`'s seam (decision 0982), and the
//! home of [`PetBar`], the server-authoritative state `net/apply/pet.rs` writes.
//!
//! **One concern per file** (the split owed by decision 1003, restated by 1005, done in 1066). What
//! decides the cut is *which clock a thing moves on*, which is also why these never wanted to be
//! one file:
//!
//! | module | what it owns | moves on |
//! |---|---|---|
//! | [`bar`] | the action bar's **feed** — ten packed words rendered as ten `PetActionView`s | `SMSG_PET_SPELLS` |
//! | [`unit`] | the **`"pet"` unit token** and the pet frame's events | the pet's descriptor |
//! | [`drain`] | the bar's click **intents** → the wire, and the state they latch locally | a click |
//! | [`menu`] | the **right-click menu**: two predicates, three verbs | a menu pick |
//!
//! [`PetBar`], [`PetUnit`] and [`UiPetPlugin`] stay here, because every module reads them.
//!
//! **The slot law** (decode and constants in `benilla_protocol::messages::pet`, VERIFIED against
//! the client itself in decision 0988): each slot is one packed word whose type — bits 24–29,
//! **masked** — decides its whole reading:
//!
//! - `PET_ACT_COMMAND 7` — `action` is a `CommandStates`: Stay 0 / Follow 1 / Attack 2 / Dismiss 3.
//! A token: `GetPetActionInfo` returns the NAMES of two globals rather than values (the
//! reference's own `PetActionBarFrame.lua:98-104` fork), which `PetActionBar.xml` resolves —
//! `PET_ACTION_*` out of the shipped `GlobalStrings.lua`, `PET_*_TEXTURE` out of the constants
//! that file declares and ours quotes.
//! - `PET_ACT_REACTION 6` — `action` is a `ReactStates`: Passive 0 / Defensive 1 / Aggressive 2.
//! Also a token.
//! - types **1–5** — `action` is a spell id, and the autocast pair rides in bits 31/30 rather than
//! in the type. That split is why the server's `ACT_PASSIVE`/`ACT_DISABLED`/`ACT_ENABLED` are
//! one branch here and not three.
//!
//! The two halves of `GetPetActionInfo`'s signature never overlap: `isActive` is a **token**
//! concept and `autoCast*` a **spell** one, each nil on the other's branch.
//!
//! The **3 commands / 4 spells / 3 reactions** arrangement everyone recognises is therefore
//! *server data*, not a layout: it is what `CharmInfo::InitPetActionBar` happens to write, and a
//! possessed minion or a charmed creature arrives with a different mix through the same ten words.
//! Nothing in this folder or in `PetActionBar.xml` assumes it.
use bevy::prelude::*;
use benilla_protocol::messages::PetSpells;
use crate::cooldowns::Cooldowns;
use crate::net::{GuidIndex, ObjectStore};
use crate::ui_script::UiInput;
use crate::ui_unit::UnitFeed;
mod bar;
mod drain;
mod menu;
mod unit;
use bar::feed_pet_bar;
use drain::{drain_pet_actions, pet_stop_on_old_target_clear};
use menu::{drain_pet_menu, feed_pet_menu};
use unit::feed_pet_unit;
#[cfg(test)]
mod tests;
/// The pet action bar's mirror of the server's state — written only by `net/apply/pet.rs`, read
/// only by [`feed_pet_bar`] and [`drain_pet_actions`].
///
/// `spells.pet_guid == 0` is "there is no pet bar", and it is the single test: the teardown packet
/// carries exactly that and nothing else.
#[derive(Resource, Default)]
pub(crate) struct PetBar {
/// The last `SMSG_PET_SPELLS` in full, with `SMSG_PET_MODE`'s state edits folded in.
pub(crate) spells: PetSpells,
/// The **pet's own** cooldown store — a second [`Cooldowns`] instance, not the player's. The
/// two must not share: a hunter and their pet both know Growl-shaped spells on independent
/// timers, and the reference keeps a `SPELLHISTORY` list per unit for exactly this reason.
/// Seeded from the `SMSG_PET_SPELLS` tail and topped up by `SMSG_SPELL_COOLDOWN` addressed to
/// the pet's guid.
pub(crate) cooldowns: Cooldowns,
/// **"The possessed unit is attacking"** — the client's own `[0xb714b0]`, a purely local latch
/// with no field behind it anywhere (wow-re §1), and **the possess bar's, not the pet bar's**.
///
/// It is `IsPetAttackActive` entire, and `GetPetActionInfo`'s COMMAND branch ORs it into
/// `isActive` for action 2 (`0x4bdf16`–`0x4bdf22`), so it is also the only thing that can ever
/// light an Attack button. What gates the one write that raises it is the whole story
/// ([`possessing`]): `0x4bd420` demands `0x5ee5a0(player) == the bar's unit`, and `0x5ee5a0`
/// answers the **possessed** unit or nothing. A hunter's pet is not possessed, so on an
/// ordinary pet bar this latch **can never rise** — which is exactly the director's report:
/// Attack is a button you press, never a mode that stays lit.
///
/// The three ways down are transcribed anyway, because under possession they are live: the lit
/// button's own second press (Lua `PetStopAttack` → `0x4bd650`), a new pet (`0x4bc8ce`), and
/// the **old-target clear `0x493910` at `0x493a18`** ([`pet_stop_on_old_target_clear`]), which
/// every selection writer runs.
///
/// benilla read this off the pet's streamed `UNIT_FIELD_TARGET` until the RE landed. That is a
/// different question with a different answer: a defensive pet that retaliates on its own has
/// a target the player never ordered, and the reference does not light the Attack button for
/// it either.
///
/// **It is not the source of `PET_ATTACK_START`/`PET_ATTACK_STOP`**, however much it reads like
/// one — decision 0990 made exactly that inference and it was wrong. Those ride the pet's
/// server-owned `UNIT_FIELD_FLAGS` bit `0x800` instead ([`feed_pet_unit`]). This latch answers
/// "did the player order the unit they are *driving* to attack"; the flag answers "is the pet
/// fighting", and it is the flag that moves for a hunter.
pub(crate) attacking: bool,
/// **`SignalEvent(0x161 PET_BAR_UPDATE)`, counted** — the half of a press that is not the state
/// write and matters just as much.
///
/// Both state writers end on an **unconditional** signal, with no compare of the old value
/// against the new one:
///
/// ```text
/// 0x4bc940 [0xb71468] = state & 0xFFFFFF00 | action ; ecx = 0x161 ; jmp SignalEvent (reaction)
/// 0x4bc960 [0xb71468] = state & 0x080000FF | a << 8 ; ecx = 0x161 ; jmp SignalEvent (command)
/// ```
///
/// That is not redundancy, it is load-bearing, and missing it is visible on the first click:
/// the CheckButton widget flips itself before `OnClick` runs and the reference's first line is
/// `this:SetChecked(0)`, so **every** press starts by taking the light off. What puts it back
/// is `PetActionBar_Update` running on this signal and re-deriving `isActive`. Press the mode
/// the pet is *already* in and nothing about the state changes — but the signal still fires and
/// the light still comes back.
///
/// benilla's feed dedups on what it last pushed ([`PetBarMemory`]), which is right for a
/// per-frame feed and wrong here: a no-change press pushed nothing, fired nothing, and left the
/// button visually un-toggled with no way back until something else moved. So the counter is
/// folded into the dedup key — a bump is a forced repaint, which is exactly what the signal
/// buys the reference. Wrapping, because only its *changes* are ever read.
pub(crate) bar_signals: u32,
}
impl PetBar {
/// `PetHasActionBar()` — is there a bar at all. The client's own gate is exactly this, a
/// nonzero cached pet guid: no alive check, no control check (wow-re §3).
pub(crate) fn has_bar(&self) -> bool {
self.spells.pet_guid != 0
}
}
pub(crate) struct UiPetPlugin;
impl Plugin for UiPetPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<PetBar>().add_systems(
Update,
(
// Every feed rides with the unit feed (before the VM ticks, like the stance bar's);
// each drain runs after the input pass so a click goes out the same frame.
// The old-target clear runs BEFORE the feed so the Attack button goes out in the
// same frame the selection moved, not one behind it.
pet_stop_on_old_target_clear
.in_set(UnitFeed)
.before(feed_pet_bar),
feed_pet_bar.in_set(UnitFeed).before(UiInput),
feed_pet_unit.in_set(UnitFeed).before(UiInput),
feed_pet_menu.in_set(UnitFeed).before(UiInput),
drain_pet_actions.after(UiInput),
drain_pet_menu.after(UiInput),
),
);
}
}
/// **The unit behind [`PetBar`]'s cached guid, plus our own identity** — the one lookup both pet
/// systems need and neither owns.
///
/// It exists because the client's two newest pet answers are both *about the pet's own
/// descriptor* rather than about the bar: the `PET_ATTACK_*` edge reads the pet's
/// `UNIT_FIELD_FLAGS`, and the ATTACK order's validator reads the pet's whole eligibility. Both
/// also need the active player's guid, because both test **ownership** before they trust what they
/// read (`0x5ff780` and `0x612e33`).
#[derive(bevy::ecs::system::SystemParam)]
pub(crate) struct PetUnit<'w, 's> {
index: Res<'w, GuidIndex>,
stores: Query<'w, 's, &'static ObjectStore>,
self_guid: Res<'w, crate::net::SelfGuid>,
}
impl PetUnit<'_, '_> {
/// The pet's streamed descriptor, or `None` while the bar names a guid whose object has not
/// arrived (or has left) — the case the reference treats as "no pet", never as "a bad pet".
pub(crate) fn store(&self, pet_guid: u64) -> Option<&ObjectStore> {
let e = *self.index.0.get(&pet_guid)?;
self.stores.get(e).ok()
}
/// The pet's ECS **entity**, under [`Self::store`]'s exact contract — `None` while the named
/// guid's object has not arrived (or has left). The pet paper doll's body booth needs the
/// entity itself, not its fields (decision 1057, `crate::ui_pet_doll`); gating it on the same
/// store presence keeps "there is a pet" one answer rather than two that can disagree.
pub(crate) fn entity(&self, pet_guid: u64) -> Option<Entity> {
let e = *self.index.0.get(&pet_guid)?;
self.stores.contains(e).then_some(e)
}
}
+885
View File
@@ -0,0 +1,885 @@
//! The pet system's unit tests — one module for all three concerns, because they share one set of
//! fixtures (a packed word, a slot view, a `PetSpells` state) and splitting them per module would
//! fork those three ways.
use bevy::prelude::*;
use benilla_protocol::messages::{
PetActionEntry, PetSpells, PET_ACT_COMMAND, PET_ACT_DISABLED, PET_ACT_ENABLED, PET_ACT_PASSIVE,
PET_ACT_REACTION, PET_COMMAND_ATTACK, PET_COMMAND_DISMISS, PET_COMMAND_FOLLOW,
PET_COMMAND_STAY, PET_REACT_AGGRESSIVE, PET_REACT_DEFENSIVE, PET_REACT_PASSIVE,
PET_STATE_BAR_DISABLED,
};
use benilla_ui::script::PetActionView;
use crate::net::{ClientCommand, NetCommands, ObjectStore};
use super::bar::*;
use super::drain::*;
use super::menu::*;
use super::unit::*;
use super::*;
fn packed(action: u32, kind: u8) -> PetActionEntry {
PetActionEntry::from(action | (u32::from(kind) << 24))
}
/// [`slot_view`] for a slot that is **not** showing active — the ordinary case, and the only
/// one the token/spell/cooldown tests care about. The showing-active leg has its own tests,
/// which call `slot_view` directly so the flag is visible at the call.
fn view(
entry: PetActionEntry,
bar: &PetSpells,
spell: Option<&benilla_formats::SpellDisplay>,
cooldown: Option<(i64, u32, bool)>,
pet_attacking: bool,
) -> PetActionView {
slot_view(entry, bar, spell, cooldown, pet_attacking, false)
}
fn spell(name: &str, rank: Option<&str>) -> benilla_formats::SpellDisplay {
benilla_formats::SpellDisplay {
name: name.to_string(),
rank: rank.map(str::to_string),
icon: Some("Interface\\Icons\\Ability_Druid_Rake".into()),
..Default::default()
}
}
/// The state dword as the server packs it: react in byte 0, command in byte 1.
fn state(command: u32, react: u32) -> PetSpells {
PetSpells {
pet_guid: 0x2A,
state: react | (command << 8),
..Default::default()
}
}
/// A command token returns GLOBAL NAMES for both name and texture, and is lit exactly when it
/// IS the pet's current command — the whole reason three command buttons can share one state.
#[test]
fn command_tokens_light_on_the_current_command() {
let bar = state(PET_COMMAND_FOLLOW, PET_REACT_DEFENSIVE);
let follow = view(
packed(PET_COMMAND_FOLLOW, PET_ACT_COMMAND),
&bar,
None,
None,
false,
);
assert_eq!(follow.name.as_deref(), Some("PET_ACTION_FOLLOW"));
assert_eq!(follow.texture.as_deref(), Some("PET_FOLLOW_TEXTURE"));
assert!(follow.is_token && follow.active);
assert!(!follow.attack_active, "Follow is not the attack fork");
let stay = view(
packed(PET_COMMAND_STAY, PET_ACT_COMMAND),
&bar,
None,
None,
false,
);
assert!(!stay.active, "only the CURRENT command is lit");
}
/// The Attack button's second clause: the attack latch lights it and arms the call-off fork
/// **independently of the command state**, which is how a pet told to Follow can still show
/// Attack lit while it is on something.
#[test]
fn the_attack_latch_lights_attack_whatever_the_command_state_says() {
let following = state(PET_COMMAND_FOLLOW, PET_REACT_DEFENSIVE);
let attack = packed(PET_COMMAND_ATTACK, PET_ACT_COMMAND);
let idle = view(attack, &following, None, None, false);
assert!(!idle.active && !idle.attack_active);
let on = view(attack, &following, None, None, true);
assert!(on.active, "the latch lights it even on a FOLLOW command");
assert!(on.attack_active, "and the next press calls the pet off");
// The latch never reaches another command's button, however busy the pet is.
let follow = view(
packed(PET_COMMAND_FOLLOW, PET_ACT_COMMAND),
&following,
None,
None,
true,
);
assert!(!follow.attack_active);
assert!(follow.active, "…but Follow is still the standing command");
}
/// Reaction tokens use the `PET_MODE_*` keys — the state words — not the right-click menu's
/// `PET_AGGRESSIVE` imperatives. Identical in enUS, distinguishable only by key.
#[test]
fn reaction_tokens_use_the_mode_keys_and_light_on_the_current_react() {
let bar = state(PET_COMMAND_FOLLOW, PET_REACT_DEFENSIVE);
let def = view(
packed(PET_REACT_DEFENSIVE, PET_ACT_REACTION),
&bar,
None,
None,
false,
);
assert_eq!(def.name.as_deref(), Some("PET_MODE_DEFENSIVE"));
assert_eq!(def.texture.as_deref(), Some("PET_DEFENSIVE_TEXTURE"));
assert!(def.is_token && def.active);
assert!(
!view(
packed(PET_REACT_AGGRESSIVE, PET_ACT_REACTION),
&bar,
None,
None,
false
)
.active
);
}
/// A DISABLED bar (bit 27) changes what both token classes report, and neither is a special
/// case we wrote: the reaction compare's left side is forced to Passive, and the command
/// compare — unmasked `state >> 8` — is put out of range of every command at once.
#[test]
fn a_disabled_bar_reads_passive_and_lights_no_command() {
let mut bar = state(PET_COMMAND_FOLLOW, PET_REACT_DEFENSIVE);
bar.state |= PET_STATE_BAR_DISABLED;
let passive = view(
packed(PET_REACT_PASSIVE, PET_ACT_REACTION),
&bar,
None,
None,
false,
);
assert!(passive.active, "a bar that cannot be ordered reads Passive");
assert!(
!view(
packed(PET_REACT_DEFENSIVE, PET_ACT_REACTION),
&bar,
None,
None,
false
)
.active
);
assert!(
!view(
packed(PET_COMMAND_FOLLOW, PET_ACT_COMMAND),
&bar,
None,
None,
false
)
.active,
"the command it IS on goes dark too"
);
}
/// `GetPetActionsUsable` — bit 27 OR the pet's own crowd-control flags. The second half is the
/// leg benilla was missing: a feared/stunned/confused pet's bar greys.
#[test]
fn usability_is_the_disabled_bit_and_the_pets_crowd_control() {
let mut bar = PetBar {
spells: state(PET_COMMAND_FOLLOW, PET_REACT_DEFENSIVE),
..Default::default()
};
assert!(actions_usable(&bar, Some(0)));
assert!(
actions_usable(&bar, None),
"a missing descriptor is not a no"
);
for flag in [0x0004_0000, 0x0040_0000, 0x0080_0000] {
assert!(!actions_usable(&bar, Some(flag)), "flag {flag:#x} disables");
}
// POSSESSED is deliberately not one of them — that IS the pet-bar case.
assert!(actions_usable(&bar, Some(0x0100_0000)));
bar.spells.state |= PET_STATE_BAR_DISABLED;
assert!(!actions_usable(&bar, Some(0)));
}
/// A spell slot: real name, rank subtext, icon PATH — and the autocast pair read off **bits
/// 31/30**, not the type byte. `isActive` is nil on every spell path.
#[test]
fn spell_slots_read_their_autocast_off_bits_31_and_30() {
let bar = state(PET_COMMAND_FOLLOW, PET_REACT_DEFENSIVE);
let claw = spell("Claw", Some("Rank 3"));
let on = view(
packed(3010, PET_ACT_ENABLED),
&bar,
Some(&claw),
None,
false,
);
assert_eq!(on.name.as_deref(), Some("Claw"));
assert_eq!(on.subtext.as_deref(), Some("Rank 3"));
assert_eq!(on.spell_id, Some(3010));
assert!(!on.is_token);
assert!(on.autocast_allowed && on.autocast_enabled);
assert!(!on.active, "a SPELL slot never reports isActive");
let off = view(
packed(3010, PET_ACT_DISABLED),
&bar,
Some(&claw),
None,
false,
);
assert!(off.autocast_allowed && !off.autocast_enabled);
// A passive pet spell shows, but can never autocast — no ring, no sparkle.
let passive = view(
packed(3010, PET_ACT_PASSIVE),
&bar,
Some(&claw),
None,
false,
);
assert!(!passive.autocast_allowed && !passive.autocast_enabled);
}
/// The two routes to an empty-looking button: the client's zero WORD, and vmangos's own
/// `(0, ACT_DISABLED)` filler, which takes the spell branch and misses the catalog. Both must
/// draw nothing — reading either one wrong puts four "?" buttons mid-bar.
#[test]
fn both_kinds_of_empty_slot_draw_nothing() {
let bar = state(PET_COMMAND_FOLLOW, PET_REACT_DEFENSIVE);
let zero = PetActionEntry::default();
assert!(zero.is_empty());
assert_eq!(
view(zero, &bar, None, None, false),
PetActionView::default()
);
let filler = packed(0, PET_ACT_DISABLED);
assert!(!filler.is_empty(), "the WORD is not zero");
assert_eq!(
view(filler, &bar, None, None, false),
PetActionView {
// Draws nothing and still CARRIES its word (decision 1010) — this is the exact
// slot the drop core hunts for as a relocation candidate (type 1, low 16 zero),
// so zeroing it here would both send the wrong word and lose the candidate.
packed: filler.packed,
..Default::default()
},
"…but spell id 0 resolves to nothing, so the button is still empty"
);
}
/// A spell the catalog cannot name draws nothing rather than a nameless icon — and with it
/// goes the autocast pair, which the client also gates on the record resolving.
#[test]
fn an_unresolvable_spell_draws_nothing() {
let bar = state(PET_COMMAND_FOLLOW, PET_REACT_DEFENSIVE);
let entry = packed(999, PET_ACT_ENABLED);
let v = view(entry, &bar, None, None, false);
assert_eq!(
v,
PetActionView {
packed: entry.packed,
..Default::default()
}
);
assert!(!v.autocast_allowed && !v.autocast_enabled);
}
/// A type outside 1–7 is inert. The client's own default arm under-pushes here (wow-re §2.5);
/// we answer the empty slot instead, and in particular never reach the spell catalog with an
/// arbitrary number.
#[test]
fn an_unknown_type_byte_is_inert() {
let bar = state(PET_COMMAND_FOLLOW, PET_REACT_DEFENSIVE);
let claw = spell("Claw", None);
let entry = packed(3010, 0x33);
assert_eq!(
view(entry, &bar, Some(&claw), None, false),
PetActionView {
packed: entry.packed,
..Default::default()
}
);
}
/// The slot index map is 1-based and bounded — a stale index from the VM cannot read past the
/// ten words or wrap into slot 10.
#[test]
fn slot_lookup_is_one_based_and_bounded() {
let mut bar = PetBar {
spells: state(PET_COMMAND_FOLLOW, PET_REACT_DEFENSIVE),
..Default::default()
};
bar.spells.bar[0] = packed(PET_COMMAND_ATTACK, PET_ACT_COMMAND);
bar.spells.bar[9] = packed(PET_REACT_PASSIVE, PET_ACT_REACTION);
assert_eq!(slot_entry(&bar, 1).unwrap().action(), PET_COMMAND_ATTACK);
assert_eq!(slot_entry(&bar, 10).unwrap().action(), PET_REACT_PASSIVE);
assert!(slot_entry(&bar, 0).is_none());
assert!(slot_entry(&bar, 11).is_none());
assert!(slot_entry_mut(&mut bar, 0).is_none());
assert!(slot_entry_mut(&mut bar, 11).is_none());
}
/// The local latch, which exists because the server confirms none of it (module doc), written
/// with the client's own masks: a command press moves bits 8–15 and keeps byte 0 **and bit
/// 27**, a reaction press moves byte 0 only, a spell press moves neither — and DISMISS moves
/// nothing, because it ends the pet rather than becoming its standing order.
#[test]
fn a_press_latches_the_state_the_server_never_echoes() {
let mut bar = PetBar {
spells: state(PET_COMMAND_FOLLOW, PET_REACT_DEFENSIVE),
..Default::default()
};
bar.spells.state |= PET_STATE_BAR_DISABLED;
latch_press(&mut bar, packed(PET_COMMAND_STAY, PET_ACT_COMMAND), false);
assert_eq!(bar.spells.command_state() & 0xFF, PET_COMMAND_STAY);
assert_eq!(bar.spells.react_state(), PET_REACT_DEFENSIVE, "untouched");
assert!(
bar.spells.bar_disabled(),
"the disabled bit is the SERVER's — a command press must preserve it"
);
latch_press(
&mut bar,
packed(PET_REACT_AGGRESSIVE, PET_ACT_REACTION),
false,
);
assert_eq!(bar.spells.react_state(), PET_REACT_AGGRESSIVE);
assert_eq!(bar.spells.command_state() & 0xFF, PET_COMMAND_STAY);
assert!(bar.spells.bar_disabled());
latch_press(&mut bar, packed(3010, PET_ACT_DISABLED), false);
assert_eq!(bar.spells.command_state() & 0xFF, PET_COMMAND_STAY);
assert_eq!(bar.spells.react_state(), PET_REACT_AGGRESSIVE);
latch_press(
&mut bar,
packed(PET_COMMAND_DISMISS, PET_ACT_COMMAND),
false,
);
assert_eq!(
bar.spells.command_state() & 0xFF,
PET_COMMAND_STAY,
"Dismiss ends the pet; it never becomes its standing command"
);
}
/// **The director's report: clicking a lit Follow or Passive un-toggles it.**
///
/// The CheckButton flips itself before `OnClick` runs and the reference's first line is
/// `this:SetChecked(0)`, so every press starts by taking the light off. `0x4bc940`/`0x4bc960`
/// put it back by signalling `PET_BAR_UPDATE` **unconditionally** — no old-vs-new compare — so
/// pressing the mode the pet is already in still repaints. Our feed dedups on what it pushed,
/// so without a counter in that key the light had no way back.
///
/// A press that the reference does NOT signal must not bump: DISMISS and a spell reach
/// `0x4bd444` straight, and a hunter's ATTACK skips `0x4bd429` with the latch it never raises.
#[test]
fn every_press_the_reference_signals_forces_a_repaint() {
let mut bar = PetBar {
spells: state(PET_COMMAND_FOLLOW, PET_REACT_DEFENSIVE),
..Default::default()
};
let signals = |b: &PetBar| b.bar_signals;
// Pressing the mode the pet is ALREADY in: nothing about the state moves, and the signal
// still fires. This is the whole bug.
let before = signals(&bar);
latch_press(&mut bar, packed(PET_COMMAND_FOLLOW, PET_ACT_COMMAND), false);
assert_eq!(bar.spells.command_state(), PET_COMMAND_FOLLOW, "unmoved");
assert_ne!(signals(&bar), before, "and it repaints anyway");
let before = signals(&bar);
latch_press(
&mut bar,
packed(PET_REACT_DEFENSIVE, PET_ACT_REACTION),
false,
);
assert_eq!(bar.spells.react_state(), PET_REACT_DEFENSIVE, "unmoved");
assert_ne!(signals(&bar), before, "same for the reaction side");
// The presses the reference leaves silent.
let before = signals(&bar);
latch_press(
&mut bar,
packed(PET_COMMAND_DISMISS, PET_ACT_COMMAND),
false,
);
latch_press(&mut bar, packed(3010, PET_ACT_ENABLED), false);
latch_press(&mut bar, packed(PET_COMMAND_ATTACK, PET_ACT_COMMAND), false);
assert_eq!(
signals(&bar),
before,
"Dismiss, a spell and a pet's Attack all reach 0x4bd444 without signalling"
);
// A POSSESSED unit's Attack does signal — `0x4bd429`, one instruction before the latch.
latch_press(&mut bar, packed(PET_COMMAND_ATTACK, PET_ACT_COMMAND), true);
assert_ne!(signals(&bar), before);
}
/// ATTACK is the one command press that can raise the attack latch — **and only while we are
/// possessing the unit** (`0x4bd420`). On an ordinary pet bar the arm is unreachable, which is
/// the whole reason its button never checks.
#[test]
fn only_a_possessed_units_attack_press_raises_the_latch() {
let mut bar = PetBar {
spells: state(PET_COMMAND_FOLLOW, PET_REACT_DEFENSIVE),
..Default::default()
};
let attack = packed(PET_COMMAND_ATTACK, PET_ACT_COMMAND);
assert!(!bar.attacking);
// A hunter's pet: the press goes out (the caller sends regardless) and latches nothing.
latch_press(&mut bar, attack, false);
assert!(
!bar.attacking,
"an ordinary pet can never raise the latch, so its button can never light"
);
latch_press(&mut bar, attack, true);
assert!(bar.attacking, "a possessed unit can");
bar.attacking = false;
latch_press(&mut bar, packed(PET_COMMAND_DISMISS, PET_ACT_COMMAND), true);
latch_press(&mut bar, packed(PET_REACT_PASSIVE, PET_ACT_REACTION), true);
assert!(!bar.attacking, "only ATTACK raises it");
}
/// `0x5ee5a0` itself — the compare that decides all of the above.
///
/// This is the fact the pet bar was missing: the reference is not asking "is this my pet", it
/// is asking "is this the unit I am *driving*". A hunter pet answers no on the flag, so no
/// hunter press ever reaches the latch.
#[test]
fn the_latch_gate_is_possession_not_ownership() {
const FLAGS: u16 = 46;
const CHARMEDBY: u16 = 10;
const CREATEDBY: u16 = 14;
let unit =
|pairs: &[(u16, u32)]| ObjectStore(benilla_protocol::ObjectFields::from_pairs(pairs));
let me = Some(0x77u64);
// An ordinary hunter pet: ours, in combat, not possessed. The answer is no.
let hunter_pet = unit(&[
(CREATEDBY, 0x77),
(CREATEDBY + 1, 0),
(FLAGS, UNIT_FLAG_PET_IN_COMBAT),
]);
assert!(!possessing(Some(&hunter_pet), me));
// The same pet under Eyes of the Beast — possessed, and now the latch arm is live.
let driven = unit(&[
(CREATEDBY, 0x77),
(CREATEDBY + 1, 0),
(FLAGS, UNIT_FLAG_POSSESSED),
]);
assert!(possessing(Some(&driven), me));
// A mind-controlled mob: CHARMEDBY is the primary leg, so it needs no CREATEDBY at all.
let charmed = unit(&[(CHARMEDBY, 0x77), (CHARMEDBY + 1, 0), (FLAGS, 0x0100_0000)]);
assert!(possessing(Some(&charmed), me));
// Somebody ELSE's possessed unit is not ours to latch for.
let theirs = unit(&[
(CHARMEDBY, 0x99),
(CHARMEDBY + 1, 0),
(FLAGS, UNIT_FLAG_POSSESSED),
]);
assert!(!possessing(Some(&theirs), me));
// No descriptor is no possession — the honest reading of missing data, and the same
// posture the rest of the bar takes.
assert!(!possessing(None, me));
}
/// **The director's report, twice over: the Attack button is never a toggle.**
///
/// Reported by eye against the reference client — *"there is no way in the ref client for the
/// attack button to stay lit up/toggled like the follow or stay… it's simply a button to send
/// the pet"* — and the first fix was wrong because it took the latch's *lifetime* to be the
/// question. It is not: the latch never rises at all on a pet bar, because `0x4bd420` gates it
/// on possession. This drives a whole pet's worth of ATTACK presses and asserts the button
/// stays dark through every one.
#[test]
fn a_pets_attack_button_never_lights() {
let mut bar = PetBar {
spells: state(PET_COMMAND_FOLLOW, PET_REACT_DEFENSIVE),
..Default::default()
};
let attack = packed(PET_COMMAND_ATTACK, PET_ACT_COMMAND);
let follow = packed(PET_COMMAND_FOLLOW, PET_ACT_COMMAND);
for _ in 0..3 {
assert!(
commit_press(&mut bar, attack, false, false),
"the order goes"
);
let lit = view(attack, &bar.spells, None, None, bar.attacking);
assert!(!lit.active, "and the button does not light — ever");
assert!(
!lit.attack_active,
"so the next press orders again, never calls off"
);
}
assert_eq!(
bar.spells.command_state(),
PET_COMMAND_FOLLOW,
"an attack order leaves the standing command alone"
);
assert!(
view(follow, &bar.spells, None, None, bar.attacking).active,
"and Follow keeps the light that is actually a mode's"
);
// Stay is still reachable as a mode — the command byte was never hijacked.
latch_press(&mut bar, packed(PET_COMMAND_STAY, PET_ACT_COMMAND), false);
assert_eq!(bar.spells.command_state(), PET_COMMAND_STAY);
}
/// The **possess** bar is the other half of the same button, and it does light — which is why
/// the `isActive` clause, `IsPetAttackActive` and `PetStopAttack` are all still modelled rather
/// than deleted as dead weight.
#[test]
fn a_possessed_units_attack_button_does_light() {
let mut bar = PetBar {
spells: state(PET_COMMAND_FOLLOW, PET_REACT_DEFENSIVE),
..Default::default()
};
let attack = packed(PET_COMMAND_ATTACK, PET_ACT_COMMAND);
let follow = packed(PET_COMMAND_FOLLOW, PET_ACT_COMMAND);
assert!(commit_press(&mut bar, attack, false, true));
assert!(view(attack, &bar.spells, None, None, bar.attacking).active);
assert!(view(attack, &bar.spells, None, None, bar.attacking).attack_active);
assert!(
view(follow, &bar.spells, None, None, bar.attacking).active,
"the latch is not the command byte — Follow keeps its own light"
);
// The call-off: `PetStopAttack` clears the latch, and the latch was the ONLY thing lighting
// the button — so it goes out.
bar.attacking = false;
let called_off = view(attack, &bar.spells, None, None, bar.attacking);
assert!(!called_off.active);
assert!(!called_off.attack_active);
}
/// **A refused ATTACK costs everything** — no packet, no latch, no light. The reference's veto
/// jumps to the function epilogue rather than the shared send (`0x4bd414 je 0x4bd4c6`), so a
/// pet that is dead, stunned, feared, confused, pacified or charmed away simply does not take
/// the order, and the button must not report that it did.
///
/// This is the join decision 0998 could not test: the gate and the latch, composed.
#[test]
fn a_refused_attack_neither_sends_nor_lights_the_button() {
let mut bar = PetBar {
spells: state(PET_COMMAND_FOLLOW, PET_REACT_DEFENSIVE),
..Default::default()
};
let attack = packed(PET_COMMAND_ATTACK, PET_ACT_COMMAND);
assert!(
!commit_press(&mut bar, attack, true, true),
"a vetoed order never reaches the wire"
);
assert!(!bar.attacking, "and never raises the latch");
assert!(
!view(attack, &bar.spells, None, None, bar.attacking).active,
"so the button stays dark, even on the one bar where it could light"
);
assert_eq!(
bar.spells.command_state(),
PET_COMMAND_FOLLOW,
"a refusal cannot move the standing command either"
);
// The same press with the gate clear is the ordinary attack, unchanged. The two gates are
// NOT symmetric: this one costs the packet, possession costs only the latch.
assert!(commit_press(&mut bar, attack, false, true));
assert!(bar.attacking);
assert!(view(attack, &bar.spells, None, None, bar.attacking).active);
}
/// **Touching your target calls the pet off** — `0x493910`'s `0x493a18`, the second call site
/// of `PetStopAttack`'s core and the one benilla was missing.
///
/// This is the mechanism behind the director's report that Attack is not a toggle. Without it
/// the latch had only two ways down (the lit button's own second press and a new pet), so an
/// order lit the button for the rest of the pet's life and read exactly like the Stay/Follow
/// modes it is supposed to contrast with.
#[test]
fn touching_your_target_calls_the_pet_off() {
let (tx, rx) = crossbeam_channel::unbounded();
let commands = NetCommands(tx);
let mut bar = PetBar {
spells: PetSpells {
pet_guid: 0xF14,
..state(PET_COMMAND_FOLLOW, PET_REACT_DEFENSIVE)
},
attacking: true,
..Default::default()
};
// Selecting when nothing was selected is NOT a clear — `0x493937` returns before anything.
assert!(!old_target_cleared(None, Some(7)));
// Replacing one target with another IS, and so is dropping it: the guard is on the OLD
// selection existing, never on the new one being empty.
assert!(old_target_cleared(Some(7), Some(9)));
assert!(old_target_cleared(Some(7), None));
// A no-op re-select never reaches the clear at all (`0x493540`'s own dedup).
assert!(!old_target_cleared(Some(7), Some(7)));
assert!(stop_pet_attack(&mut bar, &commands));
assert!(!bar.attacking, "the latch is down");
assert!(
matches!(rx.try_recv(), Ok(ClientCommand::PetStopAttack { pet_guid }) if pet_guid == 0xF14),
"and the server is told, with the BAR's guid"
);
// The button is dark now, because the latch was the only thing lighting it.
let attack = packed(PET_COMMAND_ATTACK, PET_ACT_COMMAND);
assert!(!view(attack, &bar.spells, None, None, bar.attacking).active);
// Re-clearing is a no-op: `0x4bd65e` returns before the send when the latch is already
// down, so a target change per second does not spray packets.
assert!(!stop_pet_attack(&mut bar, &commands));
assert!(rx.try_recv().is_err());
}
/// `PET_ATTACK_START`/`STOP` read the pet's **server-owned** in-combat flag, and only for a
/// unit we own — the correction to decision 0990, which derived them from the local click
/// latch. The owner test uses SUMMONEDBY as its fallback, which is the callback's own choice
/// and not the one `0x5ee5a0` makes for the same-shaped read.
#[test]
fn the_attack_events_read_the_pets_combat_flag_not_the_click_latch() {
const FLAGS: u16 = 46;
const CHARMEDBY: u16 = 10;
const SUMMONEDBY: u16 = 12;
let unit =
|pairs: &[(u16, u32)]| ObjectStore(benilla_protocol::ObjectFields::from_pairs(pairs));
let me = Some(0x77u64);
// Ours by SUMMONEDBY, fighting / not fighting.
let mine = |flags: u32| unit(&[(SUMMONEDBY, 0x77), (SUMMONEDBY + 1, 0), (FLAGS, flags)]);
assert_eq!(
pet_combat_flag(&mine(UNIT_FLAG_PET_IN_COMBAT), me),
Some(true)
);
assert_eq!(pet_combat_flag(&mine(0), me), Some(false));
// An unrelated flag bit is not combat — the callback tests one bit.
assert_eq!(pet_combat_flag(&mine(0x1000), me), Some(false));
// Somebody else's minion never fires, however hard it is fighting.
let theirs = unit(&[
(SUMMONEDBY, 0x99),
(SUMMONEDBY + 1, 0),
(FLAGS, UNIT_FLAG_PET_IN_COMBAT),
]);
assert_eq!(pet_combat_flag(&theirs, me), None);
// CHARMEDBY wins over SUMMONEDBY: a mob WE mind-controlled is ours even though it was
// summoned by nobody, and a minion charmed AWAY from us stops being ours.
let charmed_by_me = unit(&[
(CHARMEDBY, 0x77),
(CHARMEDBY + 1, 0),
(FLAGS, UNIT_FLAG_PET_IN_COMBAT),
]);
assert_eq!(pet_combat_flag(&charmed_by_me, me), Some(true));
let stolen = unit(&[
(CHARMEDBY, 0x99),
(CHARMEDBY + 1, 0),
(SUMMONEDBY, 0x77),
(SUMMONEDBY + 1, 0),
(FLAGS, UNIT_FLAG_PET_IN_COMBAT),
]);
assert_eq!(pet_combat_flag(&stolen, me), None);
}
/// Only the ATTACK order consults the validator. The reference's type-7 arm branches exactly
/// twice, and every other action — the two modes, DISMISS, and anything `>= 4` — leaves down a
/// path that sends unconditionally. Gating them too would make a stunned pet impossible to
/// dismiss or to put back on Follow, which is not what the binary does.
#[test]
fn only_the_attack_order_is_gated() {
assert!(is_attack_order(packed(PET_COMMAND_ATTACK, PET_ACT_COMMAND)));
for action in [
PET_COMMAND_STAY,
PET_COMMAND_FOLLOW,
PET_COMMAND_DISMISS,
4,
9,
] {
assert!(
!is_attack_order(packed(action, PET_ACT_COMMAND)),
"command {action} sends unconditionally"
);
}
// A REACTION slot whose action happens to equal ATTACK's 2 (that is Aggressive) is a
// different word entirely — the type byte decides, never the action alone.
assert!(!is_attack_order(packed(
PET_REACT_AGGRESSIVE,
PET_ACT_REACTION
)));
}
/// A pet with one aura in slot 0. `AURAFLAGS` is nibble-packed 8 slots to the u32, so slot 0's
/// nibble is the low four bits: `0x2` is an effect-index bit (what makes the slot *live*) and
/// `0x1` is `AFLAG_CANCELABLE` — the bit `0x4bcea0` actually tests.
fn pet_running(spell_id: u32, nibble: u32) -> ObjectStore {
const AURA: u16 = 47;
const AURAFLAGS: u16 = 95;
ObjectStore(benilla_protocol::ObjectFields::from_pairs(&[
(AURA, spell_id),
(AURAFLAGS, nibble),
]))
}
/// A spell with an active icon — the shape the predicate needs before it can ever fire.
fn toggle_spell() -> benilla_formats::SpellDisplay {
benilla_formats::SpellDisplay {
active_icon_id: 122,
active_icon: Some("Interface\\Icons\\Ability_Druid_Cower".into()),
..spell("Cower", Some("Rank 1"))
}
}
/// The showing-active predicate, all four of its ways to say no. The pet-side twin of
/// `0x4e55f0` is the *same function* we already had, so what these assert is the wiring: the
/// pet's store goes in, and the three tests are the reference's three.
#[test]
fn a_pet_spell_shows_active_only_while_it_is_a_live_cancelable_aura_on_the_pet() {
let slot = packed(2645, benilla_protocol::messages::PET_TYPE_SPELL_FIRST);
let running = pet_running(2645, 0x3);
let d = toggle_spell();
assert_eq!(
active_aura_press(slot, Some(&running), Some(&d)),
Some(2645)
);
// No ActiveIconID: never a toggle — and that gate is on the SEND too, so this spell
// re-casts rather than cancelling however live its aura is (the reference's own quirk).
let plain = spell("Growl", None);
assert_eq!(active_aura_press(slot, Some(&running), Some(&plain)), None);
// Live but NOT cancelable (effect-index bit only).
let uncancelable = pet_running(2645, 0x2);
assert_eq!(active_aura_press(slot, Some(&uncancelable), Some(&d)), None);
// A different spell's aura, and no pet descriptor at all.
assert_eq!(
active_aura_press(slot, Some(&pet_running(768, 0x3)), Some(&d)),
None
);
assert_eq!(active_aura_press(slot, None, Some(&d)), None);
// A COMMAND slot whose action equals the spell id is a different word entirely — the type
// byte decides here exactly as it does for the ATTACK gate.
assert_eq!(
active_aura_press(packed(2645, PET_ACT_COMMAND), Some(&running), Some(&d)),
None
);
}
/// The icon swap itself: `ActiveIconID`'s texture replaces `SpellIconID`'s while the spell is
/// running, and the button keeps everything else it had.
#[test]
fn an_active_pet_spell_draws_its_active_icon() {
let slot = packed(2645, benilla_protocol::messages::PET_TYPE_SPELL_FIRST);
let bar = state(PET_COMMAND_FOLLOW, PET_REACT_DEFENSIVE);
let d = toggle_spell();
let idle = slot_view(slot, &bar, Some(&d), None, false, false);
assert_eq!(idle.texture, d.icon);
let active = slot_view(slot, &bar, Some(&d), None, false, true);
assert_eq!(active.texture, d.active_icon);
assert_eq!(
active.name, idle.name,
"only the icon swaps — the name, rank and autocast flags are untouched"
);
assert!(
!active.active,
"and it is still not `isActive`: a spell slot pushes nil there on every path"
);
}
/// An active spell whose `ActiveIconID` does not resolve in `SpellIcon.dbc` pushes **nil**,
/// not the inactive art. The reference looks up whichever id the predicate chose and gives up
/// if that lookup fails (`0x4bdd50`) — falling back would draw "not running" on a running
/// spell, which is worse than drawing nothing.
#[test]
fn an_unresolvable_active_icon_hides_rather_than_falling_back() {
let slot = packed(2645, benilla_protocol::messages::PET_TYPE_SPELL_FIRST);
let bar = state(PET_COMMAND_FOLLOW, PET_REACT_DEFENSIVE);
let d = benilla_formats::SpellDisplay {
active_icon_id: 9999,
active_icon: None,
..spell("Cower", None)
};
assert!(slot_view(slot, &bar, Some(&d), None, false, true)
.texture
.is_none());
}
/// **The menu's fork, and the direction of each mask** (decision 1066).
///
/// This is the test that exists because the two bits are one nibble apart and the failure is
/// silent both ways: a hunter offered *Dismiss* loses the taming chain's only exit, and a warlock
/// offered *Abandon* is offered a row the reference never shows them.
#[test]
fn the_menu_fork_reads_abandon_and_rename_off_the_right_bits() {
// A freshly tamed hunter pet carries both: abandon/rename/paperdoll show, dismiss hides.
assert_eq!(menu_predicates(0x30), (true, true));
// …and after its first rename the server clears only the rename bit.
assert_eq!(menu_predicates(0x20), (true, false));
// A warlock's demon carries neither — the whole menu flips to Dismiss.
assert_eq!(menu_predicates(0), (false, false));
// The masks are not interchangeable, which is the point of the test.
assert_eq!(menu_predicates(0x10), (false, true));
// Neighbouring flags are not these two: 0x8 is PLAYER_CONTROLLED (on every pet) and 0x40 is
// PLUS_MOB. Reading the pair as a byte rather than as two bits would swallow both.
assert_eq!(menu_predicates(0x8 | 0x40), (false, false));
}
/// The rename signal: a pet's cached name is dropped when *that pet's* timestamp moves, and on no
/// other transition. The guid half of the pair is what keeps the NEXT pet from reading as a
/// rename of the last one.
#[test]
fn only_the_same_pets_moving_timestamp_reads_as_a_rename() {
// Nothing seen before — a login, or the pet's object just streamed.
assert!(!was_renamed(None, (0xF14, Some(100))));
// The same pet, the same stamp: the ordinary frame, which is nearly all of them.
assert!(!was_renamed(Some((0xF14, Some(100))), (0xF14, Some(100))));
// The same pet, a moved stamp: the rename.
assert!(was_renamed(Some((0xF14, Some(100))), (0xF14, Some(200))));
// A field that had never been sent starting to arrive is also a move — a pet named for the
// first time goes from absent to a stamp, and its cached name is just as stale.
assert!(was_renamed(Some((0xF14, None)), (0xF14, Some(200))));
// A DIFFERENT pet is never a rename, whatever the stamps do — dismiss one pet, summon
// another, and the second must not have its name dropped on the first's account.
assert!(!was_renamed(Some((0xF14, Some(100))), (0xABC, Some(200))));
assert!(!was_renamed(Some((0xF14, Some(100))), (0xABC, Some(100))));
}
/// **Dismiss is a bar press with no button** — the correction the wow-re carve made to this build
/// (§11c). `PetDismiss 0x4be4d0` opens no packet: it stages the packed word `0x07000003` and hands
/// it to the same dispatcher every pet-bar click uses, so it leaves as `CMSG_PET_ACTION`.
///
/// The literal is pinned here against the word the constants build, because the two ways of saying
/// it are the two halves of the finding, and the first draft of this feature sent
/// `CMSG_PET_ABANDON` for both rows — which vmangos would have honoured, silently.
#[test]
fn the_dismiss_word_is_the_carved_literal() {
assert_eq!(
PET_COMMAND_DISMISS | (u32::from(PET_ACT_COMMAND) << 24),
0x0700_0003,
);
// …and it decodes back as the command token it is, through the client's own masked read.
let entry = PetActionEntry::from(0x0700_0003u32);
assert_eq!(entry.kind(), PET_ACT_COMMAND);
assert_eq!(entry.action(), PET_COMMAND_DISMISS);
assert!(!entry.is_spell(), "dismiss is a command, never a cast");
}
+242
View File
@@ -0,0 +1,242 @@
//! The **`"pet"` unit token** and the pet frame's events ([`feed_pet_unit`], decision 0990).
//!
//! This lives with the pet bar rather than beside the `"player"`/`"target"` feed for one reason:
//! the token's identity is [`PetBar`]'s cached pet guid — the client's `[0xb714a0]`, which is also
//! what `UNIT_PET` fires off (wow-re §9) — so the token and its repaint wire read the same word,
//! from the module that owns it.
use bevy::prelude::*;
use benilla_ui::script::{ScriptValue, UiScript, UnitState};
use crate::names::NameCache;
use crate::net::{NetCommands, ObjectStore};
use crate::ui_unit::{fire_transitions, snapshot};
use super::{PetBar, PetUnit};
/// What the `"pet"` token feed last pushed — the three edges it fires on (decision 0990).
#[derive(Default)]
pub(super) struct PetUnitMemory {
/// The last snapshot pushed under `"pet"`, for [`fire_transitions`]' per-field diff.
pushed: Option<UnitState>,
/// The last pet guid — `UNIT_PET`'s trigger. `None` until the first feed, so a login with a
/// pet already out still announces it once.
guid: Option<u64>,
/// The pet's last `UNIT_FIELD_FLAGS & 0x800` — the `PET_ATTACK_START`/`PET_ATTACK_STOP` pair's
/// trigger. `None` until the first resolved pet, so a login with a pet already fighting
/// announces it once instead of reading as a transition from "calm".
in_combat: Option<bool>,
/// The last `(pet guid, UNIT_FIELD_PET_NAME_TIMESTAMP)` seen — the rename's only signal
/// (decision 1066). The guid rides along because the timestamp alone would read a *different*
/// pet's stamp as a rename of this one.
name_stamp: Option<(u64, Option<u32>)>,
}
/// `UNIT_FIELD_FLAGS` bit `0x800` — the **pet-in-combat** flag, and the whole trigger for
/// `PET_ATTACK_START`/`PET_ATTACK_STOP` (`0x5ff75e test ah,8`, wow-re
/// `object-layer/scratch/pet-command-validators.md` §4).
///
/// Server-owned and server-written: nothing client-side sets it, which is exactly why it — and not
/// the local click latch — is what the reference watches.
pub(super) const UNIT_FLAG_PET_IN_COMBAT: u32 = 0x0000_0800;
/// Feed the **`"pet"` unit token** and the pet frame's three events (decision 0990).
///
/// **The token resolves off the bar's cached pet guid, not off our own `UNIT_FIELD_SUMMON`**, and
/// that is the client's own choice rather than a convenience. wow-re §9 carves `UNIT_PET` as firing
/// from inside `SetPet 0x4bc7e0` (`0x4bc84f`: `SignalEvent(2, "%s", "player")`) — the single writer
/// of `[0xb714a0]`, the same cached guid the whole pet bar reads — and **only when that guid
/// actually changed**. Since `UNIT_PET` is the pet frame's only repaint wire, a token sourced from
/// anywhere else would repaint on the wrong edges. Reading both off one guid is what keeps them in
/// step, and it is also what makes a **possessed or charmed** unit — which has a bar but is nobody's
/// `UNIT_FIELD_SUMMON` — carry a frame at all.
///
/// The reaction argument is `0`: the pet frame reads no reaction (only `"target"` resolves one —
/// [`crate::ui_unit::feed_units`]' own note), and the party feed passes the same for the same
/// reason.
pub(super) fn feed_pet_unit(
script: Option<NonSendMut<UiScript>>,
bar: Res<PetBar>,
pet: PetUnit,
mut names: ResMut<NameCache>,
commands: Res<NetCommands>,
mut memory: Local<PetUnitMemory>,
) {
let Some(mut script) = script else {
return;
};
let pet_guid = bar.spells.pet_guid;
watch_name_timestamp(pet_guid, &mut script, &pet, &mut names, &mut memory);
// A bar whose unit has not streamed yet (or has left) pushes nothing: `UnitExists("pet")` then
// reads false and the frame hides, which is honest — we have the guid but none of the fields
// the frame draws.
let fresh = (pet_guid != 0)
.then(|| pet.store(pet_guid))
.flatten()
.map(|store| {
let name = names.resolve(pet_guid, &commands).map(str::to_string);
let mut s = snapshot(store, name, 0);
s.guid = pet_guid;
s
});
script.set_unit("pet", fresh.clone());
match &fresh {
Some(cur) => {
if memory.pushed.as_ref() != Some(cur) {
// The one line worth having here, and it is about the failure that is otherwise
// SILENT: holding a bar for a guid whose descriptor never streamed leaves
// `UnitExists("pet")` false, so the frame simply never appears and nothing says
// why. Naming the resolved unit on each change makes "the bar is up but the frame
// is not" a one-grep question.
if memory.pushed.is_none() {
debug!(
"ui_pet: \"pet\" resolved — {} ({}/{} hp)",
cur.name.as_deref().unwrap_or("<name pending>"),
cur.health,
cur.max_health,
);
}
fire_transitions(&mut script, "pet", memory.pushed.as_ref(), cur);
memory.pushed = Some(cur.clone());
}
}
// Clearing a token is not a UNIT_* event — the frame reacts to UNIT_PET below, exactly as
// the target frame reacts to PLAYER_TARGET_CHANGED rather than to a cleared snapshot.
None => memory.pushed = None,
}
// UNIT_PET(arg1 = "player") — VERIFIED wow-re §9, including the `arg1` and the changed-guid
// gate. Summon, stable swap and dismiss are the three edges; a re-sent `SMSG_PET_SPELLS` for
// the same pet (a learned spell, a mode change) is not one, which is why this diffs the guid
// rather than riding `feed_pet_bar`'s whole-state diff.
if memory.guid != Some(pet_guid) {
memory.guid = Some(pet_guid);
debug!("ui_pet: UNIT_PET — pet is now {pet_guid:#x}");
script.fire_event("UNIT_PET", vec![ScriptValue::Str("player".into())]);
}
// PET_ATTACK_START (334) / PET_ATTACK_STOP (335) — the pet frame's flashing
// `UI-Player-AttackStatus` overlay.
//
// **CORRECTED.** Decision 0990 derived these from the attack latch `[0xb714b0]`'s edges and
// said so honestly; the derivation was wrong. wow-re later carved the real fire site —
// `0x5ff793`/`0x5ff79a` inside `0x5ff580`, a per-field change callback registered *by field
// byte offset* (`0x6042e2 mov edx,0xa0`), which is why walking the call graph out of the pet
// TU never reached it. The trigger is the unit's own server-supplied
// `UNIT_FIELD_FLAGS & 0x800` **transition**, gated on the unit's owner guid being ours.
//
// The two are not the same question and they visibly diverge: the latch is a local click
// record with exactly three writers, so a pet that disengages on its own — its target dies, it
// runs out of range, it is feared off — clears the flag with no client-side call at all, and
// the latch-driven version would hold the frame's combat glow lit until the player pressed
// something. Conversely a defensive pet that retaliates unbidden raises the flag without any
// press, and the latch never moves.
//
// (`PetActionBarFrame`'s Attack *button* is the other mechanism and keeps the latch — it is
// driven by `PET_BAR_UPDATE` + `IsPetAttackActive`. Two frames, two sources, genuinely
// independent.)
let in_combat = fresh
.as_ref()
.and_then(|_| pet_combat_flag(pet.store(pet_guid)?, pet.self_guid.0));
if let Some(now) = in_combat {
if memory.in_combat != Some(now) {
memory.in_combat = Some(now);
debug!("ui_pet: pet in-combat flag → {now}");
script.fire_event(
if now {
"PET_ATTACK_START"
} else {
"PET_ATTACK_STOP"
},
vec![],
);
}
} else {
// No pet, or not ours: forget the edge rather than firing a STOP the reference never
// sends. `0x5ff580` is a *change* callback — a unit going away does not call it.
memory.in_combat = None;
}
}
/// **Watch the pet's name timestamp, drop the cached name when it moves, and announce it**
/// (decision 1066; the mechanism is VERIFIED at the bytes — wow-re §11c).
///
/// A pet's name is the one name in the client that can change under us. It does not ride the
/// descriptor — it is answered once by `CMSG_PET_NAME_QUERY` into `petnamecache.wdb` and keyed by
/// pet NUMBER — so after a rename `UnitName("pet")` would keep reporting the old name until relog,
/// which is exactly what a taming session does three times an hour.
///
/// **This is the reference's own mechanism, not a workaround for one.** `0x604400` registers a
/// field-change callback *by byte offset* — `mov edx,0x218`, which is
/// `UNIT_FIELD_PET_NAME_TIMESTAMP` — and `0x604aa0` evicts the cache row, re-queries, and fires
/// `LOCALPLAYER_PET_RENAMED` when the unit is ours. (Registration by offset is why no call-graph
/// walk out of the pet TU ever finds it — the same shape as `PET_ATTACK_*`'s `0x5ff580` above, and
/// the second time in this file that has hidden a mechanism.)
///
/// The **event has no handler in stock 1.12** — `UIParent.lua:95` registers it and defines nothing
/// — so the refresh is the eviction, not the event. It is fired anyway because an addon may listen,
/// and because a client that evicts silently is one where "did that fire?" has no answer.
///
/// **A changed pet guid is not a rename.** Comparing timestamps alone would read the next pet's
/// stamp as this one's having moved, so the memory holds the pair and a new guid only records.
///
/// Scope, stated because it is narrower than the reference's: this watches **our** pet, the one the
/// `"pet"` token resolves; the reference's callback is on the field itself and so covers every
/// streamed unit. Another player's pet renamed while we watch it keeps its old name in our cache
/// until it despawns. Widening it means watching the field at the descriptor apply, not here.
fn watch_name_timestamp(
pet_guid: u64,
script: &mut UiScript,
pet: &PetUnit,
names: &mut NameCache,
memory: &mut PetUnitMemory,
) {
let Some(store) = (pet_guid != 0).then(|| pet.store(pet_guid)).flatten() else {
memory.name_stamp = None;
return;
};
let now = store.0.unit_pet_name_timestamp();
let previous = memory.name_stamp.replace((pet_guid, now));
if !was_renamed(previous, (pet_guid, now)) {
return;
}
if let Some(pet_number) = benilla_protocol::guid::pet_number(pet_guid) {
debug!("ui_pet: pet {pet_guid:#x} was renamed (stamp → {now:?}) — re-asking its name");
names.forget_pet(pet_number);
}
// `0x604aa0`'s own gate on the event, and only on the event: the eviction above is
// unconditional, the announcement is for a unit charmed or summoned by us.
if store
.0
.unit_owner(benilla_protocol::OwnerFallback::SummonedBy)
== pet.self_guid.0
{
script.fire_event("LOCALPLAYER_PET_RENAMED", vec![]);
}
}
/// [`watch_name_timestamp`]'s decision alone: did the SAME pet's name timestamp move?
///
/// Four cases, and only one of them is a rename: nothing seen before (a login, or the pet just
/// streamed) is not; a different guid is not, however the stamps compare; the same guid with the
/// same stamp is not; the same guid with a different stamp is.
pub(super) fn was_renamed(previous: Option<(u64, Option<u32>)>, now: (u64, Option<u32>)) -> bool {
previous.is_some_and(|(guid, stamp)| guid == now.0 && stamp != now.1)
}
/// What the `PET_ATTACK_*` field-change callback would see for this unit: `Some(fighting)` when
/// the unit is **ours**, `None` when it is nobody's business of ours and no event may fire.
///
/// Both halves are `0x5ff580`'s, in its order: the owner test at `0x5ff780` first — CHARMEDBY when
/// set, else SUMMONEDBY, which is *not* the fallback `0x5ee5a0` uses for the same-shaped read — and
/// only then the flag at `0x5ff78d`. Reading the flag without the owner test would let any unit in
/// the world flash our pet frame.
pub(super) fn pet_combat_flag(store: &ObjectStore, self_guid: Option<u64>) -> Option<bool> {
(store
.0
.unit_owner(benilla_protocol::OwnerFallback::SummonedBy)
== self_guid)
.then(|| store.0.unit_flags() & UNIT_FLAG_PET_IN_COMBAT != 0)
}
+231
View File
@@ -0,0 +1,231 @@
//! The app-side **pet paper-doll feed** (decision 1057) — `PetPaperDollFrame`'s data, the
//! [`crate::ui_char`] pattern pointed at the pet.
//!
//! Two jobs, each frame, before the VM ticks ([`UiInput`]):
//!
//! - **The pet's [`UnitCombatStats`]**, built through [`crate::ui_char::unit_combat_stats`] — the
//! descriptor-only core, which is all a creature has. It is the *same* snapshot type and the
//! *same* bindings the character sheet reads, because that is the reference's own arrangement:
//! `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.
//!
//! **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
//! the character sheet uses too, and every event is one `PaperDollFrame` fires for the player),
//! while that file is the hunter-only block behind the `0x6116e0` class gate. One diff over both
//! would fire the shared repaint events off happiness drift, and the hunter gate would sit in the
//! path of numbers a warlock's imp has too.
//!
//! Events: [`crate::ui_char::fire_stat_transitions`] with `arg1 = "pet"` — the eight groups the
//! ref's page registers (`PetPaperDollFrame.lua:12-20`) that also have a source. `UNIT_LEVEL` is
//! already fired for `"pet"` by [`crate::ui_pet`]'s token feed, and `UNIT_PET` /
//! `UNIT_PET_EXPERIENCE` / `UNIT_PET_TRAINING_POINTS` by that file and
//! [`crate::ui_pet_stats`] — none is re-fired here. `PET_UI_UPDATE`/`PET_UI_CLOSE` have no source
//! anywhere yet (a named deferral in 1057), and `UNIT_DEFENSE` has none either while
//! `UnitDefense("pet")` is the INTERIM `(0, 0)`.
use bevy::prelude::*;
use benilla_ui::script::{UiScript, UnitCombatStats};
use crate::portrait::PetDollBooth;
use crate::ui_char::{fire_stat_transitions, unit_combat_stats};
use crate::ui_pet::{PetBar, PetUnit};
use crate::ui_script::UiInput;
use crate::ui_unit::UnitFeed;
pub(crate) struct UiPetDollPlugin;
impl Plugin for UiPetDollPlugin {
fn build(&self, app: &mut App) {
// Rides the unit feed beside the pet bar's and the stat block's, and before the VM ticks —
// the whole page repaints out of the one pass that pushes the pet's health.
app.add_systems(Update, feed_pet_doll.in_set(UnitFeed).before(UiInput));
}
}
fn feed_pet_doll(
script: Option<NonSendMut<UiScript>>,
bar: Res<PetBar>,
pet: PetUnit,
mut booth: ResMut<PetDollBooth>,
mut last: Local<Option<UnitCombatStats>>,
) {
let Some(mut script) = script else {
return;
};
// 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();
let pet_guid = bar.spells.pet_guid;
let store = (pet_guid != 0).then(|| pet.store(pet_guid)).flatten();
// `None` empties the booth — the same "no pet" the rest of the page shows, and the same test:
// a bar naming a guid whose object never streamed is not a pet we can draw.
booth.unit = (pet_guid != 0).then(|| pet.entity(pet_guid)).flatten();
let fresh = store.map(unit_combat_stats);
// Push only on change (the pet-bar feed's discipline): the page repaints off the UNIT_* events
// below, so a per-frame push would be pure churn — but the DIFF is what makes it cheap, so a
// real change still lands the frame it arrives.
if *last == fresh {
return;
}
// PUSH before firing: event dispatch runs the Lua handlers synchronously, so the snapshot must
// already be in the VM when they repaint (the `ui_unit` rule — a fire-first ordering paints the
// OLD values and, being transition-gated, never corrects itself).
let prev = last.take();
script.set_pet_combat_stats(fresh.clone());
if let Some(stats) = &fresh {
if prev.is_none() {
debug!(
"ui_pet_doll: pet stats resolved — {} armor, {}-{} damage",
stats.resistances[0], stats.min_damage, stats.max_damage
);
}
fire_stat_transitions(&mut script, "pet", prev.as_ref(), stats);
}
// A pet going away fires nothing: the page hides on `UNIT_PET` (which `crate::ui_pet` fires on
// the guid edge), exactly as the character sheet's stat lines are not cleared by an event.
*last = fresh;
}
#[cfg(test)]
mod tests {
use super::*;
use benilla_protocol::ObjectFields;
use crate::net::ObjectStore;
// The UNIT-block wire indices the core reads (`benilla_protocol`'s private
// `fields::FIELD_UNIT_*` table; the `ui_pet_stats` fixtures' own convention).
const BASEATTACKTIME: u16 = 126;
const MINDAMAGE: u16 = 134;
const MAXDAMAGE: u16 = 135;
const STAT0: u16 = 150;
const RESISTANCES0: u16 = 155;
const ATTACK_POWER: u16 = 165;
const ATTACK_POWER_MODS: u16 = 166;
/// A boar's descriptor: the UNIT half a creature really streams, and nothing else — no PLAYER
/// block at all, which is the whole point of the fixture.
fn boar() -> ObjectStore {
ObjectStore(ObjectFields::from_pairs(&[
(STAT0, 63), // strength
(STAT0 + 1, 45), // agility
(STAT0 + 2, 68), // stamina
(STAT0 + 3, 32), // intellect
(STAT0 + 4, 42), // spirit
(RESISTANCES0, 1810),
(RESISTANCES0 + 2, 15), // fire
(BASEATTACKTIME, 2000),
(MINDAMAGE, 30.5f32.to_bits()),
(MAXDAMAGE, 44.5f32.to_bits()),
(ATTACK_POWER, 178),
// The MODS field is a packed signed pair: pos in the low word, neg in the high.
(ATTACK_POWER_MODS, (((-4i16) as u16 as u32) << 16) | 12),
]))
}
/// The descriptor-only core over a real creature's field set: the UNIT values come through,
/// and **every PLAYER-block-sourced value keeps its default** — which is what makes the ref's
/// pet sheet plain white numbers rather than a buff decomposition.
#[test]
fn the_core_reads_a_creatures_unit_block_and_defaults_the_rest() {
let s = unit_combat_stats(&boar());
assert_eq!(s.stats, [63, 45, 68, 32, 42]);
assert_eq!(s.resistances, [1810, 0, 15, 0, 0, 0, 0]);
assert_eq!(s.min_damage, 30.5);
assert_eq!(s.max_damage, 44.5);
assert_eq!(s.main_attack_time_ms, 2000);
assert_eq!(
(s.attack_power, s.attack_power_pos, s.attack_power_neg),
(178, 12, -4)
);
// No PLAYER block ⇒ no buff splits, no damage-done mods…
assert_eq!(s.stat_pos, [0; 5]);
assert_eq!(s.stat_neg, [0; 5]);
assert_eq!(s.resistance_pos, [0; 7]);
assert_eq!(s.resistance_neg, [0; 7]);
assert_eq!((s.physical_bonus_pos, s.physical_bonus_neg), (0, 0));
// …and `damage_percent` MUST stay 1.0: the ref Lua divides the damage range by it.
assert_eq!(s.damage_percent, 1.0);
// The equipment/skill half is the player feed's; a pet has neither.
assert!(!s.has_offhand && !s.has_wand);
assert_eq!(s.main_weapon_skill, (0, 0));
assert_eq!(s.ranged_weapon_skill, (0, 0));
assert_eq!(
s.defense_skill,
(0, 0),
"INTERIM — pending the wow-re verdict"
);
}
/// An empty store (a guid whose fields have not streamed) is the absent shape, not a panic and
/// not a division-by-zero waiting to happen in the Lua.
#[test]
fn an_unstreamed_pet_reads_the_absent_shape() {
let s = unit_combat_stats(&ObjectStore(ObjectFields::from_pairs(&[])));
assert_eq!(s.stats, [0; 5]);
assert_eq!(s.resistances, [0; 7]);
assert_eq!(s.damage_percent, 1.0);
// The base-swing fallback, so `damage / speed` is finite on the first frames.
assert_eq!(s.main_attack_time_ms, 2000);
}
/// **The seam the gates cannot see**: the exact composition [`feed_pet_doll`] performs —
/// descriptor → core → `set_pet_combat_stats` → the ref's own bindings — really answers the
/// boar's numbers under `"pet"`. A feed wired to the wrong setter, or a core that dropped a
/// field on the way, compiles and shows an all-zero pet sheet; only reading it back through
/// the VM catches that.
#[test]
fn the_feeds_composition_answers_the_pet_bindings() {
let mut s = benilla_ui::script::UiScript::new().unwrap();
s.set_pet_combat_stats(Some(unit_combat_stats(&boar())));
// `PetPaperDollFrame_SetStats`'s read (ref l.149) — stamina is the 3rd, 1-based.
assert_eq!(
s.eval::<(i64, i64, i64, i64)>(r#"return UnitStat("pet", 3)"#)
.unwrap(),
(68, 68, 0, 0)
);
// `PetPaperDollFrame_SetResistances`'s (ref l.112) — fire is school 2.
assert_eq!(
s.eval::<(i64, i64, i64, i64)>(r#"return UnitResistance("pet", 2)"#)
.unwrap(),
(15, 15, 0, 0)
);
// `PaperDollFrame_SetArmor("pet", "Pet")`'s.
assert_eq!(
s.eval::<(i64, i64, i64, i64, i64)>(r#"return UnitArmor("pet")"#)
.unwrap(),
(1810, 1810, 1810, 0, 0)
);
// `PaperDollFrame_SetDamage`'s, and the `/ percent` divisor it feeds.
assert_eq!(
s.eval::<(f64, f64, f64, f64, i64, i64, f64)>(r#"return UnitDamage("pet")"#)
.unwrap(),
(30.5, 44.5, 0.0, 0.0, 0, 0, 1.0)
);
// `PaperDollFrame_SetAttackPower`'s.
assert_eq!(
s.eval::<(i64, i64, i64)>(r#"return UnitAttackPower("pet")"#)
.unwrap(),
(178, 12, -4)
);
// Dismissing the pet is the feed's `None` push — every line falls back to the absent
// shape rather than freezing on the last pet's numbers.
s.set_pet_combat_stats(None);
assert_eq!(
s.eval::<(i64, i64, i64, i64, i64)>(r#"return UnitArmor("pet")"#)
.unwrap(),
(0, 0, 0, 0, 0)
);
}
}
+322 -18
View File
@@ -15,12 +15,24 @@
//! `== 4 || == 0xB` is Rogue and Druid, wow-re `combo-point-gate.md`.) A warlock's imp therefore
//! resolves perfectly and still answers nothing — happiness, loyalty and training points are
//! hunter machinery — and each binding says "nothing" in its own way ([`benilla_ui::script::PetStats`]).
//!
//! **Two more bindings ride here off the pet's `CreatureFamily.dbc` row** (decision 1062), and they
//! sit on **opposite sides of that gate** — which is the whole reason they are worth naming
//! together. `UnitCreatureFamily("pet")` (`0x51a310`) has no class test whatsoever, so a warlock's
//! imp shows "Imp" on the page's level line; `GetPetFoodTypes()` (`0x4bea10`) shares the very same
//! `0x6116e0` gate as the four stats above, so a minion — or a charmed beast under a non-hunter —
//! answers an empty diet even when its family row has a food mask. Both are one lookup on the same
//! clock as the block above, pushed by the same setter, which is why they live here rather than in
//! [`crate::ui_pet`]'s token feed: splitting them would mean two systems racing the same
//! [`crate::names::NameCache`] entry for the same answer. See [`family_for`] for the one thing that
//! is not obvious: a pet's creature-template entry is its *descriptor's*, never its guid's.
use bevy::prelude::*;
use benilla_ui::script::{PetStats, UiScript};
use crate::net::ObjectStore;
use crate::names::NameCache;
use crate::net::{NetCommands, ObjectStore};
use crate::ui_pet::{PetBar, PetUnit};
use crate::ui_script::UiInput;
use crate::ui_unit::UnitFeed;
@@ -43,6 +55,18 @@ pub(crate) struct PetStatTables {
pub(crate) loyalty: benilla_formats::PetLoyaltyNames,
}
/// The **family** pair — `CreatureFamily.dbc` (the word) and `ItemPetFood.dbc` (the diet the row's
/// mask names), decision 1062.
///
/// A separate resource from [`PetStatTables`] rather than four fields on it, so the two halves
/// degrade independently: a missing `ItemPetFood.dbc` must not take happiness down with it, and a
/// missing `PetPersonality.dbc` must not blank the level line.
#[derive(Resource)]
pub(crate) struct PetFamilyTables {
pub(crate) families: benilla_formats::CreatureFamilies,
pub(crate) foods: benilla_formats::PetFoodNames,
}
pub(crate) struct UiPetStatsPlugin;
impl Plugin for UiPetStatsPlugin {
@@ -53,16 +77,70 @@ impl Plugin for UiPetStatsPlugin {
}
}
/// The pet's family word and diet, resolved from its cached creature template (decision 1062).
///
/// **A pet's creature template entry is its descriptor's `OBJECT_FIELD_ENTRY`, never its guid.**
/// A `HIGHGUID_PET` guid carries a *pet number* in the entry-shaped slot (`crate::guid::pet_number`
/// — vmangos `Pet::Create` feeds `Object::_Create`'s entry parameter `petNumber`), which is exactly
/// why [`NameCache::resolve`] sends a pet-*name* query for one; but `Creature::InitEntry` still
/// writes the real template id into the descriptor (`Creature.cpp:376`, `SetEntry(entry) // normal
/// entry always`), so the template query has a key after all. That is the whole reason this reaches
/// past the guid: an Imp's descriptor says entry 416, whose template says `pet_family` 23, whose
/// `CreatureFamily.dbc` row says "Imp".
///
/// This is the **resolving** read — it issues the ask-once creature query on a miss — and it is the
/// only one for the pet, so the answer lands in the shared cache for anything else that wants it.
/// A miss returns `(None, vec![])`, which is the same shape as "this template has no family": the
/// binding's nil either way, and the query is in flight for the next frame.
fn family_for(
pet: Option<&ObjectStore>,
names: &mut NameCache,
commands: &NetCommands,
tables: Option<&PetFamilyTables>,
) -> (Option<String>, Vec<String>) {
let Some(entry) = pet.and_then(|s| s.0.object_entry()).filter(|&e| e != 0) else {
return (None, Vec::new());
};
// The guid argument is the query body's second field and the server ignores it entirely
// (vmangos `HandleCreatureQueryOpcode` answers off `packet.entry` alone), so the template-only
// `0` convention `Items::template` already uses applies here too.
let _ = names.resolve_creature(entry, 0, commands);
let Some(tables) = tables else {
return (None, Vec::new());
};
let Some(family) = names
.creature_record(entry)
.and_then(|r| tables.families.get(r.pet_family))
else {
return (None, Vec::new());
};
(
Some(family.name.clone()),
tables
.foods
.for_mask(family.pet_food_mask)
.into_iter()
.map(str::to_string)
.collect(),
)
}
/// Resolve the whole stat block for the current pet, or [`PetStats::default`] when there is none.
///
/// Split from the system so the composition is testable: every one of these five values passes
/// Split from the system so the composition is testable: every one of the five *stat* values passes
/// through the *same* `hunter_pet` gate, and a bug that let one leak past it would show up as a
/// warlock with a loyalty level rather than as a compile error.
///
/// `family` ([`family_for`]'s pair) is threaded in already resolved, and the two halves land on
/// **opposite sides of the hunter gate** — the word survives it (`UnitCreatureFamily` has no class
/// test), the diet does not (`GetPetFoodTypes` shares `0x6116e0`). See the gate's own comment.
fn stats_for(
pet: Option<&ObjectStore>,
self_store: Option<&ObjectStore>,
tables: Option<&PetStatTables>,
family: (Option<String>, Vec<String>),
) -> (bool, PetStats) {
let (family, food_types) = family;
let Some(fields) = pet.map(|s| &s.0) else {
return (false, PetStats::default());
};
@@ -76,7 +154,22 @@ fn stats_for(
&& self_store.map(|s| ((s.0.unit_bytes_0().unwrap_or(0) >> 8) & 0xff) as u8)
== Some(CLASS_HUNTER);
if !hunter {
return (has_ui, PetStats::default());
// **The family WORD rides past the gate; the DIET does not** — and the split is carved,
// not chosen. `UnitCreatureFamily 0x51a310` has no class test at all (its only nil paths
// are "no record / id 0 / out of range / a null row"), so a warlock's minion shows "Imp"
// on the page's level line. `GetPetFoodTypes 0x4bea10` is gated on `0x6116e0(pet)` — the
// same owner-is-me + class-is-Hunter gate as the four stat bindings — so it answers
// *nothing* for a minion even though the word is there. The shipped data hides the
// difference for warlocks (every minion family ships food mask 0), but not for a
// **charmed beast under a non-hunter**: a mind-controlled boar has family 5 and mask 63,
// and the reference still answers an empty diet for it. (wow-re, 2026-08-06.)
return (
has_ui,
PetStats {
family,
..PetStats::default()
},
);
}
let (happiness, damage_percentage, loyalty_rate) = tables
.and_then(|t| t.personalities.for_pet(None))
@@ -100,26 +193,30 @@ fn stats_for(
}),
training_points: fields.unit_training_points(),
experience: fields.unit_pet_experience(),
family,
food_types,
},
)
}
#[allow(clippy::too_many_arguments)]
fn feed_pet_stats(
script: Option<NonSendMut<UiScript>>,
bar: Res<PetBar>,
pet: PetUnit,
self_store: Query<&ObjectStore, With<crate::net::SelfPlayer>>,
tables: Option<Res<PetStatTables>>,
family_tables: Option<Res<PetFamilyTables>>,
mut names: ResMut<NameCache>,
commands: Res<NetCommands>,
mut last: Local<Option<(bool, PetStats)>>,
) {
let Some(mut script) = script else {
return;
};
let fresh = stats_for(
pet.store(bar.spells.pet_guid),
self_store.iter().next(),
tables.as_deref(),
);
let store = pet.store(bar.spells.pet_guid);
let family = family_for(store, &mut names, &commands, family_tables.as_deref());
let fresh = stats_for(store, self_store.iter().next(), tables.as_deref(), family);
// Push only on change. Happiness moves on its own clock and the frame repaints off UNIT_*
// events, so a per-frame push would be pure churn — but the DIFF is what makes this cheap,
// not a timer, so a real change still lands the same frame it arrives.
@@ -143,17 +240,54 @@ fn feed_pet_stats(
fresh.1.damage_percentage.to_bits(),
fresh.1.loyalty_rate.to_bits(),
));
// The pet page's other two repaint wires (ref `PetPaperDollFrame.lua:9,21`), cut the same
// narrow way: `UNIT_PET_EXPERIENCE` is the XP bar's ONLY wire (`PetExpBar_Update`, l.43-44 —
// the one event arm that repaints nothing else), and `UNIT_PET_TRAINING_POINTS` reaches only
// `PetPaperDollFrame_Update`'s training-point text. Firing each off its own pair rather than
// off the whole block keeps happiness drift — which moves every few seconds while a pet is
// out — from repainting two numbers that did not change.
let xp_moved = last.as_ref().map(|(_, s)| s.experience) != Some(fresh.1.experience);
let training_moved =
last.as_ref().map(|(_, s)| s.training_points) != Some(fresh.1.training_points);
if happiness_moved {
debug!(
"ui_pet_stats: happiness {:?} ({}% damage), loyalty {:?}",
fresh.1.happiness, fresh.1.damage_percentage, fresh.1.loyalty
);
}
// **The family lands LATE and fires NOTHING — a named hole, not an oversight** (decision
// 1062). It arrives with the creature-query answer, a round trip after the pet streams, and
// no event the pet page registers is guaranteed to fire then: `fire_transitions` has no arm
// for it, `UNIT_CLASSIFICATION_CHANGED` (the reference's own "a creature query landed" wire,
// decision 0782) is neither registered by the page nor even edge-able for a pet, whose gated
// rank is pinned at 0. Inventing a fire site — `PET_UI_UPDATE`, or `UNIT_LEVEL` because the
// family shares the level's line — would be asserting a mechanism nobody has carved.
//
// What makes it *survivable* rather than broken: the query goes out the frame the pet's
// descriptor arrives, thousands of frames before a human can open the character window, so
// the page's own `OnShow` → `_Update` covers every ordinary open. The one reachable blank is
// a page already sitting open at the instant a pet is summoned, which the next stat event
// (`UNIT_STATS`/`UNIT_DAMAGE`/…, all of which the page registers and all of which fire
// repeatedly while a pet's descriptor streams) repaints within a frame or two. This line is
// the instrument for it: a family that never appears names itself here.
if last.as_ref().map(|(_, s)| &s.family) != Some(&fresh.1.family) {
debug!(
"ui_pet_stats: pet family {:?}, diet {:?}",
fresh.1.family, fresh.1.food_types
);
}
*last = Some(fresh.clone());
script.set_pet_stats(fresh.0, fresh.1);
// Push before firing — dispatch runs the Lua handlers synchronously (the `ui_unit` rule).
if happiness_moved {
script.fire_event("UNIT_HAPPINESS", vec![]);
}
if xp_moved {
script.fire_event("UNIT_PET_EXPERIENCE", vec![]);
}
if training_moved {
script.fire_event("UNIT_PET_TRAINING_POINTS", vec![]);
}
}
#[cfg(test)]
@@ -168,6 +302,12 @@ mod tests {
const PETXP: u16 = 141;
const PETNEXTXP: u16 = 142;
const TRAINING: u16 = 149;
/// `OBJECT_FIELD_ENTRY` — the pet's creature-template id ([`family_for`]'s whole key).
const ENTRY: u16 = 3;
/// Real `creature_template` entries on the live VM, so the family ids below are the ones the
/// server would actually send: Imp → `pet_family` 23, Stonetusk Boar → 5.
const IMP_ENTRY: u32 = 416;
const BOAR_ENTRY: u32 = 113;
fn hunter() -> ObjectStore {
// BYTES_0 byte 1 = class. Hunter = 3.
@@ -181,9 +321,11 @@ mod tests {
ObjectStore(ObjectFields::from_pairs(&[(BYTES_0, 9 << 8)]))
}
/// A boar: pet number set, loyalty level 6, happy, part-trained, mid-XP.
/// A boar: pet number set, loyalty level 6, happy, part-trained, mid-XP, and its real
/// `creature_template` entry in `OBJECT_FIELD_ENTRY`.
fn boar() -> ObjectStore {
ObjectStore(ObjectFields::from_pairs(&[
(ENTRY, BOAR_ENTRY),
(PETNUMBER, 42),
(BYTES_1, 6 << 8), // loyalty level in byte 1
(POWER5, 1_000_000), // maximum happiness
@@ -193,13 +335,17 @@ mod tests {
]))
}
fn tables() -> Option<PetStatTables> {
fn chain() -> Option<benilla_formats::Chain> {
let data = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data");
if !data.is_dir() {
eprintln!("skipping: vanilla client not present at {}", data.display());
return None;
}
let mut chain = benilla_formats::open_chain(&data).expect("open chain");
Some(benilla_formats::open_chain(&data).expect("open chain"))
}
fn tables() -> Option<PetStatTables> {
let mut chain = chain()?;
Some(PetStatTables {
personalities: benilla_formats::load_pet_personalities(&mut chain)
.expect("personality"),
@@ -207,13 +353,54 @@ mod tests {
})
}
fn family_tables() -> Option<PetFamilyTables> {
let mut chain = chain()?;
Some(PetFamilyTables {
families: benilla_formats::load_creature_families(&mut chain).expect("families"),
foods: benilla_formats::load_pet_food_names(&mut chain).expect("foods"),
})
}
/// The "no family resolved" pair, for the stat tests that are not about the family.
fn no_family() -> (Option<String>, Vec<String>) {
(None, Vec::new())
}
/// A `NameCache` with `entry`'s creature record already cached, carrying `pet_family` —
/// i.e. the state after `SMSG_CREATURE_QUERY_RESPONSE` has landed.
fn cache_with(entry: u32, pet_family: u32) -> NameCache {
let mut names = NameCache::default();
names.insert_creature(
entry,
Some(crate::names::CreatureRecord {
name: "Snarl".into(),
subname: None,
creature_type: 1,
pet_family,
rank: 0,
type_flags: 0,
civilian: false,
racial_leader: false,
}),
);
names
}
fn commands() -> (
NetCommands,
crossbeam_channel::Receiver<crate::net::ClientCommand>,
) {
let (tx, rx) = crossbeam_channel::unbounded();
(NetCommands(tx), rx)
}
/// The whole block end to end on the real DBC data — the packed/derived reads are the point:
/// the training-points word order, the loyalty byte, and happiness coming out PRE-BUCKETED
/// with vanilla's own 125% damage for a maxed pet.
#[test]
fn a_hunters_boar_reads_every_field() {
let Some(t) = tables() else { return };
let (has_ui, s) = stats_for(Some(&boar()), Some(&hunter()), Some(&t));
let (has_ui, s) = stats_for(Some(&boar()), Some(&hunter()), Some(&t), no_family());
assert!(has_ui && s.hunter_pet);
assert_eq!(s.happiness, Some(3));
assert_eq!(s.damage_percentage, 125.0);
@@ -238,7 +425,7 @@ mod tests {
(BYTES_1, 0),
(POWER5, raw),
]));
let (_, s) = stats_for(Some(&store), Some(&hunter()), Some(&t));
let (_, s) = stats_for(Some(&store), Some(&hunter()), Some(&t), no_family());
(s.happiness, s.damage_percentage)
};
assert_eq!(at(0), (Some(1), 75.0), "an unhappy pet deals 75%");
@@ -252,7 +439,7 @@ mod tests {
#[test]
fn a_warlocks_minion_has_a_ui_and_no_stats() {
let Some(t) = tables() else { return };
let (has_ui, s) = stats_for(Some(&boar()), Some(&warlock()), Some(&t));
let (has_ui, s) = stats_for(Some(&boar()), Some(&warlock()), Some(&t), no_family());
assert!(
has_ui,
"the pet number is what HasPetUI's first return reads"
@@ -270,7 +457,7 @@ mod tests {
fn no_pet_number_means_no_pet_ui() {
let Some(t) = tables() else { return };
let possessed = ObjectStore(ObjectFields::from_pairs(&[(POWER5, 1_000_000)]));
let (has_ui, s) = stats_for(Some(&possessed), Some(&hunter()), Some(&t));
let (has_ui, s) = stats_for(Some(&possessed), Some(&hunter()), Some(&t), no_family());
assert!(!has_ui);
assert!(!s.hunter_pet);
}
@@ -285,7 +472,7 @@ mod tests {
(BYTES_1, 0),
(POWER5, 1_000_000),
]));
let (_, s) = stats_for(Some(&fresh), Some(&hunter()), Some(&t));
let (_, s) = stats_for(Some(&fresh), Some(&hunter()), Some(&t), no_family());
assert_eq!(s.loyalty, None);
assert_eq!(s.happiness, Some(3), "…but happiness still answers");
}
@@ -294,7 +481,7 @@ mod tests {
/// wrong bucket: nil happiness with `(100.0, 0.0)` beside it.
#[test]
fn absent_dbc_data_degrades_to_the_failure_numbers() {
let (has_ui, s) = stats_for(Some(&boar()), Some(&hunter()), None);
let (has_ui, s) = stats_for(Some(&boar()), Some(&hunter()), None, no_family());
assert!(has_ui && s.hunter_pet);
assert_eq!(s.happiness, None);
assert_eq!((s.damage_percentage, s.loyalty_rate), (100.0, 0.0));
@@ -344,8 +531,125 @@ mod tests {
/// No pet at all: no UI, no stats, and nothing that could be mistaken for a real answer.
#[test]
fn no_pet_is_no_ui() {
let (has_ui, s) = stats_for(None, Some(&hunter()), None);
let (has_ui, s) = stats_for(None, Some(&hunter()), None, no_family());
assert!(!has_ui);
assert_eq!(s, PetStats::default());
}
/// **The family lookup end to end on the real DBC data** (decision 1062), including all three
/// nil sources the binding has to reproduce.
///
/// The load-bearing fact under test is the KEY: the pet's template entry is read from
/// `OBJECT_FIELD_ENTRY`, not from its guid (whose entry-shaped slot holds a pet number). A
/// version that asked the guid would query a nonexistent template and answer nil forever —
/// silently, since nil is a legitimate answer here.
#[test]
fn the_pets_family_resolves_off_its_descriptor_entry() {
let Some(t) = family_tables() else { return };
let (cmds, rx) = commands();
// 1. No pet at all.
let mut names = NameCache::default();
assert_eq!(family_for(None, &mut names, &cmds, Some(&t)), no_family());
assert!(rx.try_recv().is_err(), "nothing to ask about");
// 2. A pet whose creature query has NOT answered yet — nil, and the ask goes out (once).
let pet = ObjectStore(ObjectFields::from_pairs(&[
(ENTRY, IMP_ENTRY),
(PETNUMBER, 7),
]));
assert_eq!(
family_for(Some(&pet), &mut names, &cmds, Some(&t)),
no_family(),
"un-queried is nil, not a guess"
);
assert!(
matches!(
rx.try_recv(),
Ok(crate::net::ClientCommand::CreatureQuery { entry, .. }) if entry == IMP_ENTRY
),
"the pet's DESCRIPTOR entry is what gets queried"
);
assert!(rx.try_recv().is_err(), "ask-once");
// 3. The answer lands with family 0 — a template with no family. Still nil, and this is
// the common case for every non-tameable creature.
let mut names = cache_with(IMP_ENTRY, 0);
assert_eq!(
family_for(Some(&pet), &mut names, &cmds, Some(&t)),
no_family()
);
// 4. The answer lands with the Imp's real family (23, from the live `creature_template`).
// A warlock minion: a word, and an EMPTY diet — mask 0 in the shipped DBC.
let mut names = cache_with(IMP_ENTRY, 23);
assert_eq!(
family_for(Some(&pet), &mut names, &cmds, Some(&t)),
(Some("Imp".into()), Vec::new())
);
// 5. A hunter's boar (entry 113 → family 5): a word AND the six-diet list, in bit order.
let mut names = cache_with(BOAR_ENTRY, 5);
let (name, diet) = family_for(Some(&boar()), &mut names, &cmds, Some(&t));
assert_eq!(name.as_deref(), Some("Boar"));
assert_eq!(diet, ["Meat", "Fish", "Cheese", "Bread", "Fungus", "Fruit"]);
// 6. No DBC tables at all: nil, degraded to exactly the blank level line 1057 shipped.
let mut names = cache_with(BOAR_ENTRY, 5);
assert_eq!(
family_for(Some(&boar()), &mut names, &cmds, None),
no_family()
);
}
/// **The family WORD survives the hunter gate; the DIET does not** — the carved split
/// (wow-re, 2026-08-06: `UnitCreatureFamily 0x51a310` has no class test, `GetPetFoodTypes
/// 0x4bea10` shares `0x6116e0` with the four stats).
///
/// The pet here is deliberately a **boar under a non-hunter** — a charmed beast, family 5,
/// food mask 63 — because that is the one case where the gate is observable at all: a warlock
/// minion's family ships mask 0, so an ungated implementation would look identical for it and
/// diverge only here. Folding the family word into the gate would blank a level line the
/// reference fills; leaving the diet out of it would feed a priest's mind-controlled boar a
/// six-item menu the reference never shows.
#[test]
fn a_charmed_beast_keeps_its_family_word_and_loses_its_diet() {
let Some(t) = tables() else { return };
let boar_diet: Vec<String> = ["Meat", "Fish", "Cheese", "Bread", "Fungus", "Fruit"]
.map(String::from)
.to_vec();
let (_, s) = stats_for(
Some(&boar()),
Some(&warlock()),
Some(&t),
(Some("Boar".into()), boar_diet.clone()),
);
assert!(!s.hunter_pet);
assert_eq!(s.family.as_deref(), Some("Boar"), "the word is ungated");
assert!(
s.food_types.is_empty(),
"…but the diet shares 0x6116e0 with the stats"
);
assert_eq!(s.loyalty, None, "…as does the hunter machinery");
assert_eq!(s.happiness, None);
// The same pet under a HUNTER gets both.
let (_, s) = stats_for(
Some(&boar()),
Some(&hunter()),
Some(&t),
(Some("Boar".into()), boar_diet.clone()),
);
assert_eq!(s.family.as_deref(), Some("Boar"));
assert_eq!(s.food_types, boar_diet);
}
/// …and with no pet, the family goes with everything else — a stale word left on the block
/// would outlive the pet on a page that is about to close.
#[test]
fn no_pet_drops_the_family_too() {
let (_, s) = stats_for(None, Some(&hunter()), None, (Some("Imp".into()), vec![]));
assert_eq!(s.family, None);
assert!(s.food_types.is_empty());
}
}
+7
View File
@@ -322,6 +322,12 @@ fn resolve_item(
let texture = icons
.and_then(|i| i.catalog.get(it.display_id))
.and_then(|d| d.icon.clone());
// The ctrl/shift click arms' payload (`GetQuestItemLink`, decisions 1059/1060) — built through
// THE link builder, never a hand-rolled format (`ui_items::item_link`'s own doc). `None` until
// the template lands: the link needs both the name and the quality.
let link = name
.as_ref()
.map(|n| crate::ui_items::item_link(it.item_id, n, quality));
QuestItemView {
name,
texture,
@@ -329,6 +335,7 @@ fn resolve_item(
quality,
item_id: it.item_id,
usable: true, // v1: soft gray only, server authoritative (decision 0088)
link,
}
}
+6
View File
@@ -237,6 +237,11 @@ fn resolve_template_item(
.and_then(|i| i.catalog.get(t.display_info_id))
.and_then(|d| d.icon.clone())
});
// `GetQuestLogItemLink`'s payload (decisions 1059/1060) — the same shared link builder, and the
// same in-flight `None`, as the questgiver panels' [`crate::ui_quest::resolve_item`].
let link = name
.as_ref()
.map(|n| crate::ui_items::item_link(entry, n, quality));
QuestItemView {
name,
texture,
@@ -244,6 +249,7 @@ fn resolve_template_item(
quality,
item_id: entry,
usable: true, // v1: soft-gray only, server authoritative (mirrors ui_quest.rs's resolve_item)
link,
}
}
@@ -14,8 +14,8 @@
//! command itself is still owed once the unrelated breakage clears, per this crate's own gates.
use benilla_ui::script::{
ExtractedQuad, InvSlotView, InventorySlots, PlayerCombatStats, QuadContent, ScriptValue,
SoundRequest, UiScript, UnitState,
ExtractedQuad, InvSlotView, InventorySlots, QuadContent, ScriptValue, SoundRequest, UiScript,
UnitCombatStats, UnitState,
};
/// Load one shipped `assets/ui/<file>` into `s`, panicking on any loader error (the questlog/panel
@@ -62,8 +62,8 @@ fn player_unit() -> UnitState {
/// A minimal combat-stats snapshot: STR effective 15 (a +2 pos buff, so the stat line shows green),
/// 120 armor (school 0), 3 arcane resistance (school 6), a mainhand-only melee weapon (2.6s speed,
/// 10-15 damage, 80 AP) and NO ranged weapon equipped (exercises the "N/A" ranged fallback).
fn combat_stats() -> PlayerCombatStats {
PlayerCombatStats {
fn combat_stats() -> UnitCombatStats {
UnitCombatStats {
stats: [15, 12, 20, 8, 9],
stat_pos: [2, 0, 0, 0, 0],
resistances: [120, 5, 0, 0, 0, 0, 3],
@@ -0,0 +1,378 @@
//! The dressing room + the chat-link insert, driven through the real click paths (decisions
//! 1059/1060).
//!
//! What these pin is the **fork**, at the sites a player actually clicks: CTRL opens the room
//! wearing the item, SHIFT posts its link into an open chat edit box — and, at a bag slot, SHIFT
//! with chat *closed* still opens the stack splitter it always did (the reference's own `else`,
//! and the regression these two features could most easily cause).
use benilla_ui::script::{
ContainerSlot, ContainerState, DressUpIntent, InvSlotView, InventorySlots, SoundRequest,
UiScript,
};
/// 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";
fn load_xml(s: &UiScript, file: &str) {
let text = std::fs::read_to_string(
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("assets/ui")
.join(file),
)
.unwrap();
let doc = benilla_ui::framexml::parse(&text).unwrap();
let report = benilla_ui::loader::load(s, &doc, &|_| None);
assert!(
report.errors.is_empty(),
"{file}: loader errors: {:?}",
report.errors
);
}
/// The shipped files the dressing room's click sites need, in manifest order.
fn load_room(s: &UiScript) {
for file in [
"Fonts.xml",
"UiPanels.xml",
"UIParent.xml",
"GameTooltip.xml",
"UIDropDownMenu.xml",
"UnitPopup.xml",
"ItemRef.xml",
"Cooldown.xml",
"MerchantFrame.xml",
"BagFrame.xml",
"StackSplit.xml",
"DressUpFrame.xml",
"ChatFrame.xml",
] {
load_xml(s, file);
}
}
/// A backpack holding a 5-stack of Tough Jerky in slot 1, opened; returns that button's centre.
fn backpack_with_jerky(s: &mut UiScript) -> (f32, f32) {
s.set_money(0);
let mut slots = std::collections::HashMap::new();
slots.insert(
1,
ContainerSlot {
bar_placeable: true,
texture: Some("Interface\\Icons\\INV_Misc_Food_16".into()),
count: 5,
quality: Some(1),
item_id: 117,
link: Some(JERKY_LINK.into()),
..Default::default()
},
);
s.set_container(
0,
Some(ContainerState {
name: Some("Backpack".into()),
num_slots: 16,
slots,
}),
);
s.run("BenillaBagToggle_OnClick()").unwrap();
let _ = s.take_sounds();
s.resolve();
s.run(
"BENILLA_TEST_BTN = nil\n\
for i = 1, 16 do local b = getglobal(\"BenillaBagSlot\" .. i)\n\
if b and b.slot == 1 then BENILLA_TEST_BTN = b end\n\
end",
)
.unwrap();
s.eval(
"return (BENILLA_TEST_BTN:GetLeft() + BENILLA_TEST_BTN:GetRight()) / 2, \
(BENILLA_TEST_BTN:GetTop() + BENILLA_TEST_BTN:GetBottom()) / 2",
)
.unwrap()
}
/// CTRL + left-click on a bag item opens the dressing room and puts that item on — the ref's
/// `DressUpItemLink(GetContainerItemLink(...))` (ContainerFrame.lua:565-566). The two intents are
/// ordered: `Dress` (the window was closed, so it re-dresses in the player's own gear first) then
/// `TryOn` — reversed, the room would show the player's own gear and not the clicked item.
#[test]
fn ctrl_click_on_a_bag_item_opens_the_room_wearing_it() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_room(&s);
let (x, y) = backpack_with_jerky(&mut s);
assert!(!s
.eval::<bool>("return BenillaDressUpFrame:IsVisible()")
.unwrap());
s.set_modifiers(false, true, false);
s.mouse_button(x, y, "LeftButton", true);
s.mouse_button(x, y, "LeftButton", false);
s.set_modifiers(false, false, false);
assert!(
s.eval::<bool>("return BenillaDressUpFrame:IsVisible()")
.unwrap(),
"ctrl-click opened the dressing room"
);
assert_eq!(
s.take_dressup_intents(),
vec![DressUpIntent::Dress, DressUpIntent::TryOn(117)],
"re-dress first, then try the clicked item on"
);
assert!(
s.cursor_item().is_none(),
"the ctrl fork never picks the item up"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// SHIFT + left-click posts the item's link into the chat edit box **when it is open**, and still
/// opens the stack splitter when it is not — the reference's own if/else (ContainerFrame.lua:
/// 567-577). Both halves in one test because it is the *fork* that matters: the split was the
/// behaviour that already shipped, and the insert must not have eaten it.
#[test]
fn shift_click_posts_the_link_with_chat_open_and_splits_with_it_closed() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_room(&s);
let (x, y) = backpack_with_jerky(&mut s);
// Chat closed → the splitter, exactly as before.
s.set_modifiers(true, false, false);
s.mouse_button(x, y, "LeftButton", true);
s.mouse_button(x, y, "LeftButton", false);
s.set_modifiers(false, false, false);
assert!(
s.eval::<bool>("return BenillaStackSplitFrame:IsShown()")
.unwrap(),
"with chat closed, shift-click still opens the stack splitter"
);
s.run("BenillaStackSplitFrame:Hide()").unwrap();
// Chat open → the link, and no splitter.
assert!(s.focus_editbox("ChatFrameEditBox"));
s.set_modifiers(true, false, false);
s.mouse_button(x, y, "LeftButton", true);
s.mouse_button(x, y, "LeftButton", false);
s.set_modifiers(false, false, false);
assert_eq!(
s.eval::<String>("return ChatFrameEditBox:GetText()")
.unwrap(),
JERKY_LINK,
"the item's full escaped link landed in the chat box"
);
assert!(
!s.eval::<bool>("return BenillaStackSplitFrame:IsShown()")
.unwrap(),
"with chat open the splitter must NOT open"
);
assert!(
s.cursor_item().is_none(),
"neither shift fork picks the stack up"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// A link already IN chat: ctrl-clicking it previews that item (ref ItemRef.lua:49-50), shift
/// -clicking it re-inserts it (the branch that already shipped). The router is `SetItemRef`, which
/// is what the message frame's `OnHyperlinkClick` calls with the full markup.
#[test]
fn ctrl_clicking_a_chat_link_previews_it() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_room(&s);
s.set_modifiers(false, true, false);
s.run(&format!(
"SetItemRef(\"item:117\", \"{JERKY_LINK}\", \"LeftButton\")"
))
.unwrap();
s.set_modifiers(false, false, false);
assert_eq!(
s.take_dressup_intents(),
vec![DressUpIntent::Dress, DressUpIntent::TryOn(117)],
"a chat link's id reached TryOn through DressUpItemLink's own gsub"
);
assert!(
s.eval::<bool>("return BenillaDressUpFrame:IsVisible()")
.unwrap(),
"and the room opened"
);
// The plain click still shows the link tooltip rather than the room (no modifier).
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"`.
#[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",
"UiPanels.xml",
"UIParent.xml",
"GameTooltip.xml",
"CharacterFrame.xml",
"DressUpFrame.xml",
"ChatFrame.xml",
] {
load_xml(&s, file);
}
let mut slots = InventorySlots::default();
slots[1] = Some(InvSlotView {
item_id: 1234,
icon: Some("Interface\\Icons\\INV_Helmet_01".into()),
count: 1,
quality: 2,
name: Some("Test Helm".into()),
link: Some("|cff1eff00|Hitem:1234:0:0:0|h[Test Helm]|h|r".into()),
equip_slots: vec![1],
..Default::default()
});
s.set_inventory_slots(slots);
assert_eq!(
s.eval::<String>("return GetInventoryItemLink(\"player\", 1)")
.unwrap(),
"|cff1eff00|Hitem:1234:0:0:0|h[Test Helm]|h|r"
);
// CTRL on the head slot → the room, wearing the helm.
s.set_modifiers(false, true, false);
s.run("BenillaPaperDollSlot_OnClick(BenillaCharacterHeadSlot, \"LeftButton\")")
.unwrap();
s.set_modifiers(false, false, false);
assert_eq!(
s.take_dressup_intents(),
vec![DressUpIntent::Dress, DressUpIntent::TryOn(1234)]
);
// 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(BenillaCharacterHeadSlot, \"LeftButton\")")
.unwrap();
s.set_modifiers(false, false, false);
assert_eq!(
s.eval::<String>("return ChatFrameEditBox:GetText()")
.unwrap(),
"|cff1eff00|Hitem:1234:0:0:0|h[Test Helm]|h|r"
);
assert!(
s.cursor_item().is_none(),
"a modified doll click never picks the worn item up"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// 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.
#[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",
"UiPanels.xml",
"UIParent.xml",
"GameTooltip.xml",
"CharacterFrame.xml",
"DressUpFrame.xml",
"ChatFrame.xml",
] {
load_xml(&s, file);
}
// 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 {
item_id: 1234,
count: 1,
..Default::default()
});
s.set_inventory_slots(slots);
assert!(s.focus_editbox("ChatFrameEditBox"));
s.set_modifiers(true, false, false);
s.run("BenillaPaperDollSlot_OnClick(BenillaCharacterHeadSlot, \"LeftButton\")")
.unwrap();
s.set_modifiers(false, false, false);
assert_eq!(
s.eval::<String>("return ChatFrameEditBox:GetText()")
.unwrap(),
"",
"nothing posts while the template is in flight"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
// 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(BenillaCharacterHeadSlot, \"LeftButton\")")
.unwrap();
s.set_modifiers(false, false, false);
assert!(
s.take_dressup_intents().is_empty(),
"no try-on for an item we cannot even name yet"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The window's own controls: Reset re-dresses (and clicks), closing it empties the booth, and the
/// rotate buttons move the pane's yaw by the reference's own ±0.03 per OnClick — which fires on
/// BOTH mouse edges, so one tap is 0.06 (decision 0638 §3).
#[test]
fn reset_re_dresses_close_empties_and_the_arrows_spin_the_pane() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_room(&s);
s.run("ShowUIPanel(BenillaDressUpFrame)").unwrap();
let _ = s.take_sounds();
let _ = s.take_dressup_intents();
s.resolve();
// The pane's OnLoad seeded the ref's default facing.
assert!(
(s.dressup_yaw() - 0.61).abs() < 1e-6,
"ref UIParent.lua:1422"
);
s.run("BenillaDressUpFrameResetButton:Click()").unwrap();
assert_eq!(s.take_dressup_intents(), vec![DressUpIntent::Dress]);
assert_eq!(
s.take_sounds(),
vec![SoundRequest::KitName("gsTitleOptionOK".into())],
"Reset plays the ref's own kit"
);
// One tap of rotate-left: OnClick on press AND release, −0.03 each.
let before = s.dressup_yaw();
let (x, y) = s
.eval::<(f32, f32)>(
"return (BenillaDressUpModelFrameRotateLeftButton:GetLeft() \
+ BenillaDressUpModelFrameRotateLeftButton:GetRight()) / 2, \
(BenillaDressUpModelFrameRotateLeftButton:GetTop() \
+ BenillaDressUpModelFrameRotateLeftButton:GetBottom()) / 2",
)
.unwrap();
s.mouse_button(x, y, "LeftButton", true);
s.mouse_button(x, y, "LeftButton", false);
assert!(
(s.dressup_yaw() - (before - 0.06)).abs() < 1e-5,
"a tap fires OnClick twice: {} → {}",
before,
s.dressup_yaw()
);
s.run("HideUIPanel(BenillaDressUpFrame)").unwrap();
assert_eq!(
s.take_dressup_intents(),
vec![DressUpIntent::Close],
"closing the window empties the booth"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
@@ -76,6 +76,7 @@ fn escape_closes_bag_and_panel_releases_loot_and_clears_cursor() {
quantity: 1,
quality: Some(1),
is_coin: false,
link: None,
}],
}));
s.fire_event("LOOT_OPENED", vec![]);
@@ -463,15 +463,36 @@ pub(super) fn drive_script(
}
// A portrait region samples the circular-masked variant so the square icon/model
// doesn't poke past the frame ring's thin band (SetPortraitToTexture, decision 0084).
let handle = path.as_deref().and_then(|p| {
assets.as_mut().and_then(|a| {
if circular {
//
// A path the archives don't have draws **nothing** — never a white slab. This arm
// used to fall through to `color.unwrap_or(WHITE)` with a `None` texture, which
// `ui_pass` renders as the shared 1×1 white image tinted white: an opaque white
// rectangle at the region's rect, which is how B221's macro icons reached the
// director's screen. wow-re settles what the reference does: `TextureCreate` does
// build an 8×8 placeholder, but `CSimpleTexture::SetTexture` (`0x770200`) checks the
// status severity and at ≥2 releases it and returns **without touching the widget's
// texture** — the widget keeps what it had, and Lua gets `nil`. Nothing goes white.
// We can't keep the *previous* art (a path is re-resolved per frame at extract, not
// latched at `SetTexture`), so the faithful-enough result is an empty cell; what
// matters is that it is never a phantom quad. The `Backdrop` and live-portrait arms
// already guard exactly this way.
//
// `assets` missing ENTIRELY is a data-less run (the headless UI tests), not a bad
// path — those keep the old behaviour rather than blanking every textured quad.
let handle = match (path.as_deref(), assets.as_mut()) {
(Some(p), Some(a)) => {
let resolved = if circular {
a.portrait_texture(p, &mut images)
} else {
a.sprite_texture(p, &mut images)
};
if resolved.is_none() {
continue;
}
})
});
resolved
}
_ => None,
};
// A pathless Texture region is a solid color; a textured one tints by it.
let mut color = color.unwrap_or([1.0, 1.0, 1.0, 1.0]);
color[3] *= eq.alpha;
@@ -250,6 +250,7 @@ fn the_open_menu_takes_the_screen_and_refuses_every_other_panel() {
quantity: 1,
quality: Some(1),
is_coin: false,
link: None,
}],
}));
s.fire_event("LOOT_OPENED", vec![]);
@@ -4,7 +4,7 @@
//! `loot_tests.rs` mirrors it for `set_loot`/`LootState`).
use benilla_ui::script::{
ExtractedQuad, LootRollEntry, LootRollsState, QuadContent, ScriptValue, UiScript,
DressUpIntent, ExtractedQuad, LootRollEntry, LootRollsState, QuadContent, ScriptValue, UiScript,
};
/// Load one shipped `assets/ui/<file>` into `s` (the loot tests' loader, duplicated here so this
@@ -90,6 +90,11 @@ fn setup() -> UiScript {
s
}
/// The two resolved rolls' item links, exactly as `ui_loot_roll.rs` builds them (`item_link`:
/// quality colour + the four-field `|Hitem:` payload) — what the icon button's ctrl/shift arms read.
const STAFF_LINK: &str = "|cffa335ee|Hitem:17182:0:0:0|h[Staff of Jordan]|h|r";
const SWORD_LINK: &str = "|cffffffff|Hitem:25:0:0:0|h[Worn Shortsword]|h|r";
/// A resolved Epic (BoP) roll and an in-flight one (item template not landed yet) — the same two
/// shapes `loot_roll.rs`'s own test module fixtures, reused here against the real shipped XML.
fn rolls() -> LootRollsState {
@@ -104,6 +109,8 @@ fn rolls() -> LootRollsState {
bind_on_pickup: true,
time_left_ms: 42_000,
item_id: 17182,
// The link lands with the name — one template answer fills both (decision 1059).
link: Some(STAFF_LINK.into()),
},
LootRollEntry {
roll_id: 8,
@@ -114,8 +121,10 @@ fn rolls() -> LootRollsState {
bind_on_pickup: false,
time_left_ms: 60_000,
item_id: 25,
link: Some(SWORD_LINK.into()),
},
// The item-template query hasn't landed: name/texture/quality all nil (loot_roll.rs).
// The item-template query hasn't landed: name/texture/quality all nil (loot_roll.rs) —
// and no link either: it embeds the name, so it cannot exist before the name does.
LootRollEntry {
roll_id: 9,
name: None,
@@ -125,6 +134,7 @@ fn rolls() -> LootRollsState {
bind_on_pickup: false,
time_left_ms: 55_000,
item_id: 4306,
link: None,
},
],
}
@@ -504,3 +514,80 @@ fn managed_positions_engage_for_the_bare_frame_name() {
s.resolve();
assert_eq!(bottom(&s), 102.0, "back to the multibar-only stack");
}
/// The roll popup's item icon (ref `$parentIconFrame` OnClick, LootFrame.xml l.353-361): CTRL
/// previews the rolled item in the dressing room (decision 1060), SHIFT posts its link into an open
/// chat edit box (decision 1059). Both read `GetLootRollItemLink(rollID)`, the binding this arc
/// added — so this pins that getter against the real shipped XML too.
///
/// The control that must not change: the Need/Greed/Pass buttons still vote. The icon button is new
/// click surface on a frame whose whole job is a three-button vote, and a stray vote from the icon
/// (or a swallowed one from the dice) is exactly the regression worth catching.
#[test]
fn ctrl_and_shift_on_the_roll_icon_preview_and_post_its_link() {
let mut s = setup();
load_xml(&s, "GroupLootFrame.xml");
load_xml(&s, "UIParent.xml"); // BenillaChatEdit_InsertLink, the shared shift-insert helper
load_xml(&s, "DressUpFrame.xml");
load_xml(&s, "ChatFrame.xml");
s.set_loot_rolls(rolls());
// Roll 8 (BoE Worn Shortsword) claims frame 1 — a non-BoP roll, so the dice below can land a
// real vote rather than the bind-on-pickup confirm.
s.fire_event(
"START_LOOT_ROLL",
vec![ScriptValue::Int(8), ScriptValue::Int(60_000)],
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
s.resolve();
let quads = s.extract();
let (x, y) = quad_center(&quads, "INV_Sword_04");
// A plain click on the icon does nothing at all — the reference's handler has no third branch,
// and the vote is the dice's job, not the icon's.
s.mouse_button(x, y, "LeftButton", true);
s.mouse_button(x, y, "LeftButton", false);
assert!(
s.take_loot_roll_votes().is_empty() && s.take_loot_roll_confirms().is_empty(),
"an unmodified icon click votes nothing"
);
// SHIFT with the chat edit box open → the link.
assert!(s.focus_editbox("ChatFrameEditBox"));
s.set_modifiers(true, false, false);
s.mouse_button(x, y, "LeftButton", true);
s.mouse_button(x, y, "LeftButton", false);
s.set_modifiers(false, false, false);
assert_eq!(
s.eval::<String>("return ChatFrameEditBox:GetText()")
.unwrap(),
SWORD_LINK,
"the rolled item's full escaped link landed in the chat box"
);
// CTRL → the dressing room wearing it.
s.set_modifiers(false, true, false);
s.mouse_button(x, y, "LeftButton", true);
s.mouse_button(x, y, "LeftButton", false);
s.set_modifiers(false, false, false);
assert_eq!(
s.take_dressup_intents(),
vec![DressUpIntent::Dress, DressUpIntent::TryOn(25)],
"re-dress first, then try the rolled item on"
);
assert!(
s.take_loot_roll_votes().is_empty() && s.take_loot_roll_confirms().is_empty(),
"no modified icon click may vote"
);
// The control: Need on the dice still reaches the wire (roll 8 is not bind-on-pickup).
let (nx, ny) = quad_center(&quads, "UI-GroupLoot-Dice-Up");
s.mouse_button(nx, ny, "LeftButton", true);
s.mouse_button(nx, ny, "LeftButton", false);
assert_eq!(
s.take_loot_roll_votes(),
vec![(8, 1)],
"the Need button still votes"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
+142 -2
View File
@@ -1,6 +1,6 @@
use benilla_ui::script::{
ExtractedQuad, LootRow, LootState, MerchantItem, MerchantState, QuadContent, SoundRequest,
UiScript,
DressUpIntent, ExtractedQuad, LootRow, LootState, MerchantItem, MerchantState, QuadContent,
SoundRequest, UiScript,
};
/// Load one shipped `assets/ui/<file>` into `s` (the panel tests' loader, duplicated here so this
@@ -71,6 +71,7 @@ fn coin_and_two_items() -> LootState {
quantity: 1,
quality: Some(1),
is_coin: true,
link: None,
},
LootRow {
item_id: 0,
@@ -79,6 +80,7 @@ fn coin_and_two_items() -> LootState {
quantity: 3,
quality: Some(2), // uncommon → green text
is_coin: false,
link: None,
},
LootRow {
item_id: 0,
@@ -87,6 +89,7 @@ fn coin_and_two_items() -> LootState {
quantity: 1,
quality: Some(1), // common → white text
is_coin: false,
link: None,
},
],
}
@@ -297,6 +300,7 @@ fn shipped_loot_frame_pages_five_items() {
quantity: 1,
quality: Some(1),
is_coin: false,
link: None,
})
.collect();
s.set_loot(Some(LootState { rows }));
@@ -366,6 +370,7 @@ fn shipped_loot_pushed_to_center_by_merchant() {
num_available: -1,
item_id: 159,
stats: None,
link: None,
}],
..Default::default()
}));
@@ -455,3 +460,138 @@ fn the_loot_window_draws_over_the_party_frames() {
"they must differ by STRATUM, not by luck within one"
);
}
/// The row click's modifier fork (ref `LootFrameItem_OnClick`, LootFrame.lua l.147-154): CTRL
/// previews the row's item in the dressing room (decision 1060), SHIFT posts its link into an open
/// chat edit box (decision 1059) — and **neither loots**.
///
/// That last clause is the whole point of the test, and it is ours to get right rather than the
/// reference's: in the real client the loot itself is the C `LootButton` widget's click behaviour
/// (l.94's `button:SetSlot(slot)`), so its Lua arms fall through harmlessly; ours owns the
/// `LootSlot` call, so the arms had to grow a `return` the reference does not have. The unmodified
/// click still loots — the regression that would otherwise ship silently.
#[test]
fn ctrl_and_shift_on_a_loot_row_preview_and_post_without_looting() {
const WOOL_LINK: &str = "|cffffffff|Hitem:2589:0:0:0|h[Wool Cloth]|h|r";
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
for file in [
"Fonts.xml",
"UiPanels.xml",
"UIParent.xml", // BenillaChatEdit_InsertLink, the shared shift-insert helper
"LootFrame.xml",
"DressUpFrame.xml",
"ChatFrame.xml",
] {
load_xml(&s, file);
}
// A coin row + one resolved item: the item's link is fed exactly as `ui_loot.rs` builds it.
s.set_loot(Some(LootState {
rows: vec![
LootRow {
item_id: 0,
name: Some("1g 23s 45c".into()),
texture: Some("Interface\\Icons\\INV_Misc_Coin_01".into()),
quantity: 1,
quality: Some(1),
is_coin: true,
link: None, // synthesized row, no item behind it
},
LootRow {
item_id: 2589,
name: Some("Wool Cloth".into()),
texture: Some("Interface\\Icons\\INV_Fabric_Wool_01".into()),
quantity: 3,
quality: Some(1),
is_coin: false,
link: Some(WOOL_LINK.into()),
},
],
}));
s.fire_event("LOOT_OPENED", vec![]);
s.resolve();
let quads = s.extract();
let (x, y) = icon_center(&quads, "INV_Fabric_Wool_01");
let (coin_x, coin_y) = icon_center(&quads, "INV_Misc_Coin_01");
// The control first, while nothing else has been opened: a plain click still loots row 2.
s.mouse_button(x, y, "LeftButton", true);
s.mouse_button(x, y, "LeftButton", false);
assert_eq!(
s.take_loot_picks(),
vec![2],
"an unmodified click still loots the row"
);
// SHIFT with the chat edit box open → the link, and no loot.
assert!(s.focus_editbox("ChatFrameEditBox"));
s.set_modifiers(true, false, false);
s.mouse_button(x, y, "LeftButton", true);
s.mouse_button(x, y, "LeftButton", false);
s.set_modifiers(false, false, false);
assert_eq!(
s.eval::<String>("return ChatFrameEditBox:GetText()")
.unwrap(),
WOOL_LINK,
"the row's full escaped link landed in the chat box"
);
assert!(
s.take_loot_picks().is_empty(),
"a shift-click must not also loot the row"
);
// The COIN row has no link at all, and a modified click on it must be inert rather than an
// error: our `EditBox:Insert` binding is typed `String` and raises on a nil, so the handler
// guards it (the reference's C Insert tolerates the nil its own callers hand it).
s.set_modifiers(true, false, false);
s.mouse_button(coin_x, coin_y, "LeftButton", true);
s.mouse_button(coin_x, coin_y, "LeftButton", false);
s.set_modifiers(false, false, false);
assert!(
s.errors().is_empty(),
"shift-clicking the linkless coin row must not raise: {:?}",
s.errors()
);
assert_eq!(
s.eval::<String>("return ChatFrameEditBox:GetText()")
.unwrap(),
WOOL_LINK,
"and it must not disturb what is already typed"
);
assert!(
s.take_loot_picks().is_empty(),
"a shift-click on the coin row must not loot the money either"
);
// ALT → nothing at all, and in particular NOT a loot (decision 1067). No FrameXML file binds
// alt on a loot row; the suppression is the C `CLootButton::OnClick`'s own third gate
// (`0x41f8f0(2)` @ `0x4c1841`, VERIFIED at the bytes), which our Lua-side take has to
// reproduce. This is the arm nothing else in the suite would catch: before 1067, alt looted.
s.set_modifiers(false, false, true);
s.mouse_button(x, y, "LeftButton", true);
s.mouse_button(x, y, "LeftButton", false);
s.set_modifiers(false, false, false);
assert!(
s.take_loot_picks().is_empty(),
"an alt-click must not loot the row (the C gate's third modifier)"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
// CTRL → the dressing room wearing it, still no loot. Last, because opening the room takes the
// left UIPanel slot and moves the loot window (pushable 2 vs 7).
s.set_modifiers(false, true, false);
s.mouse_button(x, y, "LeftButton", true);
s.mouse_button(x, y, "LeftButton", false);
s.set_modifiers(false, false, false);
assert_eq!(
s.take_dressup_intents(),
vec![DressUpIntent::Dress, DressUpIntent::TryOn(2589)],
"re-dress first, then try the looted item on"
);
assert!(
s.take_loot_picks().is_empty(),
"a ctrl-click must not also loot the row"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
+15 -1
View File
@@ -84,7 +84,9 @@ const UI_MANIFEST: &[&str] = &[
// never has one never sees it. Right after the stance bar, whose row it shares (either
// one showing raises UIParent.xml's managed "pet" delta), and after Cooldown.xml
// (CooldownFrame_SetTimer), ActionBar.xml (the `BenillaActionBar` anchor target) and
// GameTooltip.xml (its hover).
// GameTooltip.xml (its hover). It is also the pet ARC's file, so decision 1066's three
// right-click-menu dialogs (ABANDON_PET / RENAME_PET / PETRENAMECONFIRM) register here —
// which adds UiPanels.xml to its dependency set, already far above.
"PetActionBar.xml",
// The micro-button row in the bar's right-hand recess (the ref's own file split, its TOC
// loads MainMenuBarMicroButtons right after MainMenuBar). After ActionBar.xml: it anchors
@@ -136,6 +138,12 @@ const UI_MANIFEST: &[&str] = &[
// 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 Skills tab (decision 0437 phase 4): a CharacterFrame child page, but a separate
// top-level file (SkillFrame.xml's own header comment — this engine has no cross-file
// `parent=` attachment, so it's positioned by name via `<Anchors relativeTo=
@@ -149,6 +157,12 @@ const UI_MANIFEST: &[&str] = &[
// itself (its slot template, handlers, and booth slot are all its own — the two windows
// share only the reference's art).
"InspectFrame.xml",
// The dressing room (decision 1060): the ctrl-click item preview. Loaded right after the
// windows whose slots feed it (the character window above, the bags before that) and before
// everything else that ctrl-clicks into it — order is legibility only, since `DressUpItemLink`
// is a global resolved at call time. Needs UiPanels (the panel manager, its left-slot row) and
// Fonts; its booth pane needs nothing loaded at all.
"DressUpFrame.xml",
// The spellbook window (decision 0216 §8, slice 5): the P-key window, the spell SOURCE
// for the cursor-payload arc. Loaded right after CharacterFrame (the same "player-state
// windows grouped" posture, same Fonts/UiPanels/GameTooltip-only dependency set); the
@@ -5,8 +5,8 @@
//! folder's one-file-per-window convention.
use benilla_ui::script::{
ContainerState, ExtractedQuad, ItemStatsHead, MerchantItem, MerchantState, QuadContent,
ScriptValue, SoundRequest, UiScript,
ContainerState, DressUpIntent, ExtractedQuad, ItemStatsHead, MerchantItem, MerchantState,
QuadContent, ScriptValue, SoundRequest, UiScript,
};
/// Load one shipped `assets/ui/<file>` into `s`, panicking on any loader error and returning the
@@ -91,6 +91,7 @@ fn shipped_merchant_frame_drives_end_to_end() {
num_available: -1,
item_id: 159,
stats: None,
link: None,
},
MerchantItem {
name: Some("Linen Bandage".into()),
@@ -100,6 +101,7 @@ fn shipped_merchant_frame_drives_end_to_end() {
num_available: 5,
item_id: 1251,
stats: None,
link: None,
},
],
..Default::default()
@@ -589,6 +591,7 @@ fn shipped_merchant_hover_scopes_highlight_and_anchors_item_tooltip() {
block: 0,
sell_price: 0,
}),
link: None,
},
MerchantItem {
name: Some("Chipped Buckler".into()),
@@ -611,6 +614,7 @@ fn shipped_merchant_hover_scopes_highlight_and_anchors_item_tooltip() {
block: 1,
sell_price: 0,
}),
link: None,
},
],
..Default::default()
@@ -794,6 +798,7 @@ fn money_display_shrinks_to_content_and_stays_flush() {
num_available: -1,
item_id: 159,
stats: None,
link: None,
},
MerchantItem {
name: Some("Linen Bandage".into()),
@@ -803,6 +808,7 @@ fn money_display_shrinks_to_content_and_stays_flush() {
num_available: -1,
item_id: 1251,
stats: None,
link: None,
},
],
..Default::default()
@@ -937,6 +943,7 @@ fn merchant_tabs_drive_buyback_page_and_repair_pair() {
num_available: -1,
item_id: 159,
stats: None,
link: None,
}],
buyback: vec![
buyback_item("Bandit Cloak", 116),
@@ -1089,6 +1096,7 @@ fn shipped_merchant_frame_arms_the_buy_cursor_on_hover() {
num_available: -1,
item_id: 159,
stats: None,
link: None,
},
MerchantItem {
name: Some("Linen Bandage".into()),
@@ -1098,6 +1106,7 @@ fn shipped_merchant_frame_arms_the_buy_cursor_on_hover() {
num_available: 5,
item_id: 1251,
stats: None,
link: None,
},
],
..Default::default()
@@ -1200,3 +1209,114 @@ fn trade_recipient_money_renders_the_digit_not_ellipsis() {
"recipient money shows the digit. all text quads: {texts:?}"
);
}
/// The vendor row's LEFT-button modifier fork (ref `MerchantItemButton_OnClick`,
/// MerchantFrame.lua l.301-306): CTRL previews the item in the dressing room (decision 1060), SHIFT
/// posts its link into an open chat edit box (decision 1059) — both over `GetMerchantItemLink`, the
/// binding this arc added. Neither may buy: this window's click *is* a purchase, so a modified click
/// that fell through would spend the player's money.
///
/// The controls that must not change: a plain RIGHT-click still buys, and (the reference's own
/// right-button guard, l.332-333) a CTRL-held right-click buys nothing at all.
#[test]
fn ctrl_and_shift_on_a_vendor_row_preview_and_post_without_buying() {
const WATER_LINK: &str = "|cffffffff|Hitem:159:0:0:0|h[Refreshing Spring Water]|h|r";
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
for file in [
"Fonts.xml",
"UiPanels.xml",
"UIParent.xml", // BenillaChatEdit_InsertLink, the shared shift-insert helper
"GameTooltip.xml",
"MerchantFrame.xml",
"DressUpFrame.xml",
"ChatFrame.xml",
] {
load_xml(&s, file);
}
s.set_money(12_345);
s.set_merchant(Some(MerchantState {
items: vec![MerchantItem {
name: Some("Refreshing Spring Water".into()),
texture: Some("Interface\\Icons\\INV_Drink_18".into()),
price: 25,
quantity: 1,
num_available: -1,
item_id: 159,
stats: None,
// Fed exactly as `ui_merchant.rs` builds it off the row's template answer.
link: Some(WATER_LINK.into()),
}],
..Default::default()
}));
s.fire_event("MERCHANT_SHOW", vec![]);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
s.resolve();
let quads = s.extract();
let icon = quads
.iter()
.find(|q| {
matches!(&q.content, QuadContent::Texture { path: Some(p), .. }
if p.contains("INV_Drink_18"))
})
.and_then(|q| q.rect)
.expect("no row icon quad");
let (x, y) = (
(icon.left + icon.right) * 0.5,
(icon.bottom + icon.top) * 0.5,
);
// The control first: an unmodified right-click still buys row 1.
s.mouse_button(x, y, "RightButton", true);
s.mouse_button(x, y, "RightButton", false);
assert_eq!(
s.take_merchant_buys(),
vec![(1, 1)],
"an unmodified right-click still buys"
);
// The reference's right-button guard (l.332-333): CTRL-held, a right-click does nothing.
s.set_modifiers(false, true, false);
s.mouse_button(x, y, "RightButton", true);
s.mouse_button(x, y, "RightButton", false);
s.set_modifiers(false, false, false);
assert!(
s.take_merchant_buys().is_empty(),
"a ctrl-held right-click must not buy (ref l.332-333)"
);
// SHIFT + LEFT with the chat edit box open → the link, no purchase.
assert!(s.focus_editbox("ChatFrameEditBox"));
s.set_modifiers(true, false, false);
s.mouse_button(x, y, "LeftButton", true);
s.mouse_button(x, y, "LeftButton", false);
s.set_modifiers(false, false, false);
assert_eq!(
s.eval::<String>("return ChatFrameEditBox:GetText()")
.unwrap(),
WATER_LINK,
"the vendor row's full escaped link landed in the chat box"
);
assert!(
s.take_merchant_buys().is_empty(),
"a shift-click must not also buy"
);
// CTRL + LEFT → the dressing room wearing it, no purchase. Last: opening the room takes a
// UIPanel slot and can move the vendor window.
s.set_modifiers(false, true, false);
s.mouse_button(x, y, "LeftButton", true);
s.mouse_button(x, y, "LeftButton", false);
s.set_modifiers(false, false, false);
assert_eq!(
s.take_dressup_intents(),
vec![DressUpIntent::Dress, DressUpIntent::TryOn(159)],
"re-dress first, then try the vendor's item on"
);
assert!(
s.take_merchant_buys().is_empty(),
"a ctrl-click must not also buy"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
+6
View File
@@ -718,9 +718,15 @@ mod questlog_tests;
#[cfg(test)]
mod character_tests;
#[cfg(test)]
mod pet_paperdoll_tests;
#[cfg(test)]
mod inspect_tests;
#[cfg(test)]
mod dressup_tests;
#[cfg(test)]
mod minimap_tests;
@@ -1396,3 +1396,267 @@ fn the_page_scroll_bar_wears_the_trough_with_its_arrows_in_the_sockets() {
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
// ── B223 · the row tooltips (decision 1054) ─────────────────────────────────────────────────────
/// Hover the middle of a named row's LABEL half (left of the control column) — the surface the
/// reporter's cursor was on when the row lit and said nothing. Callers pin `ERA_WINDOW_SCALE = 1`
/// first (the wash test's own idiom): frame rects are in the frame's scale space, the pointer is
/// in the screen's, and at scale 1 the two are the same numbers.
fn hover_label(s: &mut UiScript, frame: &str) {
s.resolve();
let g = |s: &mut UiScript, verb: &str| -> f32 {
s.eval(&format!("return {frame}:{verb}()")).unwrap()
};
let (l, t, b) = (g(s, "GetLeft"), g(s, "GetTop"), g(s, "GetBottom"));
s.mouse_move(l + 60.0, (t + b) * 0.5);
}
/// A hovered row raises its 1.12 description, on the era's seat, and drops it on leave — the row
/// itself, its checkbox, and (B223's report) the label the cursor actually crosses. The string
/// resolves by KEY at hover, so seeding it AFTER the window loaded still paints: that is the
/// property the 1.12 panel's own `getglobal("OPTION_TOOLTIP_"..key)` lookup buys.
#[test]
fn a_hovered_row_raises_its_1_12_description_on_the_era_seat() {
let mut s = harness_on(audio_harness());
s.run("OPTION_TOOLTIP_GAMEFIELD_DESELECT = \"Checking this will prevent the deselection.\"")
.unwrap();
s.run("ERA_WINDOW_SCALE = 1").unwrap();
s.run("ShowUIPanel(OptionsFrame)").unwrap();
let row = "OptionsFrameContainerBodyControlsRowStickyTarget";
hover_label(&mut s, row);
s.resolve();
assert!(
s.eval::<bool>("return GameTooltip:IsVisible()").unwrap(),
"the label half raises the plate — the reported gap"
);
assert_eq!(
s.eval::<String>("return GameTooltipTextLeft1:GetText()")
.unwrap(),
"Checking this will prevent the deselection."
);
assert_eq!(
s.eval::<i64>("return GameTooltip:NumLines()").unwrap(),
1,
"the description ALONE — the era's white name line is cut (1054)"
);
// The era seat: BOTTOMLEFT on the label region's TOPRIGHT, hung 10 back over the control
// column (DefaultTooltipMixin's ANCHOR_RIGHT / x -10).
let owned: bool = s
.eval(&format!("return GameTooltip:IsOwned({row}Tip)"))
.unwrap();
assert!(owned, "owned by the row's $parentTip region");
let (tip_right, tip_top): (f32, f32) = (
s.eval(&format!("return {row}Tip:GetRight()")).unwrap(),
s.eval(&format!("return {row}Tip:GetTop()")).unwrap(),
);
let (left, bottom): (f32, f32) = (
s.eval("return GameTooltip:GetLeft()").unwrap(),
s.eval("return GameTooltip:GetBottom()").unwrap(),
);
assert!(
(left - (tip_right - 10.0)).abs() < 0.01 && (bottom - tip_top).abs() < 0.01,
"plate at ({left}, {bottom}); the era seat is ({}, {tip_top})",
tip_right - 10.0
);
// Crossing onto the checkbox keeps it up (one seam lights the wash and raises the plate, so
// the row's OnLeave and the box's OnEnter cancel out inside the one move).
let (bl, br, bt, bb): (f32, f32, f32, f32) = (
s.eval(&format!("return {row}Check:GetLeft()")).unwrap(),
s.eval(&format!("return {row}Check:GetRight()")).unwrap(),
s.eval(&format!("return {row}Check:GetTop()")).unwrap(),
s.eval(&format!("return {row}Check:GetBottom()")).unwrap(),
);
s.mouse_move((bl + br) * 0.5, (bt + bb) * 0.5);
assert!(
s.eval::<bool>("return GameTooltip:IsVisible()").unwrap(),
"the plate survives the crossing onto the control"
);
// Leaving the row puts it away.
s.mouse_move(5.0, 5.0);
assert!(
!s.eval::<bool>("return GameTooltip:IsVisible()").unwrap(),
"OnLeave drops the plate"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The row with no 1.12 string raises NOTHING — not a plate echoing the label under the cursor,
/// and not the LAST row's description left standing over it. Auto Loot is that row: 1.12 has no
/// Auto Loot setting, so no `OPTION_TOOLTIP_*` for it. The second leg is the live probe's find: a
/// hover driven without the outgoing row's OnLeave (1054) must still put the neighbour away.
#[test]
fn a_row_with_no_1_12_string_raises_no_plate() {
let mut s = harness_on(audio_harness());
s.run("OPTION_TOOLTIP_GAMEFIELD_DESELECT = \"Sticky's own description.\"")
.unwrap();
s.run("ERA_WINDOW_SCALE = 1").unwrap();
s.run("ShowUIPanel(OptionsFrame)").unwrap();
hover_label(&mut s, "OptionsFrameContainerBodyControlsRowAutoLoot");
s.resolve();
assert!(
!s.eval::<bool>("return GameTooltip:IsVisible()").unwrap(),
"no 1.12 description, no plate"
);
assert!(
s.eval::<bool>("return OptionsFrameContainerBodyControlsRowAutoLootHover:IsVisible()")
.unwrap(),
"…but the row still lights: the wash is not gated on the string"
);
// Sticky's plate up, then straight into the mute row with no OnLeave in between.
s.run("OptionsRow_Hover(OptionsFrameContainerBodyControlsRowStickyTarget, 1)")
.unwrap();
assert!(s.eval::<bool>("return GameTooltip:IsVisible()").unwrap());
s.run("OptionsRow_Hover(OptionsFrameContainerBodyControlsRowAutoLoot, 1)")
.unwrap();
assert!(
!s.eval::<bool>("return GameTooltip:IsVisible()").unwrap(),
"the mute row puts the neighbour's description away — it never describes THIS row"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The RUNTIME leg on the real client data (`ui_quest`'s pattern): every key the rows carry
/// resolves to a non-empty string in the shipped 1.12 `GlobalStrings.lua` — the guard against a
/// typo'd key degrading a description to silence — and the ONE row without a key is Auto Loot.
/// Pins the reporter's own row end to end (B223's screenshot text). Skips without client data.
#[test]
fn every_row_tooltip_key_resolves_in_the_real_global_strings() {
let data = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data");
if !data.is_dir() {
eprintln!("skipping: vanilla client not present at {}", data.display());
return;
}
let mut chain = benilla_formats::open_chain(&data).expect("open chain");
let src = chain
.read_file("Interface\\FrameXML\\GlobalStrings.lua")
.expect("GlobalStrings.lua in the chain");
let strings = UiScript::new().expect("VM");
strings
.run(&String::from_utf8_lossy(&src))
.expect("GlobalStrings runs clean");
// The mapping, read off the LIVE rows so a page added later can't slip the check.
let s = harness();
let listing: String = s
.eval(
"local out = {} \
for page, rows in pairs(OPTIONS_PAGE_ROWS) do \
for _, rkey in ipairs(rows) do \
local row = getglobal(\"OptionsFrameContainerBody\" .. page .. rkey) \
table.insert(out, page .. rkey .. \"=\" .. (row.tip or \"\")) \
end \
end \
return table.concat(out, \",\")",
)
.unwrap();
let mut untipped = vec![];
let mut checked = 0;
for entry in listing.split(',') {
let (row, key) = entry.split_once('=').expect("row=key");
if key.is_empty() {
untipped.push(row.to_string());
continue;
}
assert!(
key.starts_with("OPTION_TOOLTIP_"),
"{row}: {key} is not a 1.12 option-tooltip key"
);
let text: String = strings
.lua()
.globals()
.get::<String>(key)
.unwrap_or_default();
assert!(
!text.is_empty() && text != "PLACE_HOLDER",
"{row}: {key} resolves to nothing in the real GlobalStrings"
);
checked += 1;
}
assert_eq!(checked, 14, "every row but one carries a live 1.12 key");
assert_eq!(
untipped,
vec!["ControlsRowAutoLoot".to_string()],
"Auto Loot is the only row 1.12 never had"
);
// The reporter's row, byte for byte off the MPQ chain.
assert_eq!(
strings
.lua()
.globals()
.get::<String>("OPTION_TOOLTIP_GAMEFIELD_DESELECT")
.unwrap(),
"Checking this will prevent the deselection of targets by clicking on the gamefield. \
Targets can only be cleared by pressing escape or clicking another target."
);
}
/// EVERY row on every page, all three control flavors: hovering it raises a plate exactly when
/// the row carries a 1.12 key, and no row's hover errors. The teeth are the per-template
/// `$parentTip` seat — a flavor missing that region would take `SetOwner(nil, …)` and print a red
/// line instead of a plate, which no key-mapping check would ever see.
#[test]
fn every_flavor_of_row_raises_its_plate_from_the_page_it_lives_on() {
let mut s = harness_on(audio_harness());
// Stand-in strings for every key the rows name (the real texts are the data test's job).
s.run(
"for page, rows in pairs(OPTIONS_PAGE_ROWS) do \
for _, rkey in ipairs(rows) do \
local row = getglobal(\"OptionsFrameContainerBody\" .. page .. rkey) \
if row.tip then setglobal(row.tip, \"described: \" .. rkey) end \
end \
end",
)
.unwrap();
s.run("ERA_WINDOW_SCALE = 1").unwrap();
s.run("ShowUIPanel(OptionsFrame)").unwrap();
let mut raised = 0;
for page in ["Controls", "Audio", "Graphics", "Nameplates"] {
s.run(&format!("OptionsFrameCategoryListRow{page}:Click()"))
.unwrap();
let rows: String = s
.eval(&format!(
"return table.concat(OPTIONS_PAGE_ROWS.{page}, \",\")"
))
.unwrap();
for rkey in rows.split(',') {
let row = format!("OptionsFrameContainerBody{page}{rkey}");
hover_label(&mut s, &row);
s.resolve();
let tipped: bool = s.eval(&format!("return {row}.tip ~= nil")).unwrap();
let shown: bool = s.eval("return GameTooltip:IsVisible()").unwrap();
assert_eq!(
shown, tipped,
"{row}: plate shown {shown}, has key {tipped}"
);
if tipped {
assert_eq!(
s.eval::<String>("return GameTooltipTextLeft1:GetText()")
.unwrap(),
format!("described: {rkey}"),
"{row}: the plate reads ITS OWN row's description"
);
assert!(
s.eval::<bool>(&format!("return GameTooltip:IsOwned({row}Tip)"))
.unwrap(),
"{row}: seated on its own $parentTip region"
);
raised += 1;
}
s.mouse_move(5.0, 5.0);
}
assert!(
s.errors().is_empty(),
"{page}: script errors: {:?}",
s.errors()
);
}
assert_eq!(raised, 14, "14 of the 15 rows have a 1.12 description");
}
@@ -528,6 +528,9 @@ fn shipped_panel_slot_replaces_gossip_with_merchant() {
num_available: -1,
item_id: 159,
stats: None,
// Not this test's subject (the slot manager is) — a row with no template answer yet
// carries no link (decision 1059).
link: None,
}],
..Default::default()
}));
@@ -657,6 +660,9 @@ fn shipped_panel_slot_pushable_promotes_to_center() {
num_available: -1,
item_id: 159,
stats: None,
// Not this test's subject (the slot manager is) — a row with no template answer yet
// carries no link (decision 1059).
link: None,
}],
..Default::default()
}));
@@ -517,10 +517,15 @@ fn shift_clicking_a_pet_button_picks_it_up_rather_than_casting() {
/// fires the slot.
///
/// The load-bearing half is what the key path does NOT do: it is a bare `CastPetAction`, with none
/// of `PetActionButton_OnClick`'s forks. Pinned below on the Attack token with the pet ALREADY
/// attacking — a left click there calls the attack off (the test above), while the key re-issues
/// it. That asymmetry is the reference's, and the only thing that could hide a regression into it
/// is a test that never presses a key on an active slot.
/// of `PetActionButton_OnClick`'s forks. Pinned below on an Attack token fed as already-active — a
/// left click on that same view calls the attack off (the test above) while the key re-issues it.
///
/// **What that fixture is and is not.** `attack_active` is pushed straight onto the view here, and
/// on a real *pet* bar it can never be true: `ui_pet`'s `possessing` carve (`0x4bd420`) raises the
/// latch only for a unit you are POSSESSING, so on a hunter's bar the click fork is dead code and
/// key and click behave identically. The state below is a **possess** bar's — Mind Control, Eye of
/// Kilrogg — which is the one case where the two paths genuinely diverge, and therefore the only
/// one worth pinning. Read as "ordinary pets differ from clicks", this test would be lying.
#[test]
fn the_keybind_pair_pushes_and_casts_without_the_clicks_forks() {
let mut s = UiScript::new().unwrap();
@@ -544,8 +549,9 @@ fn the_keybind_pair_pushes_and_casts_without_the_clicks_forks() {
"the press is visual only — the ref fires on the release"
);
// Up releases the art and casts — slot 1 is Attack and the pet is already attacking, so a
// LEFT CLICK here would call it off instead. The key has no such fork.
// Up releases the art and casts — slot 1 is Attack and the view says the order is live, so a
// LEFT CLICK here would call it off instead (a possess bar's case; see the header). The key
// has no such fork.
s.run("BenillaPetActionButtonUp(1)").unwrap();
assert_eq!(state(&s), "NORMAL");
assert_eq!(s.take_pet_actions(), vec![1]);
@@ -310,6 +310,74 @@ fn left_clicking_the_pet_frame_targets_it() {
assert!(s.take_target_requests().is_empty());
}
/// **All three legs of `PetFrame_OnClick` survive the click** — B208's "dropping food from the bag
/// onto the pet doesn't feed" (decision 1055).
///
/// The handler transcribed the reference's three legs correctly from the day it shipped, but two of
/// the globals it calls — `DropItemOnUnit` and `SpellTargetUnit` — were never registered, so the
/// middle leg called a nil value and the whole handler errored out. Nothing fed, and nothing said
/// why. The bottom-leg test above could not catch it: it only ever exercises the `else`.
///
/// So this walks the legs the reference's own order picks between them (`SpellIsTargeting()` first,
/// then `CursorHasItem()`, then plain target — `PetFrame.lua:114-129`), and asserts each *runs*.
/// The feed itself is the app's half, gated and sent in `ui_action::drop_item`; what the VM owes is
/// a queued token and no error.
#[test]
fn every_leg_of_the_pet_frame_click_reaches_a_live_binding() {
let mut s = load_pet_frame();
s.set_unit("pet", Some(pet("Grimjaw", 72, 45, 80, 0)));
s.fire_event("UNIT_PET", vec![ScriptValue::Str("player".into())]);
// The three globals the handler reaches for must all exist. Named individually so a failure
// says which one went missing rather than just "attempt to call a nil value".
for global in [
"SpellIsTargeting",
"CursorHasItem",
"DropItemOnUnit",
"SpellTargetUnit",
] {
assert!(
s.eval::<bool>(&format!("return _G[\"{global}\"] ~= nil"))
.unwrap_or(false),
"{global} is not registered — the pet frame's click calls it"
);
}
// The middle leg, for real: put food in the backpack, pick it up the way a player does, then
// click the pet. Before the fix this click errored instead of queueing anything.
s.set_container(
0,
Some(benilla_ui::script::ContainerState {
name: Some("Backpack".into()),
num_slots: 16,
slots: std::collections::HashMap::from([(
1,
benilla_ui::script::ContainerSlot {
item_id: 2287, // Haunch of Meat — pet food
count: 1,
..Default::default()
},
)]),
}),
);
s.fire_event("BAG_UPDATE", vec![ScriptValue::Int(0)]);
s.run("C_Container.PickupContainerItem(0, 1)").unwrap();
assert!(
s.eval::<bool>("return CursorHasItem()").unwrap(),
"the food is on the cursor"
);
s.run("BenillaPetFrame_OnClick(\"LeftButton\")")
.expect("the cursor-holds-an-item leg must not error");
assert_eq!(
s.take_drop_item_on_unit(),
vec!["pet".to_string()],
"a held item + a pet click queues the drop"
);
// …and it is the DROP leg, not the target leg — the reference's if/elseif is exclusive.
assert!(s.take_target_requests().is_empty());
}
/// The GlobalStrings the happiness tooltip resolves by key. Loaded from the MPQ in production; the
/// test declares the ones it reads, the pet-bar tests' convention. (That the real keys exist in the
/// shipped file is asserted separately, by name, in `ui_pet_stats`.)
@@ -336,6 +404,10 @@ fn stats(
loyalty: Some("(Loyalty Level 6) Best Friend".into()),
training_points: (170, 130),
experience: (4200, 8000),
// The unit frame draws neither the family word (the paper doll's level line) nor the diet
// (its tooltip) — decision 1062. Left at their defaults on purpose: the happiness icon
// these tests drive must not start depending on them.
..benilla_ui::script::PetStats::default()
}
}
@@ -0,0 +1,451 @@
//! 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 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
//! **conditional tab**: it goes up and down with the pet, the tab beside it slides to close the
//! gap, the page refuses to open without a pet, and it closes itself when the pet leaves. That
//! machinery is the half a green build cannot see.
use benilla_ui::script::{
PetStats, QuadContent, ScriptValue, UiScript, UnitCombatStats, UnitState,
};
/// Load one shipped `assets/ui/<file>`, panicking on any loader error (the character tests' loader).
fn load_xml(s: &UiScript, file: &str) {
let text = std::fs::read_to_string(
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("assets/ui")
.join(file),
)
.unwrap();
let doc = benilla_ui::framexml::parse(&text).unwrap();
let report = benilla_ui::loader::load(s, &doc, &|_| None);
assert!(
report.errors.is_empty(),
"{file}: loader errors: {:?}",
report.errors
);
}
/// The 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.
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, "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");
s.set_unit("player", Some(player_unit()));
s
}
fn player_unit() -> UnitState {
UnitState {
exists: true,
name: Some("Benilla".into()),
health: 100,
max_health: 100,
level: 60,
race: Some("Night Elf".into()),
race_file: Some("NightElf".into()),
class: Some("Hunter".into()),
class_file: Some("HUNTER".into()),
sex: 2,
is_player: true,
..UnitState::default()
}
}
fn pet_unit() -> UnitState {
UnitState {
exists: true,
name: Some("Snarl".into()),
health: 900,
max_health: 1000,
level: 58,
..UnitState::default()
}
}
/// A hunter pet's stat block — `hunter_pet` is `HasPetUI`'s SECOND return, the one that gates the
/// training-point line and the diet icon. The family pair is the shipped `CreatureFamily.dbc` row
/// for a Boar (id 5, food mask 63) resolved through `ItemPetFood.dbc` (decision 1062).
fn hunter_pet_stats() -> PetStats {
PetStats {
hunter_pet: true,
happiness: Some(3),
damage_percentage: 125.0,
loyalty_rate: 20.0,
loyalty: Some("(Loyalty Level 6) Best Friend".into()),
training_points: (170, 130),
experience: (4200, 8000),
family: Some("Boar".into()),
food_types: ["Meat", "Fish", "Cheese", "Bread", "Fungus", "Fruit"]
.map(String::from)
.to_vec(),
}
}
/// Numbers a pet's descriptor really carries: stats and resistances, no PLAYER-block buff split
/// (a creature has none — decision 1057), so every pos/neg stays zero.
fn pet_combat_stats() -> UnitCombatStats {
UnitCombatStats {
stats: [123, 88, 210, 20, 45],
resistances: [2400, 0, 55, 0, 0, 0, 30],
min_damage: 90.0,
max_damage: 130.0,
main_attack_time_ms: 2000,
attack_power: 640,
..UnitCombatStats::default()
}
}
/// Every string the UI actually draws this frame.
fn texts(s: &mut UiScript) -> Vec<String> {
s.resolve();
s.extract()
.into_iter()
.filter_map(|q| match q.content {
QuadContent::Text { text: Some(t), .. } => Some(t),
_ => None,
})
.collect()
}
/// A tab's resolved left edge. Only meaningful with the **window open** — a frame nothing lays out
/// has no rect at all, and `GetLeft()` answers nil rather than a stale number.
fn tab_left(s: &mut UiScript, tab: u32) -> f64 {
s.resolve();
s.eval::<f64>(&format!("return BenillaCharacterFrameTab{tab}:GetLeft()"))
.unwrap_or_else(|e| panic!("tab {tab} has no resolved rect: {e}"))
}
/// Put a pet in the world and tell the page about it, the way the app's feeds do.
fn give_pet(s: &mut UiScript) {
s.set_unit("pet", Some(pet_unit()));
s.set_pet_stats(true, hunter_pet_stats());
s.set_pet_combat_stats(Some(pet_combat_stats()));
s.fire_event("PET_BAR_UPDATE", vec![]);
}
/// Take it away again — the dismiss/death path: `UNIT_PET` names the OWNER, not the pet (0990).
fn take_pet(s: &mut UiScript) {
s.set_unit("pet", None);
s.set_pet_stats(false, PetStats::default());
s.set_pet_combat_stats(None);
s.fire_event("UNIT_PET", vec![ScriptValue::Str("player".into())]);
}
#[test]
fn shipped_pet_page_loads_clean() {
let s = load_pet_page();
assert!(s.errors().is_empty(), "load errors: {:?}", s.errors());
// The page itself exists and starts down, like every other subframe.
assert!(!s
.eval::<bool>("return BenillaPetPaperDollFrame: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.
#[test]
fn the_pet_tab_rises_and_falls_with_the_pet_and_skills_closes_the_gap() {
let mut s = load_pet_page();
// The rects only exist while something lays the window out.
s.run(r#"ToggleCharacter("BenillaPaperDollFrame")"#)
.unwrap();
assert!(
!s.eval::<bool>("return BenillaCharacterFrameTab2:IsVisible()")
.unwrap(),
"no pet: the Pet tab is down"
);
let closed3 = tab_left(&mut s, 3);
give_pet(&mut s);
assert!(
s.eval::<bool>("return BenillaCharacterFrameTab2:IsVisible()")
.unwrap(),
"a pet raises the Pet tab"
);
let open2 = tab_left(&mut s, 2);
let open3 = tab_left(&mut s, 3);
assert_eq!(
open2, closed3,
"with no pet, Skills stood exactly where the Pet tab stands with one"
);
assert!(
open3 > open2,
"…and a pet pushes Skills out past it ({open3} vs {open2})"
);
take_pet(&mut s);
assert!(
!s.eval::<bool>("return BenillaCharacterFrameTab2:IsVisible()")
.unwrap(),
"the pet leaving lowers the tab again"
);
assert_eq!(tab_left(&mut s, 3), closed3, "…and the row closes back up");
assert!(s.errors().is_empty(), "errors: {:?}", s.errors());
}
/// The reference's `ToggleCharacter` guard (ref `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 s = load_pet_page();
s.run(r#"ToggleCharacter("BenillaPetPaperDollFrame")"#)
.unwrap();
assert!(
!s.eval::<bool>("return BenillaCharacterFrame:IsVisible()")
.unwrap(),
"no pet: the window stays shut"
);
// …and with the window already open on the character page, the refusal leaves it open.
s.run(r#"ToggleCharacter("BenillaPaperDollFrame")"#)
.unwrap();
s.run(r#"ToggleCharacter("BenillaPetPaperDollFrame")"#)
.unwrap();
assert!(
s.eval::<bool>("return BenillaPaperDollFrame:IsVisible()")
.unwrap(),
"the refusal must not switch pages or close the window"
);
assert!(s.errors().is_empty(), "errors: {:?}", s.errors());
}
/// 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.
#[test]
fn the_page_reads_the_pets_own_snapshot() {
let mut s = load_pet_page();
give_pet(&mut s);
s.run(r#"ToggleCharacter("BenillaPetPaperDollFrame")"#)
.unwrap();
assert!(s
.eval::<bool>("return BenillaPetPaperDollFrame:IsVisible()")
.unwrap());
let drawn = texts(&mut s);
for want in ["123", "88", "210", "Snarl", "(Loyalty Level 6) Best Friend"] {
assert!(
drawn.iter().any(|t| t.contains(want)),
"expected {want:?} on the page; drew {drawn:?}"
);
}
// Unspent training points = total - spent (ref l.85-86), not either number by itself.
assert!(
drawn.iter().any(|t| t == "40"),
"unspent training points (170-130); drew {drawn:?}"
);
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
/// 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 mut s = load_pet_page();
give_pet(&mut s);
s.run(r#"ToggleCharacter("BenillaPetPaperDollFrame")"#)
.unwrap();
assert!(!s
.eval::<bool>("return BenillaCharacterNameText:IsVisible()")
.unwrap());
assert!(s
.eval::<bool>("return BenillaPetNameText:IsVisible()")
.unwrap());
s.run(r#"ToggleCharacter("BenillaPaperDollFrame")"#)
.unwrap();
assert!(
s.eval::<bool>("return BenillaCharacterNameText:IsVisible()")
.unwrap(),
"the player's name line comes back"
);
assert!(!s
.eval::<bool>("return BenillaPetNameText:IsVisible()")
.unwrap());
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
/// 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 mut s = load_pet_page();
give_pet(&mut s);
s.run(r#"ToggleCharacter("BenillaPetPaperDollFrame")"#)
.unwrap();
assert!(s
.eval::<bool>("return BenillaCharacterFrame:IsVisible()")
.unwrap());
take_pet(&mut s);
assert!(
!s.eval::<bool>("return BenillaCharacterFrame:IsVisible()")
.unwrap(),
"the pet's page cannot outlive the pet"
);
assert!(s.errors().is_empty(), "errors: {:?}", s.errors());
}
/// 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).
#[test]
fn a_minion_gets_the_page_without_the_hunter_furniture() {
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
s.set_pet_combat_stats(Some(pet_combat_stats()));
s.fire_event("PET_BAR_UPDATE", vec![]);
s.run(r#"ToggleCharacter("BenillaPetPaperDollFrame")"#)
.unwrap();
assert!(
s.eval::<bool>("return BenillaCharacterFrameTab2:IsVisible()")
.unwrap(),
"a minion still gets the tab"
);
assert!(!s
.eval::<bool>("return BenillaPetPaperDollPetInfo:IsVisible()")
.unwrap());
assert!(!s
.eval::<bool>("return BenillaPetTrainingPointText:IsVisible()")
.unwrap());
assert!(!s
.eval::<bool>("return BenillaPetTrainingPointLabel:IsVisible()")
.unwrap());
// …and the stat rows still read the minion's own numbers.
assert!(
texts(&mut s).iter().any(|t| t.contains("210")),
"the shared rows are not hunter-gated"
);
assert!(s.errors().is_empty(), "errors: {:?}", s.errors());
}
/// **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.
///
/// 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`;
/// 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
/// never goes away under a live pet, and the page closes with the pet anyway. The case that IS
/// 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() {
let with_family = |family: Option<String>| {
let mut s = load_pet_page();
s.set_unit("pet", Some(pet_unit()));
s.set_pet_combat_stats(Some(pet_combat_stats()));
s.set_pet_stats(
true,
PetStats {
family,
..PetStats::default() // hunter_pet false — a warlock's minion
},
);
s.fire_event("PET_BAR_UPDATE", vec![]);
s.run(r#"ToggleCharacter("BenillaPetPaperDollFrame")"#)
.unwrap();
let drawn = texts(&mut s);
assert!(s.errors().is_empty(), "errors: {:?}", s.errors());
drawn
};
let drawn = with_family(Some("Imp".into()));
assert!(
drawn.iter().any(|t| t == "Level 58 Imp"),
"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.
let drawn = with_family(None);
assert!(
!drawn.iter().any(|t| t.starts_with("Level")),
"no family ⇒ no level line at all; 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.
///
/// 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.
#[test]
fn hovering_the_diet_icon_lists_what_the_pet_eats() {
let mut s = load_pet_page();
give_pet(&mut s);
s.run(r#"ToggleCharacter("BenillaPetPaperDollFrame")"#)
.unwrap();
assert!(
s.eval::<bool>("return BenillaPetPaperDollPetInfo:IsVisible()")
.unwrap(),
"the diet icon is shown for a hunter pet"
);
s.run(
"local f = BenillaPetPaperDollPetInfo \
f:GetScript(\"OnEnter\")(f)",
)
.unwrap();
assert_eq!(
s.eval::<String>("return GameTooltipTextLeft1:GetText()")
.unwrap(),
"Diet: Meat, Fish, Cheese, Bread, Fungus, Fruit",
"PET_DIET_TEMPLATE around a plain \", \" join — 1.12's BuildListString has no \"and\""
);
assert!(s.errors().is_empty(), "errors: {:?}", s.errors());
}
/// `BuildListString`'s own edges, on the shipped `UIParent.xml` (decision 1062). The reference's is
/// a bare comma join with **no** "and" and **no** localization string, and it answers **nil** for
/// zero arguments — the shape `format("%s", …)` would error on, which is exactly why the diet icon
/// 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 s = load_pet_page();
assert_eq!(
s.eval::<String>("return BuildListString('Meat', 'Fish', 'Cheese')")
.unwrap(),
"Meat, Fish, Cheese"
);
assert_eq!(
s.eval::<String>("return BuildListString('Meat')").unwrap(),
"Meat",
"one item is the item, with no separator anywhere"
);
assert!(
s.eval::<bool>("return BuildListString() == nil").unwrap(),
"zero arguments is nil, not an empty string"
);
}
+135 -1
View File
@@ -179,6 +179,7 @@ fn detail_panel_reward_grid_follows_the_refs_two_per_row_layout() {
count: 1,
quality,
usable: true,
..Default::default()
};
s.set_quest(Some(QuestState {
panel: QuestPanel::Detail,
@@ -239,9 +240,17 @@ fn detail_panel_reward_grid_follows_the_refs_two_per_row_layout() {
);
// The detail panel's choice rows are informational only (ref: only the REWARD panel's rows
// select) — clicking one must not raise (no OnClick wired) and must not set an itemChoice.
// select). They DO carry an OnClick now (`BenillaQuestItem_OnClick`, the ref's own
// QuestItemTemplate script — the ctrl/shift fork, decisions 1059/1060), but it has no select
// arm at all: an unmodified click must not raise and must not set an itemChoice.
s.run("BenillaQuestDetailChoice1:Click()").ok();
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
assert_eq!(
s.eval::<i64>("return BenillaQuestFrame.itemChoice")
.unwrap(),
0,
"a detail-panel row never selects a reward"
);
}
/// The reward panel's choice rows ARE selectable (ref `QuestRewardItem_OnClick`) — clicking one moves
@@ -263,6 +272,7 @@ fn reward_panel_choice_click_selects_and_completes_with_zero_based_index() {
count: 1,
quality: 1,
usable: true,
..Default::default()
};
s.set_quest(Some(QuestState {
panel: QuestPanel::Reward,
@@ -646,3 +656,127 @@ fn greeting_panel_title_rows_grow_to_their_wrapped_titles() {
"row 2 starts at/below row 1's bottom: row1 bottom {b1}, row2 top {t2}"
);
}
/// The questgiver rows' modifier fork (decisions 1059/1060), on the panel where it can do the most
/// damage: the REWARD panel, whose choice rows are also the quest's reward *selection*. Ref
/// `QuestRewardItem_OnClick` (QuestFrame.lua:127-141) — ctrl previews, shift posts the link, and the
/// choice SELECT is the third arm, so neither modified click may also pick the reward. The fixed
/// reward row beside it rides the plainer `QuestItem_OnClick` (l.115-125, the base
/// QuestItemTemplate's own OnClick) and previews the same way with no select arm to disturb.
///
/// Ordering note: the dressing room docks the same left UIPanel slot as the questgiver window
/// (`UiPanels.xml`, `pushable = 2` vs the giver's `0`), so the ctrl arm is exercised last — opening
/// it closes the giver window, exactly as it does in play.
#[test]
fn reward_rows_preview_and_post_without_selecting_the_choice() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Fonts.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "MerchantFrame.xml");
load_xml(&s, "ScrollTemplates.xml");
load_xml(&s, "QuestFrame.xml");
load_xml(&s, "UIParent.xml"); // BenillaChatEdit_InsertLink lives here
load_xml(&s, "DressUpFrame.xml"); // DressUpItemLink lives here
load_xml(&s, "ChatFrame.xml"); // ChatFrameEditBox lives here
const SWORD: &str = "|cffffffff|Hitem:2299:0:0:0|h[Worn Sword]|h|r";
const MACE: &str = "|cffffffff|Hitem:2300:0:0:0|h[Worn Mace]|h|r";
let item = |id: u32, name: &str, link: &str| benilla_ui::script::QuestItemView {
item_id: id,
name: Some(name.into()),
texture: None,
count: 1,
quality: 1,
usable: true,
link: Some(link.into()),
};
s.set_quest(Some(QuestState {
panel: QuestPanel::Reward,
title: "A Threat Within".into(),
body: "Well done.".into(),
choices: vec![
item(2299, "Worn Sword", SWORD),
item(2300, "Worn Mace", MACE),
],
rewards: vec![item(
2504,
"Worn Shortbow",
"|cffffffff|Hitem:2504:0:0:0|h[Worn Shortbow]|h|r",
)],
..QuestState::default()
}));
s.fire_event(
"QUEST_COMPLETE",
vec![ScriptValue::Str("Marshal McBride".into())],
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
// The regression this fork could break: a PLAIN click still picks the choice (the ref's third
// arm). Row 2 is picked here so the modified clicks below — aimed at row 1 — would visibly
// move it if they leaked into the select arm.
s.run("BenillaQuestRewardChoice2:Click()").unwrap();
assert_eq!(
s.eval::<i64>("return BenillaQuestFrame.itemChoice")
.unwrap(),
2,
"a plain click still selects the reward choice"
);
assert!(s
.eval::<bool>("return BenillaQuestRewardChoiceHighlight:IsShown()")
.unwrap());
assert!(
s.take_dressup_intents().is_empty(),
"a plain click never opens the dressing room"
);
// SHIFT + chat open → the row's full escaped link, and the selection is untouched.
assert!(s.focus_editbox("ChatFrameEditBox"));
s.set_modifiers(true, false, false);
s.run("BenillaQuestRewardChoice1:Click()").unwrap();
s.set_modifiers(false, false, false);
assert_eq!(
s.eval::<String>("return ChatFrameEditBox:GetText()")
.unwrap(),
SWORD,
"shift-click posted choice 1's link"
);
assert_eq!(
s.eval::<i64>("return BenillaQuestFrame.itemChoice")
.unwrap(),
2,
"the shift arm returns — it must NOT also select the clicked choice"
);
// A FIXED reward row (plain BenillaQuestItemTemplate → BenillaQuestItem_OnClick) posts too.
s.run("ChatFrameEditBox:SetText(\"\")").unwrap();
s.set_modifiers(true, false, false);
s.run("BenillaQuestRewardReward1:Click()").unwrap();
s.set_modifiers(false, false, false);
assert_eq!(
s.eval::<String>("return ChatFrameEditBox:GetText()")
.unwrap(),
"|cffffffff|Hitem:2504:0:0:0|h[Worn Shortbow]|h|r"
);
// CTRL → the dressing room wearing the clicked reward, and STILL no reselect. The intent pair
// is ordered: Dress (the room was closed, so it re-dresses in the player's own gear) then TryOn.
s.set_modifiers(false, true, false);
s.run("BenillaQuestRewardChoice1:Click()").unwrap();
s.set_modifiers(false, false, false);
assert_eq!(
s.take_dressup_intents(),
vec![
benilla_ui::script::DressUpIntent::Dress,
benilla_ui::script::DressUpIntent::TryOn(2299)
],
"ctrl-click opened the room wearing choice 1"
);
assert_eq!(
s.eval::<i64>("return BenillaQuestFrame.itemChoice")
.unwrap(),
2,
"the ctrl arm returns — it must NOT also select the clicked choice"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
@@ -80,11 +80,18 @@ fn eight_entries() -> QuestLogState {
count: 1,
quality: 1,
usable: true,
// The reward row's ctrl/shift payload (`GetQuestLogItemLink`, decisions 1059/1060)
// — `ui_quest_log.rs` builds it through `ui_items::item_link` once the template
// lands, so the fixture carries that exact shape.
link: Some(HAMMER_LINK.into()),
}],
}),
}
}
/// The fixture reward's full escaped link — a real 1.12 item, quality white.
const HAMMER_LINK: &str = "|cffffffff|Hitem:2024:0:0:0|h[Militia Hammer]|h|r";
/// The loader itself: every file the window depends on parses and materializes with no errors.
#[test]
fn shipped_questlog_frame_loads_clean() {
@@ -594,6 +601,7 @@ fn reward_rows_follow_the_refs_two_per_row_layout() {
count: 1,
quality: 1,
usable: true,
..Default::default()
},
QuestItemView {
item_id: 0,
@@ -602,6 +610,7 @@ fn reward_rows_follow_the_refs_two_per_row_layout() {
count: 1,
quality: 1,
usable: true,
..Default::default()
},
],
rewards: vec![QuestItemView {
@@ -611,6 +620,7 @@ fn reward_rows_follow_the_refs_two_per_row_layout() {
count: 1,
quality: 1,
usable: true,
..Default::default()
}],
});
s.set_quest_log(state);
@@ -711,6 +721,7 @@ fn overflowing_entry() -> QuestLogState {
count: 1,
quality: 1,
usable: true,
..Default::default()
}],
}),
}
@@ -985,3 +996,138 @@ fn popup_children_inherit_the_dialog_stratum() {
"the buttons draw ABOVE their dialog's backdrop (button z {button_z:x} vs backdrop z {backdrop_z:x})"
);
}
/// The quest log's reward rows carry the same modifier fork as the questgiver's (decisions
/// 1059/1060) — ref `QuestLogRewardItem_OnClick` (QuestLogFrame.lua:542-552), the OnClick the ref's
/// own `QuestLogRewardItemTemplate` adds (ref QuestLogFrame.xml:118-120). There is no third arm
/// here: a quest-log reward row is never selectable, so a plain click stays inert — which is the
/// regression this pins, since the row had NO OnClick at all before this arc.
///
/// Ordering note: the dressing room docks the same left UIPanel slot as the log window, so the ctrl
/// arm runs last — opening the room closes the log, exactly as it does in play.
#[test]
fn reward_rows_preview_and_post_and_a_plain_click_stays_inert() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Fonts.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "MerchantFrame.xml");
load_xml(&s, "QuestLogFrame.xml");
load_xml(&s, "UIParent.xml"); // BenillaChatEdit_InsertLink lives here
load_xml(&s, "DressUpFrame.xml"); // DressUpItemLink lives here
load_xml(&s, "ChatFrame.xml"); // ChatFrameEditBox lives here
s.set_quest_log(eight_entries());
s.run("ToggleQuestLog()").unwrap();
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
assert!(
s.eval::<bool>("return BenillaQuestLogReward1:IsShown()")
.unwrap(),
"the fixture's one fixed reward row is laid out"
);
let _ = s.take_dressup_intents();
// A PLAIN click is inert: no room, no chat text, no error (the row has no select arm).
s.run("BenillaQuestLogReward1:Click()").unwrap();
assert!(
s.take_dressup_intents().is_empty(),
"a plain click never opens the dressing room"
);
assert_eq!(
s.eval::<String>("return ChatFrameEditBox:GetText()")
.unwrap(),
"",
"a plain click posts nothing"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
// SHIFT + chat open → the reward's full escaped link (ref l.547-550).
assert!(s.focus_editbox("ChatFrameEditBox"));
s.set_modifiers(true, false, false);
s.run("BenillaQuestLogReward1:Click()").unwrap();
s.set_modifiers(false, false, false);
assert_eq!(
s.eval::<String>("return ChatFrameEditBox:GetText()")
.unwrap(),
HAMMER_LINK,
"shift-click posted the reward's link"
);
assert!(
s.take_dressup_intents().is_empty(),
"the shift arm never opens the room"
);
// CTRL → the room wearing the reward (ref l.543-546): re-dress first, then try it on.
s.set_modifiers(false, true, false);
s.run("BenillaQuestLogReward1:Click()").unwrap();
s.set_modifiers(false, false, false);
assert_eq!(
s.take_dressup_intents(),
vec![
benilla_ui::script::DressUpIntent::Dress,
benilla_ui::script::DressUpIntent::TryOn(2024)
],
"ctrl-click opened the room wearing the reward"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// A quest log TITLE row is a quest NAME, not an item — ref `QuestLogTitleButton_OnClick`
/// (QuestLogFrame.lua:469-505): with the chat edit box up, shift-click inserts the row's own text
/// with its leading indent trimmed (l.478-480, plain text — 1.12 has no `|Hquest:` link); the
/// quest-watch toggle is that branch's `else` (l.481-500). Both halves in one test because it is the
/// *fork* that matters: watching is the behaviour that already shipped, and the insert must not have
/// eaten it. The select-and-repaint tail (l.503-504) runs either way.
#[test]
fn shift_click_on_a_title_posts_the_quest_name_with_chat_open_and_watches_with_it_closed() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Fonts.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "MerchantFrame.xml");
load_xml(&s, "QuestLogFrame.xml");
load_xml(&s, "UIParent.xml"); // BenillaChatEdit_InsertLink lives here
load_xml(&s, "ChatFrame.xml"); // ChatFrameEditBox lives here
s.set_quest_log(eight_entries());
s.run("ToggleQuestLog()").unwrap();
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
// Row 1's centre — the same seat `shift_click_toggles_the_watch_checkbox_and_the_tracker_hud`
// computes (list TOPLEFT+(19,-75), 300×16).
s.resolve();
let win = frame_rect(&s.extract(), 384.0, 512.0);
let (rx, ry) = (win.left + 19.0 + 150.0, win.top - 75.0 - 8.0);
let shift_click = |s: &mut UiScript| {
s.set_modifiers(true, false, false);
s.mouse_button(rx, ry, "LeftButton", true);
s.mouse_button(rx, ry, "LeftButton", false);
s.set_modifiers(false, false, false);
};
// Chat CLOSED → the watch toggle, exactly as before.
shift_click(&mut s);
assert!(
s.eval::<bool>("return IsQuestWatched(1)").unwrap(),
"with chat closed, shift-click still toggles the quest watch"
);
shift_click(&mut s); // and back off, so the assertion below is unambiguous
assert!(!s.eval::<bool>("return IsQuestWatched(1)").unwrap());
// Chat OPEN → the bare quest name, and NO watch.
assert!(s.focus_editbox("ChatFrameEditBox"));
shift_click(&mut s);
assert_eq!(
s.eval::<String>("return ChatFrameEditBox:GetText()")
.unwrap(),
"Quest 1",
"the trimmed quest name landed in the chat box (ref's gsub result, no row indent)"
);
assert!(
!s.eval::<bool>("return IsQuestWatched(1)").unwrap(),
"with chat open the watch must NOT toggle — the ref makes watching the else"
);
// The tail runs either way (ref l.503-504): the click still selects the row.
assert_eq!(s.eval::<i64>("return GetQuestLogSelection()").unwrap(), 1);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
@@ -98,7 +98,8 @@ fn loading_the_shipped_ui_queues_no_sounds() {
///
/// The shape half runs everywhere; the resolve half needs client data and skips without it (a
/// hand-kept list of real file names would rot into agreeing with itself — the `text=` sweep's
/// argument below). Resolution goes through the renderer's own `sprite_key`, not a copy of it.
/// argument below). Resolution goes through the renderer's own `sprite_candidates`, not a copy of it —
/// including its `.blp`/`.tga` fallback, so the sweep accepts exactly what the renderer accepts.
#[test]
fn every_shipped_texture_path_resolves_in_the_client_archives() {
use benilla_ui::framexml::{Element, TopLevel};
@@ -163,7 +164,11 @@ fn every_shipped_texture_path_resolves_in_the_client_archives() {
let chain = benilla_formats::open_chain(&data).expect("open chain");
let missing: Vec<String> = refs
.iter()
.filter(|(_, _, path)| !chain.contains(&crate::assets::sprite_key(path)))
.filter(|(_, _, path)| {
!crate::assets::sprite_candidates(path)
.iter()
.any(|c| chain.contains(c))
})
.map(|(file, tag, path)| format!("{file}: <{tag} file=\"{path}\">"))
.collect();
assert!(
@@ -425,3 +425,299 @@ fn solo_target_inspect_click_reaches_inspect_unit() {
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
// ── The PET menu (decision 1066; report B219) ───────────────────────────────────────────────────
/// The pet menu's own prefix. Two things join the popup prefix: `UiPanels.xml` (the StaticPopup
/// engine, because two of the four rows go behind a dialog) and `PetActionBar.xml`, which is where
/// those three dialogs are registered — with the pet arc, not with the rows. `Cooldown.xml` and
/// `ActionBar.xml` are the pet bar's own load deps, not this menu's.
fn load_pet_menu_frames(s: &UiScript) {
for file in [
"Fonts.xml",
"UIParent.xml",
"UiPanels.xml",
"GameTooltip.xml",
"UIDropDownMenu.xml",
"UnitPopup.xml",
"UnitFrames.xml",
"Cooldown.xml",
"ActionBar.xml",
"PetActionBar.xml",
] {
load_xml(s, file);
}
}
/// The PET rows' labels and both dialogs' text, verbatim from the real
/// `Interface\FrameXML\GlobalStrings.lua` off the 1.12.1 patch chain (l.3028-3052 and l.3) — which
/// is what the app itself runs at boot; this only stands in for it in a bare harness.
fn bake_pet_strings(s: &UiScript) {
s.run(
r#"
PET_PAPERDOLL = "Pet Details"
PET_RENAME = "Rename"
PET_ABANDON = "Abandon"
PET_DISMISS = "Dismiss"
CANCEL = "Cancel"
OKAY = "Okay"
ACCEPT = "Accept"
YES = "Yes"
NO = "No"
ABANDON_PET = "Are you sure you want to permanently abandon your pet?"
PET_RENAME_LABEL = "Enter desired name of pet:"
PET_RENAME_CONFIRMATION = "Name your pet '%s'?"
-- ToggleCharacter lives in CharacterFrame.xml, which the paperdoll row needs and this
-- isolation prefix does not load. Recorded rather than stubbed away, so the row's click
-- is still proven to reach the right panel name.
function ToggleCharacter(tab) BENILLA_TEST_TOGGLED = tab end
"#,
)
.unwrap();
}
/// A pet that exists, so `UnitExists("pet")` passes the dropdown's own gate.
fn a_pet(s: &mut UiScript, name: &str) {
s.set_unit(
"pet",
Some(UnitState {
exists: true,
name: Some(name.into()),
health: 100,
max_health: 100,
level: 20,
..UnitState::default()
}),
);
s.fire_event(
"UNIT_PET",
vec![benilla_ui::script::ScriptValue::Str("player".into())],
);
}
/// Open the pet menu through the real hit path, and return nothing — the assertions read
/// `DropDownList1` afterwards.
fn right_click_the_pet_frame(s: &mut UiScript) {
s.resolve();
let (cx, cy) = s
.eval::<(f64, f64)>("return BenillaPetFrame:GetCenter()")
.unwrap();
s.mouse_button(cx as f32, cy as f32, "RightButton", true);
s.mouse_button(cx as f32, cy as f32, "RightButton", false);
s.resolve();
}
/// **A hunter's pet shows Abandon and hides Dismiss; a warlock's demon does the reverse** — the
/// one predicate that forks the whole menu (`UnitPopup.lua:402-417`).
///
/// This is the assertion B219 turns on. Getting the sense backwards is silent both ways: a hunter
/// offered only Dismiss still cannot get past a taming step, and a demon offered Abandon is
/// offered a row the reference never shows.
#[test]
fn the_pet_menu_forks_between_abandon_and_dismiss() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
bake_pet_strings(&s);
load_pet_menu_frames(&s);
a_pet(&mut s, "Bruce");
// A freshly tamed hunter pet: both bits set.
s.set_pet_menu(true, true);
right_click_the_pet_frame(&mut s);
assert!(
s.eval::<bool>("return DropDownList1:IsVisible()").unwrap(),
"right-clicking the pet frame opens the PET menu"
);
assert_eq!(
s.eval::<i64>("return DropDownList1.numButtons").unwrap(),
5,
"title + Pet Details + Rename + Abandon + Cancel — no Dismiss"
);
for (row, text) in [
(2, "Pet Details"),
(3, "Rename"),
(4, "Abandon"),
(5, "Cancel"),
] {
assert_eq!(
s.eval::<String>(&format!("return DropDownList1Button{row}:GetText()"))
.unwrap(),
text
);
}
// The same pet after one rename — the server clears only the rename bit, so only that row goes.
s.run("CloseDropDownMenus()").unwrap();
s.set_pet_menu(true, false);
right_click_the_pet_frame(&mut s);
assert_eq!(
s.eval::<i64>("return DropDownList1.numButtons").unwrap(),
4,
"title + Pet Details + Abandon + Cancel"
);
assert_eq!(
s.eval::<String>("return DropDownList1Button3:GetText()")
.unwrap(),
"Abandon",
"Rename is gone and Abandon has moved up into its row"
);
// A warlock's demon: neither bit. The menu flips whole.
s.run("CloseDropDownMenus()").unwrap();
s.set_pet_menu(false, false);
right_click_the_pet_frame(&mut s);
assert_eq!(
s.eval::<i64>("return DropDownList1.numButtons").unwrap(),
3,
"title + Dismiss + Cancel"
);
assert_eq!(
s.eval::<String>("return DropDownList1Button2:GetText()")
.unwrap(),
"Dismiss"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// **Dismiss goes straight out; Abandon goes behind the confirm** — the reference's own asymmetry
/// (`UnitPopup_OnClick` l.590-593), and the one that matters: sending a summon away costs nothing,
/// giving up a tamed pet is a server-side delete.
#[test]
fn dismiss_sends_immediately_and_abandon_waits_for_the_confirm() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
bake_pet_strings(&s);
load_pet_menu_frames(&s);
a_pet(&mut s, "Snuffles");
// A demon: Dismiss is row 2, and clicking it queues the verb with no dialog in between.
s.set_pet_menu(false, false);
right_click_the_pet_frame(&mut s);
click_row(&mut s, 2);
assert!(
!s.eval::<bool>("return StaticPopup1:IsVisible()").unwrap(),
"Dismiss asks nothing"
);
assert_eq!(s.take_pet_gives_up(), (0, 1), "one dismiss, no abandon");
// A hunter pet: Abandon is row 4, and clicking it only opens the confirm.
s.set_pet_menu(true, true);
right_click_the_pet_frame(&mut s);
click_row(&mut s, 4);
assert!(
s.eval::<bool>("return StaticPopup1:IsVisible()").unwrap(),
"Abandon opens the ABANDON_PET confirm"
);
assert_eq!(
s.eval::<String>("return StaticPopup1Text:GetText()")
.unwrap(),
"Are you sure you want to permanently abandon your pet?"
);
assert_eq!(
s.take_pet_gives_up(),
(0, 0),
"and sends NOTHING until it is accepted"
);
// Cancel really cancels.
click_frame(&mut s, "StaticPopup1Button2");
assert_eq!(s.take_pet_gives_up(), (0, 0));
assert!(!s.eval::<bool>("return StaticPopup1:IsVisible()").unwrap());
// Accept sends.
right_click_the_pet_frame(&mut s);
click_row(&mut s, 4);
click_frame(&mut s, "StaticPopup1Button1");
assert_eq!(s.take_pet_gives_up(), (1, 0), "one abandon");
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// **The rename is a chain of two dialogs**, and the second reads the typed name back before
/// anything is sent (`RENAME_PET` → `PETRENAMECONFIRM`, ref StaticPopup.lua l.1069-1102 + l.365).
///
/// The chain is also what proves the popup engine's two instances are both live, and why the
/// dialogs must read their OWN edit box rather than `StaticPopup1EditBox` by name: the confirm
/// opens while the name dialog is still up, so the second one lands in instance 2.
#[test]
fn renaming_a_pet_reads_the_name_back_before_sending_it() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
bake_pet_strings(&s);
load_pet_menu_frames(&s);
a_pet(&mut s, "Bruce");
s.set_pet_menu(true, true);
right_click_the_pet_frame(&mut s);
click_row(&mut s, 3);
assert_eq!(
s.eval::<String>("return StaticPopup1Text:GetText()")
.unwrap(),
"Enter desired name of pet:"
);
assert!(
s.eval::<bool>("return StaticPopup1EditBox:IsVisible()")
.unwrap(),
"the name dialog carries an edit box"
);
s.run(r#"StaticPopup1EditBox:SetText("Rexxar")"#).unwrap();
click_frame(&mut s, "StaticPopup1Button1");
assert_eq!(
s.take_pet_renames(),
Vec::<String>::new(),
"accepting the NAME dialog sends nothing — it only asks again"
);
assert!(
s.eval::<bool>("return StaticPopup2:IsVisible()").unwrap(),
"the confirm opens in the second instance, while the first is still up"
);
assert_eq!(
s.eval::<String>("return StaticPopup2Text:GetText()")
.unwrap(),
"Name your pet 'Rexxar'?",
"and it reads the typed name back"
);
click_frame(&mut s, "StaticPopup2Button1");
assert_eq!(
s.take_pet_renames(),
vec!["Rexxar".to_string()],
"only the CONFIRM sends, and it sends the name from the box"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The paperdoll row reaches the pet paper doll panel (decision 1057) — the fifth build the menu
/// was blocked on, and the only row here that opens a window rather than a wire verb.
#[test]
fn the_pet_details_row_opens_the_pet_paper_doll() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
bake_pet_strings(&s);
load_pet_menu_frames(&s);
a_pet(&mut s, "Bruce");
s.set_pet_menu(true, true);
right_click_the_pet_frame(&mut s);
click_row(&mut s, 2);
assert_eq!(
s.eval::<String>("return BENILLA_TEST_TOGGLED").unwrap(),
"BenillaPetPaperDollFrame"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// Click an open dropdown row through the real hit path.
fn click_row(s: &mut UiScript, row: u32) {
click_frame(s, &format!("DropDownList1Button{row}"));
}
/// Click any named frame through the real hit path.
fn click_frame(s: &mut UiScript, name: &str) {
let (x, y) = s
.eval::<(f64, f64)>(&format!("return {name}:GetCenter()"))
.unwrap();
s.mouse_button(x as f32, y as f32, "LeftButton", true);
s.mouse_button(x as f32, y as f32, "LeftButton", false);
s.resolve();
}
+1
View File
@@ -904,6 +904,7 @@ mod tests {
name: "Ol' Sooty".into(),
subname: None,
creature_type: 1,
pet_family: 4, // Bear — a real tameable family, so the record is a plausible one
rank: 1,
type_flags: 0,
civilian: false,
@@ -0,0 +1,376 @@
//! `CreatureFamily.dbc` + `ItemPetFood.dbc` — the pet's **family word** and the **diet** it implies
//! (decision 1062): `UnitCreatureFamily`'s "Imp"/"Wolf"/"Cat", and `GetPetFoodTypes`'s
//! "Meat, Fish".
//!
//! Both layouts below were **dumped from this install's own shipped files** rather than taken from
//! memory or a wiki — the WDBC header first, then every column of every row, then a census of what
//! each column actually holds. What that dump says:
//!
//! **`CreatureFamily.dbc` — 18 fields, `record_size` 0x48, 23 rows, string block 933 bytes.**
//!
//! | field | byte | holds |
//! |---|---|---|
//! | 0 | 0x00 | `ID` — 1..28 with gaps (10, 13, 14, 18, 22 are absent) |
//! | 1 | 0x04 | `minScale`, a **float** (0.4 … 1.0) |
//! | 2 | 0x08 | `minScaleLevel` — 1 for every hunter family, 0 for the warlock ones and row 28 |
//! | 3 | 0x0c | `maxScale`, a **float** (0.6 … 1.1) |
//! | 4 | 0x10 | `maxScaleLevel` — 60, or 0 on the same rows |
//! | 5 | 0x14 | `skillLine[0]` — a `SkillLine.dbc` id (188…758), distinct on every row |
//! | 6 | 0x18 | `skillLine[1]` — 270 on every tameable family, 0 on the rest |
//! | 7 | 0x1c | **`petFoodMask`** — the bitfield this module's other half indexes |
//! | 8..15 | 0x20 | the localized `Name` block; **enUS is field 8**, the other seven are all zero |
//! | 16 | 0x40 | the name block's locale-present flags (0x7EFFFE or 0x3F007E) — **not** zero |
//! | 17 | 0x44 | `iconFile` — `Interface\Icons\Ability_Hunter_Pet_*` (a string, not a dword) |
//!
//! That confirms the `0x48` record and the 23 rows [`crate::pet_stats`]'s header already recorded,
//! and **corrects its "every dword from `0x28` up is zero"**: fields 10..15 (`0x28`–`0x3c`) are
//! indeed all zero — which is what that argument actually rested on, since the reads it was ruling
//! out were at `+0x28`/`+0x30`/`+0x3c`/`+0x48` — but `0x40` and `0x44` are not. The conclusion
//! stands (a `rec+0x48` read still runs off the end of a 0x48-byte record); the sentence was one
//! column too wide.
//!
//! **`ItemPetFood.dbc` — 10 fields, `record_size` 0x28, 8 rows, string block 55 bytes**: `ID`@0,
//! the localized `Name` block@1..8 (**enUS is field 1**, the rest zero), name flags@9. The eight
//! rows in id order are exactly `Meat / Fish / Cheese / Bread / Fungus / Fruit / Raw Meat /
//! Raw Fish`.
//!
//! **The mask's bit → row mapping is `bit b` → `ItemPetFood` row `b + 1`** (see
//! [`PetFoodNames::for_mask`]) — DATA-derived twice over. The highest bit any shipped row sets is
//! **7** (Turtle, mask 178) and the table has exactly 8 rows, so a 0-based mapping would leave row
//! 8 permanently unreachable and Wolf (mask 1) with an empty diet. And vmangos, reading the same
//! file, tests exactly `1 << (item->FoodType - 1)` against this column (`Objects/Pet.cpp:1503-1504`,
//! `Database/DBCStructure.h:251` — its `CreatureFamilyfmt` `"nfifiiiissssssssxx"` is this table's
//! layout above, field for field). Every resulting diet matches vanilla's documented ones: Wolf =
//! Meat, Cat = Meat+Fish, Bear/Boar = all six, Gorilla = Fungus+Fruit, Wind Serpent = Fish+Cheese+
//! Bread.
//!
//! **All of the above is now also byte-VERIFIED** (wow-re, 2026-08-06 — `GetPetFoodTypes 0x4bea10`
//! and `UnitCreatureFamily 0x51a310`), and the two dumps agree line for line with nothing left
//! over: the mask is column 7, the bit map is `1 << (recordID - 1)`, the family name is column
//! `8 + locale`, the food name is column `1 + locale`, the id space really does have null rows at
//! 10/13/14/18/22, and the binary's own positive controls (Wolf → Meat; Bear and Boar → all six;
//! Turtle → Fish/Fungus/Fruit/Raw Fish) are the rows asserted below. The one claim that had been
//! flagged INFERRED here — that the walk is **low bit first** — is settled the same way: the client
//! pushes in **record order**, which is what ascending bits produce.
//!
//! What this module deliberately does **not** model is `GetPetFoodTypes`' own `0x6116e0` gate
//! (owner-is-me + the local player is a Hunter). That is a live-state question, not a table one, so
//! it lives at the feed (`benilla_app::ui_pet_stats`) — and it is why a charmed boar under a priest
//! answers an empty diet despite a mask of 63.
use std::collections::HashMap;
use anyhow::{Context, Result};
use benilla_dbc::{FieldType, Schema, SchemaField};
use crate::chain::Chain;
use crate::dbc::{parse, str_at, u32_at};
const CREATURE_FAMILY: &str = "DBFilesClient\\CreatureFamily.dbc";
const ITEM_PET_FOOD: &str = "DBFilesClient\\ItemPetFood.dbc";
/// `CreatureFamily.dbc`'s column count (the DBC header's `field_count`; `benilla-dbc` enforces it,
/// so a wrong number fails loudly instead of misaligning every column).
const FAMILY_FIELDS: usize = 18;
/// `ItemPetFood.dbc`'s column count.
const FOOD_FIELDS: usize = 10;
/// `CreatureFamily.dbc`'s pet-food mask — byte `0x1c` = field 7.
const FAMILY_FOOD_MASK_FIELD: usize = 0x1c / 4;
/// `CreatureFamily.dbc`'s enUS `Name` — byte `0x20` = field 8.
const FAMILY_NAME_FIELD: usize = 0x20 / 4;
/// `CreatureFamily.dbc`'s `iconFile` — byte `0x44` = field 17. The last column, past the name
/// block's locale flags; not read by anything yet, typed as a string so the schema is honest.
const FAMILY_ICON_FIELD: usize = 0x44 / 4;
/// `ItemPetFood.dbc`'s enUS `Name` — field 1, the same shape every other localized table here uses.
const FOOD_NAME_FIELD: usize = 1;
/// The number of `ItemPetFood` rows a mask can name. Not a guess at the file's size: it is the
/// width the *mask* can address, and the shipped table happens to fill it exactly (bit 7 → row 8,
/// Turtle's "Raw Fish"). Bits above this are ignored rather than fabricating rows.
const MAX_FOOD_BITS: u32 = 8;
/// One `CreatureFamily.dbc` row, reduced to its two live columns.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CreatureFamily {
/// The localized family word — "Wolf", "Imp", "Wind Serpent". What `UnitCreatureFamily` pushes.
pub name: String,
/// The pet-food bitfield (field 7). **`0` for every warlock minion** — Imp, Voidwalker,
/// Succubus, Felhunter, Doomguard all ship a zero mask, and so does row 28 ("Remote Control") —
/// which is why a diet list can be legitimately empty without anything having gone wrong.
pub pet_food_mask: u32,
}
/// `CreatureFamily.dbc`, by id.
#[derive(Debug, Default, Clone)]
pub struct CreatureFamilies(HashMap<u32, CreatureFamily>);
impl CreatureFamilies {
/// The row for a family id, or `None`.
///
/// **Family `0` is `None` and that is the common case, not an error**: the file's ids start at
/// 1, and every creature that is neither a tameable beast nor a warlock minion carries `0` in
/// the wire's `pet_family` slot. `UnitCreatureFamily`'s nil is this `None`.
pub fn get(&self, id: u32) -> Option<&CreatureFamily> {
self.0.get(&id)
}
/// The localized family word for an id — [`Self::get`]'s name half.
pub fn name(&self, id: u32) -> Option<&str> {
self.get(id).map(|f| f.name.as_str())
}
pub fn len(&self) -> usize {
self.0.len()
}
pub fn is_empty(&self) -> bool {
self.0.is_empty()
}
}
/// `ItemPetFood.dbc`: food-type id → its localized name.
#[derive(Debug, Default, Clone)]
pub struct PetFoodNames(HashMap<u32, String>);
impl PetFoodNames {
/// The name for a food-type id (1-based, as the file numbers them).
pub fn name(&self, id: u32) -> Option<&str> {
self.0.get(&id).map(String::as_str)
}
/// Expand a [`CreatureFamily::pet_food_mask`] into the localized names it selects — **bit `b`
/// selects row `b + 1`**, ascending, which is the client's own **record order**
/// (`0x4bea10`'s `1 << (recordID - 1)`; the module header carries the derivation).
///
/// An empty result is a real answer, not a failure: a warlock minion's family ships mask `0`.
/// `GetPetFoodTypes` then returns nothing at all, which is exactly what the reference's own
/// `BuildListString()` turns into nil — and the diet icon that would show it is hidden for a
/// minion anyway (`HasPetUI`'s second return, decision 1005).
///
/// **This is the table half only.** The binding's live gate — owner-is-me and the local player
/// is a Hunter (`0x6116e0`) — is the feed's to apply; a charmed boar has a mask of 63 and still
/// answers nothing under a non-hunter.
pub fn for_mask(&self, mask: u32) -> Vec<&str> {
(0..MAX_FOOD_BITS)
.filter(|b| mask & (1 << b) != 0)
.filter_map(|b| self.name(b + 1))
.collect()
}
pub fn len(&self) -> usize {
self.0.len()
}
pub fn is_empty(&self) -> bool {
self.0.is_empty()
}
}
fn family_schema() -> Schema {
let mut s = Schema::new("CreatureFamily");
for i in 0..FAMILY_FIELDS {
let ty = match i {
// minScale / maxScale — the only two floats in the record.
1 | 3 => FieldType::Float32,
FAMILY_NAME_FIELD | FAMILY_ICON_FIELD => FieldType::String,
// The rest of the locale block reads as dwords: every one is 0 in the shipped file, and
// typing them as strings would make the parser chase seven zero offsets per row.
_ => FieldType::UInt32,
};
s.add_field(SchemaField::new(format!("F{i}"), ty));
}
s
}
fn food_schema() -> Schema {
let mut s = Schema::new("ItemPetFood");
for i in 0..FOOD_FIELDS {
let ty = if i == FOOD_NAME_FIELD {
FieldType::String
} else {
FieldType::UInt32
};
s.add_field(SchemaField::new(format!("F{i}"), ty));
}
s
}
/// Load `CreatureFamily.dbc` from the patch chain.
pub fn load_creature_families(chain: &mut Chain) -> Result<CreatureFamilies> {
let bytes = chain
.read_file(CREATURE_FAMILY)
.with_context(|| format!("reading {CREATURE_FAMILY}"))?;
let rs = parse(&bytes, family_schema(), "CreatureFamily.dbc")?;
let mut by_id = HashMap::with_capacity(rs.records().len());
for r in rs.records() {
let Some(id) = u32_at(r, 0) else { continue };
// A row with no enUS name is dropped rather than stored empty: the binding's contract is
// "a word or nil", and an empty string would render as a trailing space on the level line.
let Some(name) = str_at(&rs, r, FAMILY_NAME_FIELD).filter(|n| !n.is_empty()) else {
continue;
};
by_id.insert(
id,
CreatureFamily {
name,
pet_food_mask: u32_at(r, FAMILY_FOOD_MASK_FIELD).unwrap_or(0),
},
);
}
Ok(CreatureFamilies(by_id))
}
/// Load `ItemPetFood.dbc` from the patch chain.
pub fn load_pet_food_names(chain: &mut Chain) -> Result<PetFoodNames> {
let bytes = chain
.read_file(ITEM_PET_FOOD)
.with_context(|| format!("reading {ITEM_PET_FOOD}"))?;
let rs = parse(&bytes, food_schema(), "ItemPetFood.dbc")?;
let mut by_id = HashMap::with_capacity(rs.records().len());
for r in rs.records() {
let Some(id) = u32_at(r, 0) else { continue };
if let Some(name) = str_at(&rs, r, FOOD_NAME_FIELD) {
by_id.insert(id, name);
}
}
Ok(PetFoodNames(by_id))
}
#[cfg(test)]
mod tests {
use super::*;
fn chain() -> Option<Chain> {
let data = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data");
if !data.is_dir() {
eprintln!("skipping: vanilla client not present at {}", data.display());
return None;
}
Some(crate::open_chain(&data).expect("open chain"))
}
/// The real 5875 `CreatureFamily.dbc`, byte-anchored: 23 rows, the ids with their gaps, and
/// the two columns we read.
///
/// **The name column is the trap.** Field 8 sits immediately after `petFoodMask` (7) and is
/// followed by seven zero locale slots — read one column early and every name is a *number*;
/// read one late and every name is empty. Both fail silently at runtime (a blank level line),
/// so the names are asserted verbatim, not merely for presence.
#[test]
fn the_real_creature_families_name_every_pet() {
let Some(mut chain) = chain() else { return };
let f = load_creature_families(&mut chain).expect("load CreatureFamily.dbc");
assert_eq!(f.len(), 23, "5875 ships 23 creature families");
// The hunter families, at both ends of the id range.
assert_eq!(f.name(1), Some("Wolf"));
assert_eq!(f.name(2), Some("Cat"));
assert_eq!(f.name(7), Some("Carrion Bird"));
assert_eq!(f.name(27), Some("Wind Serpent"));
// …the warlock minions, which is what the pet page's level line reads most often.
assert_eq!(f.name(15), Some("Felhunter"));
assert_eq!(f.name(16), Some("Voidwalker"));
assert_eq!(f.name(17), Some("Succubus"));
assert_eq!(f.name(19), Some("Doomguard"));
assert_eq!(f.name(23), Some("Imp"));
// The id column really has gaps — 10/13/14/18/22 are absent from the shipped file, so a
// dense 1..=23 assumption would silently shift half the table.
for missing in [10, 13, 14, 18, 22] {
assert_eq!(f.name(missing), None, "id {missing} is not in the file");
}
// Family 0 is the wire's "no family", and it must be nil rather than the first row.
assert_eq!(f.name(0), None);
}
/// The eight food names, verbatim and in id order — the row set the mask indexes.
#[test]
fn the_real_pet_food_names_are_the_eight_diets() {
let Some(mut chain) = chain() else { return };
let n = load_pet_food_names(&mut chain).expect("load ItemPetFood.dbc");
assert_eq!(n.len(), 8);
for (id, want) in [
(1, "Meat"),
(2, "Fish"),
(3, "Cheese"),
(4, "Bread"),
(5, "Fungus"),
(6, "Fruit"),
(7, "Raw Meat"),
(8, "Raw Fish"),
] {
assert_eq!(n.name(id), Some(want), "food id {id}");
}
assert_eq!(n.name(0), None, "the file is 1-based; there is no row 0");
assert_eq!(n.name(9), None);
}
/// **The join, on the real data** — the half neither file can check alone, and the one that
/// would fail silently as "a bear eats fish and cheese".
///
/// Wolf, Bear, Boar and Turtle are **the binary's own positive controls** (wow-re's
/// `0x4bea10` carve, 2026-08-06); the rest are vanilla's documented diets, which is the
/// independent corroboration that `bit b → row b+1` is right (vmangos's
/// `1 << (FoodType - 1)` is the third).
#[test]
fn the_shipped_masks_expand_to_vanillas_own_diets() {
let Some(mut chain) = chain() else { return };
let fam = load_creature_families(&mut chain).expect("families");
let food = load_pet_food_names(&mut chain).expect("foods");
let diet = |id: u32| food.for_mask(fam.get(id).expect("family").pet_food_mask);
const EVERYTHING: [&str; 6] = ["Meat", "Fish", "Cheese", "Bread", "Fungus", "Fruit"];
assert_eq!(diet(1), ["Meat"], "Wolf — control");
assert_eq!(diet(2), ["Meat", "Fish"], "Cat");
assert_eq!(diet(4), EVERYTHING, "Bear — control");
assert_eq!(diet(5), EVERYTHING, "Boar — control");
assert_eq!(diet(9), ["Fungus", "Fruit"], "Gorilla");
assert_eq!(diet(12), ["Cheese", "Fungus", "Fruit"], "Tallstrider");
assert_eq!(diet(27), ["Fish", "Cheese", "Bread"], "Wind Serpent");
// Turtle (mask 178) is the control that PINS the bit width: it is the only family to set
// bit 7, and bit 7 → row 8 is what makes "Raw Fish" reachable at all.
assert_eq!(
diet(21),
["Fish", "Fungus", "Fruit", "Raw Fish"],
"Turtle — control"
);
// **Exactly which families have an EMPTY diet**, because the pet page's diet tooltip
// depends on it: `BuildListString()` returns nil for an empty list and `format`'s "%s"
// errors on nil, so the icon is safe only while no HUNTER-tameable family has a zero mask.
// The shipped file's zero-mask set is the five warlock minions plus row 28 ("Remote
// Control") — no tameable beast among them. Asserted as an exact set, not a spot check: a
// future row with a zero mask is exactly the thing that would break that tooltip.
let empty: Vec<u32> = {
let mut v: Vec<u32> = fam
.0
.iter()
.filter(|(_, f)| food.for_mask(f.pet_food_mask).is_empty())
.map(|(id, _)| *id)
.collect();
v.sort_unstable();
v
};
assert_eq!(
empty,
[15, 16, 17, 19, 23, 28],
"Felhunter/Voidwalker/Succubus/Doomguard/Imp + Remote Control — no tameable beast"
);
}
/// The mask expansion's own edges, away from the shipped data: an unknown bit names no row
/// rather than panicking or inventing one, and bits past the table are ignored.
#[test]
fn mask_bits_past_the_table_are_ignored() {
let mut names = HashMap::new();
names.insert(1, "Meat".to_string());
names.insert(3, "Cheese".to_string());
let n = PetFoodNames(names);
assert_eq!(n.for_mask(0), Vec::<&str>::new());
assert_eq!(n.for_mask(0b101), ["Meat", "Cheese"]);
// Bit 1 names row 2, which this cut-down table doesn't have: skipped, not empty-stringed.
assert_eq!(n.for_mask(0b111), ["Meat", "Cheese"]);
// Bit 31 is past `MAX_FOOD_BITS` entirely.
assert_eq!(n.for_mask(0x8000_0001), ["Meat"]);
}
}
-38
View File
@@ -63,44 +63,6 @@ pub(crate) fn str_at(rs: &RecordSet, r: &Record, i: usize) -> Option<String> {
}
}
/// The **macro icon chooser** list — every `SpellIcon.dbc` texture path whose basename begins
/// `Spell_` or `Ability_`, in DBC row order, deduplicated (decision 0983).
///
/// Those two prefixes are the client's own filter, byte-read from its string table: the literals
/// `"Spell_"` (`0x44cb30`) and `"Ability_"` (`0x44cb38`) sit inside the `UIMacros.cpp` translation
/// unit, beside `Interface\Icons\` (`0x44ca64`), the save format `MACRO %d "%s" %s` (`0x44cb60`)
/// and the `GetNumMacroIcons`/`GetMacroIconInfo` binding names — and that TU owns nothing else
/// that would want an icon-name prefix. It is also why the vanilla macro picker famously shows
/// *spell and ability art only*, never the `INV_*` item icons: on build 5875 the filter keeps
/// **519 of the 1018** distinct paths.
///
/// Row order is kept rather than sorted: the chooser is a scrolled grid, and the DBC's own order
/// is the only order the reference could be reading.
pub fn load_macro_icons(chain: &mut Chain) -> Result<Vec<String>> {
let bytes = chain
.read_file("DBFilesClient\\SpellIcon.dbc")
.context("reading SpellIcon.dbc")?;
let mut schema = Schema::new("SpellIcon");
schema.add_field(SchemaField::new("ID", FieldType::UInt32));
schema.add_field(SchemaField::new("TextureFilename", FieldType::String));
let set = parse(&bytes, schema, "SpellIcon.dbc")?;
let mut seen = std::collections::HashSet::new();
let mut icons = Vec::new();
for r in set.records() {
let Some(path) = str_at(&set, r, 1) else {
continue;
};
let base = path.rsplit('\\').next().unwrap_or(&path);
let keeps = ["Spell_", "Ability_"].iter().any(|p| {
base.len() >= p.len() && base.as_bytes()[..p.len()].eq_ignore_ascii_case(p.as_bytes())
});
if keeps && seen.insert(path.to_ascii_lowercase()) {
icons.push(path);
}
}
Ok(icons)
}
/// `SpellIcon.dbc`: id → texture path (`Interface\Icons\…`, extensionless). 1033 records × 2
/// fields (`ID(0)`, `TextureFilename(1, str)`) — verified against build 5875 (`spells.rs`'s own
/// module doc). Shared by [`crate::spells`] (the action-bar catalog) and [`crate::skill_lines`]
+6 -1
View File
@@ -26,7 +26,8 @@ mod unit_blood;
pub use creatures::{
load_creature_catalog, CreatureCatalog, CreatureModel, FootprintParams, NpcAppearance,
};
pub use dbc::load_macro_icons;
pub use macro_icons::load_macro_icons;
mod macro_icons;
pub use unit_blood::{load_blood_catalog, BloodCatalog};
mod itemsets;
pub use itemsets::{load_item_sets, ItemSetCatalog, ItemSetInfo};
@@ -40,6 +41,10 @@ pub use factions::{
};
mod creature_types;
pub use creature_types::{load_creature_type_flags, CreatureTypeFlags};
mod creature_families;
pub use creature_families::{
load_creature_families, load_pet_food_names, CreatureFamilies, CreatureFamily, PetFoodNames,
};
mod gameobjects;
pub use gameobjects::{
go_sound_slot, load_gameobject_catalog, load_gameobject_sounds, GameObjectCatalog,
+89
View File
@@ -0,0 +1,89 @@
//! The **macro icon chooser's catalog** — the list `GetNumMacroIcons`/`GetMacroIconInfo` serve.
//!
//! Byte-derived from the reference client's `BuildMacroIconList` (`0x4f0090`); the full RE note is
//! `wow-5875-re` `system/ui/scratch/macro-icon-chooser.md`. The headline: **the chooser does not
//! read `SpellIcon.dbc`**. It enumerates the files that actually exist under `Interface\Icons\`.
use anyhow::Result;
use crate::Chain;
/// The directory the chooser enumerates — and, at `GetMacroIconInfo`, the prefix it splices back on
/// (`0x84c988`, spliced by `SStrPrintf("%s%s", …)` at `0x4f1a8d`). 16 characters, which is the
/// literal count the client strips when it stores an entry.
const ICON_DIR: &str = "Interface\\Icons\\";
/// The **macro icon chooser** list — every `Interface\Icons\` file whose name begins `Spell_` or
/// `Ability_`, case-insensitively sorted and deduplicated, each as a full texture path.
///
/// This is the *enumeration*, not a DBC scan, because that is what the client does
/// (`BuildMacroIconList 0x4f0090`). It unions three sources: the `(listfile)` of every open
/// `patch*.MPQ` plus `interface.MPQ` (name-table index 6, `0x82e12c`), read via `0x648fb0`; a disk
/// scan of `SFileGetBasePath() + "Interface\Icons\"`; and a CWD-relative `Interface\Icons\` scan.
/// We serve the archive half through [`Chain::list`] — a stock install has no loose `Interface\Icons`
/// directory, so sources 2 and 3 contribute nothing and are not implemented. (They are how the
/// reference lets a user drop a custom icon in; if we ever want that, it belongs here.)
///
/// Per entry the client keeps the name **after the 16-char prefix, with the extension stripped**,
/// accepting only a last-dot extension in `{.blp, .tga}` and rejecting directories. It then
/// `qsort`s case-insensitively (`0x4f0128`, comparator `0x4f05e0` = `SStrCmpI`) and adjacent-dedups
/// walking down (`0x4f0140`–`0x4f01a4`) — so the dedup key is the whole stored name, folded for
/// case, and `Foo.blp` beside `Foo.tga` collapses to one entry. **Sorted, not DBC row order**: the
/// order the player scrolls is alphabetical.
///
/// We store the reassembled full path rather than the bare name. `GetMacroIconInfo` splices
/// [`ICON_DIR`] back on anyway, so what Lua sees is identical, and every consumer here (the popup's
/// `SetTexture`, `CreateMacro`'s saved texture) wants the whole path.
///
/// **Why this replaces a `SpellIcon.dbc` scan** (bug B221, decision 1053): the DBC names art that
/// does not ship. Five of its chooser-eligible rows have no file under any name (`Ability_Temp`,
/// `Spell_Holy_Invulnerable`, `Spell_Misc_Food_08`, `Spell_Orc_Omniscience`,
/// `Ability_Hunter_BeastSooth`), and two more are malformed for a texture lookup
/// (`Spells\Icon\Spell_Fire_Fire`, a different directory root; `Ability_Druid_Mangle.tga`, carrying
/// a literal extension) — so the picker showed solid white cells where those landed. Enumerating
/// the archive cannot produce a name with no file behind it: the defect class is gone by
/// construction, not by an allow-list. On a stock 5875 install this yields **517** icons (the RE
/// counts the same 517 off `patch.MPQ` 77 + `interface.MPQ` 443 = 520 raw, less 3 duplicate names).
pub fn load_macro_icons(chain: &mut Chain) -> Result<Vec<String>> {
let mut names: Vec<String> = Vec::new();
for entry in chain.list()? {
let path = entry.name.replace('/', "\\");
// The prefix test is case-insensitive (`SStrCmpI`), as is everything else here: archive
// listfiles are not consistently cased.
if path.len() <= ICON_DIR.len()
|| !path.as_bytes()[..ICON_DIR.len()].eq_ignore_ascii_case(ICON_DIR.as_bytes())
{
continue;
}
let file = &path[ICON_DIR.len()..];
// Directories are rejected on `flags & 0x10`; the listfile's equivalent is a name that is
// still a path, i.e. anything in a SUBdirectory of Interface\Icons.
if file.contains('\\') {
continue;
}
// Extension = the LAST dot, and only `.blp`/`.tga` are icons. `Foo.tga.blp` therefore
// stores as `Foo.tga` — which is exactly the entry that makes the reference's own picker
// carry an `Ability_Druid_Mangle.tga` beside `Ability_Druid_Mangle`… except that both
// resolve to the same art, so the CI dedup below cannot collapse them and both are shown.
let Some((stem, ext)) = file.rsplit_once('.') else {
continue;
};
if !ext.eq_ignore_ascii_case("blp") && !ext.eq_ignore_ascii_case("tga") {
continue;
}
let keeps = ["Spell_", "Ability_"].iter().any(|p| {
stem.len() >= p.len() && stem.as_bytes()[..p.len()].eq_ignore_ascii_case(p.as_bytes())
});
if keeps {
names.push(stem.to_string());
}
}
// `qsort` by `SStrCmpI`, then adjacent-dedup walking down — the client's own two steps, in its
// own order (sorting first is what makes a single adjacent pass a complete dedup).
names.sort_by_key(|n| n.to_ascii_lowercase());
names.dedup_by(|a, b| a.eq_ignore_ascii_case(b));
Ok(names
.into_iter()
.map(|n| format!("{ICON_DIR}{n}"))
.collect())
}
+4
View File
@@ -347,6 +347,10 @@ pub enum SessionEvent {
/// The template's `CreatureType.dbc` id (Beast, Humanoid, Critter, …) — the TAB-target
/// critter/totem filter's input. `None` on a server miss.
creature_type: Option<u32>,
/// The template's `CreatureFamily.dbc` id (Wolf, Cat, Imp, …) — `UnitCreatureFamily`'s
/// word and, through that row's pet-food mask, the diet tooltip (decision 1062). `0` for
/// everything that is neither a tameable beast nor a warlock minion, and `0` on a miss.
pet_family: u32,
/// Elite rank 0..4 (the unit tooltip's rank word, decision 0276). `0` on a miss.
rank: u32,
/// The template type flags — bit `0x10` hides the tooltip's faction-name line. `0` on a miss.
+31 -17
View File
@@ -608,28 +608,42 @@ pub fn decode(packet: ServerPacket) -> Vec<SessionEvent> {
vec![SessionEvent::PetName { pet_number, name }]
}
ServerPacket::CreatureQueryResponse { entry, info } => {
let (name, subname, creature_type, rank, type_flags, civilian, racial_leader) =
match info {
Some(i) => (
Some(i.name),
// An EMPTY wire subname is NO subname (vmangos sends "" for creatures
// without one; the client renders no line for it — the nameplate
// builder's verified shape, 0x608f50). Mapped here, once, so no consumer
// ever sees a present-but-empty tooltip/nameplate line.
Some(i.subname).filter(|s| !s.is_empty()),
Some(i.creature_type),
i.rank,
i.type_flags,
i.civilian,
i.racial_leader,
),
None => (None, None, None, 0, 0, false, false),
};
let (
name,
subname,
creature_type,
pet_family,
rank,
type_flags,
civilian,
racial_leader,
) = match info {
Some(i) => (
Some(i.name),
// An EMPTY wire subname is NO subname (vmangos sends "" for creatures
// without one; the client renders no line for it — the nameplate
// builder's verified shape, 0x608f50). Mapped here, once, so no consumer
// ever sees a present-but-empty tooltip/nameplate line.
Some(i.subname).filter(|s| !s.is_empty()),
Some(i.creature_type),
// NOT wrapped in an `Option` like the type beside it: family `0` already
// means "this template has no family", which is exactly what a miss means
// too, so both answer the same `0` rather than two shapes for one state
// (`CreatureFamily.dbc` has no row 0 — decision 1062).
i.pet_family,
i.rank,
i.type_flags,
i.civilian,
i.racial_leader,
),
None => (None, None, None, 0, 0, 0, false, false),
};
vec![SessionEvent::CreatureName {
entry,
name,
subname,
creature_type,
pet_family,
rank,
type_flags,
civilian,
+7 -6
View File
@@ -130,12 +130,13 @@ pub use opcode_names::opcode_name;
pub use packet::{CreatureQueryInfo, MonsterMoveFacing, ServerPacket};
pub use parse::parse_server;
pub use pet::{
pet_action, pet_cancel_aura, pet_set_action, pet_spell_autocast, pet_stop_attack,
PetActionEntry, PetMode, PetSpellCooldown, PetSpells, PET_ACTION_SLOTS, PET_ACT_COMMAND,
PET_ACT_DISABLED, PET_ACT_ENABLED, PET_ACT_PASSIVE, PET_ACT_REACTION, PET_AUTOCAST_ALLOWED,
PET_AUTOCAST_ON, PET_COMMAND_ATTACK, PET_COMMAND_DISMISS, PET_COMMAND_FOLLOW, PET_COMMAND_STAY,
PET_COOLDOWN_PERMANENT, PET_REACT_AGGRESSIVE, PET_REACT_DEFENSIVE, PET_REACT_PASSIVE,
PET_STATE_BAR_DISABLED, PET_TYPE_SPELL_FIRST, PET_TYPE_SPELL_LAST, PET_UNUSABLE_UNIT_FLAGS,
pet_abandon, pet_action, pet_cancel_aura, pet_rename, pet_set_action, pet_spell_autocast,
pet_stop_attack, PetActionEntry, PetMode, PetSpellCooldown, PetSpells, PET_ACTION_SLOTS,
PET_ACT_COMMAND, PET_ACT_DISABLED, PET_ACT_ENABLED, PET_ACT_PASSIVE, PET_ACT_REACTION,
PET_AUTOCAST_ALLOWED, PET_AUTOCAST_ON, PET_COMMAND_ATTACK, PET_COMMAND_DISMISS,
PET_COMMAND_FOLLOW, PET_COMMAND_STAY, PET_COOLDOWN_PERMANENT, PET_REACT_AGGRESSIVE,
PET_REACT_DEFENSIVE, PET_REACT_PASSIVE, PET_STATE_BAR_DISABLED, PET_TYPE_SPELL_FIRST,
PET_TYPE_SPELL_LAST, PET_UNUSABLE_UNIT_FLAGS,
};
pub use pose::{set_sheathed, stand_state_change};
pub use progression::{learn_talent, ExplorationXp, LevelUpInfo, XpGain};
@@ -256,6 +256,29 @@ pub const CMSG_PET_STOP_ATTACK: u16 = 0x02EA; // 746
/// whose aura to drop.
pub const CMSG_PET_CANCEL_AURA: u16 = 0x026B; // 619
// The pet right-click menu's two outbound verbs (decision 1066; VERIFIED vmangos
// `Opcodes_1_12_1.h`: 374/375). Bodies in [`super::pet`].
/// **Give the pet up permanently** — the right-click menu's Abandon row, and `PetAbandon
/// 0x4be4c0`'s only packet (wow-re `ui/scratch/pet-action-bar-api.md` §11c, `push 0x176`
/// @`0x4bd765`). `HandlePetAbandon` deletes a hunter pet (`PET_SAVE_AS_DELETED`) and merely
/// unsummons anything else (`PET_SAVE_NOT_IN_SLOT`, `PetHandler.cpp:347-374`).
///
/// The menu's **Dismiss** row is NOT this opcode, which is worth stating because the two would be
/// indistinguishable in play: `PetDismiss 0x4be4d0` opens no packet at all and hands the packed
/// word `0x07000003` to the pet bar's own dispatcher, so it leaves as [`CMSG_PET_ACTION`].
pub const CMSG_PET_ABANDON: u16 = 0x0176; // 374
/// Rename the pet — `PetRename 0x4be4e0` → `0x4bd840` (`push 0x177` @`0x4bd8fe`). Gated server-side
/// on `UNIT_FLAG_PET_RENAME` and **one-shot**: the handler clears that bit on success, so the row
/// disappears after the first rename (`PetHandler.cpp:302-345`). Nothing client-side clears it —
/// wow-re's census found no writer of that byte anywhere in `.text`.
///
/// A refused name answers with `SMSG_PET_NAME_INVALID` (0x178), which is **not modelled** and does
/// not need to be: vmangos sends it with an empty body — `SendPetNameInvalid` drops both the reason
/// code and the name with the comment "not read by vanilla client" (`PetHandler.cpp:542-548`) — and
/// the shipped 1.12 `FrameXML` has no event and no string for it. It lands in `ServerPacket::Other`
/// under its own name, which is the whole of what there is to do with it.
pub const CMSG_PET_RENAME: u16 = 0x0177; // 375
/// VERIFIED vmangos `Opcodes_1_12_1.h`: 94 (decision 0236). Body in
/// [`super::gameobject::gameobject_query`] — the ask-once GO template lookup, identical shape to
/// `CMSG_CREATURE_QUERY`.
@@ -44,6 +44,11 @@ pub struct CreatureQueryInfo {
pub subname: String,
/// `CreatureType.dbc` id (Beast/Humanoid/… — the level line's type word + the TAB filter).
pub creature_type: u32,
/// `CreatureFamily.dbc` id (Wolf/Cat/Imp/… — `UnitCreatureFamily`'s row, and through that row's
/// pet-food mask, `GetPetFoodTypes`'s diet list; decision 1062). `0` for anything that is not a
/// tameable beast or a warlock minion — which is most of the table, and the reason both
/// consumers treat a missing row as nil rather than as an error.
pub pet_family: u32,
/// Elite rank: 0 normal, 1 elite, 2 rare-elite, 3 world boss, 4 rare — the unit tooltip's
/// rank word `{"", Elite, Elite, Boss, ""}` (decision 0276's byte-verified table).
pub rank: u32,
@@ -226,9 +231,9 @@ pub enum ServerPacket {
},
/// `SMSG_CREATURE_QUERY_RESPONSE` — a creature template's display data, answering
/// `CMSG_CREATURE_QUERY`. We surface what the UI consumes ([`CreatureQueryInfo`]: name,
/// subname, `CreatureType.dbc` id, the elite **rank**, the civilian flag); the rest of the
/// tail (type flags/family/pet spells/display id/racial leader) is parsed for alignment and
/// dropped until a consumer exists. A **miss** (unknown entry) is a lone `u32` of
/// subname, `CreatureType.dbc` id, the `CreatureFamily.dbc` id, the elite **rank**, the type
/// flags, and the civilian/racial-leader pair); only `unk`, the pet spell-list id and the
/// display id are still parsed for alignment and dropped. A **miss** (unknown entry) is a lone `u32` of
/// `entry | 0x8000_0000` → `None` (VERIFIED vmangos `HandleCreatureQueryOpcode`, both
/// branches).
CreatureQueryResponse {
@@ -765,13 +765,15 @@ pub fn parse_server(opcode: u16, body: &[u8]) -> io::Result<ServerPacket> {
// The tail (VERIFIED vmangos `HandleCreatureQueryOpcode`, 5875 = every build
// guard included): type_flags, type, pet_family, RANK, unk, pet_spell_list_id,
// display_id (u32 ×7), then civilian + racial_leader (u8 ×2). We keep `type`
// (the `CreatureType.dbc` id — the TAB-target filter's input), `rank` (the unit
// tooltip's Elite/Boss word, decision 0276's level-line law), `type_flags` (bit
// 0x10 hides its faction line), and the `civilian`/`racial_leader` pair (its
// green CIVILIAN / white LEADER lines); the rest stays alignment-only.
// (the `CreatureType.dbc` id — the TAB-target filter's input), `pet_family` (the
// `CreatureFamily.dbc` id behind `UnitCreatureFamily` and the diet tooltip,
// decision 1062), `rank` (the unit tooltip's Elite/Boss word, decision 0276's
// level-line law), `type_flags` (bit 0x10 hides its faction line), and the
// `civilian`/`racial_leader` pair (its green CIVILIAN / white LEADER lines); the
// rest stays alignment-only.
let type_flags = read_u32_le(&mut r)?;
let creature_type = read_u32_le(&mut r)?;
let _pet_family = read_u32_le(&mut r)?;
let pet_family = read_u32_le(&mut r)?;
let rank = read_u32_le(&mut r)?;
let _unk = read_u32_le(&mut r)?;
let _pet_spell_list = read_u32_le(&mut r)?;
@@ -784,6 +786,7 @@ pub fn parse_server(opcode: u16, body: &[u8]) -> io::Result<ServerPacket> {
name,
subname,
creature_type,
pet_family,
rank,
type_flags,
civilian,
@@ -484,6 +484,32 @@ pub fn pet_set_action(pet_guid: u64, entries: &[(u32, u32)]) -> Vec<u8> {
body
}
/// Body of `CMSG_PET_ABANDON` (VERIFIED vmangos `PetAbandon::ReadFromWorldPacket`,
/// `Server/Packets/Pet.cpp:16-19`; opcode 374): the lone `u64` pet guid.
///
/// **The menu's Abandon and its Dismiss are this same message** — see [`super::opcode`]'s note.
/// Nothing in the body says which the player picked, and nothing needs to: the server forks on the
/// pet's own type, deleting a hunter pet and unsummoning anything else. The client's fork is purely
/// about which *word* the menu shows.
pub fn pet_abandon(pet_guid: u64) -> Vec<u8> {
pet_guid.to_le_bytes().to_vec()
}
/// Body of `CMSG_PET_RENAME` (VERIFIED vmangos `PetRename::ReadFromWorldPacket`,
/// `Server/Packets/Pet.cpp:21-25`; opcode 375): `u64 petGuid` then the name as a **C string**.
///
/// The name is not length-prefixed and not padded — vmangos's `>> std::string` reads to the first
/// NUL. The 12-character cap is the popup's (`RENAME_PET`'s `maxLetters = 12`), not the wire's; the
/// server re-checks with `ObjectMgr::CheckPetName` and refuses with `SMSG_PET_NAME_INVALID`
/// rather than truncating.
pub fn pet_rename(pet_guid: u64, name: &str) -> Vec<u8> {
let mut body = Vec::with_capacity(9 + name.len());
body.extend_from_slice(&pet_guid.to_le_bytes());
body.extend_from_slice(name.as_bytes());
body.push(0);
body
}
#[cfg(test)]
mod tests {
use super::*;
@@ -671,6 +697,22 @@ mod tests {
// form is 16 bytes and the two-entry form is exactly 24.
assert_eq!(pet_set_action(0x2A, &[(3, packed)]).len(), 16);
assert_eq!(pet_set_action(0x2A, &[(3, packed), (4, 0)]).len(), 24);
// CMSG_PET_ABANDON: the lone guid — the same shape as stop-attack, a different verb.
assert_eq!(pet_abandon(0x2A), 0x2Au64.to_le_bytes());
// CMSG_PET_RENAME: guid then a NUL-TERMINATED name. The terminator is the whole framing —
// there is no length prefix and no padding, so dropping it would leave the server reading
// the name off the end of the body.
assert_eq!(
pet_rename(0x2A, "Bruce"),
[&0x2Au64.to_le_bytes()[..], b"Bruce\0"].concat()
);
assert_eq!(
pet_rename(0x2A, "").len(),
9,
"an empty name is still framed"
);
}
/// The autocast flip touches **bit 30 and nothing else** — the client's own in-place edit, and
@@ -112,6 +112,14 @@ const FIELD_UNIT_MOUNTDISPLAYID: u16 = 133;
/// client decides a creature has **no** classification (rank forced to 0), so no elite dragon, no
/// ELITE/BOSS tooltip word and no world-boss skull on an enslaved mob.
const FIELD_UNIT_PETNUMBER: u16 = 139;
/// `UNIT_FIELD_PET_NAME_TIMESTAMP` (`OBJECT_END(6) + 0x86` = 140, PUBLIC — vmangos
/// `UpdateFields_1_12_1.cpp:80`) — the unix second at which this pet's name was last set.
///
/// It matters because a pet's name does **not** ride its descriptor: it is answered once by
/// `CMSG_PET_NAME_QUERY` and cached by pet NUMBER, so a rename would otherwise stay invisible until
/// relog. The server bumps this on every `SetName` (`Pet.cpp:285`, and `HandlePetRename`'s last
/// line `PetHandler.cpp:344`), which makes a **change** here the cache's one invalidation signal.
const FIELD_UNIT_PET_NAME_TIMESTAMP: u16 = 140;
/// `UNIT_FIELD_PETEXPERIENCE` (idx 141) / `UNIT_FIELD_PETNEXTLEVELEXP` (142) — a hunter pet's XP
/// bar, the `(currXP, nextXP)` pair `GetPetExperience 0x4be840` returns in that order.
/// Byte-verified client-side at `[unit+0x110]+0x21c` / `+0x220` (wow-re
@@ -127,6 +135,15 @@ const FIELD_UNIT_DYNAMIC_FLAGS: u16 = 143;
/// `UNIT_CHANNEL_SPELL` (idx 144, `OBJECT_END(6) + 0x8A`, PUBLIC — VERIFIED vmangos
/// `UpdateFields_1_12_1.h:89`) — the spell id a unit is channeling (`0` = not channeling).
const FIELD_UNIT_CHANNEL_SPELL: u16 = 144;
/// `UNIT_CREATED_BY_SPELL` (idx 146, `OBJECT_END(6) + 0x8C`, PUBLIC — vmangos
/// `UpdateFields_1_12_1.h`) — the spell that summoned this unit (`0` = not summoned by one). The
/// **first** of the three gates on the reference's feed-pet path `0x6ea1e0`: a unit with no
/// creating spell is not a pet you can feed, whatever else it is.
///
/// The index is fixed by construction rather than asserted from a header we don't ship: this table
/// already pins both ends of a contiguous run — `PETEXPERIENCE` 141, `PETNEXTLEVELEXP` 142,
/// `DYNAMIC_FLAGS` 143, `CHANNEL_SPELL` 144, `MOD_CAST_SPEED` 145, then this, then `NPC_FLAGS` 147.
const FIELD_UNIT_CREATED_BY_SPELL: u16 = 146;
const FIELD_UNIT_NPC_FLAGS: u16 = 147;
/// `UNIT_NPC_EMOTESTATE` (`OBJECT_END + 0x8E`; PUBLIC — VERIFIED vmangos `UpdateFields_1_12_1.h:93`)
/// — the unit's looping **state emote** as an `Emotes.dbc` id (dance, NPC cooking/working flavor).
@@ -6,7 +6,15 @@ use super::*;
impl ObjectFields {
/// `OBJECT_FIELD_ENTRY` (3) — the object's template entry (an item object's item id, a
/// GameObject's template). The one field that names WHAT a streamed object is.
/// GameObject's template, a creature's `creature_template` row). The one field that names WHAT
/// a streamed object is.
///
/// **For a PET this is the only route to its template**, and that is load-bearing: a
/// `HIGHGUID_PET` guid carries a *pet number* in the slot a creature's entry occupies
/// ([`crate::guid::pet_number`]), so [`crate::guid::entry`] refuses it — but vmangos still
/// writes the real `cinfo->entry` here (`Creature::InitEntry`, `Creature.cpp:376`,
/// `SetEntry(entry) // normal entry always`). Decision 1062's creature-family lookup goes
/// through this field for exactly that reason.
pub fn object_entry(&self) -> Option<u32> {
self.get_u32(3)
}
@@ -109,6 +109,13 @@ impl ObjectFields {
pub fn unit_created_by(&self) -> Option<u64> {
self.get_guid(FIELD_UNIT_CREATEDBY).filter(|&g| g != 0)
}
/// `UNIT_CREATED_BY_SPELL` — the spell that summoned this unit, `None` when zero (nothing
/// summoned it). The reference's feed-pet validator `0x6ea1e0` requires it non-zero before it
/// will accept a unit as a feedable pet — the first of that function's three gates.
pub fn unit_created_by_spell(&self) -> Option<u32> {
self.get_u32(FIELD_UNIT_CREATED_BY_SPELL)
.filter(|&s| s != 0)
}
/// `UNIT_FIELD_PETNUMBER` — non-zero iff this unit is somebody's **pet or charm**.
///
/// Not a count and not an index anyone displays: the client reads it as a pure boolean, as the
@@ -125,6 +132,15 @@ impl ObjectFields {
pub fn unit_is_pet_or_charm(&self) -> bool {
self.get_u32(FIELD_UNIT_PETNUMBER).unwrap_or(0) != 0
}
/// `UNIT_FIELD_PET_NAME_TIMESTAMP` — when this pet's name was last set. `None` when the field
/// has never been sent (a create omits zeros), which reads the same as "never renamed".
///
/// The **only** thing to do with the value is compare it with the last one seen: a pet's name
/// lives in a query cache keyed by pet number, and this changing is what says that cache has
/// gone stale (see the constant).
pub fn unit_pet_name_timestamp(&self) -> Option<u32> {
self.get_u32(FIELD_UNIT_PET_NAME_TIMESTAMP)
}
/// `UNIT_FIELD_HEALTH` — current hit points (0 ⇒ dead).
pub fn unit_health(&self) -> Option<u32> {
self.get_u32(FIELD_UNIT_HEALTH)
@@ -1,7 +1,7 @@
//! The pet bar's `WorldWriter` sends — mirroring [`crate::messages::pet`] (decisions 0982, 0988).
//! The pet's `WorldWriter` sends — mirroring [`crate::messages::pet`] (decisions 0982, 0988, 1066).
//!
//! Four verbs, and the thing to know about them is that **no reply packet answers any of them**.
//! The first three are intents the client has already applied to its own state before sending
//! Six verbs, and the thing to know about the bar's four is that **no reply packet answers any of
//! them**. The first three are intents the client has already applied to its own state before sending
//! (wow-re §10.1/§10.2: the reaction, command and autocast paths all write `[0xb71468]` or the
//! slot word and fire `PET_BAR_UPDATE` *before* the packet leaves). The bar's contents change only
//! when a fresh `SMSG_PET_SPELLS` arrives — a summon, a swap, a learned spell — never as a reply
@@ -12,6 +12,11 @@
//! descriptor. The aura leaves in a `UNIT_FIELD_AURA` delta, and the slot's icon follows from
//! that — so the round trip is visible, just not as an answer.
//!
//! The menu's two ([`WorldWriter::pet_abandon`], [`WorldWriter::pet_rename`]) invert that: neither
//! is pre-applied, because what each asks for is not a UI state the server ignores but a change to
//! the world that it may refuse — and the answer arrives as the pet leaving, or as a bumped name
//! timestamp in its descriptor.
//!
//! Every one carries the pet's guid: the server re-checks ownership on each
//! (`PetHandler.cpp`'s `GetCharmerOrOwnerGuid` gate) and silently drops anything naming a unit we
//! don't control.
@@ -90,4 +95,29 @@ impl WorldWriter {
&messages::pet_spell_autocast(pet_guid, spell_id, enabled),
)
}
/// Give the pet up (`CMSG_PET_ABANDON`, layout in [`messages::pet_abandon`]) — the right-click
/// menu's Abandon **and** its Dismiss, which are one message (decision 1066).
///
/// Unlike everything above it this one is not pre-applied and does not need to be: what answers
/// it is the pet leaving — `SMSG_PET_SPELLS` with a zero guid tears the bar down, and the
/// object's own removal takes the frame with it. Optimism would only race that.
pub fn pet_abandon(&mut self, pet_guid: u64) -> Result<()> {
self.send(opcode::CMSG_PET_ABANDON, &messages::pet_abandon(pet_guid))
}
/// Rename the pet (`CMSG_PET_RENAME`, layout in [`messages::pet_rename`]) — the `RENAME_PET` →
/// `PETRENAMECONFIRM` popup chain's payload.
///
/// Also not pre-applied, and here that is load-bearing: the server validates the name
/// (`ObjectMgr::CheckPetName`, the reserved-name list) and may refuse it outright, so a client
/// that wrote the new name locally would show a name the world does not agree with. What
/// arrives on success is a bumped `UNIT_FIELD_PET_NAME_TIMESTAMP` in the pet's descriptor,
/// which is what makes the name cache re-ask.
pub fn pet_rename(&mut self, pet_guid: u64, name: &str) -> Result<()> {
self.send(
opcode::CMSG_PET_RENAME,
&messages::pet_rename(pet_guid, name),
)
}
}
+15 -10
View File
@@ -478,17 +478,21 @@ fn name_and_creature_query_roundtrip() {
}
// SMSG_CREATURE_QUERY_RESPONSE hit: entry, name, 3 empty names, subname, the 7-u32 tail
// (type_flags, TYPE — kept: the TAB critter filter's input — family, rank, unk,
// pet_spell_id, display_id), 2 u8 tail.
// (type_flags, TYPE — the TAB critter filter's input — FAMILY, RANK, unk, pet_spell_id,
// display_id), 2 u8 tail. Family and rank are given DIFFERENT non-zero values on purpose:
// they are adjacent dwords, so a one-column slip between them reads as plausible data and
// shows up only as a pet whose level line names the wrong beast (decision 1062).
let body = hx(concat!(
"45000000",
"596f756e6720576f6c6600", // "Young Wolf"
"000000", // name2..4 empty
"5465737400", // subname "Test"
"10000000", // type_flags = 0x10 (hide-faction-tooltip)
"01000000", // type = 1 (Beast)
"0000000000000000000000000000000000000000", // family..display_id (5 × u32)
"0101" // civilian, racial_leader
"596f756e6720576f6c6600", // "Young Wolf"
"000000", // name2..4 empty
"5465737400", // subname "Test"
"10000000", // type_flags = 0x10 (hide-faction-tooltip)
"01000000", // type = 1 (Beast)
"01000000", // pet_family = 1 (Wolf)
"02000000", // rank = 2 (rare elite)
"000000000000000000000000", // unk, pet_spell_list_id, display_id
"0101" // civilian, racial_leader
));
match messages::parse_server(messages::opcode::SMSG_CREATURE_QUERY_RESPONSE, &body).unwrap() {
ServerPacket::CreatureQueryResponse { entry, info } => {
@@ -499,7 +503,8 @@ fn name_and_creature_query_roundtrip() {
name: "Young Wolf".into(),
subname: "Test".into(),
creature_type: 1,
rank: 0,
pet_family: 1,
rank: 2,
type_flags: 0x10,
civilian: true,
racial_leader: true,
+379 -42
View File
@@ -1,12 +1,25 @@
//! The character-window stats + equipment seam (decision 0208 §3) — the paper doll's data feed.
//!
//! Same engine-free shape as [`super::unit`]: the app pushes a **player combat-stats snapshot**
//! ([`super::UiScript::set_player_combat_stats`]) and an **inventory-slot snapshot**
//! Same engine-free shape as [`super::unit`]: the app pushes a **combat-stats snapshot** per unit
//! that has one ([`super::UiScript::set_player_combat_stats`] /
//! [`super::UiScript::set_pet_combat_stats`]) and an **inventory-slot snapshot**
//! ([`super::UiScript::set_inventory_slots`]) each frame they change, and the stat/slot globals
//! here read that plain data. Every stat binding is **player-token-only, faithfully**: the 1.12
//! fields behind them (`UNIT_FIELD_STAT*`, `POSSTAT`/`NEGSTAT`, the damage/AP block) are
//! PRIVATE/OWNER_ONLY — no other unit ever streams them — so any non-`"player"` token serves the
//! absent shape (zeros / nils), exactly like `UnitXP` (decision 0208 §3).
//! here read that plain data.
//!
//! **The stat family is unit-parameterised, as the reference's is** (decision 1057): every
//! `PaperDollFrame_Set*(unit, prefix)` helper the character sheet calls with `"player"`, the pet
//! sheet calls with `"pet"` (ref `PetPaperDollFrame.lua:73-81`), through these very bindings. So
//! `UnitStat`/`UnitResistance`/`UnitArmor`/… route on the token — `"player"` and `"pet"` each read
//! their own pushed snapshot, and **every other token (and a snapshot the app has not pushed yet)
//! serves the absent shape**: zeros, `percent` 1.0. That last part *is* faithful — the 1.12 fields
//! behind these (`UNIT_FIELD_STAT*`, `POSSTAT`/`NEGSTAT`, the damage/AP block) are
//! PRIVATE/OWNER_ONLY, so no third unit ever streams them. A pet's descriptor carries the UNIT
//! half and no PLAYER block at all, which is why its buff decompositions read `0` and the ref's
//! own pet sheet shows plain white numbers.
//!
//! (Until 1057 the router was a hard `token == "player"` test documented as "the faithful
//! player-only gate". It was neither: it was "no consumer yet". The reference passes `"pet"` into
//! the same bindings, and the gate was simply never exercised.)
//!
//! Return shapes are the ref Lua's own inverse math (ref-PaperDollFrame): the descriptor carries
//! the *effective* value plus the split positive/negative buff deltas; `base = effective − pos −
@@ -57,13 +70,25 @@ pub fn weapon_subclass_skill(subclass: u32) -> Option<u32> {
/// when no weapon is equipped (`Player::GetBaseWeaponSkillValue`, `Objects/Player.cpp:20184`).
pub const SKILL_UNARMED: u32 = 162;
/// The player's combat-stats snapshot behind the paper doll's stat pane — plain data the app
/// derives from the descriptor accessors ([`UnitStat`]/[`UnitResistance`]/… read it). Arrays are
/// `SKILL_DEFENSE` — the Defense skill line `UnitDefense` reports. **Read out of the shipped
/// 1.12.1 `SkillLine.dbc` this session**, not remembered: the file's 123 records carry exactly one
/// row whose enUS `displayName` (column 3, the `SkillLinefmt` layout in
/// `benilla_formats::skill_lines`) is `"Defense"`, and it is id **95**, category 6 (Weapon Skills).
/// Corroborated by vmangos `SharedDefines.h:961` (`SKILL_DEFENSE = 95`).
pub const SKILL_DEFENSE: u32 = 95;
/// One unit's combat-stats snapshot behind a paper doll's stat pane — plain data the app derives
/// from that unit's descriptor accessors ([`UnitStat`]/[`UnitResistance`]/… read it). Arrays are
/// in field order: stats 0..4 = Str/Agi/Stam/Int/Spi (`UNIT_FIELD_STAT0..4`), schools 0..6 with
/// `[0]` = armor/physical. The `*_neg` values are **negative-or-zero** (the stored wire sign; see
/// the module doc).
///
/// Two units carry one: the player and (decision 1057) the pet. A **creature's** descriptor has no
/// PLAYER block, so every field sourced from one — the stat/resistance buff splits, the
/// damage-done mods, the skill pairs — keeps its default for a pet, which is exactly the ref pet
/// sheet's plain white numbers with no buff decomposition in the tooltip.
#[derive(Clone, Debug, PartialEq)]
pub struct PlayerCombatStats {
pub struct UnitCombatStats {
/// Effective (post-buff) primary stats (`UNIT_FIELD_STAT0..4`).
pub stats: [i32; 5],
/// Positive stat buff deltas (`PLAYER_FIELD_POSSTAT0..4`, ≥ 0).
@@ -113,16 +138,24 @@ pub struct PlayerCombatStats {
pub main_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`
/// like the two weapon pairs above; `UnitDefense` serves it verbatim.
///
/// **The player's only.** A creature has no skill block, and the client does not read one for
/// it: `UnitDefense` forks on the resolved unit's vtable and gives a non-player
/// `UNIT_FIELD_LEVEL * 5` instead ([`cgunit_skill`]), so the pet feed never fills this and the
/// binding never reads it for a pet.
pub defense_skill: (u32, i32),
/// Whether a wand is equipped (`HasWandEquipped` — the ref swaps the ranged-attack action for
/// wand Shoot on it).
pub has_wand: bool,
}
impl Default for PlayerCombatStats {
impl Default for UnitCombatStats {
/// All-zeros except `damage_percent` = `1.0` — the multiplicative identity, so the absent
/// shape never feeds the ref's `bonus / percent` math a division by zero.
fn default() -> Self {
PlayerCombatStats {
UnitCombatStats {
stats: [0; 5],
stat_pos: [0; 5],
stat_neg: [0; 5],
@@ -150,6 +183,7 @@ impl Default for PlayerCombatStats {
ranged_max_damage: 0.0,
main_weapon_skill: (0, 0),
ranged_weapon_skill: (0, 0),
defense_skill: (0, 0),
has_wand: false,
}
}
@@ -304,10 +338,16 @@ fn ammo_alert_status(slot: &Option<InvSlotView>) -> u8 {
impl super::UiScript {
/// Push (or clear, with `None`) the player's combat-stats snapshot — the app calls this each
/// frame any of the backing descriptor fields changed (decision 0208 §3).
pub fn set_player_combat_stats(&mut self, stats: Option<PlayerCombatStats>) {
pub fn set_player_combat_stats(&mut self, stats: Option<UnitCombatStats>) {
self.model_mut().player_combat_stats = stats;
}
/// The `"pet"` twin (decision 1057) — `None` whenever there is no pet, which is what makes
/// every `Unit*("pet")` fall back to the absent shape the moment one is dismissed.
pub fn set_pet_combat_stats(&mut self, stats: Option<UnitCombatStats>) {
self.model_mut().pet_combat_stats = stats;
}
/// Push the equipment/ammo slot snapshot (index 0 = ammo, 1..=19 equipment). Recomputes the
/// 11 durability-alert region statuses off the live pairs; a change fires
/// `UPDATE_INVENTORY_ALERTS` — the DurabilityFrame's own repaint signal (the real engine
@@ -347,23 +387,44 @@ impl super::UiScript {
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 the player combat-stats snapshot under a short model borrow — but only for the
/// `"player"` token; any other token (and a not-yet-pushed snapshot) reads the default (the
/// absent shape: zeros, `damage_percent` 1.0). The faithful player-only gate (module doc).
fn with_player_stats<T>(
/// Read a unit's combat-stats snapshot under a short model borrow, routed by token: `"player"` and
/// `"pet"` each read their own pushed snapshot, anything else — and a snapshot the app has not
/// pushed yet — reads the default (the absent shape: zeros, `damage_percent` 1.0). See the module
/// doc for why exactly these two tokens and no more.
fn with_unit_stats<T>(
lua: &Lua,
token: &Option<String>,
f: impl FnOnce(&PlayerCombatStats) -> T,
f: impl FnOnce(&UnitCombatStats) -> T,
) -> T {
let model = lua.app_data_ref::<Model>().expect("model app_data");
let absent = PlayerCombatStats::default();
let stats = match token.as_deref() {
Some("player") => model.player_combat_stats.as_ref().unwrap_or(&absent),
_ => &absent,
let absent = UnitCombatStats::default();
let pushed = match token.as_deref() {
Some("player") => model.player_combat_stats.as_ref(),
Some("pet") => model.pet_combat_stats.as_ref(),
_ => None,
};
f(stats)
f(pushed.unwrap_or(&absent))
}
/// A **non-player** unit's answer to both skill-shaped questions — `UnitDefense` and
/// `UnitAttackBothHands` — which is `UNIT_FIELD_LEVEL * 5`, flat.
///
/// It is one helper because it is one function in the client: both bindings dispatch through the
/// resolved unit's vtable, and a `CGUnit_C` lands on a three-line body that multiplies the level by
/// five and writes 0 to the modifier (`0x613680` / `0x6136b0`, wow-re
/// `ui/scratch/pet-paperdoll-stat-api.md`). Nothing here is skill data — a creature has no skill
/// block at all, which is exactly why the client substitutes a formula.
fn cgunit_skill(model: &Model, token: &str) -> i64 {
model.units.get(token).map_or(0, |u| i64::from(u.level)) * 5
}
/// Read inventory slot `slot` under a short borrow, cloned out so the caller holds no borrow.
@@ -413,7 +474,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
g.set(
"UnitStat",
lua.create_function(|lua, (token, i): (Option<String>, i64)| {
Ok(with_player_stats(lua, &token, |s| {
Ok(with_unit_stats(lua, &token, |s| {
let Some(idx) = i.checked_sub(1).and_then(|v| usize::try_from(v).ok()) else {
return (0, 0, 0, 0);
};
@@ -436,7 +497,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
g.set(
"UnitResistance",
lua.create_function(|lua, (token, school): (Option<String>, i64)| {
Ok(with_player_stats(lua, &token, |s| {
Ok(with_unit_stats(lua, &token, |s| {
let Some(idx) = usize::try_from(school).ok().filter(|&v| v < 7) else {
return (0, 0, 0, 0);
};
@@ -460,7 +521,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
g.set(
"UnitArmor",
lua.create_function(|lua, token: Option<String>| {
Ok(with_player_stats(lua, &token, |s| {
Ok(with_unit_stats(lua, &token, |s| {
let (eff, pos, neg) = (s.resistances[0], s.resistance_pos[0], s.resistance_neg[0]);
(
i64::from(eff - pos - neg),
@@ -480,7 +541,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
g.set(
"UnitDamage",
lua.create_function(|lua, token: Option<String>| {
Ok(with_player_stats(lua, &token, |s| {
Ok(with_unit_stats(lua, &token, |s| {
(
f64::from(s.min_damage),
f64::from(s.max_damage),
@@ -500,7 +561,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
g.set(
"UnitAttackSpeed",
lua.create_function(|lua, token: Option<String>| {
Ok(with_player_stats(lua, &token, |s| {
Ok(with_unit_stats(lua, &token, |s| {
(
f64::from(s.main_attack_time_ms) / 1000.0,
s.has_offhand
@@ -515,7 +576,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
g.set(
"UnitAttackPower",
lua.create_function(|lua, token: Option<String>| {
Ok(with_player_stats(lua, &token, |s| {
Ok(with_unit_stats(lua, &token, |s| {
(
i64::from(s.attack_power),
i64::from(s.attack_power_pos),
@@ -529,7 +590,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
g.set(
"UnitRangedAttackPower",
lua.create_function(|lua, token: Option<String>| {
Ok(with_player_stats(lua, &token, |s| {
Ok(with_unit_stats(lua, &token, |s| {
(
i64::from(s.ranged_attack_power),
i64::from(s.ranged_attack_power_pos),
@@ -542,15 +603,25 @@ 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).
//
// 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.
g.set(
"UnitAttackBothHands",
lua.create_function(|lua, token: Option<String>| {
Ok(with_player_stats(lua, &token, |s| {
(
i64::from(s.main_weapon_skill.0),
i64::from(s.main_weapon_skill.1),
)
}))
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),
})
})?,
)?;
@@ -558,7 +629,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
g.set(
"UnitRangedAttack",
lua.create_function(|lua, token: Option<String>| {
Ok(with_player_stats(lua, &token, |s| {
Ok(with_unit_stats(lua, &token, |s| {
(
i64::from(s.ranged_weapon_skill.0),
i64::from(s.ranged_weapon_skill.1),
@@ -567,13 +638,40 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
})?,
)?;
// UnitDefense(unit) → (base, modifier). The ref reads exactly two numbers and folds the
// modifier's sign itself (PaperDollFrame.lua:259-271: modifier > 0 → a green posBuff, < 0 → a
// red negBuff).
//
// **The fork is a VIRTUAL CALL on the resolved unit, not a token test** (wow-re
// `ui/scratch/pet-paperdoll-stat-api.md`, the §5 that corrected decision 1057's INTERIM):
// `[vtbl+0xac]` is either `CGPlayer_C 0x5eda20` — resolve the Defense SkillLine (`0x6de040`)
// and read `PLAYER_SKILL_INFO` — or `CGUnit_C 0x613680`, which is three lines:
// `*out1 = UNIT_FIELD_LEVEL * 5; *out2 = 0`. **A level-60 pet shows 300**, not the 0 that
// 1057 shipped while the dispatch was out. The outer gate is SELF **or**
// `UNIT_FIELD_SUMMONEDBY == my guid`, which our pet passes and no other token we serve does —
// so `"player"` takes the skill leg, `"pet"` the level leg (a warlock's minion included: it is
// summoned by us too), and everything else the gate-failure zeros.
g.set(
"UnitDefense",
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("pet") => (cgunit_skill(&model, "pet"), 0),
_ => (0, 0),
})
})?,
)?;
// UnitRangedDamage("player") → (speed, minDamage, maxDamage, physicalBonusPos,
// physicalBonusNeg, percent) — RANGEDATTACKTIME/1000 + the ranged damage range + the same
// school-0 mods as UnitDamage.
g.set(
"UnitRangedDamage",
lua.create_function(|lua, token: Option<String>| {
Ok(with_player_stats(lua, &token, |s| {
Ok(with_unit_stats(lua, &token, |s| {
(
f64::from(s.ranged_attack_time_ms) / 1000.0,
f64::from(s.ranged_min_damage),
@@ -623,6 +721,22 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
})?,
)?;
// GetInventoryItemLink(unit, slot) → the full escaped `|cff…|Hitem:…|h[Name]|h|r` | nil (nil
// while the template answer is in flight — the link is built from the item's name + quality,
// and neither is known until it lands). Unit-keyed like its siblings, so the reference's paper
// doll and its inspect twin both reach it from one binding:
// `DressUpItemLink(GetInventoryItemLink("player", this:GetID()))` (PaperDollFrame.lua:650) and
// the shift-click `ChatFrameEditBox:Insert(...)` beside it (l.653). Decision 1059.
g.set(
"GetInventoryItemLink",
lua.create_function(|lua, (token, slot): (Option<String>, i64)| {
match player_inv_slot(lua, &token, slot).and_then(|v| v.link) {
Some(link) => Ok(Value::String(lua.create_string(&link)?)),
None => Ok(Value::Nil),
}
})?,
)?;
// GetInventoryItemCount("player", slot) → the stack count; **0 for an empty slot** (the
// wiki's documented shape — warcraft.wiki.gg: "1 for most items", 0 when nothing's there;
// the ammo slot carries the bag-summed count the app resolves).
@@ -727,17 +841,30 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
})?,
)?;
// 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(())
}
#[cfg(test)]
mod tests {
use super::{weapon_subclass_skill, SKILL_UNARMED};
use crate::script::{InvSlotView, PlayerCombatStats, UiScript};
use crate::script::{InvSlotView, UiScript, UnitCombatStats};
/// A filled snapshot exercising every field the bindings read.
fn stats() -> PlayerCombatStats {
PlayerCombatStats {
fn stats() -> UnitCombatStats {
UnitCombatStats {
stats: [25, 20, 22, 10, 11],
stat_pos: [4, 0, 0, 0, 0],
stat_neg: [0, -2, 0, 0, 0],
@@ -765,10 +892,27 @@ mod tests {
ranged_max_damage: 47.0,
main_weapon_skill: (25, 2),
ranged_weapon_skill: (18, 0),
defense_skill: (55, 4),
has_wand: false,
}
}
/// A pet's snapshot: the UNIT half filled, every PLAYER-block-sourced field at its default —
/// what `ui_char::unit_combat_stats` really produces over a creature's descriptor.
fn pet_stats() -> UnitCombatStats {
UnitCombatStats {
stats: [63, 45, 68, 32, 42],
resistances: [1810, 0, 15, 0, 0, 0, 0],
min_damage: 30.5,
max_damage: 44.5,
main_attack_time_ms: 2000,
attack_power: 178,
attack_power_pos: 12,
attack_power_neg: -4,
..Default::default()
}
}
#[test]
fn weapon_skill_table_matches_vmangos_item_cpp() {
// The populated rows (Item.cpp:700-707).
@@ -885,7 +1029,7 @@ mod tests {
.eval::<bool>(r#"local m, o = UnitAttackSpeed("player") return m == 2.9 and o == nil"#)
.unwrap());
// With an offhand: both speeds, in seconds.
s.set_player_combat_stats(Some(PlayerCombatStats {
s.set_player_combat_stats(Some(UnitCombatStats {
has_offhand: true,
..stats()
}));
@@ -951,11 +1095,190 @@ mod tests {
);
}
/// **The pet routing, end to end** (decision 1057) — the thing no build gate can catch: a
/// pushed pet snapshot really is what `Unit*("pet")` answers, while `"player"` still answers
/// the player's and a third token still answers the absent shape. The failure this guards is
/// silent: a mis-routed reader shows a pet sheet full of the *player's* numbers, or of zeros,
/// and nothing errors.
#[test]
fn the_pet_token_reads_the_pet_snapshot_and_only_it() {
let mut s = UiScript::new().unwrap();
s.set_player_combat_stats(Some(stats()));
s.set_pet_combat_stats(Some(pet_stats()));
// Stamina (index 3): the pet's 68 with no buff split, the player's 22 with its own.
assert_eq!(
s.eval::<(i64, i64, i64, i64)>(r#"return UnitStat("pet", 3)"#)
.unwrap(),
(68, 68, 0, 0)
);
assert_eq!(
s.eval::<(i64, i64, i64, i64)>(r#"return UnitStat("player", 3)"#)
.unwrap(),
(22, 22, 0, 0)
);
// Str, where the two genuinely differ AND the player carries a buff: the pet must not
// inherit either number.
assert_eq!(
s.eval::<(i64, i64, i64, i64)>(r#"return UnitStat("pet", 1)"#)
.unwrap(),
(63, 63, 0, 0)
);
assert_eq!(
s.eval::<(i64, i64, i64, i64)>(r#"return UnitStat("player", 1)"#)
.unwrap(),
(21, 25, 4, 0)
);
// Resistances: fire (school 2) — 15 for the pet, 20 (+25/−5) for the player.
assert_eq!(
s.eval::<(i64, i64, i64, i64)>(r#"return UnitResistance("pet", 2)"#)
.unwrap(),
(15, 15, 0, 0)
);
assert_eq!(
s.eval::<(i64, i64, i64, i64)>(r#"return UnitResistance("player", 2)"#)
.unwrap(),
(0, 20, 25, -5)
);
// UnitArmor (school 0): the pet's 1810 undecomposed, the player's 150 split.
assert_eq!(
s.eval::<(i64, i64, i64, i64, i64)>(r#"return UnitArmor("pet")"#)
.unwrap(),
(1810, 1810, 1810, 0, 0)
);
assert_eq!(
s.eval::<(i64, i64, i64, i64, i64)>(r#"return UnitArmor("player")"#)
.unwrap(),
(130, 150, 150, 30, -10)
);
// The rest of the family routes too — and `percent` stays the divide-safe 1.0 for the pet
// (the ref Lua divides the damage range by it).
assert_eq!(
s.eval::<(f64, f64, f64, f64, i64, i64, f64)>(r#"return UnitDamage("pet")"#)
.unwrap(),
(30.5, 44.5, 0.0, 0.0, 0, 0, 1.0)
);
assert_eq!(
s.eval::<(i64, i64, i64)>(r#"return UnitAttackPower("pet")"#)
.unwrap(),
(178, 12, -4)
);
// No offhand ⇒ the second return is nil, the same gate as the player's.
assert!(s
.eval::<bool>(r#"local m, o = UnitAttackSpeed("pet") return m == 2.0 and o == nil"#)
.unwrap());
// A third token is still the absent shape…
assert_eq!(
s.eval::<(i64, i64, i64, i64)>(r#"return UnitStat("target", 1)"#)
.unwrap(),
(0, 0, 0, 0)
);
assert_eq!(
s.eval::<(i64, i64, i64, i64, i64)>(r#"return UnitArmor("target")"#)
.unwrap(),
(0, 0, 0, 0, 0)
);
// …and so is `"pet"` once the pet is dismissed (the feed pushes None).
s.set_pet_combat_stats(None);
assert_eq!(
s.eval::<(i64, i64, i64, i64)>(r#"return UnitStat("pet", 1)"#)
.unwrap(),
(0, 0, 0, 0)
);
assert_eq!(
s.eval::<(f64, f64, f64, f64, i64, i64, f64)>(r#"return UnitDamage("pet")"#)
.unwrap(),
(0.0, 0.0, 0.0, 0.0, 0, 0, 1.0)
);
// The player's is untouched by any of it.
assert_eq!(
s.eval::<(i64, i64, i64, i64)>(r#"return UnitStat("player", 1)"#)
.unwrap(),
(21, 25, 4, 0)
);
}
/// `UnitDefense` always answers two numbers (the ref reads `local base, modifier`), and **the
/// two legs are different functions, not one function with missing data**: the player's is the
/// skill pair, a pet's is `level * 5` with a flat 0 modifier (the vtable fork,
/// `0x5eda20`/`0x613680`). `UnitAttackBothHands` takes the same fork and must agree with it —
/// that agreement is the point, since a mismatch is exactly what a snapshot-only
/// implementation would produce.
#[test]
fn unit_defense_forks_the_player_skill_from_a_pets_level_times_five() {
let mut s = UiScript::new().unwrap();
s.set_player_combat_stats(Some(stats()));
s.set_pet_combat_stats(Some(pet_stats()));
s.set_unit(
"pet",
Some(super::super::UnitState {
exists: true,
level: 60,
..Default::default()
}),
);
assert_eq!(
s.eval::<(i64, i64)>(r#"return UnitDefense("player")"#)
.unwrap(),
(55, 4)
);
assert_eq!(
s.eval::<(i64, i64)>(r#"return UnitDefense("pet")"#)
.unwrap(),
(300, 0),
"a level-60 pet: level * 5, modifier flat 0"
);
assert_eq!(
s.eval::<(i64, i64)>(r#"return UnitAttackBothHands("pet")"#)
.unwrap(),
(300, 0),
"the Attack row takes the same fork — and ignores its hand index"
);
// The pet's snapshot carries neither pair, which is the whole point: the numbers above
// cannot have come from it.
assert_eq!(pet_stats().defense_skill, (0, 0));
assert_eq!(pet_stats().main_weapon_skill, (0, 0));
// A pet the level feed has not reached yet reads 0 rather than a stale or invented number.
s.set_unit("pet", None);
assert_eq!(
s.eval::<(i64, i64)>(r#"return UnitDefense("pet")"#)
.unwrap(),
(0, 0)
);
// A token that fails the client's SELF-or-SUMMONEDBY gate gets the failure zeros, not
// some other unit's level.
s.set_unit(
"target",
Some(super::super::UnitState {
exists: true,
level: 63,
..Default::default()
}),
);
assert_eq!(
s.eval::<(i64, i64)>(r#"return UnitDefense("target")"#)
.unwrap(),
(0, 0)
);
// A negative modifier survives the round trip — the ref branches on `modifier < 0` to
// paint the number red.
s.set_player_combat_stats(Some(UnitCombatStats {
defense_skill: (300, -25),
..stats()
}));
assert_eq!(
s.eval::<(i64, i64)>(r#"return UnitDefense("player")"#)
.unwrap(),
(300, -25)
);
}
#[test]
fn has_wand_equipped_reads_the_flag() {
let mut s = UiScript::new().unwrap();
assert!(!s.eval::<bool>("return HasWandEquipped()").unwrap());
s.set_player_combat_stats(Some(PlayerCombatStats {
s.set_player_combat_stats(Some(UnitCombatStats {
has_wand: true,
..stats()
}));
@@ -1126,4 +1449,18 @@ mod tests {
s.run("BenillaPaperDollModel_SetFacing(-0.5)").unwrap();
assert_eq!(s.paperdoll_yaw(), -0.5);
}
/// 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);
}
}
+67 -11
View File
@@ -180,14 +180,26 @@ impl super::UiScript {
std::mem::take(&mut self.model_mut().container_destroys)
}
/// The Lua-set **displayed-cursor override** (`Show*SellCursor`/`ShowInspectCursor`/
/// `ResetCursor`): `None` = show the base (world-classifier) mode, else the mode the FrameXML
/// cursor family armed. The app overlays it over the world classifier's Point — the 5875
/// mechanism (wow-re cursor-system.md §7: the displayed mode swaps only when the base mode is
/// Point; `ResetCursor` restores displayed = base).
/// The mode the last FrameXML cursor call armed — `None` after a `ResetCursor`. This is the
/// *value* of the most recent write; whether a write happened at all is
/// [`Self::take_cursor_write`], and that is what the app acts on (decision 1061).
pub fn ui_cursor(&self) -> Option<UiCursorMode> {
self.model_ref().ui_cursor
}
/// Drain the pending cursor write: `Some(mode)` = a `Show*Cursor` armed `mode`, `Some(None)` =
/// a `ResetCursor` asked for the base mode, `None` = **no FrameXML cursor call happened**, so
/// the sticky mode stands untouched.
///
/// The three-state return is the point. A UI element with no cursor handler must leave the
/// cursor exactly as it was — that is how an armed spell keeps its cast cursor while the mouse
/// crosses a spellbook button, and reading a two-state latch instead is what made it snap to
/// Point (decision 1061).
#[allow(clippy::option_option)]
pub fn take_cursor_write(&mut self) -> Option<Option<UiCursorMode>> {
let mut model = self.model_mut();
std::mem::take(&mut model.ui_cursor_dirty).then_some(model.ui_cursor)
}
}
/// A **displayed-cursor override** the FrameXML cursor family arms (wow-re cursor-system.md §7): the
@@ -204,6 +216,14 @@ pub enum UiCursorMode {
UnableBuy,
/// Inspect(7) — the magnifier the Ctrl-hover shows over an item (`ShowInspectCursor`).
Inspect,
/// Point(1) — `SetCursor("POINT_CURSOR")`, and what a `ResetCursor` resolves to.
Point,
/// Cast(2) — `SetCursor("CAST_CURSOR")`: the lit cast cursor a unit frame shows while a spell
/// that CAN bind that unit is armed (`UnitFrame_OnEnter`).
Cast,
/// UnableCast(22) — `SetCursor("CAST_ERROR_CURSOR")`: the greyed twin, for a unit the armed
/// spell cannot bind, and for `UnitFrame_OnLeave` while still targeting.
CastError,
}
/// The pick/place/swap gesture behind `PickupContainerItem` (decision 0216 §2, amended by 0218;
@@ -512,8 +532,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
// ShowContainerSellCursor(bag, slot) — arm the pouch cursor for a sellable hover (5875
// `0x4fa460`, wow-re cursor-system.md §7: Buy(3) only when the slot actually holds an item —
// an empty slot leaves the cursor unchanged; no Unable twin, no SellPrice check — selling
// never grays). The spell-targeting and base-mode-Point gates live app-side where those states
// are known.
// never grays), and the reference's own `IsTargeting` bail at its first instruction.
lua.globals().set(
"ShowContainerSellCursor",
lua.create_function(|lua, (bag, slot): (i64, u32)| {
@@ -523,8 +542,14 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
.get(&bag)
.and_then(|c| c.slots.get(&slot))
.is_some();
if occupied {
// `IsTargeting` bails at the function's FIRST instruction — an armed spell suppresses
// the sell cursor outright (wow-re `item-target-cursor-and-dropitemonunit.md`). Now
// that the mode is sticky, this gate has to be here rather than app-side: a write of
// Buy would otherwise stamp over the cast cursor and there would be no per-frame world
// write to put it back.
if occupied && !model.spell_targeting {
model.ui_cursor = Some(UiCursorMode::Buy);
model.ui_cursor_dirty = true;
}
Ok(())
})?,
@@ -536,9 +561,9 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
lua.globals().set(
"ShowInspectCursor",
lua.create_function(|lua, ()| {
lua.app_data_mut::<Model>()
.expect("model app_data")
.ui_cursor = Some(UiCursorMode::Inspect);
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
model.ui_cursor = Some(UiCursorMode::Inspect);
model.ui_cursor_dirty = true;
Ok(())
})?,
)?;
@@ -558,6 +583,36 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
})
})?,
)?;
// SetCursor(name) — the reference's `0x489490`: a name->mode table (POINT/CAST/BUY/ATTACK =
// 1..4, each with an `*_ERROR` twin at +20) into `CursorSetMode`, falling back to a custom
// bitmap for an unknown name. Only the modes FrameXML actually names are mapped here; anything
// else is ignored rather than guessed, and a **no-arg call is a `ResetCursor`**, which is the
// reference's own documented shape.
//
// Its one shipped caller is the unit-frame hover pair (`UnitFrame_OnEnter`/`OnLeave`), which is
// the ONLY lit/grey cursor split over a UI element in 1.12 — and it is authored in Lua, not in
// C++ (wow-re `item-target-cursor-and-dropitemonunit.md` §4.3).
lua.globals().set(
"SetCursor",
lua.create_function(|lua, name: Option<String>| {
let mode = match name.as_deref() {
None => None, // no-arg == ResetCursor
Some("POINT_CURSOR") => Some(UiCursorMode::Point),
Some("CAST_CURSOR") => Some(UiCursorMode::Cast),
Some("CAST_ERROR_CURSOR") => Some(UiCursorMode::CastError),
Some("BUY_CURSOR") => Some(UiCursorMode::Buy),
Some("BUY_ERROR_CURSOR") => Some(UiCursorMode::UnableBuy),
// An unmapped name: the reference would load a custom bitmap. We have no such art,
// and silently painting the wrong stock cursor would be worse than leaving the
// sticky mode alone, so this is a no-op rather than a guess.
Some(_) => return Ok(()),
};
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
model.ui_cursor = mode;
model.ui_cursor_dirty = true;
Ok(())
})?,
)?;
// ResetCursor — displayed mode back to the base (the world classifier's) mode (5875
// `0x48ac70` → `0x523d30`): clear the whole override, whichever `Show*Cursor` armed it.
lua.globals().set(
@@ -565,6 +620,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
lua.create_function(|lua, ()| {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
model.ui_cursor = None;
model.ui_cursor_dirty = true;
Ok(())
})?,
)?;
+6
View File
@@ -426,6 +426,12 @@ impl super::UiScript {
self.model_mut().item_pick_armed = armed;
}
/// Push what `SpellCanTargetUnit` answers — whether the standing targeting word could bind a
/// unit at all (see [`crate::script::Model::spell_can_target_unit`]).
pub fn set_spell_can_target_unit(&mut self, can: bool) {
self.model_mut().spell_can_target_unit = can;
}
/// Drain the `(bag, slot)` clicks the armed item half consumed since the last call. A doll
/// click reports as [`EQUIPMENT_BAG`] + its 1-based inventory slot — the ONE bag space, so
/// the app resolves both seams with one lookup.
+113
View File
@@ -0,0 +1,113 @@
//! The **dressing room** surface (decision 1060) — the intent queue behind the ref's
//! `DressUpModel:SetUnit`/`:TryOn`/`:Dress`, plus the pane's bake yaw.
//!
//! ## Why these are benilla-named, not the live API's
//!
//! In 1.12 the dressing room's model is a `<DressUpModel>` **widget** and the verbs are *methods on
//! it* (`DressUpFrame.lua:2-16`: `DressUpModel:SetUnit("player")` then `DressUpModel:TryOn(item)`;
//! the Reset button calls `DressUpModel:Dress()`). benilla has no live 3D model widget — every
//! model pane is a booth bake sampled by a plain `<Frame>` (the settled doctrine, 0105/0118, and
//! what `BenillaSetBoothTexture` exists for) — so the *widget methods* have no home. They become
//! four benilla globals over this queue, exactly as the paper doll's rotation did
//! (`BenillaPaperDollModel_SetFacing`). The window's own Lua keeps the reference's `DressUpItem` /
//! `DressUpItemLink` entry points, which is what every ctrl-click site actually calls.
//!
//! ## The seam
//!
//! - **Intents:** an ordered queue, not a set of flags — `DressUpItem` resets *then* tries on in
//! one breath when the window was closed (ref `DressUpFrame.lua:3-7`), and applying those two out
//! of order would show the player's own gear instead of the item they clicked.
//! - **State lives app-side:** the VM holds no item ids and no equipment. The app owns the
//! substitution set, resolves each item id to a display through the ask-once template cache, and
//! composes the booth's look ([`crate::script::UiScript::take_dressup_intents`]).
//! - **Yaw:** [`UiScript::dressup_yaw`], the exact twin of `paperdoll_yaw` / `inspect_yaw`.
use mlua::Lua;
use super::Model;
/// One queued dressing-room intent (see the module doc on ordering).
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum DressUpIntent {
/// `DressUpModel:SetUnit("player")` (the open) and `DressUpModel:Dress()` (the Reset button) —
/// both mean "wear what the player is actually wearing", dropping every substitution. One verb
/// because they are one effect here: our model is always the player's.
Dress,
/// `DressUpModel:TryOn(item)` — substitute this item id into whichever slot its
/// `InventoryType` belongs to.
TryOn(u32),
/// The window was hidden — there is nothing to show, so the booth empties (and stops
/// rendering). The reference's widget keeps its state while hidden, but its next `DressUpItem`
/// re-issues `SetUnit("player")` precisely *because* the frame was not visible, so the state it
/// kept is never observable — dropping it here is behaviour-identical and saves the bake.
Close,
}
impl super::UiScript {
/// Drain the dressing room's queued intents, oldest first — the app applies them in order.
pub fn take_dressup_intents(&mut self) -> Vec<DressUpIntent> {
std::mem::take(&mut self.model_mut().dressup_intents)
}
/// The dressing-room pane's bake yaw in radians (the ref's `Model:SetRotation`), read by the
/// app onto the `"dressup"` booth slot each frame — the twin of [`Self::paperdoll_yaw`].
pub fn dressup_yaw(&self) -> f32 {
self.model_ref().dressup_yaw
}
}
pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
let g = lua.globals();
let queue = |lua: &Lua, intent: DressUpIntent| {
lua.app_data_mut::<Model>()
.expect("model app_data")
.dressup_intents
.push(intent);
};
// BenillaDressUpModel_Dress() — the ref's `DressUpModel:SetUnit("player")` (DressUpFrame.lua:5)
// and `:Dress()` (the Reset button, DressUpFrame.xml:180).
g.set(
"BenillaDressUpModel_Dress",
lua.create_function(move |lua, ()| {
queue(lua, DressUpIntent::Dress);
Ok(())
})?,
)?;
// BenillaDressUpModel_TryOn(itemId) — the ref's `DressUpModel:TryOn(item)` (DressUpFrame.lua:8),
// whose argument is the bare item id `DressUpItemLink` pulled out of the `|Hitem:<id>:…` link.
g.set(
"BenillaDressUpModel_TryOn",
lua.create_function(move |lua, item: Option<u32>| {
if let Some(item) = item.filter(|i| *i != 0) {
queue(lua, DressUpIntent::TryOn(item));
}
Ok(())
})?,
)?;
// BenillaDressUpModel_Close() — the window's OnHide (see [`DressUpIntent::Close`]).
g.set(
"BenillaDressUpModel_Close",
lua.create_function(move |lua, ()| {
queue(lua, DressUpIntent::Close);
Ok(())
})?,
)?;
// BenillaDressUpModel_SetFacing(radians) — the pane's bake yaw, the exact twin of
// `BenillaPaperDollModel_SetFacing` / `BenillaInspectModel_SetFacing`.
g.set(
"BenillaDressUpModel_SetFacing",
lua.create_function(|lua, radians: f32| {
lua.app_data_mut::<Model>()
.expect("model app_data")
.dressup_yaw = radians;
Ok(())
})?,
)?;
Ok(())
}
+53 -5
View File
@@ -11,9 +11,11 @@
//! 1.12's loot API is a flat set of globals the FrameXML `LootFrame.lua` drives (VERIFIED against the
//! extracted `LootFrame.lua`, scratchpad): `GetNumLootItems()`, `GetLootSlotInfo(slot)` →
//! `texture, item, quantity, quality` (`LootFrame.lua:81`), `LootSlotIsItem(slot)` /
//! `LootSlotIsCoin(slot)` (`LootFrame.lua:80`), `LootSlot(slot)` (the C `LootButton` behaviour, the
//! action a row click performs — `LootFrame.lua:94`), and `CloseLoot()` (fired from `OnHide`,
//! `LootFrame.lua:143-145`). `slot` is **1-based**; an out-of-range slot answers `nil`.
//! `LootSlotIsCoin(slot)` (`LootFrame.lua:80`), `GetLootSlotLink(slot)` → the row's item link (what
//! the row click's ctrl/shift arms read, `LootFrame.lua:149`/`:152` — decision 1059),
//! `LootSlot(slot)` (the C `LootButton` behaviour, the action a row click performs —
//! `LootFrame.lua:94`), and `CloseLoot()` (fired from `OnHide`, `LootFrame.lua:143-145`).
//! `slot` is **1-based**; an out-of-range slot answers `nil`.
//!
//! The **coin pile is a synthesized client-side row** (first in the list when the loot carries gold):
//! `LootSlotIsCoin` is true for it, its `item` text is the formatted money amount, and `LootSlot(1)`
@@ -48,6 +50,14 @@ pub struct LootRow {
/// row. A benilla extension riding as a TRAILING return of `GetLootSlotInfo` (the era 4-tuple
/// never carried it; tooltip content was C++'s alone), same idiom as the quest item getters.
pub item_id: u32,
/// The row's full escaped `|cff…|Hitem:…|h[Name]|h|r` link (`GetLootSlotLink`, decision 1059).
/// `None` for the synthesized coin row (there is no item to link) and while the ask-once
/// template answer is in flight — the link embeds the name, so it cannot exist before `name`
/// does; the same `Option` shape [`super::char_stats::InvSlotView::link`] carries. Both arms of
/// the row click take a nil in stride: `DressUpItemLink` returns on one (its own guard,
/// `DressUpFrame.lua:10-16`) and the shift arm goes through `BenillaChatEdit_InsertLink`, which
/// drops it — our `EditBox:Insert` binding is typed `String` and would raise.
pub link: Option<String>,
}
/// One open loot window: its rows (coin first when present). Pushed whole by the app; `None` means no
@@ -129,6 +139,31 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
})?,
)?;
// GetLootSlotLink(slot) → the row's full escaped `|cff…|Hitem:…|h[Name]|h|r` link | nil. 1-based
// like GetLootSlotInfo beside it; nil out of range, nil for the coin row, and nil while the
// item-template query is in flight (the link embeds the name). The reference's row click reads
// it for BOTH modifier arms — `DressUpItemLink(GetLootSlotLink(this.slot))` (`LootFrame.lua:149`)
// and `ChatFrameEditBox:Insert(GetLootSlotLink(this.slot))` (`:152`); ours routes the second
// through `BenillaChatEdit_InsertLink`, whose whole job is the nil this getter can answer.
// Decision 1059.
g.set(
"GetLootSlotLink",
lua.create_function(|lua, slot: usize| {
let link = {
let model = lua.app_data_ref::<Model>().expect("model app_data");
model
.loot
.as_ref()
.and_then(|l| slot.checked_sub(1).and_then(|n| l.rows.get(n)))
.and_then(|r| r.link.clone())
};
match link {
Some(link) => Ok(Value::String(lua.create_string(&link)?)),
None => Ok(Value::Nil),
}
})?,
)?;
// LootSlotIsItem(slot) → true for a real item row (in range, not the coin pile).
g.set(
"LootSlotIsItem",
@@ -186,7 +221,7 @@ mod tests {
fn loot() -> LootState {
LootState {
rows: vec![
// The coin pile, always first.
// The coin pile, always first. No link: there is no item to link (decision 1059).
LootRow {
item_id: 0,
name: Some("1g 23s 45c".into()),
@@ -194,8 +229,9 @@ mod tests {
quantity: 1,
quality: Some(1),
is_coin: true,
link: None,
},
// A resolved item.
// A resolved item — name, quality AND link all landed together (one template answer).
LootRow {
item_id: 0,
name: Some("Wool Cloth".into()),
@@ -203,6 +239,7 @@ mod tests {
quantity: 3,
quality: Some(1),
is_coin: false,
link: Some("|cffffffff|Hitem:2589:0:0:0|h[Wool Cloth]|h|r".into()),
},
// An in-flight item: the loot arrived, the item-template answer hasn't.
LootRow {
@@ -212,6 +249,7 @@ mod tests {
quantity: 1,
quality: None,
is_coin: false,
link: None,
},
],
}
@@ -253,8 +291,18 @@ mod tests {
)
.unwrap());
// GetLootSlotLink: the resolved item's link, nil for the coin row and for the in-flight one
// (both arms of the reference's row click hand this straight on — decision 1059).
assert_eq!(
s.eval::<String>("return GetLootSlotLink(2)").unwrap(),
"|cffffffff|Hitem:2589:0:0:0|h[Wool Cloth]|h|r"
);
assert!(s.eval::<bool>("return GetLootSlotLink(1) == nil").unwrap());
assert!(s.eval::<bool>("return GetLootSlotLink(3) == nil").unwrap());
// Out of range → nil.
assert!(s.eval::<bool>("return GetLootSlotInfo(9) == nil").unwrap());
assert!(s.eval::<bool>("return GetLootSlotLink(9) == nil").unwrap());
assert!(s.eval::<bool>("return not LootSlotIsItem(9)").unwrap());
assert!(s.eval::<bool>("return not LootSlotIsCoin(9)").unwrap());
}
+44 -1
View File
@@ -17,6 +17,8 @@
//! (`LootFrame.lua:261`) — `bindOnPickUp` swaps the frame to the gold BoP backdrop.
//! - `GetLootRollTimeLeft(rollID)` → the milliseconds left, polled from the Timer's `OnUpdate`
//! (`LootFrame.lua:287-296`).
//! - `GetLootRollItemLink(rollID)` → the rolled item's link, read by the icon button's ctrl/shift
//! arms (`LootFrame.xml:353-361` — decision 1059).
//! - `RollOnLoot(rollID, rollType)` — `0` Pass, `1` Need, `2` Greed, wired to the frame's
//! PassButton/RollButton/GreedButton `OnClick` (`LootFrame.xml:375`/`:398`/`:425`).
//! - `CANCEL_LOOT_ROLL` fires with `arg1 = rollID`; the matching frame hides (`LootFrame.lua:279-285`).
@@ -58,6 +60,13 @@ pub struct LootRollEntry {
/// extension riding as a TRAILING return of `GetLootRollItemInfo`, the same idiom
/// [`super::loot::LootRow::item_id`] uses on `GetLootSlotInfo`.
pub item_id: u32,
/// The rolled item's full escaped `|cff…|Hitem:…|h[Name]|h|r` link (`GetLootRollItemLink`,
/// decision 1059) — what the icon button's ctrl/shift arms hand to `DressUpItemLink` /
/// `ChatFrameEditBox:Insert` (`LootFrame.xml:353-361`). `None` while the item-template query is
/// in flight: the link embeds the name, so it lands with `name`/`quality`, not before. A roll
/// popup shows the instant `START_LOOT_ROLL` fires, so this nil is the common case for the first
/// frames of every roll — both click arms drop it rather than posting an empty link.
pub link: Option<String>,
}
/// Every group loot roll currently open, in the order the app opened them. Pushed whole each frame.
@@ -117,6 +126,25 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
})?,
)?;
// GetLootRollItemLink(rollID) → the rolled item's full escaped link | nil. Unknown id → nil, and
// nil while the item template is in flight (the link embeds the name). The reference's icon
// button reads it for both modifier arms — `DressUpItemLink(GetLootRollItemLink(...))` and
// `ChatFrameEditBox:Insert(...)`, `LootFrame.xml:353-361`; ours routes the second through
// `BenillaChatEdit_InsertLink`, whose whole job is the nil this getter can answer. Decision 1059.
g.set(
"GetLootRollItemLink",
lua.create_function(|lua, roll_id: u32| {
let link = {
let model = lua.app_data_ref::<Model>().expect("model app_data");
find(&model, roll_id).and_then(|r| r.link.clone())
};
match link {
Some(link) => Ok(mlua::Value::String(lua.create_string(&link)?)),
None => Ok(mlua::Value::Nil),
}
})?,
)?;
// GetLootRollTimeLeft(rollID) → milliseconds remaining; 0 for an unknown id (the reference
// OnUpdate clamps anything outside the bar's range to its minimum anyway, LootFrame.lua:290-293).
g.set(
@@ -182,7 +210,7 @@ mod tests {
fn rolls() -> LootRollsState {
LootRollsState {
rolls: vec![
// A resolved BoP item.
// A resolved BoP item — name, quality and link land together (one template answer).
LootRollEntry {
roll_id: 7,
name: Some("Staff of Jordan".into()),
@@ -192,6 +220,7 @@ mod tests {
bind_on_pickup: true,
time_left_ms: 42_000,
item_id: 17182,
link: Some("|cffa335ee|Hitem:17182:0:0:0|h[Staff of Jordan]|h|r".into()),
},
// An in-flight item: the roll opened, the item-template answer hasn't landed.
LootRollEntry {
@@ -203,6 +232,7 @@ mod tests {
bind_on_pickup: false,
time_left_ms: 60_000,
item_id: 4306,
link: None,
},
],
}
@@ -245,10 +275,23 @@ mod tests {
)
.unwrap());
// GetLootRollItemLink: the resolved roll's link; nil while the template is in flight (the
// icon button's ctrl/shift arms hand this straight on — decision 1059).
assert_eq!(
s.eval::<String>("return GetLootRollItemLink(7)").unwrap(),
"|cffa335ee|Hitem:17182:0:0:0|h[Staff of Jordan]|h|r"
);
assert!(s
.eval::<bool>("return GetLootRollItemLink(8) == nil")
.unwrap());
// An unknown id → nil / 0, never an error.
assert!(s
.eval::<bool>("return GetLootRollItemInfo(99) == nil")
.unwrap());
assert!(s
.eval::<bool>("return GetLootRollItemLink(99) == nil")
.unwrap());
assert_eq!(s.eval::<i64>("return GetLootRollTimeLeft(99)").unwrap(), 0);
}

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