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:
@@ -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()
|
||||
|
||||
@@ -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>
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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):
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
+307
-77
@@ -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::*;
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(¬_channeled)), None);
|
||||
assert_eq!(cast_water_refusal(mf::SWIMMING, Some(¬_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![]);
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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![]);
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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"]);
|
||||
}
|
||||
}
|
||||
@@ -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`]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(())
|
||||
})?,
|
||||
)?;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user