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:
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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.
|
||||
///
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user