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
@@ -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
}