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
+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