the bank is the reference's own window, and far sight freezes your body

- the bank window is the reference's own `BankFrame.xml` run off the player's chain — our 890-line copy is deleted whole; putting bags into the bank's bag slots works (the two verbs its buttons are wired to now exist), the purse is a real `SmallMoneyFrameTemplate` splitting 987654321 copper into three coin buttons instead of one string, `BANKFRAME_OPENED` loses the argument the real client never fires, and the bank stops auto-opening your bags while the vendor and mailbox keep doing it; the empty-slot click still plays the bag sound, because the reference's own line plays it on every click the cursor did not consume — named as faithful rather than quietly fixed
- underneath it, the bank's inventory band is a VIEW over the container feed, not a second copy — inventory ids 40..69 had answered nil because the doll array stops at 24, so the stock file swapped in would have drawn an empty bank; the six bank bag slots become real inventory slots (64..=69) fed off the player descriptor, the three bag verbs are carved from the binary (`PutItemInBackpack` IS `PutItemInBag(0xFF)` through a ten-byte thunk, the occupied-by-an-ordinary-item leg auto-stores with no destination slot at all, and the return is the Lua number 1 or nil — the nil being the whole reason a plain left-click opens a bag), and the swap caught `BankButtonIDToInvSlotID`'s bag arm six slots wrong: it took a bag number 1..6 where the reference passes the button's own id, which is the container id
- far sight freezes your own body while the view is out — the fifth conjunct of the movement predicates' shared precondition, joining the health term so the walk, the turn and the follow teardown go down together; both halves are load-bearing: the `IsActivePlayer` half is what keeps a Mind Control victim walking (possession rides the same PLAYER_FARSIGHT field), and the latch half is the RESOLVED engage rather than the raw field — and the camera is untouched, you still look around freely, which is the whole point of the spell
- the reset offer no longer outlives the character that earned it — log out after walking out of the Deadmines and your next character was offered a reset the first one had earned — and the one lockout leg no probe can ever reach (vmangos writes a last-instance map only from raid binds, which the client's handler discards as not-a-party-dungeon) is now covered end to end through the real system, its module doc saying plainly: tested and never observed

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-08-31 20:18:51 +07:00
co-authored by Claude Fable 5
parent 25c865c00a
commit 14bd5ea1a4
35 changed files with 1804 additions and 1347 deletions
-889
View File
@@ -1,889 +0,0 @@
<Ui>
<!--
benilla's bank window (decision 0604 phase 4): the vault is already streamed (the 24 bank
slots + 6 bank bags ride the descriptor at login, exactly like the backpack — see the
decision's "pinned mechanism"), so this file is purely the window over the existing
container/cursor plumbing. Geometry/art below are QUOTED from the real `BankFrame.xml`
(`WoW/_extracted_framexml/BankFrame.xml`/`.lua`, read this session — line-cited inline),
turned onto OUR house idioms per decision 0068/0084: no `ItemButtonTemplate`/
`SmallMoneyFrameTemplate`/`UIPanelButtonTemplate` ship, so every one of those is either a
cross-file `inherits` of a benilla template that already reconstructs the real art
(`BenillaBagSlotTemplate`, now in this file — see the kit banner below) or a local redraw
(the Purchase button, `UI-Panel-Button-*`, the house convention every NPC window already
uses — TrainerFrame.xml/QuestFrame.xml &c.).
MAXIMAL REUSE, per the phase-4 brief: the 24 generic slots (container `-1`) are 24
`BenillaBagSlotTemplate` buttons — the SAME click/drag/tooltip/stack-split/lock/cooldown
chain the bag windows use, just parented under a frame carrying `id="-1"` (this window
itself) so `button:GetParent():GetID()` resolves to `BANK_CONTAINER`.
THE SIX BANK-BAG WINDOWS ARE NO LONGER HERE (decision 1751). They were
`BenillaBankBagFrame1..6`, six copies of `BagFrame.xml`'s window template, and the reason
given at the time was that "this engine has no `ToggleBag`/`CloseBag` globals (grepped;
none exist)". It has them now — the reference's own, off the player's chain — and the bank
bags are what decision 0604 already said they were in the real client: **ordinary
ContainerFrames at ids NUM_BAG_SLOTS+1 .. +NUM_BANKBAGSLOTS**. So the 601 lines that copied
a window six times are gone and the bag buttons call `ToggleBag`, exactly as
`BankFrameItemButtonBag_OnClick` does. What this file still owes the reference's file is
`NUM_BAG_SLOTS` and `UpdateBagButtonHighlight`, which live where BankFrame.lua puts them.
DEVIATIONS from the raw reference quote (each load-bearing, none silent):
- The 24 generic slots reuse `BenillaBagSlot_Update`'s `physIndex` chain-resolution
(`slot = size - physIndex + 1`) rather than a bespoke direct mapping, so slot i's
button is loaded with `BenillaBagSlot_OnLoad(self, 25 - i)` — container `-1` is a FIXED
24 slots (never resized, decision 0604), so this reversal is a stable constant, not a
live recompute; it buys the exact same click/drag/split/lock/cooldown chain the bag
windows get for free, at the cost of the physIndex arithmetic reading backwards from
the reference's direct `id=i` (the reference's `BankFrameItem<i>` has no chain at all
since a real ContainerFrame's `id~=0` slot count is genuinely fixed at generation time —
our shared function always resolves dynamically, so the reversal is what makes button i
show slot i).
- The Purchase confirm popup (`CONFIRM_BUY_BANK_SLOT`) shows TEXT ONLY, no embedded cost —
the house's `StaticPopup` engine (`UiPanels.xml`) has no money-frame variant (the
`hasMoneyFrame` field the reference dialog carries is inert), the same accepted gap
`MailFrame.xml`'s `SEND_MONEY`/`DELETE_MONEY` popups already live with (that file's own
header note). The row's own Cost: coins (visible before you ever open the popup) carry
the affordability information instead.
- The close button is the house's `UI-Panel-MinimizeButton` at TOPRIGHT(-30,-8) — the
Merchant/Trainer/Mail convention — rather than the reference's CENTER-relative offset
against ITS OWN `UI-BankFrame` art's button recess (which our re-skin doesn't reproduce
pixel-for-pixel).
- ESC / `CloseAllWindows` closes the MAIN bank window (registered in `UiPanels.xml`'s
`UIPanelWindows`, which drives the whole chain generically) and its `OnHide` explicitly
closes every open bank-bag popout with it — but a popout is NOT independently in
`CloseAllWindows`'s hardcoded bag list (`UiPanels.xml`), so it would survive an ESC
press that DIDN'T close the bank frame itself (impossible today: nothing else occupies
the left slot while the bank is open without closing it first, so this is a latent gap,
not a reachable bug).
Money: reuses `BenillaMoney_Set`/`_SetColor` (`MerchantFrame.xml`, loaded first — the
established cross-window reuse `TrainerFrame.xml`/`BagFrame.xml` already lean on).
-->
<Script><![CDATA[
-- NUM_BAG_SLOTS is this file's in the reference too (BankFrame.lua:2), and it is read from
-- outside it: the sourced `ContainerFrame.lua` (1751) branches on it in
-- `ContainerFrame_OnShow` to tell an equipped bag from a bank bag, and `updateContainer
-- FrameAnchors` measures against `BankFrame`. Lua resolves at CALL time, so this file
-- sitting far below ContainerFrame.xml in the manifest is fine — but the ownership is the
-- reference's, not a convenience.
NUM_BAG_SLOTS = 4 -- (ref BankFrame.lua:2)
NUM_BANKGENERIC_SLOTS = 24 -- (ref BankFrame.lua:3)
NUM_BANKBAGSLOTS = 6 -- (ref BankFrame.lua:4)
-- ══ THE CONTAINER-SLOT KIT — moved here from BagFrame.xml with decision 1751's bag swap ══
--
-- `BenillaBagSlotTemplate` and the `BenillaBagSlot_*` bodies below were the bag WINDOWS'
-- slot machinery, and this window borrowed them for its 24 vault slots. The bag windows are
-- now the reference's own `ContainerFrame1..12`, executed off the player's chain (1751), and
-- their slots are the reference's `ContainerFrameItemButtonTemplate`. That leaves this
-- window as the kit's ONLY consumer, so the kit lives here — where the next migration
-- (BankFrame's own stock files, whose `BankItemButtonTemplate`/`BankFrameItemButton_*`
-- replace all of it) deletes it in one piece.
--
-- Names unchanged on purpose: 144 `inherits=` sites and ~145 `<OnLoad>` calls in this file
-- write them, and a rename would be churn on code whose whole future is deletion.
--
-- Fully generic over `button:GetParent():GetID()`, which is why the move is a move and not
-- a rewrite — this window carries `id="-1"` (BANK_CONTAINER) and its buttons resolve their
-- container from it exactly as a bag window's did from theirs.
-- BenillaBagSlot_OnLoad(button, physIndex): physIndex is the button's CHAIN position (1 =
-- bottom-right, growing up-and-left — the anchor order below), not the game slot. The ref's
-- own formula (index = size - j + 1, ContainerFrame.lua:426/446) maps chain position → game
-- slot dynamically in BenillaBagSlot_Update, so slot 1 always reads top-left regardless of
-- how many of the window's slots are actually occupied — recomputed every Update (the ref
-- only computes it once, at window generation; recomputing is a strict robustness win for
-- the rare live bag-swap case, not a behavior change for a size that never moves).
function BenillaBagSlot_OnLoad(button, physIndex)
button.physIndex = physIndex
button.icon = getglobal(button:GetName() .. "Icon")
button.count = getglobal(button:GetName() .. "Count")
-- Both buttons must reach OnClick (the default set is LeftButtonUp only): LEFT-click is the
-- pickup gesture (PickupContainerItem — pick up / place / swap on the cursor); RIGHT-click
-- cancels a held cursor, else uses the slot (the C side sells it when a merchant is open —
-- our container drain forks).
button:RegisterForClicks("LeftButtonUp", "RightButtonUp")
-- The drag trio (decision 0216 §3): click-carry and drag are ONE code path in the
-- reference (ContainerFrame.xml:40-55 routes OnDragStart/OnReceiveDrag to the same
-- OnClick("LeftButton", ignoreModifiers=1) the plain click uses) — see
-- BenillaBagSlot_OnClick below for the identical routing (its own `ignoreModifiers`
-- suppresses the shift-split fork, the reference's own contract for drag/receive).
button:RegisterForDrag("LeftButton")
BenillaBagSlot_Update(button)
end
function BenillaBagSlot_OnClick(button, mouseButton, ignoreModifiers)
if button.slot == nil then return end -- a hidden button (physIndex past the bag's size)
local bagId = button:GetParent():GetID()
if mouseButton == "LeftButton" then
-- The reference's own five returns (ContainerFrame.lua:241) — decision 1199
-- put this binding back on its 1.12 shape.
local texture, itemCount, locked, quality, readable =
GetContainerItemInfo(bagId, button.slot)
-- 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(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(GetContainerItemLink(bagId, button.slot))
return
end
if texture ~= nil and not locked and (itemCount or 1) >= 2 then
button.SplitStack = function(btn, n)
SplitContainerItem(bagId, btn.slot, n)
BenillaBankFrame_RepaintItems()
end
-- Ref ContainerFrameItemButton_OnLoad/_OnClick (ContainerFrame.lua:558-560,
-- 573-576): the owner's own SplitStack closure carries the bag/slot so
-- StackSplitFrameOkay_Click's generic `owner.SplitStack(owner, split)` call
-- needs no window-specific knowledge. SplitContainerItem lives in cursor.rs
-- rather than container.rs — it is the one container-named verb whose
-- payload is the cursor's, not a bag's — but like every container verb it is
-- a plain top-level global (decision 1198).
OpenStackSplitFrame(itemCount, button, "BOTTOMRIGHT", "TOPRIGHT")
end
return
end
-- A plain click hides any open split frame (ref ContainerFrame.lua:581) before the
-- ordinary pickup/place proceeds.
local split = getglobal("StackSplitFrame")
if split then split:Hide() end
-- PickupContainerItem is the single drag entry point (real client): an empty cursor
-- picks this slot up; a held cursor places/swaps into it (same slot = cancel). Hide the
-- hover tooltip (the item just left the slot for the cursor) and repaint so the picked
-- slot dims / un-dims immediately — the swap itself settles when the server answers.
PickupContainerItem(bagId, button.slot)
GameTooltip:Hide()
BenillaBankFrame_RepaintItems()
elseif mouseButton == "RightButton" then
-- A held cursor cancels (client-side, nothing sent); otherwise use/equip/sell the slot.
if CursorHasItem() then
ClearCursor()
BenillaBankFrame_RepaintItems()
else
UseContainerItem(bagId, button.slot)
end
end
end
-- Hover: the era-true GameTooltip:SetBagItem(bag, slot) — the ENGINE renders the full
-- item tooltip from the template store and owns the sell-price money law (real instance
-- + merchant open + repair off ⇒ OnTooltipAddMoney(SellPrice × stack), or the
-- ITEM_UNSELLABLE line — wow-re ui/scratch/tooltip-money.md's byte-verified gate,
-- decision 0274 P1). The durability-adjusted damaged price is deferred until durability
-- damage reaches the feed (a pristine item reduces to SellPrice × stack, the case we
-- ship). Nothing shows while the template is in flight — the OnUpdate re-enter fills it.
function BenillaBagSlot_OnEnter(button)
if button.slot == nil then return end
local bagId = button:GetParent():GetID()
local texture, itemCount, locked, quality, readable =
GetContainerItemInfo(bagId, button.slot)
-- An emptied slot drops its tooltip (ref ContainerFrame_Update's texture-nil branch,
-- ContainerFrame.lua:281-283) — without this the OnUpdate re-enter below would leave
-- a stale tooltip up after the item is sold or moved out from under the hover.
if not texture then
if GameTooltip:IsOwned(button) then GameTooltip:Hide() end
return
end
-- Ref ContainerFrameItemButton_OnEnter (ContainerFrame.lua:602-612) verbatim: a slot
-- in the RIGHT half of the screen hangs its tooltip LEFT, so it never runs off the
-- edge (the bag lives at the bottom-right — this is the ref's own screen-edge answer,
-- a side-pick, not a clamp).
if (button:GetRight() or 0) >= (GetScreenWidth() / 2) then
GameTooltip:SetOwner(button, "ANCHOR_LEFT")
else
GameTooltip:SetOwner(button, "ANCHOR_RIGHT")
end
GameTooltip:SetBagItem(bagId, button.slot)
-- The pouch/magnifier cursor (ref ContainerFrameItemButton_OnEnter tail,
-- ContainerFrame.lua l.636-642): merchant open on the MERCHANT tab and not repair
-- 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 (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 MerchantFrame and MerchantFrame:IsShown()
and MerchantFrame.selectedTab == 1 and not InRepairMode() then
ShowContainerSellCursor(bagId, button.slot)
elseif readable or IsControlKeyDown() then
ShowInspectCursor()
else
ResetCursor()
end
GameTooltip:Show()
end
function BenillaBagSlot_OnLeave(button)
GameTooltip:Hide()
-- Ref ItemButton OnLeave (ContainerFrame.xml): the hover's sell cursor drops with it.
ResetCursor()
end
-- Ref ContainerFrameItemButton_OnUpdate (ContainerFrame.lua:645-660): while the tooltip
-- is ours, re-run OnEnter every frame — the ref ships this loop UNTHROTTLED (its
-- updateTooltip throttle is commented out in 1.12). It is the real client's own refresh
-- mechanism: a tooltip set while the item template was still in flight fills itself in
-- the moment the answer lands, no re-hover.
function BenillaBagSlot_OnUpdate(button)
if GameTooltip:IsOwned(button) then
BenillaBagSlot_OnEnter(button)
end
end
-- The item's use-cooldown sweep (ref ContainerFrame_UpdateCooldown,
-- ContainerFrame.lua:295-299, verbatim shape over our container dialect).
function BenillaBagSlot_UpdateCooldown(button, bagId)
local cooldown = getglobal(button:GetName() .. "Cooldown")
local start, duration, enable = GetContainerItemCooldown(bagId, button.slot)
CooldownFrame_SetTimer(cooldown, start, duration, enable)
end
-- The physIndex → game-slot resolution (the module doc above) + the paint (ref
-- ContainerFrame_Update, ContainerFrame.lua:234-293). Shared by every window's slot
-- buttons. The occupied/empty fork drives the cooldown exactly as the ref does
-- (l.248-253: occupied → UpdateCooldown, empty → Cooldown:Hide()).
function BenillaBagSlot_Update(button)
local bagId = button:GetParent():GetID()
local size = GetContainerNumSlots(bagId)
if button.physIndex > size then
button.slot = nil
button:Hide()
return
end
button.slot = size - button.physIndex + 1
button:Show()
local texture, itemCount, locked = GetContainerItemInfo(bagId, button.slot)
if texture ~= nil or itemCount ~= nil then
BenillaBagSlot_UpdateCooldown(button, bagId)
button.icon:SetTexture(texture or BENILLA_FALLBACK_ICON)
-- A locked slot (its item is up on the cursor, or a send is still pending
-- server-side, decision 0216 §4/0218 §3) dims, the real client's drag feedback.
if locked then
button.icon:SetVertexColor(0.4, 0.4, 0.4)
else
button.icon:SetVertexColor(1, 1, 1)
end
if (itemCount or 1) > 1 then
button.count:SetText(itemCount)
else
button.count:SetText("")
end
else
button.icon:SetTexture(nil)
button.count:SetText("")
getglobal(button:GetName() .. "Cooldown"):Hide()
end
end
function BankFrame_OnLoad()
local frame = this
-- Named directly rather than through `$parent .. "Portrait"`: the reference's bank
-- portrait is the UNPREFIXED global `BankPortraitTexture` (ref BankFrame.xml l.93),
-- one of the reference's own naming inconsistencies, and it is the name addons write.
-- `Bagnon_Core/Events.lua:41` does `SetPortraitTexture(BankPortraitTexture, "npc")`
-- when its "show Bagnon's bank instead" option is off, and against a nil first argument
-- that binding raises. Same standing lesson as `ToggleBackpack` / `CharacterBag0Slot`:
-- the name an addon reaches for is the reference's, not ours.
SetPortraitTexture(BankPortraitTexture, "npc")
frame:RegisterEvent("BANKFRAME_OPENED")
frame:RegisterEvent("BANKFRAME_CLOSED")
frame:RegisterEvent("PLAYERBANKSLOTS_CHANGED")
frame:RegisterEvent("PLAYERBANKBAGSLOTS_CHANGED")
frame:RegisterEvent("PLAYER_MONEY")
-- Container -1 is a container like any other: the same lock/cooldown maintenance
-- events a bag window registers (the reference's `ContainerFrame_OnLoad`), handled
-- below by BankFrame_OnEvent directly: this window's 24 slots are its own, painted by
-- BenillaBankFrame_RepaintItems over the slot kit above.
frame:RegisterEvent("ITEM_LOCK_CHANGED")
frame:RegisterEvent("BAG_UPDATE_COOLDOWN")
end
-- The banker's name arrives as arg1 of BANKFRAME_OPENED (the app resolves the banker guid
-- through the NameCache — the merchant/trainer feed's own ask-once pattern); nil/empty
-- until it lands, or if this ever fires with no name, falls back to "Banker".
function BenillaBankFrame_SetTitle(name)
if name == nil or name == "" then
name = "Banker"
end
getglobal("BankFrameTitleText"):SetText(name)
end
-- Repaint every one of the 24 generic slots (PLAYERBANKSLOTS_CHANGED names ONE slot; the
-- spec allows repainting all 24 — cheap, and spares tracking a slot->button map).
function BenillaBankFrame_RepaintItems()
for i = 1, NUM_BANKGENERIC_SLOTS do
local button = getglobal("BankItem" .. i)
if button then BenillaBagSlot_Update(button) end
end
end
-- The bag row (ref UpdateBagSlotStatus, BankFrame.lua:81-117): each button's icon from the
-- app-resolved BenillaGetBankBagTexture feed (falling back to the empty paper-doll bag
-- slot art), tinted white when purchased / red when not; the purchase row's cost coins +
-- afford color; the whole row hidden once GetNumBankSlots() reports `full`.
function BenillaBankFrame_UpdateBagRow()
local numSlots, full = GetNumBankSlots()
for i = 1, NUM_BANKBAGSLOTS do
local button = getglobal("BankBagButton" .. i)
if button then
local texture = BenillaGetBankBagTexture(i)
button.icon:SetTexture(texture or "Interface\\PaperDoll\\UI-PaperDoll-Slot-Bag")
if i <= numSlots then
button.icon:SetVertexColor(1.0, 1.0, 1.0)
button.tooltipText = "Bag Slot" -- BANK_BAG
else
button.icon:SetVertexColor(1.0, 0.1, 0.1)
button.tooltipText = "Purchasable Bag Slot" -- BANK_BAG_PURCHASE
end
end
end
local cost = GetBankSlotCost(numSlots)
BankFrame.nextSlotCost = cost
BenillaMoney_Set("BankFrameCostMoney", cost, true)
if GetMoney() >= cost then
BenillaMoney_SetColor("BankFrameCostMoney", 1.0, 1.0, 1.0)
else
BenillaMoney_SetColor("BankFrameCostMoney", 1.0, 0.1, 0.1)
end
local purchaseFrame = getglobal("BankFramePurchaseInfo")
if full then
purchaseFrame:Hide()
else
purchaseFrame:Show()
end
end
-- `UpdateBagSlotStatus()` — THE REFERENCE'S NAME for the pass above, published for the same
-- standing reason `ToggleBackpack` and `BagSlotButton_OnEnter` are: an addon calls the
-- client's global, and it must exist and do the job. `Bagnon_Core/Events.lua:46` calls it
-- from its own `ShowBlizBank()`, two lines after `SetPortraitTexture(BankPortraitTexture, …)`
-- above — the path taken whenever a player turns Bagnon's "show Bagnon at the bank" option
-- OFF and gets the stock window instead. It was nil, so that whole handler died there.
--
-- The reference's own body walks its `BankFrameBag1..6` and repaints
-- `BankFrameDetailMoneyFrame`; ours is the same pass over our own bag row and cost frame,
-- which is exactly what the reference's does for its window (it is not a shim: it is this
-- window's real update, under the name the client publishes it as).
function UpdateBagSlotStatus()
BenillaBankFrame_UpdateBagRow()
end
function BankFrame_OnEvent(event)
local frame = this
if event == "BANKFRAME_OPENED" then
BenillaBankFrame_SetTitle(arg1)
BenillaBankFrame_RepaintItems()
BenillaBankFrame_UpdateBagRow()
ShowUIPanel(frame)
elseif event == "BANKFRAME_CLOSED" then
HideUIPanel(frame)
elseif event == "PLAYERBANKSLOTS_CHANGED" then
BenillaBankFrame_RepaintItems()
elseif event == "PLAYERBANKBAGSLOTS_CHANGED" then
BenillaBankFrame_UpdateBagRow()
elseif event == "PLAYER_MONEY" then
BenillaMoney_Set("BankFrameMoney", GetMoney())
BenillaBankFrame_UpdateBagRow() -- the cost coins' afford color can flip too
elseif event == "ITEM_LOCK_CHANGED" then
if arg1 == -1 then
BenillaBankFrame_RepaintItems()
end
elseif event == "BAG_UPDATE_COOLDOWN" then
-- No bag argument (ref l.243-253's own shape) — BenillaBagSlot_Update's occupied
-- leg re-runs the cooldown sweep as part of the full 24-slot repaint.
BenillaBankFrame_RepaintItems()
end
end
function BenillaBankFrame_CloseButton_OnClick()
CloseBankFrame()
HideUIPanel(getglobal("BankFrame"))
end
-- CloseBankBagFrames (ref BankFrame.lua:119-123) — now VERBATIM, because the bank bags are
-- ordinary ContainerFrames again (1751): ids NUM_BAG_SLOTS+1 .. +NUM_BANKBAGSLOTS, closed
-- through the reference's own `CloseBag`. The button's lit ring follows from
-- `ContainerFrame_OnHide`, which calls `UpdateBagButtonHighlight` for every id above
-- NUM_BAG_SLOTS — so this no longer unchecks the buttons itself.
function CloseBankBagFrames()
for i = NUM_BAG_SLOTS + 1, NUM_BAG_SLOTS + NUM_BANKBAGSLOTS do
CloseBag(i)
end
end
-- UpdateBagButtonHighlight(id) — ref BankFrame.lua:171-185, and the reference's own callers
-- are the sourced `ContainerFrame_OnShow`/`_OnHide`: every container id above NUM_BAG_SLOTS
-- routes its lit-state here instead of to a `CharacterBag<N>Slot`. Without it, opening a
-- bank bag raises inside the reference's file.
--
-- ONE DIVERGENCE, in the art and not the rule: the reference lights a dedicated
-- `BankFrameBag<n>HighlightFrameTexture` that its bag button carries; ours is a CheckButton
-- whose CheckedTexture is that highlight (this file's own lit-ring convention, shared with
-- the equipped-bag bar). So the same question — "is this bag's window open?" — writes
-- `SetChecked` rather than showing a texture, and `IsBagOpen` asks it instead of the
-- reference's open-coded ContainerFrame scan, which is the same scan under a name the
-- reference itself publishes.
function UpdateBagButtonHighlight(id)
local button = getglobal("BankBagButton" .. (id - NUM_BAG_SLOTS))
if not button then
return
end
button:SetChecked(IsBagOpen(id) and 1 or nil)
end
-- ── The 6 bag buttons (ref BankFrameItemButtonBag_OnClick/_OnShiftClick, BankFrame.lua:
-- 171-204): NOT container slots (they hold a whole BAG item, not a stack) — the doll
-- pickup idiom BagFrame.xml's BenillaBagBarSlot_* already established for the equipped
-- bag slots, reused verbatim here over BankButtonIDToInvSlotID's bag arm (id 1..6 ->
-- live 64..69). A held cursor item always PLACES; an empty cursor toggles the button's
-- popout window instead (ref: PutItemInBag first, ToggleBag only if that failed). ──
function BenillaBankBagButton_OnLoad(button, id)
button:SetID(id)
button.icon = getglobal(button:GetName() .. "Icon")
-- Both buttons, ref-verbatim (BankItemButtonBagTemplate's OnLoad runs
-- BankFrameBagButton_OnLoad -> BankFrameBaseButton_OnLoad, BankFrame.lua:12); the vault
-- slots get the same pair through BenillaBagSlot_OnLoad. The OnClick below reads no
-- button — right-click places/opens exactly as left does.
button:RegisterForClicks("LeftButtonUp", "RightButtonUp")
button:RegisterForDrag("LeftButton")
end
function BenillaBankBagButton_Place(button)
PickupInventoryItem(BankButtonIDToInvSlotID(button:GetID(), 1))
PlaySound("BAGMENUBUTTONPRESS")
end
function BenillaBankBagButton_OnClick(button)
if CursorHasItem() then
BenillaBankBagButton_Place(button)
return
end
-- A bank bag's window is a ContainerFrame like any other now (1751), at container id
-- NUM_BAG_SLOTS + <slot> — the reference's own arrangement, and the one decision 0604
-- quoted ("the reference reuses ContainerFrames for bank bags identically") before this
-- engine had `ToggleBag` to reuse them with. So the whole popout fork below is the
-- reference's single call, and `ToggleBag`'s own `size > 0` guard is the "only a slot
-- holding a bag opens" rule this file used to spell out.
local id = NUM_BAG_SLOTS + button:GetID()
PlaySound("BAGMENUBUTTONPRESS")
ToggleBag(id)
-- The lit ring: `ContainerFrame_OnShow`/`_OnHide` route here for a bank id, so this
-- tail is the ref's own (BankFrameItemButtonBag_OnClick l.195) and covers the case
-- ToggleBag declined — a bagless or unpurchased slot leaves the button unlit.
UpdateBagButtonHighlight(id)
end
function BenillaBankBagButton_OnEnter(button)
GameTooltip:SetOwner(button, "ANCHOR_RIGHT")
GameTooltip:SetText(button.tooltipText or "Bag Slot")
GameTooltip:Show()
end
-- The Purchase button (ref BankFramePurchaseButton OnClick, BankFrame.xml:478-482) + its
-- confirm popup (ref StaticPopup.lua CONFIRM_BUY_BANK_SLOT, l.47-58 — text-only here, see
-- the header's money-frame note). Accept queues PurchaseSlot(); the descriptor's byte-2
-- delta is the only success signal (decision 0604) — PLAYERBANKBAGSLOTS_CHANGED repaints.
function BenillaBankFramePurchaseButton_OnClick()
PlaySound("igMainMenuOption")
StaticPopup_Show("CONFIRM_BUY_BANK_SLOT")
end
StaticPopupDialogs["CONFIRM_BUY_BANK_SLOT"] = {
text = "Do you want to purchase a bank slot for:", -- CONFIRM_BUY_BANK_SLOT
button1 = "Yes",
button2 = "No",
OnAccept = function() PurchaseSlot() end,
timeout = 0,
hideOnEscape = 1,
}
]]></Script>
<!-- The container-slot BUTTON — see the kit banner in the script block above. Moved here from
BagFrame.xml with 1751's bag swap; this window's 24 vault slots are its only consumer. -->
<Button name="BenillaBagSlotTemplate" virtual="true">
<Size><AbsDimension x="37" y="37"/></Size>
<Layers>
<Layer level="BACKGROUND">
<Texture name="$parentIcon"/>
</Layer>
<Layer level="OVERLAY">
<FontString name="$parentCount" inherits="NumberFontNormal" justifyH="RIGHT">
<Anchors>
<Anchor point="BOTTOMRIGHT" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="-5" y="2"/></Offset></Anchor>
</Anchors>
</FontString>
</Layer>
</Layers>
<Frames>
<!-- The item's use-cooldown sweep (ref ContainerFrame.xml:13 — $parentCooldown
inherits CooldownFrameTemplate; ours is the native Cooldown widget, same as the
action buttons'). Driven by BenillaBagSlot_Update / BAG_UPDATE_COOLDOWN via
GetContainerItemCooldown + CooldownFrame_SetTimer (ActionBar.xml's transcription,
loaded before this file). -->
<Cooldown name="$parentCooldown" hidden="true">
<Size><AbsDimension x="36" y="36"/></Size>
<Anchors>
<Anchor point="CENTER" relativePoint="CENTER"><Offset><AbsDimension x="0" y="-1"/></Offset></Anchor>
</Anchors>
</Cooldown>
</Frames>
<NormalTexture file="Interface\Buttons\UI-Quickslot2">
<Size><AbsDimension x="64" y="64"/></Size>
<Anchors>
<Anchor point="CENTER" relativePoint="CENTER"><Offset><AbsDimension x="0" y="-1"/></Offset></Anchor>
</Anchors>
</NormalTexture>
<PushedTexture file="Interface\Buttons\UI-Quickslot-Depress"/>
<HighlightTexture file="Interface\Buttons\ButtonHilight-Square" alphaMode="ADD"/>
<Scripts>
<OnClick>BenillaBagSlot_OnClick(self, arg1)</OnClick>
<OnEnter>BenillaBagSlot_OnEnter(self)</OnEnter>
<OnLeave>BenillaBagSlot_OnLeave(self)</OnLeave>
<OnUpdate>BenillaBagSlot_OnUpdate(self)</OnUpdate>
<!-- The drag trio (decision 0216 §3): both route to the SAME left-click path the
reference uses for both gestures (ContainerFrame.xml:40-55) — "LeftButton" hardcoded,
exactly as the reference calls it, since RegisterForDrag above only ever arms Left —
`true` (ignoreModifiers) suppresses the shift-split fork, matching the ref's own
ignoreModifiers contract for a drag/receive. -->
<OnDragStart>BenillaBagSlot_OnClick(self, "LeftButton", true)</OnDragStart>
<OnReceiveDrag>BenillaBagSlot_OnClick(self, "LeftButton", true)</OnReceiveDrag>
</Scripts>
</Button>
<!--
One bank-bag BUTTON (ref BankItemButtonBagTemplate, BankFrame.xml:46-74): the 37×37
UI-Quickslot2 ring, a CheckButton so it lights (CheckedTexture) while its popout is open —
the same lit-ring convention BagFrame.xml's BenillaBagBarSlot uses for the equipped bags.
-->
<CheckButton name="BenillaBankBagButtonTemplate" virtual="true">
<Size><AbsDimension x="37" y="37"/></Size>
<Layers>
<Layer level="BACKGROUND">
<Texture name="$parentIcon"/>
</Layer>
</Layers>
<NormalTexture file="Interface\Buttons\UI-Quickslot2">
<Size><AbsDimension x="64" y="64"/></Size>
<Anchors><Anchor point="CENTER" relativePoint="CENTER"><Offset><AbsDimension x="0" y="-1"/></Offset></Anchor></Anchors>
</NormalTexture>
<HighlightTexture file="Interface\Buttons\ButtonHilight-Square" alphaMode="ADD"/>
<CheckedTexture file="Interface\Buttons\CheckButtonHilight" alphaMode="ADD"/>
<Scripts>
<OnClick>BenillaBankBagButton_OnClick(self)</OnClick>
<OnEnter>BenillaBankBagButton_OnEnter(self)</OnEnter>
<OnLeave>GameTooltip:Hide()</OnLeave>
<OnDragStart>BenillaBankBagButton_Place(self)</OnDragStart>
<OnReceiveDrag>BenillaBankBagButton_Place(self)</OnReceiveDrag>
</Scripts>
</CheckButton>
<Frame name="BankFrame" enableMouse="true" toplevel="true" parent="UIParent" id="-1" hidden="true">
<!-- The real 384×512 window (ref l.77-90), with the reference's own default anchor.
ShowUIPanel/SetLeftFrame still SetPoints this to whichever slot it opens in (the NPC
session convention Merchant/Trainer/Mail share), and TOPLEFT (0,-104) is exactly what
the manager writes for the centre slot — so this changes nothing about where the window
lands, and everything about the window while it is HIDDEN.
It was `No <Anchors>` until decision 1751, and that was a real divergence with a real
consequence: an unanchored frame never resolves, so `BankFrame:GetRight()` answered nil
forever. The reference's own `updateContainerFrameAnchors` reads exactly that, on every
bag open and close (ContainerFrame.lua l.505), to decide whether the bags must shrink
to clear the bank — so with the bags now running off the player's chain, a bank window
with no rect took the whole bag layout down with it.
id="-1" = BANK_CONTAINER: the 24 item buttons below read it via GetParent():GetID(), the
SAME resolution the slot kit above uses for every other container. -->
<Size><AbsDimension x="384" y="512"/></Size>
<Anchors>
<Anchor point="TOPLEFT"><Offset><AbsDimension x="0" y="-104"/></Offset></Anchor>
</Anchors>
<Layers>
<Layer level="BACKGROUND">
<!-- NPC portrait 60×60 TOPLEFT (7,-6) (ref l.93-104), bound in OnLoad via
SetPortraitTexture(region, "npc"). The name is the REFERENCE'S, unprefixed —
its bank frame breaks the $parentPortrait convention every other window here
follows, and addons write the name the reference gave them. -->
<Texture name="BankPortraitTexture">
<Size><AbsDimension x="60" y="60"/></Size>
<Anchors><Anchor point="TOPLEFT" relativePoint="TOPLEFT"><Offset><AbsDimension x="7" y="-6"/></Offset></Anchor></Anchors>
</Texture>
</Layer>
<Layer level="ARTWORK">
<!-- The whole window art, one 512×512 texture (ref l.107-114 — unlike Merchant/
Trainer's 4-quadrant re-skin, the reference Bank window is a single slab). -->
<Texture file="Interface\BankFrame\UI-BankFrame">
<Size><AbsDimension x="512" y="512"/></Size>
<Anchors><Anchor point="TOPLEFT" relativePoint="TOPLEFT"/></Anchors>
</Texture>
<!-- Title: the banker's name (ref BankFrameTitleText l.115-123, GameFontHighlight,
CENTER (6,233)); "Banker" until BANKFRAME_OPENED's arg1 lands. -->
<FontString name="BankFrameTitleText" inherits="GameFontHighlight" justifyH="CENTER" text="Banker">
<Anchors><Anchor point="CENTER" relativePoint="CENTER"><Offset><AbsDimension x="6" y="233"/></Offset></Anchor></Anchors>
</FontString>
<!-- Section labels (ref l.124-141: ITEMSLOTTEXT/BAGSLOTTEXT, GameFontNormal, CENTER
(-11,195) / (-11,-5)). -->
<FontString inherits="GameFontNormal" text="Item Slots" justifyH="CENTER">
<Anchors><Anchor point="CENTER" relativePoint="CENTER"><Offset><AbsDimension x="-11" y="195"/></Offset></Anchor></Anchors>
</FontString>
<FontString inherits="GameFontNormal" text="Bag Slots" justifyH="CENTER">
<Anchors><Anchor point="CENTER" relativePoint="CENTER"><Offset><AbsDimension x="-11" y="-5"/></Offset></Anchor></Anchors>
</FontString>
</Layer>
</Layers>
<Frames>
<!-- Close X: the house minimize-button convention (Merchant/Trainer/Mail), TOPRIGHT
(-30,-8) — see the header's deviation note on the reference's own offset. -->
<Button name="BankFrameCloseButton">
<Size><AbsDimension x="32" y="32"/></Size>
<Anchors><Anchor point="TOPRIGHT" relativePoint="TOPRIGHT"><Offset><AbsDimension x="-30" y="-8"/></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>BenillaBankFrame_CloseButton_OnClick()</OnClick></Scripts>
</Button>
<!-- The 24 generic item slots (ref BankFrameItem1..24, l.154-369): 6 cols × 4 rows,
TOPLEFT(40,-73), 12px column gap, -7px row gap — QUOTED verbatim. Each is a
BenillaBagSlotTemplate button (BagFrame.xml) read against container -1; physIndex
is `25 - i` so button i resolves to slot i (see the header's deviation note). -->
<Button name="BankItem1" inherits="BenillaBagSlotTemplate">
<Anchors><Anchor point="TOPLEFT" relativePoint="TOPLEFT"><Offset><AbsDimension x="40" y="-73"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaBagSlot_OnLoad(self, 24)</OnLoad></Scripts>
</Button>
<Button name="BankItem2" inherits="BenillaBagSlotTemplate">
<Anchors><Anchor point="TOPLEFT" relativeTo="BankItem1" relativePoint="TOPRIGHT"><Offset><AbsDimension x="12" y="0"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaBagSlot_OnLoad(self, 23)</OnLoad></Scripts>
</Button>
<Button name="BankItem3" inherits="BenillaBagSlotTemplate">
<Anchors><Anchor point="TOPLEFT" relativeTo="BankItem2" relativePoint="TOPRIGHT"><Offset><AbsDimension x="12" y="0"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaBagSlot_OnLoad(self, 22)</OnLoad></Scripts>
</Button>
<Button name="BankItem4" inherits="BenillaBagSlotTemplate">
<Anchors><Anchor point="TOPLEFT" relativeTo="BankItem3" relativePoint="TOPRIGHT"><Offset><AbsDimension x="12" y="0"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaBagSlot_OnLoad(self, 21)</OnLoad></Scripts>
</Button>
<Button name="BankItem5" inherits="BenillaBagSlotTemplate">
<Anchors><Anchor point="TOPLEFT" relativeTo="BankItem4" relativePoint="TOPRIGHT"><Offset><AbsDimension x="12" y="0"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaBagSlot_OnLoad(self, 20)</OnLoad></Scripts>
</Button>
<Button name="BankItem6" inherits="BenillaBagSlotTemplate">
<Anchors><Anchor point="TOPLEFT" relativeTo="BankItem5" relativePoint="TOPRIGHT"><Offset><AbsDimension x="12" y="0"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaBagSlot_OnLoad(self, 19)</OnLoad></Scripts>
</Button>
<Button name="BankItem7" inherits="BenillaBagSlotTemplate">
<Anchors><Anchor point="TOPLEFT" relativeTo="BankItem1" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-7"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaBagSlot_OnLoad(self, 18)</OnLoad></Scripts>
</Button>
<Button name="BankItem8" inherits="BenillaBagSlotTemplate">
<Anchors><Anchor point="TOPLEFT" relativeTo="BankItem7" relativePoint="TOPRIGHT"><Offset><AbsDimension x="12" y="0"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaBagSlot_OnLoad(self, 17)</OnLoad></Scripts>
</Button>
<Button name="BankItem9" inherits="BenillaBagSlotTemplate">
<Anchors><Anchor point="TOPLEFT" relativeTo="BankItem8" relativePoint="TOPRIGHT"><Offset><AbsDimension x="12" y="0"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaBagSlot_OnLoad(self, 16)</OnLoad></Scripts>
</Button>
<Button name="BankItem10" inherits="BenillaBagSlotTemplate">
<Anchors><Anchor point="TOPLEFT" relativeTo="BankItem9" relativePoint="TOPRIGHT"><Offset><AbsDimension x="12" y="0"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaBagSlot_OnLoad(self, 15)</OnLoad></Scripts>
</Button>
<Button name="BankItem11" inherits="BenillaBagSlotTemplate">
<Anchors><Anchor point="TOPLEFT" relativeTo="BankItem10" relativePoint="TOPRIGHT"><Offset><AbsDimension x="12" y="0"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaBagSlot_OnLoad(self, 14)</OnLoad></Scripts>
</Button>
<Button name="BankItem12" inherits="BenillaBagSlotTemplate">
<Anchors><Anchor point="TOPLEFT" relativeTo="BankItem11" relativePoint="TOPRIGHT"><Offset><AbsDimension x="12" y="0"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaBagSlot_OnLoad(self, 13)</OnLoad></Scripts>
</Button>
<Button name="BankItem13" inherits="BenillaBagSlotTemplate">
<Anchors><Anchor point="TOPLEFT" relativeTo="BankItem7" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-7"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaBagSlot_OnLoad(self, 12)</OnLoad></Scripts>
</Button>
<Button name="BankItem14" inherits="BenillaBagSlotTemplate">
<Anchors><Anchor point="TOPLEFT" relativeTo="BankItem13" relativePoint="TOPRIGHT"><Offset><AbsDimension x="12" y="0"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaBagSlot_OnLoad(self, 11)</OnLoad></Scripts>
</Button>
<Button name="BankItem15" inherits="BenillaBagSlotTemplate">
<Anchors><Anchor point="TOPLEFT" relativeTo="BankItem14" relativePoint="TOPRIGHT"><Offset><AbsDimension x="12" y="0"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaBagSlot_OnLoad(self, 10)</OnLoad></Scripts>
</Button>
<Button name="BankItem16" inherits="BenillaBagSlotTemplate">
<Anchors><Anchor point="TOPLEFT" relativeTo="BankItem15" relativePoint="TOPRIGHT"><Offset><AbsDimension x="12" y="0"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaBagSlot_OnLoad(self, 9)</OnLoad></Scripts>
</Button>
<Button name="BankItem17" inherits="BenillaBagSlotTemplate">
<Anchors><Anchor point="TOPLEFT" relativeTo="BankItem16" relativePoint="TOPRIGHT"><Offset><AbsDimension x="12" y="0"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaBagSlot_OnLoad(self, 8)</OnLoad></Scripts>
</Button>
<Button name="BankItem18" inherits="BenillaBagSlotTemplate">
<Anchors><Anchor point="TOPLEFT" relativeTo="BankItem17" relativePoint="TOPRIGHT"><Offset><AbsDimension x="12" y="0"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaBagSlot_OnLoad(self, 7)</OnLoad></Scripts>
</Button>
<Button name="BankItem19" inherits="BenillaBagSlotTemplate">
<Anchors><Anchor point="TOPLEFT" relativeTo="BankItem13" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-7"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaBagSlot_OnLoad(self, 6)</OnLoad></Scripts>
</Button>
<Button name="BankItem20" inherits="BenillaBagSlotTemplate">
<Anchors><Anchor point="TOPLEFT" relativeTo="BankItem19" relativePoint="TOPRIGHT"><Offset><AbsDimension x="12" y="0"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaBagSlot_OnLoad(self, 5)</OnLoad></Scripts>
</Button>
<Button name="BankItem21" inherits="BenillaBagSlotTemplate">
<Anchors><Anchor point="TOPLEFT" relativeTo="BankItem20" relativePoint="TOPRIGHT"><Offset><AbsDimension x="12" y="0"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaBagSlot_OnLoad(self, 4)</OnLoad></Scripts>
</Button>
<Button name="BankItem22" inherits="BenillaBagSlotTemplate">
<Anchors><Anchor point="TOPLEFT" relativeTo="BankItem21" relativePoint="TOPRIGHT"><Offset><AbsDimension x="12" y="0"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaBagSlot_OnLoad(self, 3)</OnLoad></Scripts>
</Button>
<Button name="BankItem23" inherits="BenillaBagSlotTemplate">
<Anchors><Anchor point="TOPLEFT" relativeTo="BankItem22" relativePoint="TOPRIGHT"><Offset><AbsDimension x="12" y="0"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaBagSlot_OnLoad(self, 2)</OnLoad></Scripts>
</Button>
<Button name="BankItem24" inherits="BenillaBagSlotTemplate">
<Anchors><Anchor point="TOPLEFT" relativeTo="BankItem23" relativePoint="TOPRIGHT"><Offset><AbsDimension x="12" y="0"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaBagSlot_OnLoad(self, 1)</OnLoad></Scripts>
</Button>
<!-- The 6 bag buttons (ref BankFrameBag1..6 id=5..10, l.372-425): under item19, 12px
column gap. Button id = the bag-button number (1..6, what BankButtonIDToInvSlotID
and BenillaGetBankBagTexture expect) — NOT the container id (5..10), which the
popout windows below carry instead via their own `id=`. -->
<CheckButton name="BankBagButton1" inherits="BenillaBankBagButtonTemplate">
<Anchors><Anchor point="TOPLEFT" relativeTo="BankItem19" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-32"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaBankBagButton_OnLoad(self, 1)</OnLoad></Scripts>
</CheckButton>
<CheckButton name="BankBagButton2" inherits="BenillaBankBagButtonTemplate">
<Anchors><Anchor point="TOPLEFT" relativeTo="BankBagButton1" relativePoint="TOPRIGHT"><Offset><AbsDimension x="12" y="0"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaBankBagButton_OnLoad(self, 2)</OnLoad></Scripts>
</CheckButton>
<CheckButton name="BankBagButton3" inherits="BenillaBankBagButtonTemplate">
<Anchors><Anchor point="TOPLEFT" relativeTo="BankBagButton2" relativePoint="TOPRIGHT"><Offset><AbsDimension x="12" y="0"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaBankBagButton_OnLoad(self, 3)</OnLoad></Scripts>
</CheckButton>
<CheckButton name="BankBagButton4" inherits="BenillaBankBagButtonTemplate">
<Anchors><Anchor point="TOPLEFT" relativeTo="BankBagButton3" relativePoint="TOPRIGHT"><Offset><AbsDimension x="12" y="0"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaBankBagButton_OnLoad(self, 4)</OnLoad></Scripts>
</CheckButton>
<CheckButton name="BankBagButton5" inherits="BenillaBankBagButtonTemplate">
<Anchors><Anchor point="TOPLEFT" relativeTo="BankBagButton4" relativePoint="TOPRIGHT"><Offset><AbsDimension x="12" y="0"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaBankBagButton_OnLoad(self, 5)</OnLoad></Scripts>
</CheckButton>
<CheckButton name="BankBagButton6" inherits="BenillaBankBagButtonTemplate">
<Anchors><Anchor point="TOPLEFT" relativeTo="BankBagButton5" relativePoint="TOPRIGHT"><Offset><AbsDimension x="12" y="0"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaBankBagButton_OnLoad(self, 6)</OnLoad></Scripts>
</CheckButton>
<!-- The purchase row (ref BankFramePurchaseInfo, l.426-500): 300×72, CENTER of the
window (-15,-105). House coin rig (BenillaMoney_Set) in place of the reference's
SmallMoneyFrameTemplate; the Purchase button is a local UI-Panel-Button redraw (no
UIPanelButtonTemplate ships — the house convention every NPC window uses). -->
<Frame name="BankFramePurchaseInfo">
<Size><AbsDimension x="300" y="72"/></Size>
<Anchors><Anchor point="CENTER" relativeTo="BankFrame" relativePoint="CENTER"><Offset><AbsDimension x="-15" y="-105"/></Offset></Anchor></Anchors>
<Layers>
<Layer level="BACKGROUND">
<FontString inherits="GameFontHighlight" text="Do you wish to purchase space for an additional bag?" justifyH="LEFT">
<Size><AbsDimension x="280" y="0"/></Size>
<Anchors><Anchor point="CENTER" relativePoint="CENTER"><Offset><AbsDimension x="10" y="20"/></Offset></Anchor></Anchors>
</FontString>
<FontString name="BankFrameCostLabel" inherits="GameFontNormal" text="Cost:" justifyH="LEFT">
<Anchors><Anchor point="LEFT" relativePoint="LEFT"><Offset><AbsDimension x="30" y="-10"/></Offset></Anchor></Anchors>
</FontString>
</Layer>
</Layers>
<Frames>
<Frame name="BankFrameCostMoneyCoin1">
<Size><AbsDimension x="13" y="13"/></Size>
<Anchors><Anchor point="LEFT" relativeTo="BankFrameCostLabel" relativePoint="RIGHT"><Offset><AbsDimension x="4" y="0"/></Offset></Anchor></Anchors>
<Layers><Layer level="OVERLAY">
<Texture name="$parentIcon" file="Interface\MoneyFrame\UI-MoneyIcons"><Size><AbsDimension x="13" y="13"/></Size><Anchors><Anchor point="RIGHT" relativePoint="RIGHT"/></Anchors><TexCoords left="0" right="0.25" top="0" bottom="1"/></Texture>
<FontString name="$parentNum" inherits="NumberFontNormal" justifyH="RIGHT"><Size><AbsDimension x="20" y="13"/></Size><Anchors><Anchor point="RIGHT" relativePoint="RIGHT"><Offset><AbsDimension x="-13" y="0"/></Offset></Anchor></Anchors></FontString>
</Layer></Layers>
</Frame>
<Frame name="BankFrameCostMoneyCoin2">
<Size><AbsDimension x="13" y="13"/></Size>
<Anchors><Anchor point="LEFT" relativeTo="BankFrameCostMoneyCoin1" relativePoint="RIGHT"><Offset><AbsDimension x="4" y="0"/></Offset></Anchor></Anchors>
<Layers><Layer level="OVERLAY">
<Texture name="$parentIcon" file="Interface\MoneyFrame\UI-MoneyIcons"><Size><AbsDimension x="13" y="13"/></Size><Anchors><Anchor point="RIGHT" relativePoint="RIGHT"/></Anchors><TexCoords left="0.25" right="0.5" top="0" bottom="1"/></Texture>
<FontString name="$parentNum" inherits="NumberFontNormal" justifyH="RIGHT"><Size><AbsDimension x="20" y="13"/></Size><Anchors><Anchor point="RIGHT" relativePoint="RIGHT"><Offset><AbsDimension x="-13" y="0"/></Offset></Anchor></Anchors></FontString>
</Layer></Layers>
</Frame>
<Frame name="BankFrameCostMoneyCoin3">
<Size><AbsDimension x="13" y="13"/></Size>
<Anchors><Anchor point="LEFT" relativeTo="BankFrameCostMoneyCoin2" relativePoint="RIGHT"><Offset><AbsDimension x="4" y="0"/></Offset></Anchor></Anchors>
<Layers><Layer level="OVERLAY">
<Texture name="$parentIcon" file="Interface\MoneyFrame\UI-MoneyIcons"><Size><AbsDimension x="13" y="13"/></Size><Anchors><Anchor point="RIGHT" relativePoint="RIGHT"/></Anchors><TexCoords left="0.5" right="0.75" top="0" bottom="1"/></Texture>
<FontString name="$parentNum" inherits="NumberFontNormal" justifyH="RIGHT"><Size><AbsDimension x="20" y="13"/></Size><Anchors><Anchor point="RIGHT" relativePoint="RIGHT"><Offset><AbsDimension x="-13" y="0"/></Offset></Anchor></Anchors></FontString>
</Layer></Layers>
</Frame>
<Button name="BankFramePurchaseButton" text="Purchase">
<Size><AbsDimension x="124" y="21"/></Size>
<Anchors><Anchor point="RIGHT" relativePoint="RIGHT"><Offset><AbsDimension x="-20" y="-10"/></Offset></Anchor></Anchors>
<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>
<Scripts><OnClick>BenillaBankFramePurchaseButton_OnClick()</OnClick></Scripts>
</Button>
</Frames>
</Frame>
<!-- The player's own money (ref BankFrameMoneyFrame, l.501-509: BOTTOMRIGHT(-38,102)).
Same right-to-left coin chain as MerchantFrame's purse — copper-first, right-flush
off Coin1 (BenillaMoney_Set's own module doc, MerchantFrame.xml). -->
<Frame name="BankFrameMoneyCoin1">
<Size><AbsDimension x="13" y="13"/></Size>
<Anchors><Anchor point="BOTTOMRIGHT" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="-51" y="102"/></Offset></Anchor></Anchors>
<Layers><Layer level="OVERLAY">
<Texture name="$parentIcon" file="Interface\MoneyFrame\UI-MoneyIcons"><Size><AbsDimension x="13" y="13"/></Size><Anchors><Anchor point="RIGHT" relativePoint="RIGHT"/></Anchors><TexCoords left="0.5" right="0.75" top="0" bottom="1"/></Texture>
<FontString name="$parentNum" inherits="NumberFontNormal" justifyH="RIGHT"><Size><AbsDimension x="20" y="13"/></Size><Anchors><Anchor point="RIGHT" relativePoint="RIGHT"><Offset><AbsDimension x="-13" y="0"/></Offset></Anchor></Anchors></FontString>
</Layer></Layers>
</Frame>
<Frame name="BankFrameMoneyCoin2">
<Size><AbsDimension x="13" y="13"/></Size>
<Anchors><Anchor point="RIGHT" relativeTo="BankFrameMoneyCoin1" relativePoint="LEFT"><Offset><AbsDimension x="-4" y="0"/></Offset></Anchor></Anchors>
<Layers><Layer level="OVERLAY">
<Texture name="$parentIcon" file="Interface\MoneyFrame\UI-MoneyIcons"><Size><AbsDimension x="13" y="13"/></Size><Anchors><Anchor point="RIGHT" relativePoint="RIGHT"/></Anchors><TexCoords left="0.25" right="0.5" top="0" bottom="1"/></Texture>
<FontString name="$parentNum" inherits="NumberFontNormal" justifyH="RIGHT"><Size><AbsDimension x="20" y="13"/></Size><Anchors><Anchor point="RIGHT" relativePoint="RIGHT"><Offset><AbsDimension x="-13" y="0"/></Offset></Anchor></Anchors></FontString>
</Layer></Layers>
</Frame>
<Frame name="BankFrameMoneyCoin3">
<Size><AbsDimension x="13" y="13"/></Size>
<Anchors><Anchor point="RIGHT" relativeTo="BankFrameMoneyCoin2" relativePoint="LEFT"><Offset><AbsDimension x="-4" y="0"/></Offset></Anchor></Anchors>
<Layers><Layer level="OVERLAY">
<Texture name="$parentIcon" file="Interface\MoneyFrame\UI-MoneyIcons"><Size><AbsDimension x="13" y="13"/></Size><Anchors><Anchor point="RIGHT" relativePoint="RIGHT"/></Anchors><TexCoords left="0" right="0.25" top="0" bottom="1"/></Texture>
<FontString name="$parentNum" inherits="NumberFontNormal" justifyH="RIGHT"><Size><AbsDimension x="20" y="13"/></Size><Anchors><Anchor point="RIGHT" relativePoint="RIGHT"><Offset><AbsDimension x="-13" y="0"/></Offset></Anchor></Anchors></FontString>
</Layer></Layers>
</Frame>
</Frames>
<Scripts>
<OnLoad>BankFrame_OnLoad()</OnLoad>
<OnEvent>BankFrame_OnEvent(event)</OnEvent>
<!-- OnShow/OnHide (ref l.518-526): OpenBackpack/CloseBackpack per the 0561 contract
(MerchantFrame.xml's own placement is the model); the reference OnHide order
(CloseBankBagFrames(); CloseBankFrame(); PlaySound(...)) kept exactly, with
CloseBackpack folded in at the Merchant convention's spot. -->
<OnShow>OpenBackpack(); PlaySound("igMainMenuOpen")</OnShow>
<OnHide>CloseBankBagFrames(); CloseBankFrame(); CloseBackpack(); PlaySound("igMainMenuClose")</OnHide>
</Scripts>
</Frame>
</Ui>
@@ -1024,7 +1024,7 @@
-- reference (PaperDollFrame.xml:20-25 routes OnDragStart/OnReceiveDrag to the same
-- OnClick("LeftButton", ignoreModifiers=1) the plain click uses) — see
-- BenillaPaperDollSlot_OnClick below for the identical routing, the bag slots' own
-- precedent (BagFrame.xml's BenillaBagSlot_OnClick).
-- precedent (the reference's ContainerFrameItemButton_OnClick, sourced since 1751).
button:RegisterForDrag("LeftButton")
else
button:RegisterForClicks("LeftButtonUp")
@@ -1095,7 +1095,7 @@
button.broken = textureName and GetInventoryItemBroken("player", button.invSlotId)
-- A locked slot (its item is up on the cursor, or a send is still pending
-- server-side, decision 0216 §4/0218 §3) dims — the bag slots' own vertex-dim
-- convention (BagFrame.xml's BenillaBagSlot_Update), applied here instead of the
-- convention, applied here instead of the
-- ref's separate SetItemButtonDesaturated call (no engine equivalent — header
-- comment). The transient drag-dim outranks the broken red on the icon.
if IsInventoryItemLocked(button.invSlotId) then
@@ -1272,7 +1272,7 @@
The shared equipment-slot button (ref PaperDollItemSlotButtonTemplate inherits
ItemButtonTemplate, ref-PaperDollFrame.xml:4-38 — ItemButtonTemplate's own XML isn't part of
this session's extraction, so its shape is taken from this codebase's own prior
reconstruction of it, BagFrame.xml's BenillaBagSlotTemplate: 37x37, a BACKGROUND icon fill,
reconstruction of it (BagFrame.xml's since-retired BenillaBagSlotTemplate): 37x37, a BACKGROUND icon fill,
an OVERLAY count, the 64x64 UI-Quickslot2 ring centered (0,-1), UI-Quickslot-Depress pushed,
ButtonHilight-Square ADD highlight — duplicated here rather than cross-file inherited so this
window's slot behavior (UNIT_INVENTORY_CHANGED refresh, tooltip) stays self-contained in one
@@ -70,20 +70,23 @@
never the behaviour; it was two XML declarations, because the reference ships its templates in
`ContainerFrame.xml` and that file is not in the sourced set.
So the chain costs what it actually costs: this Button, and `ContainerFrameItemButtonTemplate`
in ContainerFrameAdapters.xml beside the adapter that already wraps one of those very handlers.
Our own bag windows keep `BenillaBagSlotTemplate` and are untouched — that is our design (0216),
a thousand lines of it, and this template is the reference's PUBLISHED surface for addons that
build their own slots. Two things that look alike because they do the same job; only one of them
is an API.
So the chain cost what it actually cost: this Button, and — at the time — a transcribed
`ContainerFrameItemButtonTemplate` beside the adapter that already wrapped one of those very
handlers. **Decision 1751 then took the last step and made both moot for the windows
themselves**: `Interface\FrameXML\ContainerFrame.xml` is a manifest entry now, so the
reference's OWN declaration of that template is the live one and the transcription is deleted.
This Button stays, because it is the reference's PUBLISHED surface for addons that build their
own slots and it has to exist whether or not any window of ours uses it.
**The bank arm is NOT here, and this is the omission, not an oversight** (1134 §4).
`BankItemButtonGenericTemplate` inherits `BankItemButtonTemplate` (BankFrame.xml l.-), whose
handlers — `BankFrameItemButton_OnLoad`, `_OnEvent`, `BankFrameItemButtonGeneric_OnClick` —
live in `BankFrame.lua`, which is NOT sourced, and whose OnEnter calls `this:GetInventorySlot()`,
a widget method we do not bind. That is the real unit of work the paragraph above thought it was
describing. pfUI reaches it only for `bag == -1`, so its bag arm works and its bank arm raises
where it raises today.
**The bank arm resolves too, since 1751's second window.** `BankItemButtonGenericTemplate`
inherits `BankItemButtonTemplate`, whose handlers live in `BankFrame.lua` — and that file
arrives through `Interface\FrameXML\BankFrame.xml`'s own `<Script file=>`, off the chain, the
same way. This paragraph used to say the arm was a named omission (1134 §4) whose real cost was
the unsourced Lua plus `this:GetInventorySlot()`, a widget method we do not bind. Neither is
true any more: the Lua is sourced, and `GetInventorySlot` is not a widget method at all — it is
a plain field `BankFrameBaseButton_OnLoad` assigns (`this.GetInventorySlot =
ButtonInventorySlot`). pfUI reaches the bank arm for `bag == -1`, and it now finds a real
template.
-->
<Ui xmlns="http://www.blizzard.com/wow/ui/">
<Script><![CDATA[
@@ -363,7 +363,7 @@
end
-- pin §2's $parentCheck: the per-row watch checkbox. SetTexture(path)/
-- SetTexture(nil) as the show/hide toggle — the icon-slot blanking pattern
-- (`BenillaBagSlot_Update` etc.). (Regions DO have Show/Hide these days —
-- (the icon-clearing every slot repaint does). (Regions DO have Show/Hide these days —
-- region.rs; this site just predates it and the pattern is equivalent.)
if not isHeader and IsQuestWatched(index) then
button.check:SetTexture("Interface\\Buttons\\UI-CheckBox-Check")
@@ -1318,7 +1318,7 @@
-- publish, state slots don't).
self.foldHighlight = self:GetHighlightTexture()
-- Both buttons must reach OnClick (the default set is LeftButtonUp only) — the same
-- convention `BagFrame.xml`'s `BenillaBagSlot_OnLoad` uses for its own left/right split.
-- convention the reference's own `BankFrameBaseButton_OnLoad` uses for its left/right split.
self:RegisterForClicks("LeftButtonUp", "RightButtonUp")
</OnLoad>
<OnClick>BenillaQuestLogTitle_OnClick(self, arg1)</OnClick>
+4 -4
View File
@@ -31,16 +31,16 @@
- The ref's shift-click "insert the item link into the chat editbox" (StackSplitFrame isn't
even opened in that branch — ContainerFrame.lua:568-569 diverts to
`ChatFrameEditBox:Insert` instead of `OpenStackSplitFrame`) never triggers here: the
shift-click gesture itself has no plumbing in this engine (BagFrame.xml's own comment on
`BenillaBagSlot_OnClick` — no modifier-key global exists at all), so there is no path that
shift-click gesture itself has no plumbing in this engine (no modifier-key global exists
at all), so there is no path that
reaches this branch to defer. `EditBox:Insert` DOES exist on our engine (editbox.rs) for
when that gap closes.
-->
<Script><![CDATA[
-- OpenStackSplitFrame(maxStack, owner, anchor, anchorTo) — ref StackSplitFrame.lua:2-24,
-- verbatim shape. `owner` must already carry `owner.SplitStack = function(owner, n) ... end`
-- (BagFrame.xml's `BenillaBagSlot_OnClick` sets it right before calling this, mirroring
-- ContainerFrameItemButton_OnClick's own closure). maxStack < 2 hides instead of showing (a
-- (the reference's own `ContainerFrameItemButton_OnClick` and `BankFrameItemButton_OnLoad`
-- both set it, and both are sourced since 1751). maxStack < 2 hides instead of showing (a
-- 1-stack has nothing to split) — the ref's own guard.
function OpenStackSplitFrame(maxStack, owner, anchor, anchorTo)
local frame = StackSplitFrame
+30
View File
@@ -1624,6 +1624,36 @@
hideOnEscape = 1,
}
-- The bank-slot purchase confirm (ref StaticPopup.lua l.47-60), transcribed verbatim.
--
-- **It is HERE because its window is the reference's own file now** (1751). Every other
-- dialog in this client is declared by the window that shows it; the bank window is
-- `Interface\FrameXML\BankFrame.xml`, executed off the player's chain, and the reference
-- keeps this entry in `StaticPopup.lua` — the one file benilla replaces with the engine
-- above. So the house's StaticPopup home carries it, and `BankFramePurchaseButton`'s
-- `StaticPopup_Show("CONFIRM_BUY_BANK_SLOT")` finds it. Left out, the Purchase button
-- would be a silent no-op.
--
-- The coin row is real (1580 made `hasMoneyFrame` live): `BankFrame.nextSlotCost` is set
-- by the reference's own `UpdateBagSlotStatus` off `GetBankSlotCost`, and this OnShow is
-- the reference's own line for reading it.
CONFIRM_BUY_BANK_SLOT = CONFIRM_BUY_BANK_SLOT
or "Do you want to purchase a bank slot for:"
StaticPopupDialogs["CONFIRM_BUY_BANK_SLOT"] = {
text = CONFIRM_BUY_BANK_SLOT,
button1 = YES,
button2 = NO,
OnAccept = function()
PurchaseSlot()
end,
OnShow = function()
MoneyFrame_Update(this:GetName() .. "MoneyFrame", BankFrame.nextSlotCost)
end,
hasMoneyFrame = 1,
timeout = 0,
hideOnEscape = 1,
}
-- ref UIParent.lua l.324-343 + l.356-360, the four arms on one driver. Each equip arm
-- hides the OTHER dialog first — the two questions are mutually exclusive by construction
-- (one item, one destination), and the hide is what cancels the record the new question
+27 -10
View File
@@ -426,16 +426,33 @@ ItemTextFrame.xml
# v1 uses plain-text costs, not MerchantFrame's coin helpers) and before the other NPC
# session windows below.
TrainerFrame.xml
# The bank window (decision 0604 phase 4): the vault, purchase ladder, and the 6 bank-bag
# popouts, over the container feed already carrying container -1/5..10 (ui_items.rs) +
# the bank Lua surface (benilla-ui/src/script/bank.rs) + crate::ui_bank's feed/drain.
# Loaded AFTER MerchantFrame.xml (reuses its BenillaMoney_* coin helpers for the purchase
# row + purse) and AFTER BagFrame.xml, already above (its BenillaBagSlotTemplate/
# BenillaBagWindowTemplate + the BenillaBagSlot_*/BenillaBagFrame_* function family are
# reused verbatim — the 24 generic slots and the 6 bank-bag popouts are, respectively, a
# container-slot grid and ordinary container windows, exactly like the bag arc's own).
# Kept in the "NPC session windows grouped" cluster beside Trainer/Taxi.
BankFrame.xml
# ══ THE BANK WINDOW — the reference's own, off the player's chain (decision 1751, window 2) ══
# `BankFrame`, `BankFrameItem1..24`, `BankFrameBag1..6`, `BankFramePurchaseInfo` and the
# `BANK_CONTAINER`/`NUM_BAG_SLOTS`/`NUM_BANKGENERIC_SLOTS`/`NUM_BANKBAGSLOTS` constants — the real
# 1.12 file, running as the file, with `BankFrame.lua` arriving through its own `<Script file=>`.
# Our own 890-line BankFrame.xml is gone with it, and so are its six popout windows: a bank bag is
# an ordinary `ContainerFrame` at container id 5..10, which is what the reference always did.
#
# THE POSITION IS THE REQUIREMENT, as it was for ContainerFrame.xml. This file's `inherits=` reach
# four templates above it — `ItemButtonTemplate`, `UIPanelCloseButton` (UIPanelTemplates.xml),
# `UIPanelButtonTemplate` (same), `SmallMoneyFrameTemplate` (MoneyFrame.xml) — plus
# `parent="UIParent"`, and its `OnHide` calls `updateContainerFrameAnchors` out of
# ContainerFrame.lua. All are loaded above.
#
# What it needed from this client, all built for it: `PutItemInBag`/`PickupBagFromSlot` (the two
# verbs its bag buttons ARE), the bank-bag inventory band at live ids 64..=69 that gives those
# verbs an item to move, `GetInventorySlotInfo("Bag1".."Bag6")` for the empty-slot art, and
# `StaticPopupDialogs["CONFIRM_BUY_BANK_SLOT"]` re-homed into UiPanels.xml (the reference keeps it
# in StaticPopup.lua, which this client replaces).
#
# One reference quirk carried rather than fixed: `BankFramePurchaseButton` is declared
# `virtual="true"` INSIDE `<Frames>`. Only a top-level element is ever a template — nested nodes
# are materialized regardless (`framexml.rs`) — so the button exists and works, which is what the
# real client does too. Classic Era's Vanilla BankFrame.xml still carries the same attribute on
# the same button, and that button works there.
#
# Kept in the "NPC session windows grouped" cluster beside Trainer/Taxi, and after MerchantFrame.
Interface\FrameXML\BankFrame.xml
# The taxi-map window (decision 0484 phase 2): the flight-master session, driven by the
# taxi engine seam (`benilla-ui/src/script/taxi.rs`) over `crate::ui_taxi`'s feed/drain.
# Loaded right after TrainerFrame per the same "NPC session windows grouped" posture — it
+1 -1
View File
@@ -189,7 +189,7 @@ fn surface_report(deep: bool, dump_path: Option<String>) {
//
// Split again on the `Benilla` prefix, which is the difference between a name that CAN collide
// with an addon's expectations and one that cannot. Nothing in the 1.12 corpus feature-tests
// `BenillaBagSlot_OnClick`; our own namespace is safe by construction, and leaving 443 of those
// `BenillaPaperDollSlot_OnClick`; our own namespace is safe by construction, and leaving 443 of those
// in the list would bury the ones that are not.
if let Some(fns) = extra_by_kind.get("function") {
let (ours_ns, unprefixed): (Vec<&&str>, Vec<&&str>) = fns
+36 -19
View File
@@ -341,18 +341,20 @@ pub(super) fn seed_ui_fixture(
crate::net::SelfPlayer,
crate::net::Guid(PLAYER_GUID),
));
// Open the session — `feed_bank` fires BANKFRAME_OPENED (title + portrait + the 0561
// OpenBackpack contract) on the next frames exactly as a live SMSG_SHOW_BANK would.
// Open the session — `feed_bank` fires BANKFRAME_OPENED (title + portrait) on the
// next frames exactly as a live SMSG_SHOW_BANK would.
bank.open(NPC_GUID);
// One-shot popout: click bank bag 1 open as soon as its container feed lands (the
// click path needs `GetContainerNumSlots(5) > 0`, which arrives over the settle
// frames — the window's OnUpdate polls, flag-guarded, exactly once).
// One-shot popout: open bank bag 1 as soon as its container feed lands (the click
// path needs `GetContainerNumSlots(5) > 0`, which arrives over the settle frames —
// the window's OnUpdate polls, flag-guarded, exactly once). `BankFrameBag1`'s own
// `GetID()` is 5, the container id, which is what its handler passes to `ToggleBag`.
if let Some(s) = script.as_mut() {
if let Err(e) = s.run(
"BankFrame:SetScript(\"OnUpdate\", function()\n\
if not benillaBankPopoutDone and GetContainerNumSlots(5) > 0 then\n\
benillaBankPopoutDone = 1\n\
BenillaBankBagButton_OnClick(getglobal(\"BankBagButton1\"))\n\
this = getglobal(\"BankFrameBag1\")\n\
BankFrameItemButtonBag_OnClick(\"LeftButton\")\n\
end\n\
end)",
) {
@@ -742,11 +744,21 @@ pub(super) fn seed_ui_fixture(
..Default::default()
},
);
// Force the tooltip open over the top-left bag button (BenillaBagSlot16 ⇒ game slot 1,
// the green Small Shield) via the same OnEnter path a hover fires — deterministic, no
// synthetic mouse. A top-left seat keeps the ANCHOR_RIGHT tooltip on-screen; the
// engine's auto-size pass settles it over the capture window.
if let Err(e) = script.run("BenillaBagSlot_OnEnter(getglobal(\"BenillaBagSlot16\"))") {
// Force the tooltip open over the top-left bag button (`Item16` ⇒ game slot 1, the
// green Small Shield — see `seed_bag_window` on the backwards numbering) via the same
// OnEnter path a hover fires — deterministic, no synthetic mouse. A top-left seat keeps
// the ANCHOR_RIGHT tooltip on-screen; the engine's auto-size pass settles it over the
// capture window.
//
// **Which window the backpack landed in is ASKED, never assumed**: the reference
// recycles twelve `ContainerFrame`s across every container, so the index depends on
// what else is open. `IsBagOpen` is its own published scan.
if let Err(e) = script.run(
"local i = IsBagOpen(0)\n\
if i then\n\
ContainerFrameItemButton_OnEnter(getglobal(\"ContainerFrame\"..i..\"Item16\"))\n\
end",
) {
warn!("capture: ui-tooltip seed failed to open the tooltip: {e}");
}
}
@@ -1435,9 +1447,11 @@ fn seed_bag_window(
};
// ~5 items scattered across the 16 slots, a mix of stacked and unstacked, under their real
// vanilla template ids (the Tooltip fixture seeds a full template view for the hovered one).
// Game slot 1 (rendered top-left, button BenillaBagSlot16) carries the green-quality Small
// Shield the Tooltip fixture hovers — a top-left seat keeps its ANCHOR_RIGHT tooltip
// on-screen.
// Game slot 1 carries the green-quality Small Shield the Tooltip fixture hovers, and it is
// rendered TOP-LEFT: `ContainerFrame_GenerateFrame` numbers backwards (`index = size - j + 1`)
// and anchors `Item1` at the window's BOTTOMRIGHT, filling leftwards then upwards — so for a
// 16-slot bag the top-left button is `Item16` and it carries slot 1. A top-left seat keeps its
// ANCHOR_RIGHT tooltip on-screen.
let mut slots = std::collections::HashMap::new();
slots.insert(1, slot(DISP_SHIELD, 1, 2362, "Small Shield", 2));
slots.insert(3, slot(DISP_FOOD, 5, 117, "Tough Jerky", 1));
@@ -1454,8 +1468,11 @@ fn seed_bag_window(
);
// Player money nonzero so the purse renders all three denominations (1g 23s 45c).
script.set_money(12_345);
// Open + paint the window (BenillaBagFrame_Update fills the slots + the purse).
if let Err(e) = script.run("BenillaBagFrame_Update(); getglobal(\"BenillaBagFrame\"):Show()") {
// Open the window with the reference's own verb — it builds and paints one in the same call
// (`ToggleBag` → `OpenBag` → `ContainerFrame_GenerateFrame`), which is why nothing has to
// repaint it afterwards. `OpenBag(0)` rather than `OpenBackpack()`: only the backpack is fed
// here, and 0561 makes the latter open every equipped bag.
if let Err(e) = script.run("OpenBag(0)") {
warn!("capture: bag window seed failed: {e}");
}
@@ -1591,9 +1608,9 @@ fn seed_equipped_bags(
slots,
}),
);
if let Err(e) = script.run(&format!(
"local f = getglobal(\"BenillaBagFrame{bag_id}\"); BenillaBagWindow_Update(f); f:Show()"
)) {
// The reference's own verb, which builds the window as it opens it — and it must run
// AFTER `set_container`, since `OpenBag` refuses a container with no slots.
if let Err(e) = script.run(&format!("OpenBag({bag_id})")) {
warn!("capture: equipped bag {bag_id} seed failed: {e}");
}
}
+10
View File
@@ -991,6 +991,16 @@ pub(crate) enum ClientCommand {
src_bag: u8,
src_slot: u8,
},
/// Auto-store an item into a bag (`CMSG_AUTOSTORE_BAG_ITEM`): take `(src_bag, src_slot)` and
/// put it anywhere inside `dst_bag` — **the client names no destination slot** (wow-re
/// `bag-verbs-law.md`: AUTOSTORE carries none). Sent by `PutItemInBag`'s third leg (an
/// ordinary item dropped on an occupied bag button) and by all of `PutItemInBackpack`.
/// Refusals surface as `InventoryFailure` events.
AutoStoreBagItem {
src_bag: u8,
src_slot: u8,
dst_bag: u8,
},
/// Carry a partial stack from one bag position to another (`CMSG_SPLIT_ITEM`, decision 0216
/// §6) — either endpoint may be an equipped bag, unlike [`ClientCommand::SwapInvItem`]. The
/// container-move drain sends this instead of a swap when the queued move carries a split
+5
View File
@@ -758,6 +758,11 @@ fn writer_loop(
src_bag,
src_slot,
} => w.swap_item(dst_bag, dst_slot, src_bag, src_slot),
ClientCommand::AutoStoreBagItem {
src_bag,
src_slot,
dst_bag,
} => w.auto_store_bag_item(src_bag, src_slot, dst_bag),
ClientCommand::SplitItem {
src_bag,
src_slot,
+18 -2
View File
@@ -225,9 +225,25 @@ pub(super) fn control(
})
})
.unwrap_or((false, false));
// The shared precondition `0x5144e0`, assembled once for this tick the way the reference
// evaluates it once for the mover — health above, and the far-sight conjunct here.
//
// **Conjunct 5** is `!(IsActivePlayer(mover) && [mover+0x1c70] & 1)`: while your view is out on
// a far-sight object you may not drive your own body. Both halves matter. The latch half is the
// **resolved** subject and not the raw `PLAYER_FARSIGHT` field, because `0x5ee290` sets the
// latch only on its post-resolve ENGAGE leg; the active-player half is `foreign_mover.is_none()`,
// and it is what keeps Mind Control working — possession sets the very same field, so without
// it the victim would be frozen (wow-re §6.2 and `farsight-and-client-control.md` §2.1).
let mover = state::MoverInput {
dead,
view_is_out: state::view_is_out(
player.foreign_mover.is_none(),
view_subject.remote.is_some(),
),
};
// `0x5145b0`, evaluated here because the first thing it suppresses is the mouse turn below. Its
// translate sibling waits until after `apply_server_moves`, where the root edge it reads lands.
let may_turn = state::may_turn(dead, stunned);
let may_turn = mover.may_turn(stunned);
// Drunkenness (B210): this frame's wobble angle, computed once — the facing veer and the
// swim-pitch porpoise ([`swim::drive_step`]) both read it. Zero while sober (`wobble` early-outs on a 0.0
// fraction), and zero whenever the turn predicate is down — the reference's wobble sits behind
@@ -447,7 +463,7 @@ pub(super) fn control(
// latch with its verified cancel set — all of it decoded once in [`input::move_axes`].
// `0x514560`, evaluated now — after `apply_server_moves`, so a root edge that landed this
// frame is already in `player.modes` and not read a frame late.
let may_translate = state::may_translate(dead, player.modes.rooted);
let may_translate = mover.may_translate(player.modes.rooted);
let axes = input::move_axes(
binds,
&buttons,
+26 -10
View File
@@ -259,9 +259,17 @@ pub(super) struct FollowInput<'w, 's> {
/// which is deliberate and harmless: the cancel then lands on the frame *after* the look
/// begins, versus the reference's same-frame commit.
rig: Res<'w, CameraControl>,
/// The **mover's** descriptor block, for the one term of the cancel set that is not an input:
/// its health. See [`FollowInput::mover_dead`].
/// The **mover's** descriptor block, for the terms of the cancel set that are not inputs:
/// its health and its stun. See [`FollowInput::input_torn_down`].
mover: Query<'w, 's, &'static crate::net::ObjectStore, With<crate::net::Embodied>>,
/// What the camera orbits ([`super::view_subject`]) — the resolved half of the shared
/// precondition's far-sight conjunct, which takes both predicates down and so reaches the
/// teardown leg on its own (decision 1761).
///
/// Read possibly one frame behind, like `rig` above and for the same benign reason: the
/// publisher and this system are both merely `.before(control)` and are unordered against each
/// other, so a follow engaged and far-sighted on the very same frame ends on the next one.
view_subject: Res<'w, super::view_subject::ViewSubject>,
}
impl FollowInput<'_, '_> {
@@ -284,7 +292,7 @@ impl FollowInput<'_, '_> {
}
/// Has the input tick reached its **teardown leg** — the one that cancels click-to-move and the
/// follow and fires `AUTOFOLLOW_END` ([`super::state::input_torn_down`])? Not an input, but the
/// follow and fires `AUTOFOLLOW_END` ([`super::state::MoverInput::torn_down`])? Not an input, but the
/// member of the cancel set that is a *state*, and the one benilla had wrong in both directions.
///
/// `rf86-autofollow-cancel-set.md` §5 named the mechanism as the health test at `0x5144f8`, and
@@ -293,13 +301,20 @@ impl FollowInput<'_, '_> {
/// so a pure ROOT — which benilla cancelled on — does **not** end a follow in the reference, and
/// death — which benilla did not cancel on, so a follow taken into death kept steering the
/// corpse's facing every frame — does. Ice Block, root and stun together, ends it as well.
fn input_torn_down(&self, rooted: bool) -> bool {
///
/// **Far sight ends it too**, and for the same structural reason death does rather than as a
/// case of its own: the far-sight conjunct sits in the precondition the two predicates *share*,
/// so engaging it takes both down at once and lands on this leg.
fn input_torn_down(&self, rooted: bool, driving_own_body: bool) -> bool {
self.mover.single().is_ok_and(|s| {
super::state::input_torn_down(
s.0.unit_is_dead(),
rooted,
s.0.unit_flags() & super::UNIT_FLAG_STUNNED != 0,
)
super::state::MoverInput {
dead: s.0.unit_is_dead(),
view_is_out: super::state::view_is_out(
driving_own_body,
self.view_subject.remote.is_some(),
),
}
.torn_down(rooted, s.0.unit_flags() & super::UNIT_FLAG_STUNNED != 0)
})
}
}
@@ -341,7 +356,8 @@ pub(super) fn steer_follow(
// is benilla's own and predates this: it is not one of `0x5144e0`'s conjuncts (conjunct 4 is
// the Knockdown lockout, not a taxi test — wow-re §6.2), and nothing in this round bears on
// it either way, so it is left exactly as it stood.
input.input_torn_down(player.modes.rooted) || player.server_riding(),
input.input_torn_down(player.modes.rooted, player.foreign_mover.is_none())
|| player.server_riding(),
) {
info!("follow: cancelled by the player's own movement input");
follow.stop();
+197 -83
View File
@@ -473,93 +473,147 @@ impl MoveModes {
/// `0x5144fd`. (The field identity is **VERIFIED** three independent ways in §6.1 — the
/// descriptor-base arithmetic, the `UnitHealth` registrar pair `.data 0x850510 → 0x5174d0`, and
/// the low-health warning's `fild [eax+0x40]; fidiv [eax+0x58]`. `rf79`'s INFERRED label is
/// retired.)
/// retired.) — the [`MoverInput::dead`] field;
/// 3. the CMovement-aux gate `[[mover+0x118]+0xa4]` null-or-`&4` (`0x514516`);
/// 4. `!0x60f5b0(obj)` — the **KNOCKDOWN animation lockout**, and *not* an on-taxi predicate: it
/// returns `AnimationData.dbc` column 3 bit `0x80`, which the shipped table sets on exactly one
/// of 208 rows, id 121 `Knockdown` (§6.2's census). It contributes nothing while dead;
/// 5. `!(IsActivePlayer(mover) && [mover+0x1c70] & 1)` — the **far-sight ENGAGED latch**
/// (`0x5ee3f6` sets it, `0x5ee4c8` clears it, both inside the far-sight machine `0x5ee290`), and
/// *not* a charm bit: **while your view is out on a far-sight object you may not drive your own
/// body** — though you may still drive a *possessed* one, since `IsActivePlayer` is then false.
/// 5. `!(IsActivePlayer(mover) && [mover+0x1c70] & 1)` — the **far-sight ENGAGED latch**, the
/// [`MoverInput::view_is_out`] field.
///
/// Conjuncts 4 and 5 are **not modelled here** — named, verified, and unbuilt (decision 1753's tail);
/// they are their own behaviours with their own retests, not part of the death gate. Conjunct 1 is
/// structural: [`super::controller::control`] returns before the axes on `control_lost` and on
/// `reseat`, the frames where the mover would not resolve. Conjunct 3 has no benilla analog yet.
/// **Conjuncts 1, 3 and 4 have no field here.** Conjunct 1 is structural:
/// [`super::controller::control`] returns before the axes on `control_lost` and on `reseat`, the
/// frames where the mover would not resolve. Conjunct 3 has no benilla analog yet. Conjunct 4 is
/// named, verified and unbuilt — its own behaviour with its own retest — and this struct is where
/// it lands when it is built.
///
/// **The health term is the one this function exists for.** benilla modelled the server's root on
/// death (0308) and nothing else, and a root deliberately leaves turning live (0872), so a corpse on
/// the ground could still be spun with the turn keys or a right-drag. That is decision 1753.
///
/// `dead` is health `== 0` off the **mover's** descriptor
/// ([`benilla_protocol::messages::update_object::ObjectFields::unit_is_dead`]) — the mover's and
/// not ours, because `0x5144e0` reads `[esi+0x110]` where `esi` is whatever we are driving
/// (decision 1277's possessed creature). **A ghost is not dead by this test:** the server sets a
/// released player's health to 1 (0308 §the release), so `0x5144fd`'s `jg` is taken and the ghost
/// gets every input back. Nothing in the input path, the emitters or the send gates reads the ghost
/// bit at all — a band census finds **zero** `PLAYER`-block reads in the whole InputControl and
/// CMovement address ranges (§6.6). The ghost bit *is* read in the collision layer (it adds trace
/// mask bit `0x8000` at `0x631658`), which is a separate, unbuilt finding.
fn mover_input_ready(dead: bool) -> bool {
!dead
/// Carried as a **value** rather than as loose booleans threaded through each predicate because
/// that is the reference's own shape (one precondition, evaluated once for the mover, handed to
/// both), and because two same-typed terms passed positionally would transpose silently.
#[derive(Clone, Copy, Debug, Default)]
pub(crate) struct MoverInput {
/// **Conjunct 2, the health term** — health `== 0` off the **mover's** descriptor
/// ([`benilla_protocol::messages::update_object::ObjectFields::unit_is_dead`]). The mover's and
/// not ours, because `0x5144e0` reads `[esi+0x110]` where `esi` is whatever we are driving
/// (decision 1277's possessed creature).
///
/// **This is the term the struct exists for.** benilla modelled the server's root on death
/// (0308) and nothing else, and a root deliberately leaves turning live (0872), so a corpse on
/// the ground could still be spun with the turn keys or a right-drag — decision 1753.
///
/// **A ghost is not dead by this test:** the server puts a released player's health at 1
/// (0308 §the release), so `0x5144fd`'s `jg` is taken and the ghost gets every input back.
/// Nothing in the input path, the emitters or the send gates reads the ghost bit at all — a
/// band census finds **zero** `PLAYER`-block reads across the whole InputControl and CMovement
/// ranges (§6.6). The ghost bit *is* read in the collision layer, where it adds trace mask bit
/// `0x8000` (`0x631658`) — a separate finding, and not this one.
pub(crate) dead: bool,
/// **Conjunct 5, the far-sight term** — `!(IsActivePlayer(mover) && [mover+0x1c70] & 1)`, held
/// here in the affirmative: *my view is out on a far-sight object, and the body I would be
/// driving is my own*. While that holds the reference refuses **both** predicates — you may
/// neither walk nor turn your own body while you are looking through Mind Vision. (wow-re
/// §6.2. Both of this conjunct's labels were CORRECTED on 2026-08-31: they previously read
/// "charmed", and had never been derived.)
///
/// **Both halves are load-bearing, and `IsActivePlayer` is the one that would be easy to
/// drop.** `0x514537 call 0x5fa6d0` tests the *mover* against the active player and returns the
/// conjunct satisfied the moment they differ (`0x51453e je 0x514550`), so the latch is only
/// ever read for our own body. Mind Control rides the same `PLAYER_FARSIGHT` field the camera
/// does ([`super::view_subject`]), so during a possession the latch is engaged — and without
/// this half the victim would freeze solid, which is the one body the whole spell exists to
/// walk around. It is also why the latch is never read off a creature's descriptor, where
/// `+0x1c70` is not a field at all.
///
/// **Engaged means the subject RESOLVED, not merely that the field arrived.** The far-sight
/// machine `0x5ee290` sets the latch only on its ENGAGE leg (`0x5ee3f6 or ecx,1`), reached
/// after `0x5ee2ef` resolves the guid; the *field non-zero but object unresolved* leg falls
/// through to `0x5ee32d` — camera home, pending cast cancelled, `return 0` — and sets nothing
/// (wow-re `object-layer/scratch/farsight-and-client-control.md` §2.1). So the benilla analog
/// is the **resolved** [`super::view_subject::ViewSubject`] subject, not the raw
/// `PLAYER_FARSIGHT` field: through the ~400 ms vmangos defers the set by, the body is still
/// ours to drive.
pub(crate) view_is_out: bool,
}
/// **`0x514560` — "may this unit translate?"**, the ROOT predicate. Consumed by the input tick at
/// `0x5146c1` (`test bl,bl`), the sole gate on the forward/back emitter `0x514da0` and the strafe
/// emitter `0x514e80`. Its own terms past the shared precondition are `MOVEMENTFLAGS & 0x1200`
/// (`0x51458c test dh,0x12` — our `rooted`) and stand state `!= 7` (`0x514591`), which vmangos
/// never writes and which is therefore inert here.
/// **Conjunct 5's two halves, composed** — `IsActivePlayer(mover) && [mover+0x1c70] & 1`, the
/// value [`MoverInput::view_is_out`] carries and the field's doc explains in full.
///
/// Note the health term is tested **twice** — once inside `0x5144e0` and again at `0x51457c` on the
/// predicate's own path. Death is not a corner of this gate; it is its first question.
pub(crate) fn may_translate(dead: bool, rooted: bool) -> bool {
mover_input_ready(dead) && !rooted
/// A named function and not an inline `&&` at the one call site, because the half that is easy to
/// lose is `driving_own_body`: drop it and Mind Control freezes its victim, which is a bug nothing
/// about far sight would lead you to look for.
///
/// - `driving_own_body` — the mover is our own character, i.e. no possession is in force
/// (`0x514537 call 0x5fa6d0`, the active-player test on the *mover*).
/// - `far_sight_engaged` — our view is out on a far-sight object we have actually **resolved**
/// (`[mover+0x1c70] & 1`, which the machine `0x5ee290` sets only on its post-resolve ENGAGE leg).
pub(crate) fn view_is_out(driving_own_body: bool, far_sight_engaged: bool) -> bool {
driving_own_body && far_sight_engaged
}
/// **`0x5145b0` — "is this unit not stunned?"**, the STUN predicate. Consumed at `0x514755`
/// (`je 0x51479c`), which skips the turn emitter `0x514f50` and the pitch emitter `0x515010`
/// outright *and* force-stops either already in flight. Each emitter has exactly one caller,
/// immediately behind that gate, and no data or vtable reference anywhere in the image — so while
/// this predicate is false **no keyboard or mouse turn can reach `CMovement::StartTurn 0x7c6d90`
/// by any route** (VERIFIED, wow-re `local-move-input-gate.md` §4).
///
/// Its own term past the shared precondition is `UNIT_FIELD_FLAGS & 0x40000` — a descriptor read,
/// not an aura and not a movement flag (decision 0872). **And because the precondition is shared,
/// a dead body is "stunned" as far as this predicate is concerned**: that single fact is why the
/// reference refuses to turn a corpse.
///
/// **The MOUSE turn reaches the same answer down a different path** (§6.4, and this corrects what
/// 1753 first wrote). The right-drag handler is `0x514400`, called from the mouse-MOVE handler
/// `0x492c00`; its body hand-off `0x51447b call 0x5103e0` is gated at `0x514474` by a *third*
/// predicate, **`0x5145e0`**, whose first act is to call this one — so the health term arrives
/// through `0x5145b0` → `0x5144e0` and short-circuits before `0x5145e0` reaches its own remaining
/// conjuncts (`[input+4] & 1`, and `GetStandState() == 0` via the CGPlayer vtable slot `0x5ed570`).
/// The refusal is then **triple-redundant**: the two downstream commit gates `0x5151b0` (yaw, at
/// `0x5151e6`) and `0x515250` (pitch, at `0x515283`) each carry their own health test. A closed
/// census of all nine call sites of the body-facing setters `0x60de30`/`0x60de70` — neither of which
/// has any dword reference image-wide — finds every one health-gated.
///
/// So one term in one place really does fix the keys and the mouse together, but not because they
/// share `0x514755`: they share `0x5144e0`.
pub(crate) fn may_turn(dead: bool, stunned: bool) -> bool {
mover_input_ready(dead) && !stunned
}
impl MoverInput {
/// `0x5144e0` itself — every conjunct we model, and the answer both predicates start from.
fn ready(self) -> bool {
!self.dead && !self.view_is_out
}
/// **The input tick's teardown leg** — `0x5146d6 call 0x60fb60(0, 1)`, which cancels click-to-move
/// and `/follow` and fires `AUTOFOLLOW_END` (event `0x170`).
///
/// It is reached only when **BOTH** predicates are down: `0x5146c3 jne` not taken (`bl == 0`) *and*
/// `0x5146ce jne` not taken (`[ebp+0xf] == 0`). That is the correction wow-re's §6.3 makes to
/// `rf86-autofollow-cancel-set.md` §5, which named the health test alone — necessary, but not enough
/// to name the leg. **A pure ROOT does not cancel a follow** (translate down, turn still up), and
/// neither does a pure stun; **death does**, because it takes both. Ice Block, which grants root and
/// stun together, does too.
///
/// benilla had the root alone in this position and so cancelled on a Frost Nova, which the reference
/// does not (decision 1753).
pub(crate) fn input_torn_down(dead: bool, rooted: bool, stunned: bool) -> bool {
!may_translate(dead, rooted) && !may_turn(dead, stunned)
/// **`0x514560` — "may this unit translate?"**, the ROOT predicate. Consumed by the input tick
/// at `0x5146c1` (`test bl,bl`), the sole gate on the forward/back emitter `0x514da0` and the
/// strafe emitter `0x514e80`. Its own terms past the shared precondition are `MOVEMENTFLAGS &
/// 0x1200` (`0x51458c test dh,0x12` — our `rooted`) and stand state `!= 7` (`0x514591`), which
/// vmangos never writes and which is therefore inert here.
///
/// Note the health term is tested **twice** — once inside `0x5144e0` and again at `0x51457c` on
/// the predicate's own path. Death is not a corner of this gate; it is its first question.
pub(crate) fn may_translate(self, rooted: bool) -> bool {
self.ready() && !rooted
}
/// **`0x5145b0` — "is this unit not stunned?"**, the STUN predicate. Consumed at `0x514755`
/// (`je 0x51479c`), which skips the turn emitter `0x514f50` and the pitch emitter `0x515010`
/// outright *and* force-stops either already in flight. Each emitter has exactly one caller,
/// immediately behind that gate, and no data or vtable reference anywhere in the image — so
/// while this predicate is false **no keyboard or mouse turn can reach `CMovement::StartTurn
/// 0x7c6d90` by any route** (VERIFIED, wow-re `local-move-input-gate.md` §4).
///
/// Its own term past the shared precondition is `UNIT_FIELD_FLAGS & 0x40000` — a descriptor
/// read, not an aura and not a movement flag (decision 0872). **And because the precondition is
/// shared, a dead body is "stunned" as far as this predicate is concerned**: that single fact is
/// why the reference refuses to turn a corpse.
///
/// **The MOUSE turn reaches the same answer down a different path** (§6.4, and this corrects
/// what 1753 first wrote). The right-drag handler is `0x514400`, called from the mouse-MOVE
/// handler `0x492c00`; its body hand-off `0x51447b call 0x5103e0` is gated at `0x514474` by a
/// *third* predicate, **`0x5145e0`**, whose first act is to call this one — so the health term
/// arrives through `0x5145b0` → `0x5144e0` and short-circuits before `0x5145e0` reaches its own
/// remaining conjuncts (`[input+4] & 1`, and `GetStandState() == 0` via the CGPlayer vtable slot
/// `0x5ed570`). The refusal is then **triple-redundant**: the two downstream commit gates
/// `0x5151b0` (yaw, at `0x5151e6`) and `0x515250` (pitch, at `0x515283`) each carry their own
/// health test. A closed census of all nine call sites of the body-facing setters
/// `0x60de30`/`0x60de70` — neither of which has any dword reference image-wide — finds every one
/// health-gated.
///
/// So one term in one place really does fix the keys and the mouse together, but not because
/// they share `0x514755`: they share `0x5144e0`.
pub(crate) fn may_turn(self, stunned: bool) -> bool {
self.ready() && !stunned
}
/// **The input tick's teardown leg** — `0x5146d6 call 0x60fb60(0, 1)`, which cancels
/// click-to-move and `/follow` and fires `AUTOFOLLOW_END` (event `0x170`).
///
/// It is reached only when **BOTH** predicates are down: `0x5146c3 jne` not taken (`bl == 0`)
/// *and* `0x5146ce jne` not taken (`[ebp+0xf] == 0`). That is the correction wow-re's §6.3 makes
/// to `rf86-autofollow-cancel-set.md` §5, which named the health test alone — necessary, but not
/// enough to name the leg. **A pure ROOT does not cancel a follow** (translate down, turn still
/// up), and neither does a pure stun; **death does**, because it takes both. Ice Block, which
/// grants root and stun together, does too. So does the far-sight term, for the same reason
/// death does — it is the other conjunct of the shared precondition.
///
/// benilla had the root alone in this position and so cancelled on a Frost Nova, which the
/// reference does not (decision 1753).
pub(crate) fn torn_down(self, rooted: bool, stunned: bool) -> bool {
!self.may_translate(rooted) && !self.may_turn(stunned)
}
}
/// **The two incapacitate suppressions, applied to a freshly built move-flag word** (decision 0880)
@@ -593,7 +647,7 @@ pub(crate) fn input_torn_down(dead: bool, rooted: bool, stunned: bool) -> bool {
///
/// The two arguments are therefore not "rooted" and "stunned" but **the two predicates being down**
/// ([`may_translate`], [`may_turn`]) — a wider set by exactly one member: **death drops both**,
/// through the precondition they share ([`mover_input_ready`], decision 1753). A corpse streams
/// through the precondition they share ([`MoverInput`], decision 1753). A corpse streams
/// neither a direction bit nor a turn bit.
pub(crate) fn incapacitated_flags(flags: u32, translate_gated: bool, turn_gated: bool) -> u32 {
use crate::creature_anim::move_flags as f;
@@ -1498,8 +1552,8 @@ mod autorun_tests {
#[cfg(test)]
mod move_mode_tests {
use super::{
incapacitated_flags, input_torn_down, may_translate, may_turn, MoveModes, Player,
FEATHER_TERMINAL_VELOCITY, GRAVITY, HOVER_CLIMB_RATE, HOVER_HEIGHT, TERMINAL_VELOCITY,
incapacitated_flags, view_is_out, MoveModes, MoverInput, Player, FEATHER_TERMINAL_VELOCITY,
GRAVITY, HOVER_CLIMB_RATE, HOVER_HEIGHT, TERMINAL_VELOCITY,
};
use crate::creature_anim::move_flags as f;
use benilla_protocol::MoveMode;
@@ -1739,7 +1793,13 @@ mod move_mode_tests {
#[test]
fn death_drops_both_movement_input_predicates() {
// (dead, rooted, stunned) -> (may_translate, may_turn)
let gate = |dead, rooted, stunned| (may_translate(dead, rooted), may_turn(dead, stunned));
let gate = |dead, rooted, stunned| {
let m = MoverInput {
dead,
view_is_out: false,
};
(m.may_translate(rooted), m.may_turn(stunned))
};
assert_eq!(
gate(false, false, false),
@@ -1777,23 +1837,77 @@ mod move_mode_tests {
#[test]
fn only_both_predicates_down_tears_the_follow_down() {
assert!(
!input_torn_down(false, true, false),
!MoverInput::default().torn_down(true, false),
"a PURE root does NOT end a follow — translate is down, the turn is still up"
);
assert!(
!input_torn_down(false, false, true),
!MoverInput::default().torn_down(false, true),
"and neither does a pure stun — the emitter never consults `0x5145b0`"
);
assert!(
input_torn_down(true, false, false),
MoverInput {
dead: true,
..Default::default()
}
.torn_down(false, false),
"DEATH ends it, with nothing granted: it takes both predicates down by itself"
);
assert!(
input_torn_down(false, true, true),
MoverInput::default().torn_down(true, true),
"and so does Ice Block, which is root and stun at once"
);
}
/// **Far sight freezes your own body — and never a possessed one** (conjunct 5 of
/// `0x5144e0`). While your view is out on a far-sight object you may neither walk nor turn:
/// the term sits in the *shared* precondition, so it takes both predicates exactly the way
/// death does.
///
/// The second half is the one worth a test of its own. Mind Control rides the same
/// `PLAYER_FARSIGHT` field the camera does, so during a possession the latch is engaged — and
/// the reference reads it only after `0x514537`'s active-player test on the **mover** has
/// already passed. Model the latch without that test and the victim freezes solid, which is the
/// one body the whole spell exists to walk around.
#[test]
fn far_sight_freezes_your_own_body_but_never_a_possessed_one() {
let driving = |driving_own_body, far_sight_engaged| {
let m = MoverInput {
dead: false,
view_is_out: view_is_out(driving_own_body, far_sight_engaged),
};
(m.may_translate(false), m.may_turn(false))
};
assert_eq!(
driving(true, false),
(true, true),
"my own body, no far sight: the ordinary frame, both predicates pass"
);
assert_eq!(
driving(true, true),
(false, false),
"MIND VISION — my view is out and the body is mine, so `0x5144e0` fails and takes the \
walk and the turn together, exactly as death does"
);
assert_eq!(
driving(false, true),
(true, true),
"MIND CONTROL — the latch is engaged (possession sets the same field), but the mover \
is not the active player, so `0x51453e` returns the conjunct satisfied and the victim \
stays drivable. Dropping the `IsActivePlayer` half freezes it."
);
assert!(
MoverInput {
dead: false,
view_is_out: true,
}
.torn_down(false, false),
"and it ends a follow, with neither a root nor a stun anywhere — `0x5146d6` needs both \
predicates down, and a term in the precondition they SHARE is both by construction. \
Engaging Mind Vision mid-follow stops you following, for the same reason dying does."
);
}
/// **The two incapacitate suppressions take exactly their own bits** (decision 0880) — the
/// difference between a pure root and a stun, which is the whole distinction B179 was drawing,
/// expressed on the one word that drives both the animation and the wire.
+16 -45
View File
@@ -24,9 +24,6 @@ use bevy::prelude::*;
use benilla_ui::script::{BankState, ScriptValue, UiScript};
use crate::entities::ItemDisplays;
use crate::items::Items;
use crate::names::NameCache;
use crate::net::{ClientCommand, NetCommands, ObjectStore, SelfPlayer};
use crate::ui_script::UiInput;
use crate::ui_session::{close_npc_session_out_of_range, npc_switched, NpcSession};
@@ -118,20 +115,20 @@ fn bank_slot_error_text(result: u32) -> Option<String> {
}
}
/// Push the purchase-row snapshot + the open/close/purchased-count events (module doc). The
/// banker's name rides the `BANKFRAME_OPENED` event arg (the merchant feed's title pattern —
/// ask-once through the [`NameCache`], `BANKFRAME_UPDATE` is not a real event, so a late name
/// lands via a fresh `BANKFRAME_OPENED` only on re-open; the title shows "Banker" until then).
/// Push the purchase-row snapshot + the open/close/purchased-count events (module doc).
///
/// **`BANKFRAME_OPENED` carries no arguments**, which is a change 1751's swap forced and a
/// correction either way: the banker's name used to ride it as `arg1` (the merchant feed's title
/// pattern), and the reference's own `BankFrame_OnEvent` reads no `arg1` at all — it titles the
/// window with `UnitName("npc")`, off the interaction token [`crate::ui_session`] already points
/// at the banker. Firing an argument the reference does not fire is a divergence an addon can
/// see, and it bought nothing once the reference's file was the one reading the event.
#[allow(clippy::too_many_arguments)]
fn feed_bank(
script: Option<NonSendMut<UiScript>>,
open: Res<BankOpen>,
self_q: Query<&ObjectStore, With<SelfPlayer>>,
prices: Option<Res<BankPrices>>,
mut items: ResMut<Items>,
icons: Option<Res<ItemDisplays>>,
commands: Res<NetCommands>,
mut names: ResMut<NameCache>,
mut errors: ResMut<BankErrors>,
mut last: Local<crate::ui_script::VmMemo<Option<BankState>>>,
mut last_banker: Local<crate::ui_script::VmMemo<Option<u64>>>,
@@ -151,38 +148,13 @@ fn feed_bank(
let purchased = store
.and_then(|s| s.0.player_bank_bag_slots_purchased())
.unwrap_or(0);
let fresh = open.banker.map(|_| {
// The bag buttons' icons: each held bank bag's item → template → icon, the container
// feed's own resolution (ask-once; `None` while the answer is in flight or slot empty).
let mut bag_textures: [Option<String>; 6] = Default::default();
if let Some(store) = store {
for (i, tex) in bag_textures.iter_mut().enumerate() {
*tex = store
.0
.player_bank_bag_slot(i as u8)
.filter(|g| *g != 0)
.and_then(|guid| {
let entry = items.object(guid)?.object_entry()?;
let display_id = items.template(entry, guid, &commands)?.display_info_id;
icons
.as_deref()
.and_then(|ic| ic.catalog.get(display_id))
.and_then(|d| d.icon.clone())
});
}
}
BankState {
num_purchased: u32::from(purchased),
next_cost: prices
.as_ref()
.and_then(|p| p.0.next_slot_price(purchased))
.unwrap_or(0),
bag_textures,
}
let fresh = open.banker.map(|_| BankState {
num_purchased: u32::from(purchased),
next_cost: prices
.as_ref()
.and_then(|p| p.0.next_slot_price(purchased))
.unwrap_or(0),
});
let banker_name = open
.banker
.and_then(|g| names.resolve(g, &commands).map(str::to_string));
// A different banker while the window is already open is a real close+open (decision 0096 /
// [`npc_switched`]) — both sounds, and the OnHide-queued close intent is consumed so the
// drain doesn't clear the session we just re-opened.
@@ -191,14 +163,13 @@ fn feed_bank(
return;
}
script.set_bank(fresh.clone());
let name_arg = || vec![ScriptValue::Str(banker_name.clone().unwrap_or_default())];
if switched {
script.fire_event("BANKFRAME_CLOSED", vec![]);
script.fire_event("BANKFRAME_OPENED", name_arg());
script.fire_event("BANKFRAME_OPENED", vec![]);
let _ = script.take_bank_close();
} else {
match (&*last, &fresh) {
(None, Some(_)) => script.fire_event("BANKFRAME_OPENED", name_arg()),
(None, Some(_)) => script.fire_event("BANKFRAME_OPENED", vec![]),
// The purchased count moved while open — the no-packet buy confirmation (decision
// 0604): the reference event repaints the bag row + purchase frame.
(Some(_), Some(_)) => script.fire_event("PLAYERBANKBAGSLOTS_CHANGED", vec![]),
+81 -36
View File
@@ -42,8 +42,8 @@ use bevy::prelude::*;
use benilla_protocol::messages::PLAYER_SKILL_SLOTS;
use benilla_ui::script::{
weapon_subclass_skill, InvSlotView, InventorySlots, ScriptValue, SkillEntry, SkillsState,
UiScript, UnitCombatStats, EQUIPMENT_BAG, SKILL_DEFENSE, SKILL_UNARMED,
weapon_subclass_skill, BankBagSlots, InvSlotView, InventorySlots, ScriptValue, SkillEntry,
SkillsState, UiScript, UnitCombatStats, EQUIPMENT_BAG, SKILL_DEFENSE, SKILL_UNARMED,
};
use crate::entities::ItemDisplays;
@@ -87,6 +87,7 @@ struct CharFeedState {
struct CharFeedMemo {
last_stats: Option<UnitCombatStats>,
last_inv: Option<InventorySlots>,
last_bank_bags: Option<BankBagSlots>,
/// The gate's counter memories (1439) — the stores whose lazy resolves poison `is_changed`
/// for this feed (the equipment templates' ask-once, the enchant-name creator lookups).
items_objects: gate::Watch,
@@ -538,21 +539,25 @@ fn combat_stats(store: &ObjectStore, items: &mut Items, commands: &NetCommands)
/// equip_slots from the ask-once template (icon `None` while the answer is in flight — the
/// empty-slot art shows and the landing template flips the view, which fires
/// `UNIT_INVENTORY_CHANGED`), `locked` from the app's `PendingItemOps` (decision 0208 phase 1b —
/// the same feed `ui_items::feed_containers` reads for a bag slot's own `.locked`). `slot0` is
/// the wire `EQUIPMENT_SLOT_*`/`INV_SLOT` index (0..22 — equipment 0..18, the four equipped-bag
/// icons 19..22); the live-API id `slot_view` reports through is `slot0 + 1` either way.
/// the same feed `ui_items::feed_containers` reads for a bag slot's own `.locked`).
///
/// Takes the item's **guid and its live-API id** rather than a wire slot index, because the two
/// bands that use it resolve their guid out of two different descriptor arrays: the doll's
/// `PLAYER_FIELD_INV_SLOT_*` (live id = wire slot + 1, so equipment 1..=19 and the four
/// equipped-bag icons 20..=23) and the bank bags' own `PLAYER_FIELD_BANK_BAG_SLOT_*` (live ids
/// 64..=69). Everything past the guid — template, enchants, durability, the pending lock — is
/// identical for both, which is the whole reason there is one function here and not two.
#[allow(clippy::too_many_arguments)] // the slot resolve's full read set — the bag feed's twin
fn slot_view(
store: &ObjectStore,
items: &mut Items,
icons: Option<&ItemDisplays>,
rolls: crate::items::RollCatalogs,
commands: &NetCommands,
pending: &PendingItemOps,
names: &mut crate::names::NameCache,
slot0: u8,
guid: u64,
live_id: u32,
) -> Option<InvSlotView> {
let guid = store.0.player_inv_slot(slot0)?;
// The temp-enchant countdowns, read before the object borrow (both live on `Items`) — the bag
// feed's twin.
let enchant_ms: [Option<u64>; 7] =
@@ -635,7 +640,6 @@ fn slot_view(
let icon = icons
.and_then(|i| i.catalog.get(display))
.and_then(|d| d.icon.clone());
let live_id = u32::from(slot0) + 1;
Some(InvSlotView {
item_id: entry,
icon,
@@ -738,36 +742,60 @@ fn inventory_slots(
enchants: Vec::new(),
});
}
for slot in 1..=19u8 {
inv[usize::from(slot)] = slot_view(
store,
items,
icons,
rolls,
commands,
pending,
names,
slot - 1,
);
}
// Bag0Slot..Bag3Slot (ids 20..23, PaperDollItemFrame.dbc-verified) — the bag bar's icon
// source: the equipped bag ITEM at INV_SLOT 19..22 (BAG_SLOT_FIRST../SLOT_BAG_FIRST in the
// container feed's own numbering), same `slot_view` resolution as any equipment slot.
for (i, slot) in (20u8..=23).enumerate() {
inv[usize::from(slot)] = slot_view(
store,
items,
icons,
rolls,
commands,
pending,
names,
19 + i as u8,
);
// Equipment 1..=19, then Bag0Slot..Bag3Slot (ids 20..23, PaperDollItemFrame.dbc-verified) —
// the bag bar's icon source: the equipped bag ITEM at INV_SLOT 19..22 (BAG_SLOT_FIRST../
// SLOT_BAG_FIRST in the container feed's own numbering). One band: the doll's live id is its
// wire slot plus one across the whole range.
for live in 1..=23u32 {
let Some(guid) = store.0.player_inv_slot(live as u8 - 1).filter(|g| *g != 0) else {
continue;
};
inv[live as usize] = slot_view(items, icons, rolls, commands, pending, names, guid, live);
}
inv
}
/// The six bank-bag slots' views (live ids 64..=69) — the same [`slot_view`] resolution as any
/// doll slot, off the descriptor's own `PLAYER_FIELD_BANK_BAG_SLOT_*` guids.
///
/// These are inventory slots, not bank-*window* state: the reference's bank paints and picks up a
/// bank bag through `GetInventoryItemTexture`/`PickupBagFromSlot` at
/// `BankButtonIDToInvSlotID(i, 1)` (BankFrame.lua:28-35, 194-198), and the guids stream in the
/// player descriptor whether or not a banker is open. So they ride the inventory feed, beside the
/// doll snapshot, rather than the `BankState` the bank window pushes.
#[allow(clippy::too_many_arguments)] // [`slot_view`]'s read set, one band over
fn bank_bag_slots(
store: &ObjectStore,
items: &mut Items,
icons: Option<&ItemDisplays>,
rolls: crate::items::RollCatalogs,
commands: &NetCommands,
pending: &PendingItemOps,
names: &mut crate::names::NameCache,
) -> BankBagSlots {
let mut bags: BankBagSlots = Default::default();
for (i, bag) in bags.iter_mut().enumerate() {
let Some(guid) = store.0.player_bank_bag_slot(i as u8).filter(|g| *g != 0) else {
continue;
};
*bag = slot_view(
items,
icons,
rolls,
commands,
pending,
names,
guid,
BANK_BAG_LIVE_FIRST + i as u32,
);
}
bags
}
/// `BankButtonIDToInvSlotID(1, isBag)` — the live-API id of the FIRST bank-bag slot. The band is
/// six wide (`BANK_BAG_SLOT_COUNT`).
const BANK_BAG_LIVE_FIRST: u32 = 64;
/// 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.
///
@@ -1002,14 +1030,31 @@ fn feed_char(
&pending,
&mut names,
);
if memo.last_inv.as_ref() != Some(&inv) {
let bank_bags = bank_bag_slots(
store,
&mut items,
icons.as_deref(),
crate::items::RollCatalogs {
enchants: enchants.as_deref(),
props: props.as_deref(),
},
&commands,
&pending,
&mut names,
);
// One transition for both bands: they are the same descriptor read at two offsets, and
// `UNIT_INVENTORY_CHANGED` is the one repaint signal either has. Pushing them separately
// would fire it twice for a single wire update.
if memo.last_inv.as_ref() != Some(&inv) || memo.last_bank_bags.as_ref() != Some(&bank_bags) {
gate.audit("feed_char", "the inventory snapshot");
script.set_inventory_slots(inv.clone());
script.set_bank_bag_slots(bank_bags.clone());
script.fire_event(
"UNIT_INVENTORY_CHANGED",
vec![ScriptValue::Str("player".to_string())],
);
memo.last_inv = Some(inv);
memo.last_bank_bags = Some(bank_bags);
}
}
+136 -4
View File
@@ -99,6 +99,13 @@
//! that answers term 1. (1) is [`track_instance_state`]'s `CurrentMap` flip; (2) is
//! [`apply::update_last_instance`]'s queue, drained through the same helper so the two cannot
//! drift — [`LatchWriter`] is which of them is speaking.
//!
//! **(2) is therefore unreachable on our server by construction, and no live probe can cover it**
//! — not with any rigging, because `PermBindAllPlayers` (vmangos's only path to a permanent bind)
//! runs under `if (IsRaid())` and no GM command creates a bind. Its cover is
//! `both_latch_writers_through_the_real_system`, which drives both legs through the real system
//! against the real `Map.dbc`. That is the whole of the evidence behind (2): treat it as tested,
//! never as *observed*.
use benilla_protocol::messages::{InstanceResetFailure, RaidInstanceMessage, RaidInstanceWarning};
use benilla_ui::script::UiScript;
@@ -128,10 +135,10 @@ pub(crate) struct InstanceState {
/// benilla's own half of the same term: we watched the player walk out of a party dungeon, so
/// we know they are bound to one without being told (decision 1754).
///
/// Raised by [`LatchWriter::WorldEntry`], cleared by `SMSG_INSTANCE_RESET`, and deliberately
/// **not** cleared by an `owns_saved = false` packet — that packet is the wrong answer this
/// field exists to work around, and vmangos sends one on the very teleport that takes us out
/// of the dungeon.
/// Raised by [`LatchWriter::WorldEntry`], cleared by `SMSG_INSTANCE_RESET` and by a **logout**
/// ([`clear_witness_on_logout`]), and deliberately **not** cleared by an `owns_saved = false`
/// packet — that packet is the wrong answer this field exists to work around, and vmangos
/// sends one on the very teleport that takes us out of the dungeon.
saw_own_dungeon: bool,
/// `0xb4e374` — the `Map.dbc` id of the dungeon we were last inside, or `None` for "none
/// recorded". Only a **party dungeon** ever lands here (`0x495d33`'s `cmp [rec+8],1`).
@@ -226,6 +233,12 @@ impl InstanceState {
self.last_dungeon_at = 0;
// A reset landed, so whatever we witnessed ourselves is gone with it. The reference has
// no equivalent line because its term 1 is the server's to retract.
self.forget_witness();
}
/// Drop benilla's own half of term 1 — see [`clear_witness_on_logout`]. Kept a method so the
/// one piece of state no server will correct has exactly one place that clears it.
pub(crate) fn forget_witness(&mut self) {
self.saw_own_dungeon = false;
}
@@ -598,6 +611,24 @@ fn drain_instance(script: Option<NonSendMut<UiScript>>, commands: Res<NetCommand
/// The lockout family: the four chat lines, the bookkeeping behind the SELF menu's reset row, and
/// that row's one send.
/// Drop the witness at logout — the next login is somebody else's character (`poi_marker`'s own
/// shape, and the same reason).
///
/// Only [`InstanceState::saw_own_dungeon`] is cleared, not the reference's three globals: the
/// reference does not clear those either (`0x495d00`'s only caller is the `SMSG_INSTANCE_RESET`
/// handler), and it does not need to — its term 1 is the server's, re-advertised on the next
/// character's world entry, so a stale latch cannot offer that character a reset on its own. Ours
/// is the half no server will correct, so it gets the lifetime the packet would have given it
/// (decision 1754).
fn clear_witness_on_logout(
mut state: ResMut<InstanceState>,
mut logged_out: MessageReader<crate::net::LoggedOutMessage>,
) {
if logged_out.read().next().is_some() {
state.forget_witness();
}
}
pub(crate) struct UiInstancePlugin;
impl Plugin for UiInstancePlugin {
@@ -607,6 +638,7 @@ impl Plugin for UiInstancePlugin {
(
// The latch first: `feed_instance` publishes `CanShowResetInstances()` off it, and
// a map change and its answer should not be a frame apart.
clear_witness_on_logout.before(track_instance_state),
track_instance_state.before(feed_instance),
feed_instance.before(UiInput),
drain_instance.after(UiInput),
@@ -923,6 +955,106 @@ mod tests {
assert!(!state.can_reset(Some(0), &dungeons, 100));
}
/// The witness does not outlive the character that earned it (`clear_witness_on_logout`).
///
/// The reference can leave its stale latch lying about because term 1 is the server's and the
/// next character's world entry re-answers it. Ours is the half nothing corrects, so a logout
/// has to, or character B is offered a reset that character A walked out of.
#[test]
fn the_witness_does_not_survive_a_logout() {
let dungeons = |m: u32| m == 36;
let mut state = InstanceState::default();
state.note_last_instance(36, Some(0), true, 100, LatchWriter::WorldEntry);
assert!(state.can_reset(Some(0), &dungeons, 100));
state.forget_witness();
assert!(!state.can_reset(Some(0), &dungeons, 100));
// The reference's own three globals are untouched — we clear our addition, not its state.
assert_eq!(state.last_dungeon, Some(36));
assert_eq!(state.last_dungeon_at, 100);
}
/// **Both latch writers, driven through the real system, against the real `Map.dbc`.**
///
/// This is the only cover writer 2 — `SMSG_UPDATE_LAST_INSTANCE` — can have. vmangos can
/// never exercise it: `SendSavedInstances` names only `perm` binds, `DungeonMap::Add` takes a
/// non-perm bind for a 5-man, and `PermBindAllPlayers` (the one path that makes a bind
/// permanent) runs under `if (IsRaid())`, so the only map ids that packet can ever carry are
/// raids — which `0x495d33`'s `cmp [rec+8],1` throws away. No GM command creates a bind
/// either (`.instance` offers listbinds/unbind/groupunbind, nothing that binds). So the packet
/// leg is unreachable on our server by construction, and a live probe cannot cover it however
/// the character is rigged (decision 1754's open thread).
///
/// It runs both legs through `track_instance_state` itself rather than the methods, because
/// the thing worth guarding is the wiring: that the queue drains against the map we ended up
/// on rather than the one we left, and that the packet writer does **not** raise the witness
/// (which would hand 1754's deviation to a server's say-so). Skips without client data.
#[test]
fn both_latch_writers_through_the_real_system() {
let data = benilla_formats::wow_data_or_skip!();
let mut chain = benilla_formats::open_chain(&data).expect("open chain");
let maps = benilla_formats::load_map_catalog(&mut chain).expect("Map.dbc");
assert!(maps.is_party_dungeon(36), "Deadmines is InstanceType 1");
assert!(
!maps.is_party_dungeon(409),
"Molten Core is a raid, not a dungeon"
);
// The system carries the last observed map in a `Local`, so it has to be the SAME system
// across ticks — `run_system_once` hands out a fresh one each call and would never see a
// transition at all.
let mut app = App::new();
app.init_resource::<InstanceState>()
.init_resource::<Time<Real>>()
.insert_resource(benilla_assets::MapCatalogRes(maps))
.insert_resource(benilla_world::world_map::CurrentMap(36))
.add_systems(Update, track_instance_state);
let state = |app: &App| {
let s = app.world().resource::<InstanceState>();
(s.last_dungeon, s.saw_own_dungeon)
};
let queue = |app: &mut App, map: u32| {
apply::update_last_instance(&mut app.world_mut().resource_mut::<InstanceState>(), map);
};
// Writer 1 — we walk out of the Deadmines ourselves. The first tick only OBSERVES map 36
// (there is no map we left at login), so nothing is recorded until the flip.
app.update();
assert_eq!(state(&app), (None, false));
app.insert_resource(benilla_world::world_map::CurrentMap(0));
app.update();
assert_eq!(
state(&app),
(Some(36), true),
"the flip records the dungeon AND our own eyes (1754's term 1)"
);
// Writer 2 — the packet, on a fresh state. A raid id is dropped (the only thing vmangos
// could ever send); a party dungeon lands, and lands WITHOUT the witness.
app.insert_resource(InstanceState::default());
queue(&mut app, 409);
app.update();
assert_eq!(state(&app), (None, false), "a raid is not a party dungeon");
queue(&mut app, 36);
app.update();
assert_eq!(
state(&app),
(Some(36), false),
"the packet is the server's word, never our own eyes"
);
// …and the packet's own early-out: the instance we are STANDING IN is not one we left.
app.insert_resource(InstanceState::default());
app.insert_resource(benilla_world::world_map::CurrentMap(36));
app.update();
queue(&mut app, 36);
app.update();
assert_eq!(state(&app), (None, false));
}
/// `CanShowResetInstances()`'s four terms, one at a time.
#[test]
fn can_reset_needs_all_four_terms() {
+62
View File
@@ -124,6 +124,68 @@ pub(super) fn drain_container_autoequips(
}
}
/// Drain the auto-stores `PutItemInBag`/`PutItemInBackpack` queued (`benilla_ui`'s
/// `cursor::bag_verbs`) and send them on the wire.
///
/// **The destination is a BAG, not a slot** — that is the finding this drain exists to carry
/// (wow-re `bag-verbs-law.md`): `CMSG_AUTOSTORE_BAG_ITEM` names `(srcbag, srcslot, dstbag)` and
/// the server picks where inside it the item lands, which is why an ordinary item dropped on a
/// bag BUTTON goes in the bag rather than swapping with it. The destination bag byte is
/// [`wire_pos`]'s own answer for that container's first slot (255 for the backpack, the bag's
/// player-array slot 19..22 / 63..68 for an equipped or bank bag), so one map serves both ends.
///
/// A **split carry** takes `CMSG_SPLIT_ITEM` instead — the reference's own fork on `[0xb4b40c]`
/// — with the literal `0xFF` where a destination slot would go, because that wire has a slot
/// field and this one does not.
///
/// No pending-lock recording, matching [`drain_container_autoequips`]'s own precedent for the same
/// class of send: the destination is not a slot, so there is no second end to lock.
pub(super) fn drain_bag_autostores(
script: Option<NonSendMut<UiScript>>,
commands: Res<NetCommands>,
) {
let Some(mut script) = script else {
return;
};
for a in script.take_bag_autostores() {
let (Some((src_bag, src_slot)), Some((dst_bag, _))) =
(wire_pos(a.src_bag, a.src_slot), wire_pos(a.dst_bag, 1))
else {
debug!("ui_items: bag autostore {a:?} out of range — ignored");
continue;
};
match a.count {
None => {
debug!(
"ui_items: autostore lua {}/{} → bag {} (wire {src_bag}/{src_slot} → {dst_bag})",
a.src_bag, a.src_slot, a.dst_bag
);
let _ = commands.0.send(ClientCommand::AutoStoreBagItem {
src_bag,
src_slot,
dst_bag,
});
}
Some(n) => {
let count = n.min(u32::from(u8::MAX)) as u8;
debug!(
"ui_items: autostore split lua {}/{} → bag {} × {count} (wire {src_bag}/{src_slot} → {dst_bag})",
a.src_bag, a.src_slot, a.dst_bag
);
let _ = commands.0.send(ClientCommand::SplitItem {
src_bag,
src_slot,
dst_bag,
// `0xFF` — the reference's own literal for "no destination slot"; the split
// wire has the field, the verb has no value for it.
dst_slot: 0xFF,
count,
});
}
}
}
}
/// Drain the inventory-slot ids `UseInventoryItem` queued (decision 0208 phase 1b: the doll
/// slot's right-click) and route the equipped position (bag 255 plus the 0-based wire slot —
/// `HandleUseItemOpcode` takes equipped positions the same as bag ones, vmangos `ItemHandler.cpp`)
+3 -2
View File
@@ -53,8 +53,8 @@ pub(crate) mod feed;
pub(crate) use drain::send_auto_equip;
use drain::{
drain_container_autoequips, drain_container_destroys, drain_container_moves,
drain_container_uses, drain_inventory_uses,
drain_bag_autostores, drain_container_autoequips, drain_container_destroys,
drain_container_moves, drain_container_uses, drain_inventory_uses,
};
use feed::{
feed_containers, feed_item_sets, feed_item_stats, feed_player_req, feed_random_properties,
@@ -1112,6 +1112,7 @@ impl Plugin for UiItemsPlugin {
drain_container_destroys.after(UiInput),
// AutoEquipCursorItem's queue (decision 0208 phase 1b) → CMSG_AUTOEQUIP_ITEM.
drain_container_autoequips.after(UiInput),
drain_bag_autostores.after(UiInput),
// UseInventoryItem's queue (decision 0208 phase 1b) → CMSG_USE_ITEM against
// the equipped position.
drain_inventory_uses.after(UiInput),
@@ -778,7 +778,8 @@ fn readable_letter_hover_shows_the_inspect_magnifier() {
}
/// The drag trio (decision 0216 §3): a real press-drag-release across two slot buttons routes
/// through the SAME `BenillaBagSlot_OnClick("LeftButton")` path a two-click pickup/place does —
/// through the SAME `ContainerFrameItemButton_OnClick("LeftButton")` path a two-click pickup/place
/// does —
/// unlike every other bag test here, which calls the Lua click handler directly, this one drives
/// actual `mouse_button`/`mouse_move` so the `RegisterForDrag`/`OnDragStart`/`OnReceiveDrag` XML
/// wiring itself is under test, not just the handler body.
@@ -1330,7 +1331,7 @@ fn a_plain_click_hides_an_open_split_frame() {
/// Bag-slot item cooldowns through the shipped XML (decision 0263's deferral): a potion mid-
/// cooldown pushes its triple with the container snapshot; opening the bag runs the ref's
/// occupied-slot fork (`BenillaBagSlot_UpdateCooldown` → `GetContainerItemCooldown` →
/// occupied-slot fork (`ContainerFrame_UpdateCooldown` → `GetContainerItemCooldown` →
/// `CooldownFrame_SetTimer`) and the slot grows a live sweep; a `BAG_UPDATE_COOLDOWN` refresh
/// with the cooldown gone hides it again.
#[test]
+172 -102
View File
@@ -1,24 +1,28 @@
//! The shipped **bank window** driven end-to-end, engine-only (no Bevy): the real
//! `assets/ui/BankFrame.xml` loaded behind the bag chain (the coin rig, our bag bar, and the
//! reference's own `ContainerFrame.xml` off the player's install) — decision 0604 phase 4's
//! machine checks. Split from `merchant_tests`/`bag_tests` along the folder's one-file-per-window
//! convention.
//! The **bank window** driven end-to-end, engine-only (no Bevy): the reference's own
//! `Interface\FrameXML\BankFrame.xml`, executed off the player's patch chain behind the bag
//! chain — decision 0604 phase 4's machine checks, re-pointed at the real file by 1751 (window 2).
//! Split from `merchant_tests`/`bag_tests` along the folder's one-file-per-window convention.
//!
//! **What decision 1751 changed in here.** The bank used to own six popout windows of its own —
//! `BenillaBankBagFrame1..6`, six copies of `BagFrame.xml`'s window template — and the tests drove
//! them by name. They are gone: a bank bag is an ordinary `ContainerFrame` at container id
//! `NUM_BAG_SLOTS + slot` (5..10), which is what the real client always did and what decision 0604
//! already said it did. So the popout assertions below ask [`bag_open`] — the reference's own
//! `IsBagOpen` scan — instead of naming a window. The twelve windows are RECYCLED across every
//! container, so which `ContainerFrame<N>` a bank bag lands in is a coincidence and never an
//! assertion.
//! **What decision 1751 changed in here, in two passes.** First the bank lost six popout windows
//! of its own — `BenillaBankBagFrame1..6`, six copies of `BagFrame.xml`'s window template — because
//! a bank bag is an ordinary `ContainerFrame` at container id `NUM_BAG_SLOTS + slot` (5..10), which
//! is what the real client always did and what 0604 already said it did. Then the window itself
//! went, and every name below is the reference's: `BankFrameItem1..24`, `BankFrameBag1..6`,
//! `BankFramePurchaseButton`, `BankFrameDetailMoneyFrame`.
//!
//! Two consequences worth stating, because both are places a test could quietly assert our old
//! behaviour instead of the client's:
//!
//! · **The bag buttons' `GetID()` is the CONTAINER id, 5..10** — not a bag number. That is what
//! `ButtonInventorySlot` hands `BankButtonIDToInvSlotID`, and what `ToggleBag` takes.
//! · **Opening the bank does NOT open your bags.** Decision 0561 (a window that opens your bags
//! opens every equipped one) is ours and stays for the vendor; the bank goes faithful, and the
//! reference's `BankFrame` OnShow plays one sound and nothing else. The director's call.
//!
//! The knock-on for the fixtures: the reference generates a window only for a container that
//! exists (`OpenBag`'s `size > 0` gate), where our own `BenillaBagFrame*` were static frames that
//! `Show()` regardless. So a test that wants the backpack open has to feed a backpack, and one
//! that wants a bank bag open has to feed a bag at id 5 — which is the honest fixture anyway: a
//! player always has a 16-slot backpack, and an empty bank-bag slot has no window in the real
//! client either.
//! `Show()` regardless. So a test that wants a bank bag open has to feed a bag at id 5 — which is
//! the honest fixture anyway: an empty bank-bag slot has no window in the real client either.
use benilla_ui::script::{
BankState, ContainerSlot, ContainerState, ExtractedQuad, QuadContent, ScriptValue,
@@ -39,10 +43,14 @@ fn empty_bag(name: &str, num_slots: u32) -> Option<ContainerState> {
/// The bank's own dependency chain, in `benilla.toc` order: [`BAG_UI`] carries it whole — Fonts
/// before anything with a font `inherits`, the four templates stock `ContainerFrame.xml` inherits,
/// UiPanels before any UIPanel/StaticPopup use, GameTooltip before any slot's hover, the
/// reference's own container file, our bag bar, and `BankFrame.xml` itself (which BAG_UI already
/// needs, because `updateContainerFrameAnchors` measures every open bag against
/// `BankFrame:GetRight()`). Then `MerchantFrame.xml` for the `BenillaMoney_*` coin rig the bank's
/// purse and cost rows call — a global resolved at call time, so it may land after.
/// reference's own container file, our bag bar, and the reference's own `BankFrame.xml` (which
/// BAG_UI already needs, because `updateContainerFrameAnchors` measures every open bag against
/// `BankFrame:GetRight()`).
///
/// `MerchantFrame.xml` used to load here too, for the `BenillaMoney_*` coin rig our own bank
/// window's purse and cost rows called. The reference's rows are `SmallMoneyFrameTemplate`
/// instances off `MoneyFrame.xml`, which BAG_UI already carries — so the merchant is not a bank
/// dependency any more.
///
/// **Every caller needs client data**: `BAG_UI` names a chain entry, so each test below opens with
/// `wow_data_or_skip!`.
@@ -52,7 +60,6 @@ fn setup() -> UiScript {
for file in BAG_UI {
load_xml(&s, file);
}
load_xml(&s, "MerchantFrame.xml");
s
}
@@ -62,24 +69,31 @@ fn has_icon(quads: &[ExtractedQuad], needle: &str) -> bool {
)
}
/// Test 1 — BANKFRAME_OPENED shows the frame (through ShowUIPanel, landing at the left slot — the same
/// NPC-session chain Merchant/Trainer use), sets the title to the event arg (falling back to
/// "Banker" before any event ever fires), and opens the backpack window (the 0561 assertion
/// pattern — `vendor_opens_and_closes_all_equipped_bags` in merchant_tests).
/// Test 1 — BANKFRAME_OPENED shows the frame (through ShowUIPanel, landing at the left slot — the
/// same NPC-session chain Merchant/Trainer use) and sets the title to the event arg.
///
/// The backpack it opens is the reference's own `ContainerFrame` now (1751), so the assertion is
/// `IsBagOpen(0)` rather than a window name — and the backpack has to be FED, because the
/// reference builds a window only for a container that exists.
/// **It does NOT open your bags, and that is the change.** Decision 0561 — a window that opens
/// your bags opens every equipped one — is this client's, and the vendor keeps it; the bank went
/// faithful with 1751's swap (the director's call), and the reference's `BankFrame` OnShow plays
/// `igMainMenuOpen` and nothing else. So the one sound is the whole sound list, which is a
/// stronger assertion than it looks: a stray `OpenBackpack` anywhere on this path would put
/// `igBackPackOpen` in front of it.
///
/// **And the title comes from `UnitName("npc")`, not from the event.** The reference's
/// `BankFrame_OnEvent` reads no `arg1` (BankFrame.lua:125), so the banker's name has to arrive on
/// the interaction token `crate::ui_session` points at the banker — which is why this test seats
/// an `"npc"` unit and why `feed_bank` stopped firing a name argument the reference never fires.
#[test]
fn bankframe_opened_shows_sets_title_and_opens_backpack() {
fn bankframe_opened_shows_and_sets_the_title() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = setup();
// Pre-open default: the XML-authored placeholder.
// The reference gives `BankFrameTitleText` no `text=` — it is empty until an event names the
// banker (our own file used to author a "Banker" placeholder).
assert_eq!(
s.eval::<String>("return BankFrameTitleText:GetText()")
s.eval::<String>("return BankFrameTitleText:GetText() or \"\"")
.unwrap(),
"Banker"
""
);
assert!(!s.eval::<bool>("return BankFrame:IsVisible()").unwrap());
@@ -88,10 +102,14 @@ fn bankframe_opened_shows_sets_title_and_opens_backpack() {
s.set_money(0);
s.set_container(0, empty_bag("Backpack", 16));
s.set_bank(Some(BankState::default()));
s.fire_event(
"BANKFRAME_OPENED",
vec![ScriptValue::Str("Grumnus Steelshaper".into())],
s.set_unit(
"npc",
Some(benilla_ui::script::UnitState {
name: Some("Grumnus Steelshaper".into()),
..Default::default()
}),
);
s.fire_event("BANKFRAME_OPENED", vec![]);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
assert!(s.eval::<bool>("return BankFrame:IsVisible()").unwrap());
@@ -101,8 +119,8 @@ fn bankframe_opened_shows_sets_title_and_opens_backpack() {
"Grumnus Steelshaper"
);
assert!(
bag_open(&s, 0),
"opening the bank opens the backpack (the 0561 OpenBackpack contract)"
!bag_open(&s, 0),
"the reference's bank leaves your bags alone (0561 is the vendor's, not the bank's)"
);
assert!(
s.eval::<bool>("return GetLeftFrame() == BankFrame")
@@ -111,11 +129,8 @@ fn bankframe_opened_shows_sets_title_and_opens_backpack() {
);
assert_eq!(
s.take_sounds(),
vec![
SoundRequest::KitName("igBackPackOpen".into()),
SoundRequest::KitName("igMainMenuOpen".into()),
],
"the backpack kit leads (OpenBackpack shows it before the panel sound), then the bank's own OnShow kit"
vec![SoundRequest::KitName("igMainMenuOpen".into())],
"the bank's own OnShow kit, and only it"
);
}
@@ -124,20 +139,18 @@ fn bankframe_opened_shows_sets_title_and_opens_backpack() {
/// sound — the reference's own OnHide order (CloseBankBagFrames(); CloseBankFrame(); PlaySound).
///
/// **"Popout" is this test's own history, not a widget any more** (1751). It was
/// `BenillaBankBagFrame1`, one of six copies of `BagFrame.xml`'s window template that this file
/// owned, and the assertion was `:Show()` it then check `:IsShown()`. A bank bag is an ordinary
/// container now — id `NUM_BAG_SLOTS + 1` = 5 — so it is OPENED with the reference's own `OpenBag`
/// and asked for with `IsBagOpen`, and `CloseBankBagFrames` is the reference's verbatim
/// `CloseBag(5..10)` loop. The property is unchanged and is the point of the test: closing the
/// bank must close the bank BAGS, not merely play a sound.
/// `BenillaBankBagFrame1`, one of six copies of `BagFrame.xml`'s window template that our own bank
/// file owned, and the assertion was `:Show()` it then check `:IsShown()`. A bank bag is an
/// ordinary container now — id `NUM_BAG_SLOTS + 1` = 5 — so it is OPENED with the reference's own
/// `OpenBag` and asked for with `IsBagOpen`, and `CloseBankBagFrames` is now the reference's own
/// `CloseBag(5..10)` loop rather than a transcription of it. The property is unchanged and is the
/// point of the test: closing the bank must close the bank BAGS, not merely play a sound.
#[test]
fn bankframe_closed_queues_close_closes_open_popouts_and_plays_the_close_kit() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = setup();
s.set_money(0);
// The backpack (the bank's own OnShow opens it) and a bag in the FIRST bank bag slot —
// container 5, which is where `BenillaBankBagButton_OnClick` sends `BankBagButton1`.
s.set_container(0, empty_bag("Backpack", 16));
// A bag in the FIRST bank bag slot — container 5, which is `BankFrameBag1`'s own `GetID()`.
s.set_container(5, empty_bag("Bank Bag", 8));
s.set_bank(Some(BankState::default()));
s.fire_event("BANKFRAME_OPENED", vec![ScriptValue::Str("Banker".into())]);
@@ -245,21 +258,30 @@ fn item_slot_paints_from_container_minus_one_and_repaints_on_playerbankslots_cha
);
}
/// Test 4 — Bag buttons: with `set_bank` num_purchased=2, buttons 1-2 read normal (white) and 3-6 read
/// the red tint (1.0, 0.1, 0.1 — the reference's exact tint); `BenillaGetBankBagTexture` drives
/// button 1's icon.
/// Test 4 — Bag buttons: with `set_bank` num_purchased=2, buttons 1-2 read normal (white) and 3-6
/// read the red tint (1.0, 0.1, 0.1 — the reference's exact tint, `UpdateBagSlotStatus`
/// BankFrame.lua:88-95); the bank-bag inventory band drives button 1's icon.
#[test]
fn bag_buttons_tint_by_purchase_count_and_texture_from_the_bank_bag_feed() {
fn bag_buttons_tint_by_purchase_count_and_texture_from_the_bank_bag_band() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = setup();
s.set_money(0);
let mut state = BankState {
s.set_bank(Some(BankState {
num_purchased: 2,
next_cost: 100_000,
}));
// Bank bag slot 1 holds a bag: an inventory slot at live id 64, which is where the
// reference's `BankFrameItemButton_OnUpdate` reads its icon from
// (`GetInventoryItemTexture("player", ButtonInventorySlot())`).
let mut bags: benilla_ui::script::BankBagSlots = Default::default();
bags[0] = Some(benilla_ui::script::InvSlotView {
item_id: 4500,
icon: Some("Interface\\Icons\\INV_Misc_Bag_08".into()),
count: 1,
equip_slots: vec![20, 21, 22, 23],
..Default::default()
};
state.bag_textures[0] = Some("Interface\\Icons\\INV_Misc_Bag_08".into());
s.set_bank(Some(state));
});
s.set_bank_bag_slots(bags);
s.fire_event("BANKFRAME_OPENED", vec![]);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
s.resolve();
@@ -294,43 +316,77 @@ fn bag_buttons_tint_by_purchase_count_and_texture_from_the_bank_bag_feed() {
(c[0] - r).abs() < 0.01 && (c[1] - g).abs() < 0.01 && (c[2] - b).abs() < 0.15
};
// Button 1: its own bag icon (BenillaGetBankBagTexture(1)), purchased -> white.
let (x1, y1) = center("BankBagButton1");
let c1 = color_at(x1, y1, "INV_Misc_Bag_08").expect("button 1 shows its fed bag icon");
// Button 1: the live inventory slot's own bag icon, purchased -> white.
let (x1, y1) = center("BankFrameBag1");
let c1 = color_at(x1, y1, "INV_Misc_Bag_08").expect("button 1 shows the banked bag's icon");
assert!(
near(c1, 1.0, 1.0, 1.0),
"purchased slot 1 stays white, got {c1:?}"
"button 1 is purchased: white {c1:?}"
);
// Button 2 (purchased, no bag fed): the fallback texture, still white.
let (x2, y2) = center("BankBagButton2");
let c2 =
color_at(x2, y2, "PaperDoll-Slot-Bag").expect("button 2 shows the empty-slot fallback");
// Button 2: purchased but empty -> the paper-doll empty bag art, still white. The art comes
// from `GetInventorySlotInfo(strsub("BankFrameBag2", 10))` — the DBC's own `"Bag2"` row.
let (x2, y2) = center("BankFrameBag2");
let c2 = color_at(x2, y2, "UI-PaperDoll-Slot-Bag").expect("button 2 shows the empty-slot art");
assert!(
near(c2, 1.0, 1.0, 1.0),
"purchased slot 2 stays white, got {c2:?}"
"button 2 is purchased: white {c2:?}"
);
// Buttons 3..6 (unpurchased): the fallback texture, tinted the reference's exact red.
for name in [
"BankBagButton3",
"BankBagButton4",
"BankBagButton5",
"BankBagButton6",
] {
let (x, y) = center(name);
let c = color_at(x, y, "PaperDoll-Slot-Bag")
.unwrap_or_else(|| panic!("{name} shows the empty-slot fallback"));
// Buttons 3..6: unpurchased -> the reference's red tint.
for i in 3..=6 {
let (x, y) = center(&format!("BankFrameBag{i}"));
let c = color_at(x, y, "UI-PaperDoll-Slot-Bag")
.unwrap_or_else(|| panic!("button {i} shows the empty-slot art"));
assert!(
near(c, 1.0, 0.1, 0.1),
"{name} (unpurchased) tints red (1.0, 0.1, 0.1), got {c:?}"
"button {i} is unpurchased: red {c:?}"
);
}
}
/// Test 5 — The purchase flow: the Purchase button's click shows the `CONFIRM_BUY_BANK_SLOT` popup; the
/// popup's accept queues `take_bank_purchase()`; the whole row hides once `num_purchased` reaches
/// 6 (`GetNumBankSlots()`'s `full`).
/// The purse row at the bottom of the window, and the director's "the gold at bottom you see
/// 97…" report.
///
/// Our own window painted the purse with `BenillaMoney_Set` into a single FontString, which is
/// what could run out of room. The reference's `BankFrameMoneyFrame` is an ordinary
/// `SmallMoneyFrameTemplate` — the same three-button gold/silver/copper kit every other window in
/// this client already uses — so a large amount splits across three buttons instead of
/// overflowing one string. Asserted on the DIGITS in each button, not on the picture: whether it
/// reads right on screen is the director's call, but "98765 / 43 / 21 in three separate buttons"
/// is a fact a test can hold.
#[test]
fn the_purse_row_splits_a_large_amount_across_three_coin_buttons() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = setup();
s.set_bank(Some(BankState::default()));
s.fire_event("BANKFRAME_OPENED", vec![]);
// 98765g 43s 21c — wider than any single-string purse could hold.
s.set_money(987_654_321);
s.fire_event("PLAYER_MONEY", vec![]);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
for (coin, want) in [("Gold", "98765"), ("Silver", "43"), ("Copper", "21")] {
assert_eq!(
s.eval::<String>(&format!(
"return BankFrameMoneyFrame{coin}ButtonText:GetText()"
))
.unwrap(),
want,
"the {coin} button's digits"
);
}
}
/// Test 5 — The purchase flow: the Purchase button's click shows the `CONFIRM_BUY_BANK_SLOT` popup;
/// the popup's accept queues `take_bank_purchase()`; the whole row hides once `num_purchased`
/// reaches 6 (`GetNumBankSlots()`'s `full`).
///
/// Two things this pins that only the swap made checkable. `BankFramePurchaseButton` is declared
/// `virtual="true"` INSIDE `<Frames>` — a reference quirk Classic Era still carries — and only a
/// TOP-LEVEL element is ever a template, so the button really exists and really clicks. And the
/// dialog itself lives in `UiPanels.xml` now: the reference keeps it in `StaticPopup.lua`, which
/// this client replaces, so without the re-home the button would be a silent no-op.
#[test]
fn purchase_flow_shows_popup_queues_the_intent_and_the_row_hides_when_full() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -339,7 +395,6 @@ fn purchase_flow_shows_popup_queues_the_intent_and_the_row_hides_when_full() {
s.set_bank(Some(BankState {
num_purchased: 2,
next_cost: 100_000,
..Default::default()
}));
s.fire_event("BANKFRAME_OPENED", vec![]);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
@@ -353,12 +408,24 @@ fn purchase_flow_shows_popup_queues_the_intent_and_the_row_hides_when_full() {
"no popup before the click"
);
s.run("BenillaBankFramePurchaseButton_OnClick()").unwrap();
s.run("BankFramePurchaseButton:Click()").unwrap();
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
assert!(
s.eval::<bool>("return StaticPopup1:IsVisible()").unwrap(),
"the click shows the confirm popup"
);
// The dialog carries the cost, which is why 1580's `hasMoneyFrame` had to be live before the
// reference's own StaticPopup entry could be re-homed into UiPanels.xml: its OnShow reads
// `BankFrame.nextSlotCost`, set by `UpdateBagSlotStatus` off `GetBankSlotCost`.
assert!(
s.eval::<bool>("return StaticPopup1MoneyFrame:IsShown()")
.unwrap(),
"the confirm shows the coin row"
);
assert_eq!(
s.eval::<i64>("return BankFrame.nextSlotCost").unwrap(),
100_000
);
assert!(!s.take_bank_purchase(), "nothing queued until accept");
s.run("StaticPopup1Button1:Click()").unwrap(); // Yes
@@ -374,7 +441,6 @@ fn purchase_flow_shows_popup_queues_the_intent_and_the_row_hides_when_full() {
s.set_bank(Some(BankState {
num_purchased: 6,
next_cost: 999_999_999,
..Default::default()
}));
s.fire_event("PLAYERBANKBAGSLOTS_CHANGED", vec![]);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
@@ -392,16 +458,20 @@ fn purchase_flow_shows_popup_queues_the_intent_and_the_row_hides_when_full() {
/// nothing, so the widget default (`{"LeftButtonUp"}`) swallowed every right-click.
///
/// It was asserted on the click sound alone, because "the one observable that does not need a bag
/// object fed into the slot" was all there was while the popout was a `BenillaBankBagFrame` this
/// window drove by hand. 1751 gave the handler a real tail — `ToggleBag(NUM_BAG_SLOTS + id)` — so
/// a bag IS fed here now and the right-click is followed all the way to the window it opens.
/// That is strictly more of the same property: the right button reaches the whole OnClick body,
/// not just its first line.
/// object fed into the slot" was all there was while the popout was a `BenillaBankBagFrame` our
/// own file drove by hand. The reference's handler has a real tail — `ToggleBag(this:GetID())` —
/// so a bag IS fed here now and the right-click is followed all the way to the window it opens.
///
/// **The sound ORDER is the reference's, and it is the reverse of ours.** Its handler opens the
/// bag FIRST and plays `BAGMENUBUTTONPRESS` after (BankFrame.lua:190-197), so the window's own
/// `igBackPackOpen` leads. Ours played the press kit first. Nothing depends on it; it is here
/// because a transcription that gets the order wrong is exactly the kind of thing the swap is
/// meant to stop having to notice.
#[test]
fn a_bank_bag_button_answers_the_right_button_too() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = setup();
// A bag in bank slot 1 ⇒ container id NUM_BAG_SLOTS + 1 = 5, the id the button toggles.
// A bag in bank slot 1 ⇒ container id 5, which IS `BankFrameBag1:GetID()`.
s.set_container(5, empty_bag("Bank Bag", 8));
s.set_bank(Some(BankState::default()));
s.fire_event("BANKFRAME_OPENED", vec![]);
@@ -410,8 +480,8 @@ fn a_bank_bag_button_answers_the_right_button_too() {
let (cx, cy): (f64, f64) = s
.eval(
"return (BankBagButton1:GetLeft() + BankBagButton1:GetRight()) / 2, \
(BankBagButton1:GetTop() + BankBagButton1:GetBottom()) / 2",
"return (BankFrameBag1:GetLeft() + BankFrameBag1:GetRight()) / 2, \
(BankFrameBag1:GetTop() + BankFrameBag1:GetBottom()) / 2",
)
.unwrap();
s.mouse_button(cx as f32, cy as f32, "RightButton", true);
@@ -420,17 +490,17 @@ fn a_bank_bag_button_answers_the_right_button_too() {
assert_eq!(
s.take_sounds(),
vec![
benilla_ui::script::SoundRequest::KitName("BAGMENUBUTTONPRESS".into()),
benilla_ui::script::SoundRequest::KitName("igBackPackOpen".into()),
SoundRequest::KitName("igBackPackOpen".into()),
SoundRequest::KitName("BAGMENUBUTTONPRESS".into()),
],
"a right-click runs the same OnClick a left-click does — the button's own press kit, \
then the window the ToggleBag tail opened"
"a right-click runs the same OnClick a left-click does — the window ToggleBag opened, \
then the button's own press kit"
);
assert!(bag_open(&s, 5), "the right-click opened the bank bag");
assert!(
s.eval::<bool>("return BankBagButton1:GetChecked() and true or false")
s.eval::<bool>("return BankFrameBag1HighlightFrameTexture:IsShown()")
.unwrap(),
"…and lit the button (UpdateBagButtonHighlight, this file's SetChecked divergence)"
"…and lit the button (UpdateBagButtonHighlight's own texture)"
);
assert!(s.errors().is_empty(), "click errors: {:?}", s.errors());
}
@@ -472,8 +542,8 @@ fn clicking_item_slot_one_with_an_empty_cursor_queues_the_pickup() {
let (cx, cy): (f64, f64) = s
.eval(
"return (BankItem1:GetLeft() + BankItem1:GetRight()) / 2, \
(BankItem1:GetTop() + BankItem1:GetBottom()) / 2",
"return (BankFrameItem1:GetLeft() + BankFrameItem1:GetRight()) / 2, \
(BankFrameItem1:GetTop() + BankFrameItem1:GetBottom()) / 2",
)
.unwrap();
s.mouse_button(cx as f32, cy as f32, "LeftButton", true);
@@ -378,12 +378,6 @@ const KNOWN: &[Known] = &[
flag: Flag::Id,
why: "-2 is KEYRING_CONTAINER, which the reference also arrives at — from Lua, not XML",
},
Known {
frame: "BankFrame",
flag: Flag::Id,
why: "id=\"-1\" is ours: the bank window's own sentinel for \"not a container slot\", \
which the reference has no equivalent of",
},
// ── parent ─────────────────────────────────────────────────────────────────────────────────
//
// Three, and each is a seat inside the SAME tree the reference seats it in — which is the
+21 -26
View File
@@ -3,27 +3,17 @@ use benilla_ui::script::{
QuadContent, SoundRequest, UiScript,
};
/// Load one shipped `assets/ui/<file>` into `s`, panicking on any loader error and returning the
/// frame count it materialized — the panel tests all load `UiPanels.xml` (decision 0084 §2's slot
/// manager) before the panel frame(s) under test, exactly as `ui_script.rs`'s own shipped-list
/// order does, so `ShowUIPanel`/`HideUIPanel` already exist when a frame's OnLoad/OnEvent
/// references them.
fn load_xml(s: &UiScript, file: &str) -> usize {
let text = std::fs::read_to_string(
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("assets/ui")
.join(file),
)
.unwrap();
let doc = benilla_ui::framexml::parse(&text).unwrap();
let report = benilla_ui::loader::load(s, &doc, &|_| None);
assert!(
report.errors.is_empty(),
"{file}: loader errors: {:?}",
report.errors
);
report.frames
}
/// Load one manifest entry into `s`, panicking on any loader error and returning the frame count
/// it materialized — the panel tests all load `UiPanels.xml` (decision 0084 §2's slot manager)
/// before the panel frame(s) under test, exactly as `benilla.toc`'s own order does, so
/// `ShowUIPanel`/`HideUIPanel` already exist when a frame's OnLoad/OnEvent references them.
///
/// This was a private disk-only reader until 1751's second window: `BankFrame.xml` is the
/// reference's own file off the player's chain now, and a reader that only knows `assets/ui`
/// cannot name it. [`super::test_ui::load_ui`] knows both stores by the manifest's own rule, so a
/// caller that names a chain entry has to open with `wow_data_or_skip!` (only the bank test below
/// does).
use super::test_ui::load_ui as load_xml;
/// Find a bare frame's own rect via its `QuadContent::Frame` entry (every frame emits one, at its
/// resolved rect, whether or not it paints anything itself — `UiScript::extract`'s doc). Used for
@@ -729,25 +719,30 @@ fn shipped_panel_slot_pushable_promotes_to_center() {
/// center-parked "left-area" occupant back — UIParent.lua l.777-782).
#[test]
fn gossip_bank_option_hands_the_left_slot_to_the_bank() {
use benilla_ui::script::{BankState, ScriptValue};
use benilla_ui::script::BankState;
// The bank window is the reference's own file off the player's chain (1751).
let _data = benilla_formats::wow_data_or_skip!();
// The bank window's own dependency chain (bank_tests::setup), plus the gossip window.
let order_first = |bank_first: bool| {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Fonts.xml");
load_xml(&s, "ItemButtonTemplate.xml"); // the reference bank's slot buttons inherit it
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "Cooldown.xml");
load_xml(&s, "BagFrame.xml");
load_xml(&s, "GameTooltip.xml");
load_xml(&s, "MerchantFrame.xml");
load_xml(&s, "BankFrame.xml");
load_xml(&s, "ScrollTemplates.xml"); // the shared scroll kit the window rides
// UIPanelScrollFrameTemplate lives here, and the gossip scroll frame inherits it. NOT
// optional: a missing template is a loader *warning*, not an error, so an under-loaded
// list passes load_xml and then loses the scrollbar silently.
// Before BankFrame, not after: its close and purchase buttons inherit UIPanelCloseButton
// and UIPanelButtonTemplate, and an `inherits=` is resolved at LOAD.
load_xml(&s, "UIPanelTemplates.xml");
load_xml(&s, "Interface\\FrameXML\\BankFrame.xml");
load_xml(&s, "GossipFrame.xml");
// The gossip menu is open on the banker (its bank option showing).
@@ -772,11 +767,11 @@ fn gossip_bank_option_hands_the_left_slot_to_the_bank() {
s.set_bank(Some(BankState::default()));
s.set_gossip(None);
if bank_first {
s.fire_event("BANKFRAME_OPENED", vec![ScriptValue::Str("Banker".into())]);
s.fire_event("BANKFRAME_OPENED", vec![]);
s.fire_event("GOSSIP_CLOSED", vec![]);
} else {
s.fire_event("GOSSIP_CLOSED", vec![]);
s.fire_event("BANKFRAME_OPENED", vec![ScriptValue::Str("Banker".into())]);
s.fire_event("BANKFRAME_OPENED", vec![]);
}
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
+3 -2
View File
@@ -102,8 +102,9 @@ pub(super) const BAG_UI: &[&str] = &[
// `updateContainerFrameAnchors` measures every open bag against `BankFrame:GetRight()`
// (ContainerFrame.lua l.505) on EVERY open and close, so the bank window is not optional
// scenery for a bag test — it is a hard dependency of the reference's own layout pass. It is
// one in the real client too; the manifest just satisfies it far below the bags.
"BankFrame.xml",
// one in the real client too; the manifest just satisfies it far below the bags. The
// reference's own file since 1751's second window.
"Interface\\FrameXML\\BankFrame.xml",
];
/// The name of the `ContainerFrame` currently showing bag `id`, or `None` if it is not open.
@@ -103,6 +103,18 @@ impl WorldWriter {
)
}
/// Auto-store an item into a bag (`CMSG_AUTOSTORE_BAG_ITEM`, layout in
/// [`messages::auto_store_bag_item`]): take `(src_bag, src_slot)` and put it anywhere inside
/// `dst_bag` — **the client names no destination slot; the server picks it**. The wire for
/// `PutItemInBag`'s auto-store leg and the whole of `PutItemInBackpack` (wow-re
/// `bag-verbs-law.md`). Refusal answers `SMSG_INVENTORY_CHANGE_FAILURE`.
pub fn auto_store_bag_item(&mut self, src_bag: u8, src_slot: u8, dst_bag: u8) -> Result<()> {
self.send(
opcode::CMSG_AUTOSTORE_BAG_ITEM,
&messages::auto_store_bag_item(src_bag, src_slot, dst_bag),
)
}
/// Split a stack (`CMSG_SPLIT_ITEM`, layout in [`messages::split_item`]): carry `count` off
/// `(src_bag, src_slot)` onto `(dst_bag, dst_slot)` — either endpoint may be an equipped bag
/// (unlike [`Self::swap_inv_item`]). Success settles both slots via values deltas; refusal
+151 -52
View File
@@ -10,6 +10,13 @@
//! (`BankFrame.lua:1-4`), so the container verbs, the cursor drag-drop, and the stack split all
//! work on bank slots with no bank-specific surface.
//!
//! Nor do the six bank BAGS — the bag items themselves, as opposed to what is inside them. Those
//! are inventory slots at live ids 64..=69, fed beside the paper doll's
//! ([`super::char_stats::BankBagSlots`]) and read through the ordinary `GetInventoryItem*` /
//! `PickupBagFromSlot` surface, which is exactly how the reference's own bank reads them
//! (`BankFrame.lua:28`, `ButtonInventorySlot`). They stream in the player descriptor whether or
//! not a banker is open, so they are not part of the window's snapshot.
//!
//! ## The 5875 API shape (the reference `BankFrame.lua`, read as behavior spec this session)
//!
//! - `GetNumBankSlots()` → `numSlots, full` — purchased count 0..6, `full` as `1`/`nil`
@@ -24,9 +31,10 @@
//! - `CloseBankFrame()` — client-side close, **no packet exists** for it (decision 0604): flag the
//! app to clear its session, the merchant/gossip pattern.
//! - `BankButtonIDToInvSlotID(id, isBag)` — the pure button→live-inventory-slot map: item button
//! `i` (1..24) → live `39 + i` (wire 39..62 + 1), bag button `j` (1..6) → live `63 + j`
//! (wire 63..68 + 1) — the same "live id − 1 = wire slot" law as the doll
//! (`crate::script::container`'s `EQUIPMENT_BAG` space).
//! `i` (1..24) → live `39 + i` (wire 39..62 + 1), bag button — whose id is the **container id**
//! 5..10, not a bag number — → live `59 + id` (wire 63..68 + 1); the same "live id − 1 = wire
//! slot" law as the doll (`crate::script::container`'s `EQUIPMENT_BAG` space). See the binding
//! for the four places the reference's own file pins the bag arm's numbering.
use mlua::{Lua, MultiValue, Value};
@@ -42,12 +50,6 @@ pub struct BankState {
/// The NEXT slot's price in copper (`BankBagSlotPrices.dbc` row `num_purchased + 1`; the DBC's
/// own 999999999 sentinel past slot 6). 0 only if the DBC row is genuinely absent.
pub next_cost: u32,
/// The bag buttons' icons: bank bag slot `i`'s held bag, resolved by the app to its icon path
/// (`None` = the slot is empty or the template answer is in flight — the XML shows the empty
/// slot texture). The reference reads these through the inventory-item API; benilla's doll
/// array stops at the equipped bags, so the snapshot carries them instead
/// (`BenillaGetBankBagTexture` below — benilla-named, like the merchant's stat feed).
pub bag_textures: [Option<String>; 6],
}
impl super::UiScript {
@@ -107,24 +109,6 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
// BenillaGetBankBagTexture(i) → the bank bag slot's held-bag icon path | nil (empty slot, or
// no bank open) — benilla-named (module doc: the snapshot carries what the reference read
// through the inventory-item API).
g.set(
"BenillaGetBankBagTexture",
lua.create_function(|lua, i: usize| {
let texture = {
let model = lua.app_data_ref::<Model>().expect("model app_data");
model.bank.as_ref().and_then(|b| {
i.checked_sub(1)
.and_then(|n| b.bag_textures.get(n))
.and_then(|t| t.clone())
})
};
Ok(match texture {
Some(t) => Value::String(lua.create_string(&t)?),
None => Value::Nil,
})
})?,
)?;
// PurchaseSlot() — queue the bank-slot buy intent (the CONFIRM_BUY_BANK_SLOT popup's accept).
g.set(
"PurchaseSlot",
@@ -145,17 +129,28 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
})?,
)?;
// BankButtonIDToInvSlotID(id, isBag) — the pure button→live-slot map (module doc): item
// button 1..24 → 40..63, bag button 1..6 → 64..69. Out-of-range answers 0 (the reference
// binding is total over its buttons; our XML never asks outside them — 0 is the visible
// "wired wrong" tell rather than a silent misroute).
// BankButtonIDToInvSlotID(id, isBag) — the pure button→live-slot map (module doc).
//
// **The bag arm takes the CONTAINER id 5..=10, not a bag number 1..=6**, and getting that
// wrong reads six slots off the end of the band. The reference's own file is the oracle
// (0675) and says so four times over: `BankFrame.xml` gives `BankFrameBag1..BankFrameBag6`
// the ids **5..10** (the generic buttons `BankFrameItem1..24` take 1..24);
// `ButtonInventorySlot` hands `this:GetID()` straight in; and both
// `UpdateBagButtonHighlight(id)` (`"BankFrameBag"..(id - NUM_BAG_SLOTS)`) and
// `BankFrameItemButton_UpdateLock` (`(this:GetID() - 4) > GetNumBankSlots()`) subtract 4 from
// that same id to get back to 1..6. So the bag arm is `id + 59` — byte-identical to
// `ContainerIDToInventoryID`'s carved second arm (`t = id - 1; t >= 4 → t + 60`,
// `crate::script::container`), which is the same map under the other name.
//
// Out-of-range answers 0 (the reference binding is total over its buttons; our XML never asks
// outside them — 0 is the visible "wired wrong" tell rather than a silent misroute).
g.set(
"BankButtonIDToInvSlotID",
lua.create_function(|_, (id, is_bag): (u32, Option<Value>)| {
let is_bag = is_bag.is_some_and(|v| v.as_boolean().unwrap_or(true));
let live = match (is_bag, id) {
(false, 1..=24) => 39 + id,
(true, 1..=6) => 63 + id,
(true, 5..=10) => 59 + id,
_ => 0,
};
Ok(i64::from(live))
@@ -183,7 +178,6 @@ mod tests {
s.set_bank(Some(BankState {
num_purchased: 2,
next_cost: 100_000,
..Default::default()
}));
assert!(s
.eval::<bool>("local n, full = GetNumBankSlots()\nreturn n == 2 and full == nil")
@@ -194,7 +188,6 @@ mod tests {
s.set_bank(Some(BankState {
num_purchased: 6,
next_cost: 999_999_999,
..Default::default()
}));
assert!(s
.eval::<bool>("local n, full = GetNumBankSlots()\nreturn n == 6 and full == 1")
@@ -207,26 +200,50 @@ mod tests {
.unwrap());
}
/// The bag-button icon feed: slot 1 resolved, slot 2 empty/in-flight (nil), out of range nil.
/// The six bank BAGS are inventory slots at live ids 64..=69, read through the ordinary
/// inventory API — the band the reference's own bank uses (`ButtonInventorySlot` →
/// `BankButtonIDToInvSlotID(id, this.isBag)`, BankFrame.lua:28). Empty slots answer nil, and
/// the band does not bleed into the equipment array below it.
#[test]
fn bank_bag_texture_reads() {
fn bank_bag_slots_read_through_the_inventory_api() {
let mut s = UiScript::new().unwrap();
assert!(s
.eval::<bool>("return BenillaGetBankBagTexture(1) == nil")
.eval::<bool>("return GetInventoryItemTexture(\"player\", 64) == nil")
.unwrap());
let mut state = BankState::default();
state.bag_textures[0] = Some("Interface\\Icons\\INV_Misc_Bag_08".into());
s.set_bank(Some(state));
let mut bags: crate::script::BankBagSlots = Default::default();
bags[0] = Some(crate::script::InvSlotView {
item_id: 4500,
icon: Some("Interface\\Icons\\INV_Misc_Bag_08".into()),
count: 1,
link: Some("|cffffffff|Hitem:4500:0:0:0|h[Traveler\'s Backpack]|h|r".into()),
equip_slots: vec![20, 21, 22, 23],
..Default::default()
});
s.set_bank_bag_slots(bags);
assert_eq!(
s.eval::<String>("return BenillaGetBankBagTexture(1)")
s.eval::<i64>("return BankButtonIDToInvSlotID(5, 1)")
.unwrap(),
64,
"BankFrameBag1's own id is 5 — the container id, not a bag number"
);
assert_eq!(
s.eval::<String>("return GetInventoryItemTexture(\"player\", 64)")
.unwrap(),
"Interface\\Icons\\INV_Misc_Bag_08"
);
assert_eq!(
s.eval::<i64>("return GetInventoryItemID(\"player\", 64)")
.unwrap(),
4500
);
// Bag slot 2 is empty, and slot 70 is past the band — neither falls through to the doll.
assert!(s
.eval::<bool>("return BenillaGetBankBagTexture(2) == nil")
.eval::<bool>("return GetInventoryItemTexture(\"player\", 65) == nil")
.unwrap());
assert!(s
.eval::<bool>("return BenillaGetBankBagTexture(7) == nil")
.eval::<bool>("return GetInventoryItemTexture(\"player\", 70) == nil")
.unwrap());
}
@@ -244,9 +261,77 @@ mod tests {
assert!(!s.take_bank_close(), "drained");
}
/// The button→live-slot map: items 1..24 → 40..63, bags 1..6 → 64..69 (live id − 1 = the wire
/// slot: bank items 39..62, bank bags 63..68). The reference calls it with `this.isBag` = 1 or
/// nil, so nil/false both mean "item button".
/// **The bank's inventory BAND answers from the container feed** — the map decision 1751's
/// bank swap turns on. The reference's bank paints every slot through the inventory API
/// (`BankFrameItemButton_OnUpdate` → `GetInventoryItemTexture("player", BankButtonIDToInv
/// SlotID(id))`, BankFrame.lua:35) while benilla feeds the same items as container `-1`. If
/// those two ever disagree the bank draws empty, which is exactly the failure this pins.
///
/// Asserted through the live API rather than the model, because the API is what the
/// reference's file calls, and the tooltip must agree with the icon under it.
#[test]
fn the_bank_band_reads_the_vault_through_the_inventory_api() {
let mut s = UiScript::new().unwrap();
let mut slots = std::collections::HashMap::new();
slots.insert(
3,
super::super::ContainerSlot {
item_id: 4496,
count: 7,
texture: Some("Interface\\Icons\\INV_Misc_Bag_08".into()),
quality: Some(2),
link: Some("|cff1eff00|Hitem:4496:0:0:0|h[Small Brown Pouch]|h|r".into()),
..Default::default()
},
);
s.set_container(
-1,
Some(super::super::ContainerState {
name: Some("Bank".into()),
num_slots: 24,
slots,
}),
);
// Vault slot 3 is live-API inventory id 42 (BankButtonIDToInvSlotID(3)).
assert_eq!(
s.eval::<i64>("return BankButtonIDToInvSlotID(3)").unwrap(),
42
);
assert_eq!(
s.eval::<String>("return GetInventoryItemTexture(\"player\", 42)")
.unwrap(),
"Interface\\Icons\\INV_Misc_Bag_08"
);
assert_eq!(
s.eval::<i64>("return GetInventoryItemCount(\"player\", 42)")
.unwrap(),
7
);
assert_eq!(
s.eval::<i64>("return GetInventoryItemID(\"player\", 42)")
.unwrap(),
4496
);
// …and an empty vault slot answers nil, not the equipment slot that shares no numbering
// with it — the band must not fall through to `inventory_slots`.
assert!(s
.eval::<bool>("return GetInventoryItemTexture(\"player\", 43) == nil")
.unwrap());
// The doll is untouched either side of the band.
assert!(s
.eval::<bool>("return GetInventoryItemTexture(\"player\", 16) == nil")
.unwrap());
}
/// The button→live-slot map: item buttons 1..24 → 40..63; bag buttons — **whose ids are the
/// container ids 5..10, not bag numbers** — → 64..69 (live id − 1 = the wire slot: bank items
/// 39..62, bank bags 63..68). The reference calls it with `this.isBag` = 1 or nil, so
/// nil/false both mean "item button".
///
/// The bag arm's numbering is the reference file's, checked here because getting it wrong
/// reads six slots off the end of the band and draws an empty bag row: `BankFrame.xml` gives
/// `BankFrameBag1` `id="5"`, and `ButtonInventorySlot` hands that id straight in.
#[test]
fn bank_button_to_inv_slot() {
let s = UiScript::new().unwrap();
@@ -259,15 +344,28 @@ mod tests {
63
);
assert_eq!(
s.eval::<i64>("return BankButtonIDToInvSlotID(1, 1)")
s.eval::<i64>("return BankButtonIDToInvSlotID(5, 1)")
.unwrap(),
64
64,
"BankFrameBag1 carries id 5"
);
assert_eq!(
s.eval::<i64>("return BankButtonIDToInvSlotID(6, 1)")
s.eval::<i64>("return BankButtonIDToInvSlotID(10, 1)")
.unwrap(),
69
69,
"BankFrameBag6 carries id 10"
);
// It is the SAME map `ContainerIDToInventoryID` computes for a bank bag — one arithmetic
// under two names, and a drift between them would put the bag row and the bag windows on
// different slots.
for id in 5..=10 {
assert_eq!(
s.eval::<i64>(&format!("return BankButtonIDToInvSlotID({id}, 1)"))
.unwrap(),
s.eval::<i64>(&format!("return ContainerIDToInventoryID({id})"))
.unwrap()
);
}
// false is "not a bag" (nil-or-false truthiness), out of range answers 0.
assert_eq!(
s.eval::<i64>("return BankButtonIDToInvSlotID(2, false)")
@@ -279,9 +377,10 @@ mod tests {
0
);
assert_eq!(
s.eval::<i64>("return BankButtonIDToInvSlotID(7, 1)")
s.eval::<i64>("return BankButtonIDToInvSlotID(4, 1)")
.unwrap(),
0
0,
"4 is an EQUIPPED bag, not a bank one"
);
}
}
+123 -18
View File
@@ -267,17 +267,31 @@ pub struct InvSlotView {
pub const INVENTORY_SLOT_COUNT: usize = 24;
pub type InventorySlots = [Option<InvSlotView>; INVENTORY_SLOT_COUNT];
/// The six BANK-BAG slots' snapshot, in button order (live-API ids 64..=69 —
/// `BankButtonIDToInvSlotID(1..6, isBag)`). `None` = the slot is empty (or not purchased).
///
/// **A separate dense array rather than a wider [`InventorySlots`]**, because the live-id space
/// between them is not ours to fill: 24..=39 is the backpack and 40..=63 the vault, and both of
/// those the model already holds as CONTAINERS — [`Model::inv_slot`]'s bank band maps them rather
/// than storing them twice. Growing the doll array to 70 would leave a 40-entry hole whose
/// emptiness means "look somewhere else", which is exactly the kind of implicit second rule that
/// lets a tooltip disagree with the icon under it. Three bands, three named sources.
///
/// Fed by the same system as the doll snapshot ([`super::UiScript::set_bank_bag_slots`]): these
/// are player-descriptor inventory slots that exist whether or not the bank window is open, not
/// part of the bank *window*'s state.
pub const BANK_BAG_SLOT_COUNT: usize = 6;
pub type BankBagSlots = [Option<InvSlotView>; BANK_BAG_SLOT_COUNT];
/// The client's own `PaperDollItemFrame.dbc` rows (re-verified against the 1.12.1 (build 5875)
/// MPQ this session, byte-exact — 36 records, 3 `u32` fields each: `SlotName` offset,
/// `SlotTexture` offset, `SlotID`): `(slotName, slotId, empty-slot art suffix)` for the 24 rows
/// `GetInventorySlotInfo` actually names — the 20 equipment+ammo rows plus `Bag0Slot`..`Bag3Slot`
/// (ids **20..23**, confirmed this session — every one points at the same
/// `interface\paperdoll\UI-PaperDoll-Slot-Bag.blp`, matching the reference `CharacterBag0Slot`
/// bag-bar buttons' `GetID()`s). The DBC's remaining 12 rows (`"Bag1".."Bag12"`, ids 64..75) are a
/// different, unrelated numbering — not `GetInventorySlotInfo` names, out of scope here — left
/// untranscribed. The oddballs among the 24: `BackSlot` shows the **Chest** art and `AmmoSlot` the
/// **Ranged** art (both confirmed by their `SlotTexture` offset pointing at that other row's
/// string, not a fresh one).
/// `SlotTexture` offset, `SlotID`): `(slotName, slotId, empty-slot art suffix)` for all 36 rows —
/// the 20 equipment+ammo rows, `Bag0Slot`..`Bag3Slot` (ids **20..23**, every one pointing at the
/// same `interface\paperdoll\UI-PaperDoll-Slot-Bag.blp`, matching the reference
/// `CharacterBag0Slot` bag-bar buttons' `GetID()`s), and `Bag1`..`Bag12` (ids 64..75 — the
/// bank-bag band; see the note over those rows). The oddballs: `BackSlot` shows the **Chest** art
/// and `AmmoSlot` the **Ranged** art (both confirmed by their `SlotTexture` offset pointing at
/// that other row's string, not a fresh one).
const SLOT_INFO: [(&str, i64, &str); 36] = [
("AmmoSlot", 0, "Ranged"),
("HeadSlot", 1, "Head"),
@@ -418,6 +432,16 @@ impl super::UiScript {
self.fire_event("UPDATE_INVENTORY_ALERTS", vec![]);
}
/// Push the six bank-bag slots ([`BankBagSlots`] — live ids 64..=69). No event of its own:
/// the app pushes it in the same step as [`Self::set_inventory_slots`], whose
/// `UNIT_INVENTORY_CHANGED` is the repaint signal the reference's bank buttons already
/// register for (`BankFrameBaseButton_OnLoad` takes `PLAYERBANKSLOTS_CHANGED` /
/// `ITEM_LOCK_CHANGED` / `BANKFRAME_OPENED`, and `BankFrameItemButton_OnUpdate` re-reads
/// every frame besides).
pub fn set_bank_bag_slots(&mut self, slots: BankBagSlots) {
self.model_mut().bank_bag_slots = slots;
}
/// Drain the inventory-slot ids `UseInventoryItem` queued (decision 0208 phase 1b) — the app
/// resolves each to the equipped item's guid and sends `CMSG_USE_ITEM` (bag 255 + the
/// 0-based wire slot).
@@ -491,20 +515,101 @@ fn player_inv_slot(lua: &Lua, token: &Option<String>, slot: i64) -> Option<InvSl
let token = token.as_deref()?;
let idx = usize::try_from(slot).ok()?;
let model = lua.app_data_ref::<Model>().expect("model app_data");
model.inv_slot(token, idx).cloned()
model.inv_slot(token, idx)
}
/// The live-API inventory ids the BANK occupies — `BankButtonIDToInvSlotID`'s own two bands
/// (`super::bank`): the 24 vault slots at 40..=63, the six bank-bag slots at 64..=69.
const BANK_INV_SLOTS: std::ops::RangeInclusive<usize> = 40..=63;
const BANK_BAG_INV_SLOTS: std::ops::RangeInclusive<usize> = 64..=69;
/// The container id the vault's own slots live under — `ui_items`' constant of the same name,
/// restated here because this file is the other end of the same map.
const BANK_CONTAINER: i64 = -1;
impl Model {
/// The equipment slot `token` exposes at live-API id `slot`, or `None`. The one place the
/// two-source routing above is decided — shared by the `GetInventoryItem*` getters and
/// source routing is decided — shared by the `GetInventoryItem*` getters and
/// `GameTooltip:SetInventoryItem`, so a tooltip can never disagree with the icon under it.
pub(super) fn inv_slot(&self, token: &str, slot: usize) -> Option<&InvSlotView> {
let slots = if token.eq_ignore_ascii_case("player") {
&self.inventory_slots
} else {
&self.inspect.as_ref().filter(|v| v.unit == token)?.slots
};
slots.get(slot)?.as_ref()
///
/// **THREE sources now, and the third is a VIEW, not a store** (decision 1751's bank swap).
/// The reference's bank paints every one of its slots through the *inventory* API —
/// `BankFrameItemButton_OnUpdate` reads `GetInventoryItemTexture("player", BankButtonIDToInv
/// SlotID(id))` (BankFrame.lua:35) — while benilla feeds the very same items as *containers*
/// (`-1` for the vault, `5..10` for the bank bags), which is how our own bank window read
/// them. Those are two names for one set of descriptor fields, so the fix is a map, not a
/// second copy: a read in the bank band is answered from the container snapshot. Duplicating
/// the items into a wider `inventory_slots` array would put two truths in the model and let
/// a tooltip disagree with the icon under it, which is the one thing this function exists to
/// prevent.
pub(super) fn inv_slot(&self, token: &str, slot: usize) -> Option<InvSlotView> {
if token.eq_ignore_ascii_case("player") {
if let Some(view) = self.bank_inv_slot(slot) {
return Some(view);
}
return self.inventory_slots.get(slot)?.clone();
}
self.inspect
.as_ref()
.filter(|v| v.unit == token)?
.slots
.get(slot)?
.clone()
}
/// The bank band's answer, or `None` for any id outside it — see [`Model::inv_slot`].
fn bank_inv_slot(&self, slot: usize) -> Option<InvSlotView> {
if BANK_INV_SLOTS.contains(&slot) {
let vault = self.containers.get(&BANK_CONTAINER)?;
let n = (slot - BANK_INV_SLOTS.start() + 1) as u32;
return vault.slots.get(&n).map(InvSlotView::from_container_slot);
}
if BANK_BAG_INV_SLOTS.contains(&slot) {
// A bank BAG is not a slot in a container — it IS one, so there is no container slot
// to map and this band is a real store ([`BankBagSlots`], fed off the player
// descriptor's own `PLAYER_FIELD_BANK_BAG_SLOT_*` guids by the same system that feeds
// the doll). It has to be the whole item, not just its icon: the reference picks a
// bank bag up with `PickupBagFromSlot(BankButtonIDToInvSlotID(i, 1))` and describes it
// with `GameTooltip:SetInventoryItem("player", …)`, both of which read this band.
let i = slot - BANK_BAG_INV_SLOTS.start();
return self.bank_bag_slots.get(i)?.clone();
}
None
}
}
/// The display name inside an item link — the text between `|h[` and `]|h`.
fn link_item_name(link: &str) -> Option<String> {
let (_, rest) = link.split_once("|h[")?;
let (name, _) = rest.split_once("]|h")?;
Some(name.to_string())
}
impl InvSlotView {
/// The same item, seen through the inventory API instead of the container API — the map
/// [`Model::inv_slot`]'s bank band is built on. Every field is the same descriptor field under
/// the other name; what a `ContainerSlot` has and this does not (`equip_slots`, `cooldown`,
/// `readable`) has no inventory-API reader, and what this has and a container slot does not
/// (`flags`) is not carried by the container feed.
fn from_container_slot(slot: &super::container::ContainerSlot) -> Self {
InvSlotView {
item_id: slot.item_id,
bar_placeable: slot.bar_placeable,
icon: slot.texture.clone(),
count: slot.count,
quality: slot.quality.map_or(0, |q| q as i32),
// A container slot carries no `name` of its own — the container API never asks for one
// — but its `link` is composed FROM the name (`|Hitem:…|h[Name]|h`), so the one field
// the inventory API adds is recoverable rather than absent.
name: slot.link.as_deref().and_then(link_item_name),
durability: slot.durability,
already_bound: slot.already_bound,
link: slot.link.clone(),
locked: slot.locked,
creator: slot.creator.clone(),
enchants: slot.enchants.clone(),
..Default::default()
}
}
}
+23
View File
@@ -176,7 +176,30 @@ pub struct ContainerMove {
pub count: Option<u32>,
}
/// One queued **auto-store into a bag** — `PutItemInBag`'s third leg and the whole of
/// `PutItemInBackpack` (`crate::script::cursor::bag_verbs`): take the item at `(src_bag, src_slot)`
/// and put it anywhere inside container `dst_bag`.
///
/// **There is no destination slot, and that is the finding, not an omission**: the reference's
/// `CMSG_AUTOSTORE_BAG_ITEM 0x10B` carries `(srcbag, srcslot, dstbag)` and nothing else, and its
/// split sibling `CMSG_SPLIT_ITEM 0x10E` carries the literal `0xFF` where a slot would go. The
/// server picks. `count`: `None` = the whole stack (AUTOSTORE); `Some(n)` = a split carry, which
/// the drain sends as SPLIT instead — the same fork the reference makes on `[0xb4b40c]`.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct BagAutoStore {
pub src_bag: i64,
pub src_slot: u32,
/// Live-API container id (0 = backpack, 1..=4 an equipped bag, 5..=10 a bank bag).
pub dst_bag: i64,
pub count: Option<u32>,
}
impl super::UiScript {
/// Drain the auto-stores `PutItemInBag`/`PutItemInBackpack` queued since the last call.
pub fn take_bag_autostores(&mut self) -> Vec<BagAutoStore> {
std::mem::take(&mut self.model_mut().bag_autostores)
}
/// Push (or remove, with `None`) one bag's snapshot, keyed by live-API bag id (0 = backpack).
/// Each slot's cooldown arrives with its absolute start already on the `GetTime` clock (ms) —
/// storing is a pure unit conversion, the same seam shape as
+2
View File
@@ -15,6 +15,7 @@ use mlua::{Lua, Value};
use super::{Model, ScriptValue};
mod bag_verbs;
mod bar;
mod doll;
mod drag;
@@ -639,6 +640,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
)?;
doll::install(lua)?;
bag_verbs::install(lua)?;
bar::install(lua)?;
pet::install(lua)?;
@@ -0,0 +1,589 @@
//! The three BAG-SLOT verbs — `PutItemInBag`, `PutItemInBackpack`, `PickupBagFromSlot`.
//!
//! CARVED, not inferred (wow-re `system/ui/ui.md`, "The three bag verbs — one worker, a three-way
//! fork, and a return value that is not 'did it place'"; `scratch/bag-verbs-law.md`, §5 trio +
//! orchestrator byte arbitration 2026-08-11). The three findings that shape everything below:
//!
//! 1 · **`PutItemInBackpack()` IS `PutItemInBag(0xFF)`.** `0x4c7ed0` is a ten-byte thunk —
//! `mov ecx,0xff` / `jmp 0x4c7c00` — so there is one worker here too, and only the destination
//! differs. The `0xFF` path is *not* "bag 0": the destination guid is the player's own, which
//! makes the empty-slot leg unreachable and `0x4c7d3e je 0x4c7e0c` skip the swap fork outright,
//! so a bag dropped on the backpack button is auto-stored INTO it, never swapped with it.
//!
//! 2 · **`PutItemInBag` forks three ways**, on the state of the target slot and the held object:
//! empty slot → the paper-doll handler equips the held bag ([`super::doll::pickup_inventory_item`],
//! which is `0x4c7300` — the same function the byte path calls); occupied + the held object is a
//! CONTAINER → a bag↔bag swap; occupied + an ordinary item → auto-store into that container's
//! contents, with the client picking no destination slot at all.
//!
//! 3 · **The return value answers "was this click consumed as a placement against an existing
//! container?", not "did something get placed".** It is the Lua **number `1`**, never the boolean
//! `true` — and it is `nil` on the empty-slot leg that really does equip the bag, and whenever
//! the cursor is empty. That last one is the whole reason a plain left-click opens a bag:
//! `BagSlotButton_OnClick`'s `if ( not hadItem ) then ToggleBag(...)`.
//!
//! `PickupBagFromSlot` has its own, much shorter worker (`0x4c7b00`) and is a *destructive*
//! pickup: it drops whatever the cursor already held before it looks at the slot.
use mlua::{Lua, MultiValue, Value};
use crate::script::container::ContainerMove;
use crate::script::Model;
use super::{clear_cursor, queue_cursor_update, queue_lock_changed, CursorItem, CursorPayload};
use super::{doll::pickup_inventory_item, EQUIPMENT_BAG};
/// The live-API inventory ids the two verbs' shared argument reader (`0x4c8520`) accepts, minus
/// what each binding's own floor gate (`cmp ecx,0x13; jl` on the 0-based value — `0x4c8f1f`,
/// `0x4c8fbf`) then cuts off. In the space a Lua caller writes:
///
/// | band | what it is |
/// |---|---|
/// | 20..=23 | the four EQUIPPED bag slots (`Bag0Slot`..`Bag3Slot`) |
/// | 40..=63 | the bank's 24 generic slots |
/// | 64..=69 | the six BANK BAG slots |
/// | 82..=113 | the keyring |
///
/// The reader rejects the backpack's own item slots (24..=39) and buyback (70..=81); the floor
/// cuts equipment (1..=19) and ammo. **So a macro may legally hand either verb a bank item slot
/// or a keyring slot, and nothing downstream catches it** — the reference sends a packet the
/// server refuses. See [`autostore_container`] for the one place benilla is tighter, and why.
fn accepts(live: u32) -> bool {
(20..=23).contains(&live)
|| (40..=63).contains(&live)
|| (64..=69).contains(&live)
|| (82..=113).contains(&live)
}
/// The live-API **container** id a bag slot's own inventory id names — the inverse of
/// `ContainerIDToInventoryID` (`crate::script::container`'s own table): 20..=23 → 1..=4, 64..=69 →
/// 5..=10. `None` for an inventory id that is not a bag slot.
///
/// **This is the one place benilla's gate is tighter than the engine's**, and it is deliberate.
/// [`accepts`] admits the bank's generic slots and the keyring because the reference's argument
/// reader does; the reference then auto-stores *into* them, sending
/// `CMSG_AUTOSTORE_BAG_ITEM(…, dstbag = live − 1)` for a `dstbag` that names no container, which
/// the server refuses (`EQUIP_ERR_ITEM_DOESNT_GO_TO_SLOT`). benilla has no container id to name
/// there, so [`put_item_in_bag`]'s auto-store leg answers `nil` and sends nothing. No shipped
/// window produces the call — the reference's own two callers are `BagSlotButton_OnClick` and
/// `BankFrameItemButtonBag_OnClick`, both of which pass a bag slot — and a refusal the player
/// never sees is not worth a packet.
fn autostore_container(live: u32) -> Option<i64> {
match live {
20..=23 => Some(i64::from(live) - 19),
64..=69 => Some(i64::from(live) - 59),
_ => None,
}
}
/// `Bag0Slot` — the first of the four EQUIPPED bag slots, and the tell that an item is a
/// container. See [`is_container`].
const BAG0_SLOT: u8 = 20;
/// The six BANK BAG slots' live-API ids (`BankButtonIDToInvSlotID(1..6, isBag)`).
pub(super) const BANK_BAG_INV_SLOTS: std::ops::RangeInclusive<u32> = 64..=69;
/// Whether the held item is a CONTAINER — the fork the reference reads straight off the object
/// (`0x4c7dc4 shr eax,2; test al,1`, `OBJECT_FIELD_TYPE`'s `TYPEMASK_CONTAINER` bit).
///
/// benilla has no object-type field on a cursor payload, and asks the equivalent question of the
/// data it does carry: `equip_slots` is the server's own `Player::FindEquipSlot`, and
/// `INVTYPE_BAG` is the ONLY inventory type in that table that yields the bag slots
/// (`ui_items::find_equip_slot`, arm 18). So "this item names Bag0Slot as a place it could be
/// worn" and "this item is a container" are the same set.
fn is_container(item: &CursorItem) -> bool {
item.equip_slots.contains(&BAG0_SLOT)
}
/// Whether the held item may be dropped on doll slot `id` — the fit rule, one band wider than
/// `equip_slots` alone.
///
/// The four EQUIPPED bag slots are in `equip_slots` because the server's `FindEquipSlot` names
/// them; the six BANK BAG slots never are, because `FindEquipSlot` does not know about the bank
/// (it is `CanBankItem`'s business there). The client's own predicate `CursorCanGoInSlot`
/// (`0x5ea720`) is 0216 §5's unresolved residual, so the rule here is stated from the observable:
/// a bank bag slot takes a container and nothing else, and the server's `CanBankItem` referees
/// either way through `SMSG_INVENTORY_CHANGE_FAILURE`.
pub(super) fn fits_slot(item: &CursorItem, id: u32) -> bool {
if BANK_BAG_INV_SLOTS.contains(&id) {
return is_container(item);
}
u8::try_from(id).is_ok_and(|id| item.equip_slots.contains(&id))
}
/// `PutItemInBag(inventorySlot)` — module doc, finding 2. Returns the reference's own `1`/`nil`
/// as a bool the binding maps ("was this click consumed as a placement against an existing
/// container?", NOT "did something get placed").
pub(super) fn put_item_in_bag(model: &mut Model, live: u32) -> bool {
if !accepts(live) {
return false;
}
// `0x4c7ce4 je 0x4c7eaf` — an empty cursor is the FIRST thing tested, and it answers `nil`
// with nothing sent. A Spell/Action/Macro/PetAction/StablePet payload is not the client's
// item cursor either, and takes the same exit: this is what leaves a plain click free to open
// the bag.
let Some(CursorPayload::Item(held)) = model.cursor.clone() else {
return false;
};
let occupied = usize::try_from(live)
.ok()
.and_then(|slot| model.inv_slot("player", slot))
.is_some();
if !occupied {
// Leg 1 — the EMPTY slot. The byte path calls `0x4c7300`, the paper-doll handler, which
// is `pickup_inventory_item`'s place arm here: the fit predicate, the queued move, the
// clear and both events, all of it already the doll's. `0x4c7cae xor eax,eax` is
// unconditional, so this answers `nil` whether or not the bag actually went in.
pickup_inventory_item(model, live);
return false;
}
// A pure cancel: the held item IS this bag (put back where it came from). Sends nothing, and
// answers `1` — the click WAS consumed.
if held.bag == EQUIPMENT_BAG && held.slot == live {
model.cursor = None;
queue_cursor_update(model);
queue_lock_changed(model, held.bag, held.slot);
return true;
}
if is_container(&held) || held.bag == EQUIPMENT_BAG {
// Leg 2 — bag↔bag SWAP (`0x5e0c40`, `CMSG_SWAP_ITEM`/`CMSG_SWAP_INV_ITEM`). "Came from a
// bag equipment slot" is the reference's second entry to this fork, hence the `||`. The
// existing move drain already picks the opcode off the two endpoints.
//
// A split carry cannot swap: half a stack is not a bag. The reference reaches its SPLIT
// sender only from the auto-store leg, so this falls through to a refusal, kept.
if held.count.is_none() {
model.cursor = None;
model.container_moves.push(ContainerMove {
src_bag: held.bag,
src_slot: held.slot,
dst_bag: EQUIPMENT_BAG,
dst_slot: live,
count: None,
});
queue_cursor_update(model);
queue_lock_changed(model, held.bag, held.slot);
return true;
}
model.cursor = Some(CursorPayload::Item(held));
return true;
}
// Leg 3 — AUTO-STORE into the container's contents (`CMSG_AUTOSTORE_BAG_ITEM 0x10B`, or
// `CMSG_SPLIT_ITEM 0x10E` for a split carry). **The client never picks a destination slot**:
// AUTOSTORE carries none and SPLIT carries the literal `0xFF`.
let Some(dst_bag) = autostore_container(live) else {
return false;
};
model.cursor = None;
model.bag_autostores.push(crate::script::BagAutoStore {
src_bag: held.bag,
src_slot: held.slot,
dst_bag,
count: held.count,
});
queue_cursor_update(model);
queue_lock_changed(model, held.bag, held.slot);
true
}
/// `PutItemInBackpack()` — module doc, finding 1: the same worker with the destination fixed to
/// the player's own guid, which makes the empty-slot and swap legs unreachable. Everything that
/// lands here auto-stores into the backpack.
pub(super) fn put_item_in_backpack(model: &mut Model) -> bool {
let Some(CursorPayload::Item(held)) = model.cursor.clone() else {
return false;
};
model.cursor = None;
model.bag_autostores.push(crate::script::BagAutoStore {
src_bag: held.bag,
src_slot: held.slot,
dst_bag: 0,
count: held.count,
});
queue_cursor_update(model);
queue_lock_changed(model, held.bag, held.slot);
true
}
/// `PickupBagFromSlot(inventorySlot)` — a **destructive** pickup, not a restricted
/// `PickupInventoryItem` (`0x4c7b00`). Returns nothing.
///
/// Three things it does that the doll pickup does not:
///
/// · **`ClearCursor(1, 1)` unconditionally, before it looks at anything** (`0x4c7b79`) — whatever
/// was held is dropped and unlocked, even if the slot then turns out to be empty.
/// · Resolves the occupant with **`TYPEMASK_CONTAINER`** (`0x4c7bb3 mov ecx,4`, where
/// `PickupInventoryItem` uses `ecx = 2`). This is what makes it silently no-op at the bank item
/// slots and the keyring that its floor-only gate still admits: those hold ordinary items.
/// · Has **no place/swap path at all**, and none of `0x4c7300`'s targeting reroute, repair-cursor
/// leg or slot-`−1` ammo leg.
///
/// There is **no client-side "a bag must be empty to be moved" rule** — both workers were read end
/// to end and neither inspects container occupancy. That refusal is the server's
/// `Player::CanUnequipItem` (`EQUIP_ERR_CAN_ONLY_DO_WITH_EMPTY_BAGS`), arriving as
/// `SMSG_INVENTORY_CHANGE_FAILURE`.
pub(super) fn pickup_bag_from_slot(model: &mut Model, live: u32) {
if !accepts(live) {
return;
}
clear_cursor(model);
let picked = usize::try_from(live)
.ok()
.and_then(|slot| model.inv_slot("player", slot))
.filter(|s| s.item_id != 0 && !s.locked)
.map(|s| CursorItem {
bag: EQUIPMENT_BAG,
slot: live,
item_id: s.item_id,
texture: s.icon.clone(),
link: s.link.clone(),
quality: Some(u32::try_from(s.quality).unwrap_or(0)),
count: None,
bar_placeable: s.bar_placeable,
equip_slots: s.equip_slots.clone(),
})
// TYPEMASK_CONTAINER, applied after the view is built so the predicate reads off the same
// `equip_slots` every other bag decision here does ([`is_container`]).
.filter(is_container);
if let Some(item) = picked {
model.cursor = Some(CursorPayload::Item(item));
queue_cursor_update(model);
queue_lock_changed(model, EQUIPMENT_BAG, live);
}
}
/// Register the three verbs — top-level globals, like every other cursor binding.
pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
let g = lua.globals();
// The number `1` or nil — `0x6f3810` writes Lua tag 3 (number) with the IEEE-754 double
// `0x3FF0000000000000`; tag 1 (boolean) appears in neither body. FrameXML only ever tests it
// for truthiness, but an addon that compares against `true` must see what the reference gives
// it.
fn consumed(yes: bool) -> mlua::Result<MultiValue> {
Ok(MultiValue::from_vec(vec![if yes {
Value::Number(1.0)
} else {
Value::Nil
}]))
}
g.set(
"PutItemInBag",
lua.create_function(|lua, live: u32| {
let yes = {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
put_item_in_bag(&mut model, live)
};
consumed(yes)
})?,
)?;
g.set(
"PutItemInBackpack",
lua.create_function(|lua, ()| {
let yes = {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
put_item_in_backpack(&mut model)
};
consumed(yes)
})?,
)?;
g.set(
"PickupBagFromSlot",
lua.create_function(|lua, live: u32| {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
pickup_bag_from_slot(&mut model, live);
Ok(())
})?,
)?;
Ok(())
}
#[cfg(test)]
mod tests {
use crate::script::cursor::EQUIPMENT_BAG;
use crate::script::{
BagAutoStore, BankBagSlots, ContainerMove, ContainerSlot, ContainerState, InvSlotView,
InventorySlots, UiScript,
};
/// An item that IS a bag — `equip_slots` is `find_equip_slot(INVTYPE_BAG)`, the only inventory
/// type in that table that names Bag0Slot, which is how [`super::is_container`] reads it.
fn bag_slot_view(item_id: u32) -> InvSlotView {
InvSlotView {
item_id,
icon: Some("Interface\\Icons\\INV_Misc_Bag_08".into()),
count: 1,
equip_slots: vec![20, 21, 22, 23],
..Default::default()
}
}
/// A backpack holding one bag in slot 1 and one ordinary (head-slot) item in slot 2.
fn backpack() -> ContainerState {
let mut slots = std::collections::HashMap::new();
slots.insert(
1,
ContainerSlot {
item_id: 4500,
count: 1,
texture: Some("Interface\\Icons\\INV_Misc_Bag_08".into()),
equip_slots: vec![20, 21, 22, 23],
..Default::default()
},
);
slots.insert(
2,
ContainerSlot {
item_id: 1234,
count: 1,
texture: Some("Interface\\Icons\\INV_Helmet_01".into()),
equip_slots: vec![1],
..Default::default()
},
);
ContainerState {
name: Some("Backpack".into()),
num_slots: 16,
slots,
}
}
/// Bank bag slot 1 (live id 64) holds a bag; the equipped bag slots stay empty.
fn one_bank_bag() -> BankBagSlots {
let mut bags: BankBagSlots = Default::default();
bags[0] = Some(bag_slot_view(4500));
bags
}
/// Finding 3, the one every caller depends on: an empty cursor answers **nil** and sends
/// nothing, which is what leaves `BagSlotButton_OnClick`'s `if ( not hadItem ) then
/// ToggleBag(...)` free to open the bag.
#[test]
fn an_empty_cursor_answers_nil_and_sends_nothing() {
let mut s = UiScript::new().unwrap();
s.set_bank_bag_slots(one_bank_bag());
assert!(s.eval::<bool>("return PutItemInBag(64) == nil").unwrap());
assert!(s.eval::<bool>("return PutItemInBag(20) == nil").unwrap());
assert!(s.eval::<bool>("return PutItemInBackpack() == nil").unwrap());
assert!(s.take_container_moves().is_empty());
assert!(s.take_bag_autostores().is_empty());
}
/// Leg 1 — the EMPTY slot equips the held bag, and answers **nil** while doing it
/// (`0x4c7cae xor eax,eax` is unconditional). That pairing is the reference's, not a slip:
/// the click both puts the bag in and opens the window it just created.
#[test]
fn an_empty_bag_slot_equips_the_held_bag_and_still_answers_nil() {
let mut s = UiScript::new().unwrap();
s.set_container(0, Some(backpack()));
s.set_bank_bag_slots(Default::default());
s.run("PickupContainerItem(0, 1)").unwrap();
assert!(s.eval::<bool>("return PutItemInBag(64) == nil").unwrap());
assert!(s.cursor_item().is_none(), "placed, not kept");
assert_eq!(
s.take_container_moves(),
vec![ContainerMove {
src_bag: 0,
src_slot: 1,
dst_bag: EQUIPMENT_BAG,
dst_slot: 64,
count: None,
}]
);
assert!(s.take_bag_autostores().is_empty(), "an equip, not a store");
}
/// Leg 1's fit rule: a bank bag slot takes a CONTAINER and nothing else. An ordinary item
/// dropped on an empty bank bag slot is refused and kept — the server's `CanBankItem` would
/// refuse it anyway, and there is nothing to auto-store into.
#[test]
fn an_empty_bag_slot_refuses_an_ordinary_item() {
let mut s = UiScript::new().unwrap();
s.set_container(0, Some(backpack()));
s.run("PickupContainerItem(0, 2)").unwrap();
assert!(s.eval::<bool>("return PutItemInBag(64) == nil").unwrap());
assert!(s.cursor_item().is_some(), "refused, kept");
assert!(s.take_container_moves().is_empty());
assert!(s.take_bag_autostores().is_empty());
}
/// Leg 2 — an OCCUPIED slot plus a held container is a bag↔bag swap, and answers `1`.
#[test]
fn an_occupied_bag_slot_swaps_with_a_held_bag_and_answers_one() {
let mut s = UiScript::new().unwrap();
s.set_container(0, Some(backpack()));
s.set_bank_bag_slots(one_bank_bag());
s.run("PickupContainerItem(0, 1)").unwrap();
assert_eq!(s.eval::<i64>("return PutItemInBag(64)").unwrap(), 1);
assert!(s.cursor_item().is_none());
assert_eq!(
s.take_container_moves(),
vec![ContainerMove {
src_bag: 0,
src_slot: 1,
dst_bag: EQUIPMENT_BAG,
dst_slot: 64,
count: None,
}]
);
}
/// Leg 3 — an OCCUPIED slot plus an ORDINARY item auto-stores INTO that bag. The intent
/// carries no destination slot, because the wire has none.
#[test]
fn an_occupied_bag_slot_auto_stores_an_ordinary_item() {
let mut s = UiScript::new().unwrap();
s.set_container(0, Some(backpack()));
s.set_bank_bag_slots(one_bank_bag());
s.run("PickupContainerItem(0, 2)").unwrap();
assert_eq!(s.eval::<i64>("return PutItemInBag(64)").unwrap(), 1);
assert!(s.cursor_item().is_none());
assert!(s.take_container_moves().is_empty(), "a store, not a swap");
assert_eq!(
s.take_bag_autostores(),
vec![BagAutoStore {
src_bag: 0,
src_slot: 2,
// Live container id 5 — bank bag slot 1 (`ContainerIDToInventoryID(5) == 64`).
dst_bag: 5,
count: None,
}]
);
}
/// Finding 1 — `PutItemInBackpack()` skips both the empty-slot and the swap legs outright: a
/// held BAG dropped on the backpack button is auto-stored, never swapped with it.
#[test]
fn the_backpack_button_always_auto_stores_even_a_bag() {
let mut s = UiScript::new().unwrap();
s.set_container(0, Some(backpack()));
s.run("PickupContainerItem(0, 1)").unwrap();
assert_eq!(s.eval::<i64>("return PutItemInBackpack()").unwrap(), 1);
assert!(s.take_container_moves().is_empty());
assert_eq!(
s.take_bag_autostores(),
vec![BagAutoStore {
src_bag: 0,
src_slot: 1,
dst_bag: 0,
count: None,
}]
);
}
/// Finding 3's literal shape: the reference pushes the Lua **number** `1` (`0x6f3810` writes
/// tag 3 with the IEEE-754 double `0x3FF0…`), never the boolean `true`. FrameXML only tests
/// truthiness; an addon comparing against `true` must see what the reference gives it.
#[test]
fn the_answer_is_the_number_one_not_the_boolean_true() {
let mut s = UiScript::new().unwrap();
s.set_container(0, Some(backpack()));
s.set_bank_bag_slots(one_bank_bag());
s.run("PickupContainerItem(0, 2)").unwrap();
assert!(s
.eval::<bool>("return PutItemInBag(64) == 1 and type(PutItemInBackpack()) ~= 'boolean'")
.unwrap());
}
/// The floor gate (`cmp ecx,0x13; jl`): equipment slots and ammo are below it, so the verb
/// never reaches the paper-doll handler through them.
#[test]
fn equipment_slots_are_below_the_floor() {
let mut s = UiScript::new().unwrap();
s.set_container(0, Some(backpack()));
let mut doll: InventorySlots = Default::default();
doll[16] = Some(bag_slot_view(9999));
s.set_inventory_slots(doll);
s.run("PickupContainerItem(0, 1)").unwrap();
assert!(s.eval::<bool>("return PutItemInBag(16) == nil").unwrap());
assert!(s.eval::<bool>("return PutItemInBag(0) == nil").unwrap());
assert!(s.cursor_item().is_some(), "untouched");
assert!(s.take_container_moves().is_empty());
assert!(s.take_bag_autostores().is_empty());
}
/// `PickupBagFromSlot` picks a bank bag up, returns NOTHING, and locks the slot.
#[test]
fn pickup_bag_from_slot_takes_the_bag_and_returns_nothing() {
let mut s = UiScript::new().unwrap();
s.set_bank_bag_slots(one_bank_bag());
assert_eq!(
s.eval::<i64>("return select('#', PickupBagFromSlot(64))")
.unwrap(),
0,
"the delegate's eax is never tested — no return values at all"
);
let held = s.cursor_item().expect("picked up");
assert_eq!(
(held.bag, held.slot, held.item_id),
(EQUIPMENT_BAG, 64, 4500)
);
assert!(s.eval::<bool>("return IsInventoryItemLocked(64)").unwrap());
}
/// **Destructive, and that is the finding**: `ClearCursor(1,1)` runs unconditionally before
/// the slot is even looked at (`0x4c7b79`), so a held payload is dropped even when the slot
/// turns out to hold nothing to pick up.
#[test]
fn pickup_bag_from_slot_drops_whatever_was_held_first() {
let mut s = UiScript::new().unwrap();
s.set_container(0, Some(backpack()));
s.set_bank_bag_slots(Default::default());
s.run("PickupContainerItem(0, 1)").unwrap();
assert!(s.cursor_item().is_some());
s.run("PickupBagFromSlot(65)").unwrap();
assert!(s.cursor_item().is_none(), "dropped, and nothing picked up");
assert!(s.take_container_moves().is_empty(), "no place path exists");
}
/// The `TYPEMASK_CONTAINER` resolve (`mov ecx,4`, where `PickupInventoryItem` uses 2): the
/// bank's own item slots are inside the gate's range and hold ordinary items, so the verb
/// silently no-ops there. A locked bag is refused too.
#[test]
fn pickup_bag_from_slot_only_takes_containers_and_never_a_locked_one() {
let mut s = UiScript::new().unwrap();
// Bank vault slot 3 → live id 42, an ordinary item: inside the gate, not a container.
let mut vault = std::collections::HashMap::new();
vault.insert(
3,
ContainerSlot {
item_id: 1234,
count: 1,
equip_slots: vec![1],
..Default::default()
},
);
s.set_container(
-1,
Some(ContainerState {
name: Some("Bank".into()),
num_slots: 24,
slots: vault,
}),
);
s.run("PickupBagFromSlot(42)").unwrap();
assert!(s.cursor_item().is_none(), "not a container — silent no-op");
let mut bags = one_bank_bag();
bags[0] = Some(InvSlotView {
locked: true,
..bag_slot_view(4500)
});
s.set_bank_bag_slots(bags);
s.run("PickupBagFromSlot(64)").unwrap();
assert!(s.cursor_item().is_none(), "locked — refused");
}
}
+17 -11
View File
@@ -22,6 +22,11 @@ use super::{queue_cursor_update, queue_lock_changed, CursorItem, CursorPayload,
/// `wire_pos`'s `EQUIPMENT_BAG` arm maps live id 20..23 → the wire's equipped-bag inventory slots
/// 19..22); ammo (0) stays a named deferral — refused outright, cursor untouched.
///
/// **The bank-bag band (64..=69) rides this too**, and only through one door: `PutItemInBag`'s
/// empty-slot leg IS this function in the reference (`0x4c7c00` calls `0x4c7300` there), which is
/// how a held bag reaches a bank bag slot at all. Its fit rule is one band wider than
/// `equip_slots` alone — see [`super::bag_verbs::fits_slot`].
///
/// - empty cursor + an occupied, UNLOCKED doll slot → picks it up (payload `Item { bag:
/// EQUIPMENT_BAG, slot: id, … }`), the same lock/event pair as a bag pickup.
/// - holding + the SAME slot → cancel (mirrors `pickup_container_item`'s own same-slot branch).
@@ -34,8 +39,9 @@ use super::{queue_cursor_update, queue_lock_changed, CursorItem, CursorPayload,
///
/// Returns whether the caller should repaint (the source lock, or the held payload, changed).
pub(super) fn pickup_inventory_item(model: &mut Model, id: u32) -> bool {
// Equipment slots (1..=19) + the four equipped-bag icons (20..=23); ammo (0) is out of scope.
if !(1..=23).contains(&id) {
// Equipment slots (1..=19) + the four equipped-bag icons (20..=23) + the six BANK bag slots
// (64..=69, reached only through `PutItemInBag`); ammo (0) is out of scope.
if !(1..=23).contains(&id) && !super::bag_verbs::BANK_BAG_INV_SLOTS.contains(&id) {
return false;
}
// The targeting cursor's item half, on the doll (decision 0923). The reference's doll pickup
@@ -52,9 +58,7 @@ pub(super) fn pickup_inventory_item(model: &mut Model, id: u32) -> bool {
match model.cursor.take() {
None => {
let picked = model
.inventory_slots
.get(id as usize)
.and_then(|s| s.as_ref())
.inv_slot("player", id as usize)
.filter(|s| s.item_id != 0 && !s.locked)
.map(|s| CursorItem {
bag: EQUIPMENT_BAG,
@@ -86,7 +90,7 @@ pub(super) fn pickup_inventory_item(model: &mut Model, id: u32) -> bool {
// A split carry can't equip a partial stack, and a non-fitting item has nowhere to
// land on this slot — both refuse, kept exactly as picked up (no event: nothing
// transitioned).
if held.count.is_some() || !held.equip_slots.contains(&(id as u8)) {
if held.count.is_some() || !super::bag_verbs::fits_slot(&held, id) {
model.cursor = Some(CursorPayload::Item(held));
return false;
}
@@ -139,9 +143,7 @@ pub(super) fn equip_cursor_item(model: &mut Model, id: u32) -> bool {
/// future pin disagrees with the client's own table.
pub(super) fn cursor_can_go_in_slot(model: &Model, id: u32) -> bool {
match &model.cursor {
Some(CursorPayload::Item(item)) => {
u8::try_from(id).is_ok_and(|id| item.equip_slots.contains(&id))
}
Some(CursorPayload::Item(item)) => super::bag_verbs::fits_slot(item, id),
_ => false,
}
}
@@ -186,12 +188,16 @@ pub(super) fn use_inventory_item(model: &mut Model, id: u32) {
/// picked-up slot dims immediately, no server round-trip — the doll twin of
/// `GetContainerItemInfo`'s `held_here` derivation) OR the app's fed `InvSlotView.locked` says so
/// (an outstanding pending op the app's `PendingItemOps` tracks, decision 0216 §4/0218 §3).
///
/// Reads through [`Model::inv_slot`], not the doll array, so it answers over the WHOLE live-id
/// space that binding covers — the reference's `BankFrameItemButton_UpdateLock` calls it with
/// `BankButtonIDToInvSlotID(...)` for every bank slot, and a lock the vault feed reports has to
/// dim the button the same as an equipped one.
pub(super) fn is_inventory_item_locked(model: &Model, id: u32) -> bool {
let held_here = matches!(&model.cursor, Some(CursorPayload::Item(c)) if c.bag == EQUIPMENT_BAG && c.slot == id);
let fed = usize::try_from(id)
.ok()
.and_then(|i| model.inventory_slots.get(i))
.and_then(|s| s.as_ref())
.and_then(|i| model.inv_slot("player", i))
.is_some_and(|s| s.locked);
held_here || fed
}
+3 -3
View File
@@ -160,13 +160,13 @@ pub use bank::BankState;
pub use bind_confirm::PendingEquipAnswer;
pub use camera_view::{CameraViewRequest, CAMERA_VIEW_COUNT};
pub use char_stats::{
weapon_subclass_skill, InvSlotView, InventorySlots, UnitCombatStats, INVENTORY_SLOT_COUNT,
SKILL_DEFENSE, SKILL_UNARMED,
weapon_subclass_skill, BankBagSlots, InvSlotView, InventorySlots, UnitCombatStats,
BANK_BAG_SLOT_COUNT, INVENTORY_SLOT_COUNT, SKILL_DEFENSE, SKILL_UNARMED,
};
pub use chat_send::ChatSend;
pub use chat_window::ChatWindowLook;
pub use container::{
ContainerMove, ContainerSlot, ContainerState, EnchantView, PetitionSlotView,
BagAutoStore, ContainerMove, ContainerSlot, ContainerState, EnchantView, PetitionSlotView,
RandomPropertyView, UiCursorMode,
};
pub use craft::{CraftReagent, CraftRecipe, CraftState, CraftTooltip};
+10
View File
@@ -849,6 +849,9 @@ pub(crate) struct Model {
/// `(bag, slot)` sources queued by `AutoEquipCursorItem` (decision 0208 phase 1b, `cursor`'s
/// `doll` submodule) — drained by the app into `CMSG_AUTOEQUIP_ITEM`.
pub(crate) container_autoequips: Vec<(i64, u32)>,
/// Auto-stores queued by `PutItemInBag`/`PutItemInBackpack` (`cursor`'s `bag_verbs` submodule) —
/// drained by the app into `CMSG_AUTOSTORE_BAG_ITEM` (or `CMSG_SPLIT_ITEM` for a split carry).
pub(crate) bag_autostores: Vec<container::BagAutoStore>,
/// Per-frame drag-button registrations (`RegisterForDrag`) — kind-independent (any Frame, not
/// just a Button), so it lives beside [`Model::mouse_down_on`] rather than inside a per-kind
@@ -1253,6 +1256,11 @@ pub(crate) struct Model {
/// binding's route is a fork between them (the [`Self::pet_book`] precedent).
pub(crate) pet_combat_stats: Option<char_stats::UnitCombatStats>,
pub(crate) inventory_slots: char_stats::InventorySlots,
/// The six bank-bag slots (live ids 64..=69) — the third band of
/// [`char_stats::Model::inv_slot`], and a store rather than a view because a bank bag has no
/// container slot to be read out of: it IS the container. Fed beside
/// [`Self::inventory_slots`] off the player descriptor's own guids.
pub(crate) bank_bag_slots: char_stats::BankBagSlots,
/// The 12 alert-region statuses (`GetInventoryAlertStatus`, DurabilityFrame's armor-guy
/// feed) in the client's own `0x806eb8` table order (11 equipment regions + the low-ammo
/// 12th) — recomputed on every inventory push, which fires `UPDATE_INVENTORY_ALERTS`
@@ -1646,6 +1654,7 @@ impl Model {
ui_cursor: None,
ui_cursor_dirty: false,
container_autoequips: Vec::new(),
bag_autostores: Vec::new(),
drag_registered: HashMap::new(),
drag: None,
moving: None,
@@ -1802,6 +1811,7 @@ impl Model {
player_combat_stats: None,
pet_combat_stats: None,
inventory_slots: Default::default(),
bank_bag_slots: Default::default(),
inventory_alerts: [0; 12],
paperdoll_yaw: 0.0,
inventory_uses: Vec::new(),
@@ -168,7 +168,7 @@ fn hiding_a_frame_does_not_reopen_the_gate() {
///
/// `ContainerFrameItemButton_OnUpdate` re-runs `OnEnter` every frame while the tooltip is the
/// button's own — faithful, and UNTHROTTLED in 1.12 (its `updateTooltip` throttle is commented
/// out; `BagFrame.xml`'s `BenillaBagSlot_OnUpdate` ships the same loop). So a bag hover clears and
/// out; the reference's `ContainerFrameItemButton_OnUpdate` ships the same loop). So a bag hover clears and
/// rebuilds the SAME tooltip 60×/sec, and the engine must absorb that: identical content re-derives
/// an identical model, so the measure cache re-validates on its content key and the gate stays shut.
///
+1 -1
View File
@@ -301,7 +301,7 @@ pub(super) fn clear_content(model: &mut Model, h: FrameHandle) {
// it here invalidates nothing the key would not — while destroying the one thing that
// makes the hover re-enter loop affordable. That loop
// (`ContainerFrameItemButton_OnUpdate`, unthrottled in 1.12 and shipped verbatim as
// `BagFrame.xml`'s `BenillaBagSlot_OnUpdate`) clears and rebuilds the SAME content
// the reference's `ContainerFrameItemButton_OnUpdate`) clears and rebuilds the SAME content
// every frame: keeping the cache lets each rebuilt line re-validate against its own
// key, so nothing re-shapes, the measure list stays empty, its forced second resolve
// never happens, and the layout gate's fingerprint — which exists precisely to