//! The app-side **container feed** (decision 0068 T2) — the inward half of the container seam //! around [`benilla_ui::script`]'s `container` module. //! //! Each frame, the player's own descriptor names the bag layout (the PRIVATE `PACK_SLOT` array = //! the backpack; `INV_SLOT` 19–22 = the equipped bags, each a container object with its own //! `CONTAINER_FIELD_SLOT` array; `KEYRING_SLOT` 81.. = the keyring, decision 0765 — a container //! with no container *object*, exactly like the bank), the item store ([`crate::items::Items`]) resolves slot guids to //! instances (entry, stack count), the template cache resolves entries to name/quality (ask-once //! `ITEM_QUERY_SINGLE` — a slot whose answer is in flight shows as an unresolved occupied slot and //! fills in when it lands), and `ItemDisplayInfo.dbc` turns the template's display id into the //! icon. The assembled per-bag [`ContainerState`](benilla_ui::script::ContainerState)s are diffed //! against what the VM holds and pushed with `BAG_UPDATE(bagID)` per changed bag (+ one trailing //! `BAG_UPDATE_DELAYED`, the live client's batch-end signal that bag addons coalesce on) — the //! [`feed`] submodule. //! //! The outward half ([`drain`]) drains `UseContainerItem(bagID, slot)` intents into the wire, //! mapping the Lua bag space onto the wire's (backpack = bag 255 + the player-array slot 23…; //! equipped bags = their own array slot 19–22 + 0-based inner slot — VERIFIED vmangos `Player.h` //! enums + `UseItem::ReadFromWorldPacket`, shared by every drain here as [`wire_pos`]) and making //! the real client's **equip-vs-use fork**: an *equippable* item (template `inventoryType != 0`, //! and not a quest-starter) goes out as `CMSG_AUTOEQUIP_ITEM` — a helm click puts the helm on — //! everything else through the one use fork ([`item_use_command`], our `CGItem::Use`), which sends //! `CMSG_QUESTGIVER_QUERY_QUEST` for a quest-starter and `CMSG_USE_ITEM` for everything else //! (decision 0664). Server refusals (`SMSG_INVENTORY_CHANGE_FAILURE` → [`EquipErrors`]) surface on //! the red UI error line with the client's message strings. The cursor-payload drains (decision //! 0216, whole-space since slice 2) ride the same wire map: a queued pick/place/swap move → //! `CMSG_SWAP_INV_ITEM` (backpack-internal) or `CMSG_SWAP_ITEM` (either end an equipped bag) / //! `CMSG_SPLIT_ITEM` (`drain::drain_container_moves`), a popup-confirmed destroy → //! `CMSG_DESTROYITEM` (`drain::drain_container_destroys`). //! //! **The pending-op lock** ([`crate::pending_item_ops::PendingItemOps`], decision 0216 §4 / //! byte-verified 0218 §3): every move/split/destroy drain locks the live-API `(bag, slot)` //! positions it sends — both ends of a move/split ("a send locks both ends"), the one slot of a //! destroy — until the descriptor's own field-update stream resolves the slot or the server //! answers a non-zero `SMSG_INVENTORY_CHANGE_FAILURE`. [`feed::feed_containers`] reads it into //! each pushed `ContainerSlot::locked` and fires `ITEM_LOCK_CHANGED` on every transition the //! drains don't already fire themselves. use benilla_protocol::messages::{BAG_PLAYER_INVENTORY, SLOT_BAG_FIRST, SLOT_PACK_FIRST}; use benilla_protocol::ObjectFields; use benilla_ui::script::EQUIPMENT_BAG; use bevy::prelude::*; use crate::items::Items; use crate::net::{NetCommands, ObjectStore}; use crate::pending_item_ops::{LockClearedByFailure, PendingItemOps}; use crate::ui_script::UiInput; use crate::ui_unit::UnitFeed; mod drain; mod equip_error; pub(crate) mod feed; pub(crate) use drain::send_auto_equip; use drain::{ drain_bag_autostores, drain_container_autoequips, drain_container_destroys, drain_container_moves, drain_container_uses, drain_inventory_uses, }; use feed::{ feed_containers, feed_item_sets, feed_item_stats, feed_player_req, feed_random_properties, }; /// The backpack's fixed capacity (`PLAYER_FIELD_PACK_SLOT_1..` — 16 slots on the 1.12 wire). pub(super) const PACK_SLOTS: u8 = 16; /// The worn-equipment slots (`INV_SLOT` 0..18 — head through tabard, vmangos `PlayerSlots`), the /// first region of the reference's inventory walk (wow-re `action-item-slot.md` §8.2). pub(super) const EQUIPMENT_SLOTS: u8 = 19; /// The first equipped-bag inventory slot (`INV_SLOT` 19..22 hold bags 1..4). pub(super) const BAG_SLOT_FIRST: u8 = 19; /// Equipped bag count (live-API bag ids 1..=4). pub(super) const BAGS: u8 = 4; /// The bank's generic capacity (`PLAYER_FIELD_BANK_SLOT_1..` — 24 slots, wire 39..62; decision /// 0604: streamed at login like the backpack, the window only reveals them). pub(super) const BANK_SLOTS: u8 = 24; /// The first bank generic slot in the player array (vmangos `BANK_SLOT_ITEM_START`). pub(super) const BANK_SLOT_FIRST: u8 = 39; /// The first bank-bag slot in the player array (vmangos `BANK_SLOT_BAG_START`; wire 63..68 hold /// bank bags 1..6, and — like equipped bags — a bank bag's own slot number IS its wire bag byte). pub(super) const BANK_BAG_SLOT_FIRST: u8 = 63; /// Bank bag count (live-API bag ids [`BANK_BAG_ID_FIRST`]..=10). pub(super) const BANK_BAGS: u8 = 6; /// The bank's live-API container id (`BANK_CONTAINER`, the reference `BankFrame.lua:1`). pub(crate) const BANK_CONTAINER: i64 = -1; /// The first bank-bag live-API container id (bank bags are containers 5..=10, the reference id /// space: `NUM_BAG_SLOTS + 1 ..`). pub(crate) const BANK_BAG_ID_FIRST: i64 = 5; /// The keyring's live-API container id (`KEYRING_CONTAINER`, the reference /// `MainMenuBarBagButtons.lua:1`; decision 0765). pub(crate) const KEYRING_CONTAINER: i64 = -2; /// The first keyring slot in the player array (vmangos `KEYRING_SLOT_START`). pub(super) const KEYRING_SLOT_FIRST: u8 = 81; /// Addressable keyring positions on this wire — vmangos `KEYRING_SLOT_END 97`, i.e. slots 81..96. /// The descriptor *array* is 32 guids wide and the client's inventory walker scans all 32 (81–112, /// `player_keyring_slot`'s note), but 97.. is not a valid position: the server's own enum comment /// is "32 slots (only 16 are visible/accessible in UI)". How many of the 16 a player may actually /// use is level-gated — [`keyring_size`]. pub(super) const KEYRING_SLOTS: u8 = 16; /// `BagFamily` 9 = `BAG_FAMILY_KEYS` — the enum value that makes an item a *key*: what the server /// routes into the keyring (`Player::_CanStoreItem`'s `pProto->BagFamily == BAG_FAMILY_KEYS` arm) /// and what the reference's `HasKey` searches for ([`has_key`]). pub(super) const BAG_FAMILY_KEYS: u32 = 9; /// How many keyring slots a level-`level` player may use — the reference's own `GetKeyRingSize` /// (`ContainerFrame.lua:773`), which the server enforces with the identical ladder /// (`Player::GetMaxKeyringSize`, `Player.h:985`): **4** below 40, **8** at 40, **12** at 50, /// **16** above 60 — that last rung is unreachable at 1.12's level cap of 60, and is transcribed /// only because both the client and the server carry it. Both sides agreeing on the ladder is why /// benilla can compute this rather than being told it. /// /// The reference recomputes it in Lua from `UnitLevel("player")` at every use; benilla computes it /// once here, feeds it as the keyring container's `num_slots`, and lets Lua's `GetKeyRingSize()` /// read that back — one formula, one place, the same number. pub(crate) fn keyring_size(level: u32) -> u32 { match level { 61.. => 16, 50..=60 => 12, 40..=49 => 8, _ => 4, } } /// Lua (bag, 1-based slot) → wire `(bag_index, slot)` — the one mapping every drain shares /// (uses/moves/splits/destroys/autoequips, decision 0216 §6, extended to [`EQUIPMENT_BAG`] by /// decision 0208 phase 1b): bag `0` (the backpack) → the player's own grid /// ([`BAG_PLAYER_INVENTORY`] + [`SLOT_PACK_FIRST`] + the 0-based slot); bags `1..=4` (an equipped /// bag) → that bag's own player-array slot ([`SLOT_BAG_FIRST`] + `bag - 1`) + the 0-based inner /// slot; [`EQUIPMENT_BAG`] (a doll slot, live ids 1..=23 — the 19 equipment slots plus the four /// equipped-bag icons Bag0Slot=20..Bag3Slot=23; ammo 0 stays a named deferral) → the SAME player /// grid, `slot1 - 1` directly (`GetInventorySlotInfo`'s live id minus one IS the wire slot — /// HeadSlot 1 → wire 0 … Tabard 19 → wire 18, Bag0Slot 20 → wire 19 = `INVENTORY_SLOT_BAG_START` /// … Bag3Slot 23 → wire 22). Both backpack AND doll positions land on [`BAG_PLAYER_INVENTORY`], so /// the existing move drain's "both ends 255 ⇒ `CMSG_SWAP_INV_ITEM`" branch already routes /// doll↔backpack, doll↔doll, and a bag dragged from the backpack onto a bag slot (the equip) with /// no change of its own. The bank (decision 0604) rides the same player-array convention: /// [`BANK_CONTAINER`] (the 24 generic slots) → `(255, 39..62)`; bank bags 5..=10 → the bag's own /// player-array slot 63..68 as the wire bag byte (exactly the equipped-bag rule); and the doll /// space grows the bank-bag *buttons* as live ids 64..69 (the same "live id − 1 = wire slot" law, /// so dragging a bag onto a bank bag slot routes through the existing swap drain unchanged). The /// **keyring** ([`KEYRING_CONTAINER`], decision 0765) is the plainest case of all: its slots ARE /// player-array slots ([`KEYRING_SLOT_FIRST`] + the 0-based slot), so every keyring move lands on /// [`BAG_PLAYER_INVENTORY`] and rides the existing `CMSG_SWAP_INV_ITEM` branch to/from the /// backpack and the doll, or `CMSG_SWAP_ITEM` when the other end is an equipped bag — no drain /// changed for it. Ranged at the wire's [`KEYRING_SLOTS`] (81..96), NOT the level-gated /// [`keyring_size`]: a click past the unlocked count is the server's refusal to give, not ours to /// pre-empt (and the window never draws those slots anyway). /// `None` for `slot1 == 0` or a slot past the bag's/doll's range. pub(crate) fn wire_pos(bag: i64, slot1: u32) -> Option<(u8, u8)> { let slot0 = u8::try_from(slot1.checked_sub(1)?).ok()?; match bag { 0 if slot0 < PACK_SLOTS => Some((BAG_PLAYER_INVENTORY, SLOT_PACK_FIRST + slot0)), 1..=4 if slot0 < 36 => Some((SLOT_BAG_FIRST + (bag as u8 - 1), slot0)), BANK_CONTAINER if slot0 < BANK_SLOTS => { Some((BAG_PLAYER_INVENTORY, BANK_SLOT_FIRST + slot0)) } 5..=10 if slot0 < 36 => { Some((BANK_BAG_SLOT_FIRST + (bag - BANK_BAG_ID_FIRST) as u8, slot0)) } KEYRING_CONTAINER if slot0 < KEYRING_SLOTS => { Some((BAG_PLAYER_INVENTORY, KEYRING_SLOT_FIRST + slot0)) } EQUIPMENT_BAG if (1..=23).contains(&slot1) || (64..=69).contains(&slot1) => { Some((BAG_PLAYER_INVENTORY, slot0)) } _ => None, } } /// One inventory refusal off the wire — everything the error line's two argument-taking reasons /// need. Both fills are per-reason and neither is ever set for the other's code, so they ride as /// plain fields rather than an enum (decision 0916: exactly two of the 67 reasons format an /// argument, and the reference sources them from different places). #[derive(Debug, Clone, Copy)] pub(crate) struct EquipError { /// The wire `InventoryResult`. pub reason: u8, /// Reason 1's `%d` — the packet's own `requiredLevel` field. pub required_level: Option, /// Reason 16's `%s` source — the destination bag's ABSOLUTE player slot (255 = the player's /// own array, which names no bag). Not read off the wire for the message: it is a *slot*, and /// the drain resolves it to the bag's `BagFamily` name. pub bag_slot: u8, } /// Inventory refusals (`SMSG_INVENTORY_CHANGE_FAILURE`) queued by the net bridge for the UI error /// line — the equip twin of [`crate::ui_action::CastErrors`]. #[derive(Resource, Default)] pub(crate) struct EquipErrors(pub Vec); /// The item guid in a Lua-space bag slot, read off the player descriptor (backpack), the bag /// object's own slot array, or — [`EQUIPMENT_BAG`], decision 0208 phase 1b — the player /// descriptor's own `INV_SLOT` array directly (`slot0` is already the wire `EQUIPMENT_SLOT_*` /// id, [`wire_pos`]'s own convention). The same resolution the feed does. pub(crate) fn slot_guid(store: &ObjectFields, bag: i64, slot0: u8, items: &Items) -> Option { match bag { 0 => store.player_pack_slot(slot0).filter(|g| *g != 0), 1..=4 => { let bag_guid = store .player_inv_slot(BAG_SLOT_FIRST + bag as u8 - 1) .filter(|g| *g != 0)?; items .object(bag_guid)? .container_slot(slot0) .filter(|g| *g != 0) } BANK_CONTAINER => store.player_bank_slot(slot0).filter(|g| *g != 0), KEYRING_CONTAINER => store.player_keyring_slot(slot0).filter(|g| *g != 0), 5..=10 => { let bag_guid = store .player_bank_bag_slot((bag - BANK_BAG_ID_FIRST) as u8) .filter(|g| *g != 0)?; items .object(bag_guid)? .container_slot(slot0) .filter(|g| *g != 0) } // The doll: equipment/bag icons read the INV array (its accessor caps at 23); the // bank-bag *buttons* (wire 63..68) read their own descriptor array (decision 0604). EQUIPMENT_BAG if (BANK_BAG_SLOT_FIRST..BANK_BAG_SLOT_FIRST + BANK_BAGS).contains(&slot0) => { store .player_bank_bag_slot(slot0 - BANK_BAG_SLOT_FIRST) .filter(|g| *g != 0) } EQUIPMENT_BAG => store.player_inv_slot(slot0).filter(|g| *g != 0), _ => None, } } /// `(item guid, stack count)` at a Lua-space `(bag, 1-based slot)` — [`PendingItemOps`]'s baseline /// unit ([`slot_guid`] plus the count field, since a partial split-merge/destroy changes only the /// count, never the guid; see `crate::pending_item_ops`'s doc on why the lock tracks both). `(0, /// 0)` for an empty slot, an absent player, or a slot past the bag's range. pub(crate) fn slot_guid_count( store: Option<&ObjectStore>, bag: i64, slot1: u32, items: &Items, ) -> (u64, u32) { let Some(store) = store else { return (0, 0); }; let slot0 = slot1.saturating_sub(1) as u8; match slot_guid(&store.0, bag, slot0, items) { Some(guid) => { let count = items .object(guid) .and_then(|f| f.item_stack_count()) .unwrap_or(1); (guid, count) } None => (0, 0), } } /// An item template's icon path — its `DisplayInfoID` joined through `ItemDisplayInfo.dbc` (the /// [`ItemDisplays`] catalog the equipment feed already loads). /// /// This is the **one** thing the client's spell-icon surfaces genuinely share. The *laws* do not: /// wow-re's `system/ui/scratch/spell-icon-substitution-law.md` settled that there is no shared /// spell-icon resolver at all — six Lua getters, six laws inlined per binding, disagreeing even /// between the TradeSkill and Craft windows. But every arm that ends at an item ends *here*, at the /// same `ItemTemplate+0x18 → 0x5d88b0 → rec+0x14` chain (§5 of that note). So the join lives once, /// and each window keeps its own law above it. pub(crate) fn item_icon( icons: Option<&crate::entities::ItemDisplays>, display_info_id: u32, ) -> Option { icons .and_then(|i| i.catalog.get(display_info_id)) .and_then(|d| d.icon.clone()) } /// Which sections of the player's flat slot array a walk visits — the reference walker's own /// **section mask** (`0x622420`'s `ebx`; wow-re `ui/scratch/quest-leaderboard-law.md` §3.1 and /// `action-item-slot.md` §8.2). The reference has ONE walker over one contiguous 113-guid slot /// space (`PLAYER_FIELD_INV_SLOT_HEAD` through the end of the keyring) and parameterises it here; /// benilla had grown two hand-rolled walks that had already drifted apart, which is what decision /// 1158 collapsed back into [`walk_inventory`]. /// /// A container met in an ENABLED section is always recursed into — the reference gates sections /// only at the player's own root descriptor ("inside a recursed container every slot is visited /// unfiltered"), and its recursion bit `0x10` is clear in every mask any caller passes. Buyback /// (slots 69–80) has no bit at all and is unreachable at any mask. #[derive(Clone, Copy, PartialEq, Eq, Debug)] pub(crate) struct InventoryScope { /// `0x01` — worn equipment, slots 0–18. equipment: bool, /// `0x02` — the four equipped bag slots 19–22, and their contents. bags: bool, /// `0x04` — the backpack, slots 23–38. backpack: bool, /// `0x08` — bank items (39–62) and the six bank-bag slots (63–68) with their contents. bank: bool, /// `0x40` — the keyring band. keyring: bool, } impl InventoryScope { /// `0x47` — what every caller that passes mask `0` actually gets, because `0x622420` rewrites /// `mask |= 0x47` whenever the descriptor is the local player's root (`0x622434`–`0x622439`, /// and the CGPlayer_C ctor sets that flag unconditionally at `0x5dd44d`). Gear + bags and /// their contents + backpack + keyring; **no bank**. pub(crate) const DEFAULT: Self = Self { equipment: true, bags: true, backpack: true, bank: false, keyring: true, }; /// `0x01` — the equip-vs-use fork's first stage, "is a copy of this already worn?" /// (`0x4e5fe7`'s `push 1`). No expansion, no recursion. pub(crate) const EQUIPMENT_ONLY: Self = Self { equipment: true, bags: false, backpack: false, bank: false, keyring: false, }; /// `0x4F` — [`Self::DEFAULT`] **plus the bank**, the mask the quest surfaces pass (`8`, which /// the rewrite turns into `0x4F`). wow-re's call-site census of `0x622130` finds six such /// sites and every one is a quest surface: `GetQuestLogLeaderBoard` (`0x4e0579`/`0x4e0592`), /// the ADD_ITEM toast (`0x5dd0f5`), the whole-quest turn-in predicate (`0x4df778`), and /// `GetAbandonQuestItems` (`0x4dfc8a`). **Quest item objectives count banked copies; nothing /// else does.** pub(crate) const QUEST_ITEMS: Self = Self { bank: true, ..Self::DEFAULT }; /// Bags + backpack only — **narrower than any reference mask**, and named so the divergence is /// visible at each call site rather than buried in a walk. This is what benilla's item counts /// have always used; every non-quest caller keeps it verbatim under decision 1158 so that /// record changes exactly one surface. Closing it (to [`Self::DEFAULT`], adding worn gear and /// the keyring) moves action-bar counts and reagent counts and is its own change. pub(crate) const CARRIED: Self = Self { equipment: false, bags: true, backpack: true, bank: false, keyring: false, }; } /// The reference's inventory walk, once: a single ascending pass over the player's flat slot array, /// recursing depth-first into each container as it is passed, with `scope` gating the sections. /// `visit` sees every occupied slot as the wire `(bag_index, 0-based slot)` pair plus the instance /// guid, in walk order, and returns `Some` to stop the walk (a search) or `None` to continue (a /// count). /// /// **The order is the reference's and is load-bearing** (decision 0666 pinned it for the search /// leg; 1158 made it the only copy): equipment 0–18 → bag slot 19 and all of bag 1's contents → 20 /// (+contents) → 21 (+contents) → 22 (+contents) → backpack 23–38 → bank 39–62 → bank bag 63 and /// its contents → … → 68 (+contents) → keyring. Equipment is searched FIRST, and a bag's contents /// come before the backpack. fn walk_inventory( store: &ObjectFields, items: &Items, scope: InventoryScope, mut visit: impl FnMut(u8, u8, u64) -> Option, ) -> Option { // A container's own contents, addressed by the container's slot number as the wire bag byte // (true for equipped bags and bank bags alike — `BANK_BAG_SLOT_FIRST`'s note). let contents = |bag_slot: u8, bag_guid: u64, visit: &mut dyn FnMut(u8, u8, u64) -> Option| { let bag_fields = items.object(bag_guid)?; let num_slots = bag_fields.container_num_slots().unwrap_or(0).min(36) as u8; (0..num_slots).find_map(|j| { let guid = bag_fields.container_slot(j).unwrap_or(0); (guid != 0).then(|| visit(bag_slot, j, guid)).flatten() }) }; if scope.equipment { for i in 0..EQUIPMENT_SLOTS { let guid = store.player_inv_slot(i).unwrap_or(0); if guid != 0 { if let Some(hit) = visit(BAG_PLAYER_INVENTORY, i, guid) { return Some(hit); } } } } if scope.bags { for bag in 0..BAGS { let bag_slot = BAG_SLOT_FIRST + bag; let bag_guid = store.player_inv_slot(bag_slot).unwrap_or(0); if bag_guid == 0 { continue; } // The bag OBJECT is a candidate in its own right before its contents are. if let Some(hit) = visit(BAG_PLAYER_INVENTORY, bag_slot, bag_guid) { return Some(hit); } if let Some(hit) = contents(bag_slot, bag_guid, &mut visit) { return Some(hit); } } } if scope.backpack { for i in 0..PACK_SLOTS { let guid = store.player_pack_slot(i).unwrap_or(0); if guid != 0 { if let Some(hit) = visit(BAG_PLAYER_INVENTORY, SLOT_PACK_FIRST + i, guid) { return Some(hit); } } } } if scope.bank { for i in 0..BANK_SLOTS { let guid = store.player_bank_slot(i).unwrap_or(0); if guid != 0 { if let Some(hit) = visit(BAG_PLAYER_INVENTORY, BANK_SLOT_FIRST + i, guid) { return Some(hit); } } } for bag in 0..BANK_BAGS { let bag_slot = BANK_BAG_SLOT_FIRST + bag; let bag_guid = store.player_bank_bag_slot(bag).unwrap_or(0); if bag_guid == 0 { continue; } if let Some(hit) = visit(BAG_PLAYER_INVENTORY, bag_slot, bag_guid) { return Some(hit); } if let Some(hit) = contents(bag_slot, bag_guid, &mut visit) { return Some(hit); } } } if scope.keyring { // The ADDRESSABLE 16, not the descriptor array's 32 (the reference walks all 32): 16.. is // not a valid position on this wire, so it can never hold anything and the extra reads // would only cost time. for i in 0..KEYRING_SLOTS { let guid = store.player_keyring_slot(i).unwrap_or(0); if guid != 0 { if let Some(hit) = visit(BAG_PLAYER_INVENTORY, KEYRING_SLOT_FIRST + i, guid) { return Some(hit); } } } } None } /// How many of item `entry` the player holds within `scope`, summing each matching copy's /// `ITEM_FIELD_STACK_COUNT` — the reference's `0x622130(itemId, mask)` (`0x622160` is the /// per-item predicate: `OBJECT_FIELD_ENTRY` equality at `0x622166`, then `+= [+0x20]` at /// `0x622177`). /// /// **The scope is the caller's, and it is not cosmetic**: a quest item objective counts banked /// copies ([`InventoryScope::QUEST_ITEMS`], the reference's mask `8` → `0x4F`) while an action /// button's count and a reagent count do not. An unresolved slot (its item template still in /// flight) can't be matched to an entry and is skipped this frame; it counts once the answer lands /// and the feed reruns. pub(crate) fn count_of( store: &ObjectFields, items: &Items, entry: u32, scope: InventoryScope, ) -> u32 { let mut total = 0u32; walk_inventory::<()>(store, items, scope, |_, _, guid| { if let Some(fields) = items.object(guid) { if fields.object_entry() == Some(entry) { total += fields.item_stack_count().unwrap_or(1); } } None }); total } /// How far [`find_item`] looks, and which copies count — the two mode bits the reference's own /// callers pass into the inventory walker `0x622420` (wow-re `action-item-slot.md` §8.2). #[derive(Clone, Copy, Default)] pub(crate) struct ItemSearch { /// Mode `1` alone: the **equipment slots only** (0–18), no expansion. The equip-vs-use fork's /// first stage — "is a copy of this already worn?" (`0x4e5fe7`'s `push 1`). pub(crate) equipment_only: bool, /// Mode bit `0x20`: skip a copy whose live `ITEM_FIELD_SPELL_CHARGES[0]` is `0` — the use /// leg sets it when the TEMPLATE says the item has finite charges, so a click reaches a copy /// that still has uses left instead of a spent one. Containers are never skipped by it. pub(crate) live_charges_only: bool, } /// Where a copy of item `entry` is: the wire `(bag_index, 0-based slot)` pair ([`wire_pos`]'s own /// output shape) plus the **instance guid** that occupies it, since the use fork needs it /// ([`item_use_command`]). This is the reference's inventory search, byte-verified (wow-re /// `action-item-slot.md` §8.2: the walker `0x622420` over `PLAYER_FIELD_INV_SLOT_HEAD`, predicate /// `OBJECT_FIELD_ENTRY` equality) — the first hit of [`walk_inventory`], whose doc carries the /// order and why it is load-bearing (decision 0666; bank and buyback are not in this scope). pub(crate) fn find_item( store: &ObjectFields, items: &Items, entry: u32, search: ItemSearch, ) -> Option<(u8, u8, u64)> { let scope = if search.equipment_only { InventoryScope::EQUIPMENT_ONLY } else { InventoryScope::DEFAULT }; walk_inventory(store, items, scope, |bag, slot, guid| { let f = items.object(guid)?; if f.object_entry() != Some(entry) { return None; } // Under the charges filter, the instance must have uses left. A container is exempt // (the walker's own carve-out). if search.live_charges_only && f.container_num_slots().is_none_or(|n| n == 0) && f.item_spell_charges(0).is_some_and(|c| c == 0) { return None; } Some((bag, slot, guid)) }) } /// Every occupied slot `scope` reaches, **in the walker's own order** — [`walk_inventory`]'s visit /// sequence collected rather than searched. /// /// It exists because some predicates need the item's TEMPLATE, and the template lookup wants /// [`Items`] mutably (it is ask-once: a miss fires the query) while the walk holds it immutably. /// `has_key` solved that by hand-listing the slots it wanted; this keeps the one real walker and /// its load-bearing order (decisions 0666/1158) and just defers the judging by one step. pub(crate) fn collect_inventory( store: &ObjectFields, items: &Items, scope: InventoryScope, ) -> Vec<(u8, u8, u64)> { let mut out = Vec::new(); // `None` throughout — the walk is never stopped, so every slot in scope lands in `out`. walk_inventory(store, items, scope, |bag, slot, guid| { out.push((bag, slot, guid)); None::<()> }); out } /// The reference's **`HasKey()`** (`0x48ae90`) — "does this player own a key at all?", the one /// gate that decides whether the keyring exists in the UI (decision 0765). Byte-read: it fetches /// the active player, then runs the same inventory walker `find_item` transcribes /// (`0x6223a0` → `0x622420`) with predicate `0x6223d0` — `ItemTemplate.BagFamily == 9` /// ([`BAG_FAMILY_KEYS`]; `template+0x1d0` is the record's last int32, and `BagFamily` is the last /// field of `SMSG_ITEM_QUERY_SINGLE_RESPONSE`) — and pushes `1` on a hit, `nil` otherwise. /// /// **The mode is `0x4f`, not the walker's default `0x47`**: equipment `0x01` | bag slots `0x02` | /// backpack `0x04` | **bank + bank bags `0x08`** | keyring `0x40`. So a key sitting in the *bank* /// still shows the keyring — the one region the ordinary item search skips. Buyback has no bit and /// is never searched, here or anywhere. /// /// A slot whose item template is still in flight can't be judged and reads as "not a key"; the /// answer lands within a frame or two and the feed re-pushes. pub(crate) fn has_key(store: &ObjectFields, items: &mut Items, commands: &NetCommands) -> bool { // Every guid mode 0x4f reaches, in the walker's own order — a container is recursed into as it // is passed (the depth-first rule), which is why each bag's contents follow its own slot. // Collected first, then judged: the template lookup needs `items` mutably (the ask-once query). fn contents(bag_guid: u64, items: &Items, out: &mut Vec) { let Some(f) = items.object(bag_guid) else { return; }; let n = f.container_num_slots().unwrap_or(0).min(36) as u8; out.extend((0..n).map(|j| f.container_slot(j).unwrap_or(0))); } let mut guids = Vec::new(); for i in 0..EQUIPMENT_SLOTS { guids.push(store.player_inv_slot(i).unwrap_or(0)); } for bag in 0..BAGS { let bag_guid = store.player_inv_slot(BAG_SLOT_FIRST + bag).unwrap_or(0); guids.push(bag_guid); contents(bag_guid, items, &mut guids); } for i in 0..PACK_SLOTS { guids.push(store.player_pack_slot(i).unwrap_or(0)); } for i in 0..BANK_SLOTS { guids.push(store.player_bank_slot(i).unwrap_or(0)); } for bag in 0..BANK_BAGS { let bag_guid = store.player_bank_bag_slot(bag).unwrap_or(0); guids.push(bag_guid); contents(bag_guid, items, &mut guids); } for i in 0..KEYRING_SLOTS { guids.push(store.player_keyring_slot(i).unwrap_or(0)); } guids.into_iter().any(|guid| { if guid == 0 { return false; } let Some(entry) = items.object(guid).and_then(|f| f.object_entry()) else { return false; }; items .template(entry, guid, commands) .is_some_and(|t| t.bag_family == BAG_FAMILY_KEYS) }) } /// One resolved item-use click, as [`send_item_use`] needs it: the wire position, the two /// template scalars the fork reads, and the on-use SPELL the cast tail is keyed on. #[derive(Clone, Copy, Debug)] pub(crate) struct ItemUse { /// The live instance's guid (`None` = it didn't resolve; the fork falls through to a plain use). pub(crate) guid: Option, /// Template `StartQuest` — non-zero diverts to the quest offer, ahead of the cast tail. pub(crate) start_quest: u32, /// The wire position (`255` = the player array). pub(crate) bag_index: u8, /// The wire slot, 0-based. pub(crate) slot: u8, /// The item's template ENTRY — the cooldown store keys item records on `(use_spell, entry)` /// (the client's `[eax+8]==spellId && [eax+0xc]==itemID` match), and the ladder's not-ready /// rung queries exactly that pair for the item leg's 0x28 (decision 0948; closes the /// item-entry gap 0914 named). pub(crate) entry: u32, /// The template spell BLOCK ordinal the server should cast (decision 0666). pub(crate) spell_index: u8, /// The template's ON_USE spell id — `0x5d8c80`'s answer: the first template block whose /// `SpellId != 0` **and** `SpellTrigger == 0`. `None` = there is no such block. pub(crate) use_spell: Option, /// The GameObject this use is aimed at — the key-in-a-lock arm (decision 0769), which is /// `CGItem::Use`'s own target argument. `None` for every ordinary click. pub(crate) on_object: Option, /// The template's `ITEM_FLAG_CHARTER` (`0x2000`) — a signable guild petition /// (decision 1672). Diverts to [`ItemUseRoute::ShowPetition`]. pub(crate) is_charter: bool, } /// **The item-use fork** — our `CGItem::Use` (`0x5d8d00`): the one place that decides what "using" /// an item actually sends. The reference has exactly one such function and every use surface calls /// it — the bag click (`Script::UseContainerItem` @ `0x4fa430`), the doll click /// (`Script::UseInventoryItem` → `0x4c7af0`) and the action bar (`UseAction`'s engine @ `0x4e607b`) /// — so the fork lives here rather than in any one drain (decision 0664: three call sites each /// re-deriving it is exactly how the quest fork came to be missing from all three). /// /// A template whose **`StartQuest`** is non-zero never goes out as `CMSG_USE_ITEM`: the client /// sends `CMSG_QUESTGIVER_QUERY_QUEST{the ITEM's own guid, StartQuest}` (byte-verified — the /// `[rec+0x1a8] != 0` branch at `0x5d8dcc` calls the `0x186` builder `0x5eab80` with the CGItem's /// guid), and the server answers `SMSG_QUESTGIVER_QUEST_DETAILS` with the item as the giver /// (vmangos `HandleQuestgiverQueryQuestOpcode` resolves an `HIGHGUID_ITEM` guid through /// `TYPEMASK_CREATURE_GAMEOBJECT_OR_ITEM`) — i.e. the quest's accept panel. Sending `CMSG_USE_ITEM` /// instead is what draws *"The item was not found."*: `HandleUseItemOpcode` refuses any item whose /// `Spells[spellSlot].SpellId` is 0 with `EQUIP_ERR_ITEM_NOT_FOUND`, and **no** quest-starter /// carries an on-use spell (0 of the 215 in live `mangos.item_template`). /// /// `guid: None` (the template/instance didn't resolve) falls through to the plain use, whose /// refusal is at least visible — the same fallback the equip fork makes. /// /// [`ItemUseRoute`] is that decision as a value — the pure fork, split from [`send_item_use`] so /// the law is testable without a World. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) enum ItemUseRoute { /// Arm #3 (`0x5d8dd2`): a non-zero `StartQuest` offers its quest and **returns**, long before /// the cast tail at `0x5d9249`. An offer is not a cast. QuestOffer { npc: u64, quest: u32 }, /// The **toggle-cancel** arm (`0x5d9234`–`0x5d9246`): the item's own ON_USE spell is live on /// the caster as a cancelable active-icon aura, so the click sends `CMSG_CANCEL_AURA` for it /// and **returns `false` with no cast** — the item seam's separately-compiled twin of the /// action button's spell-branch toggle (`0x4e55f0`/`0x4e60c1`, [`crate::ui_action::toggle`]). /// This is what makes clicking your mount item while mounted **dismount** you. ToggleCancel(u32), /// The cast tail (`0x5d9249`–`0x5d9258` → `0x6e5a90` → `TryCast`): run the whole ladder. Cast(u32), /// `0x5d8c80` found no ON_USE block, so it returns 0 and the tail hands `TryCast` spell id /// **0** — whose rec lookup `[0xc0d788 + 0]` is null and whose `6e4bac` bail returns false /// with **nothing sent**. (Before decision 0914 we sent anyway, and vmangos answered /// `EQUIP_ERR_ITEM_NOT_FOUND` — a red "Item not found." the reference never shows.) Nothing, /// **The charter arm** (decision 1672): using an item whose template flags carry /// `ITEM_FLAG_CHARTER` (`0x2000`) opens the petition window — it sends /// `CMSG_PETITION_SHOW_SIGNATURES` for the instance, not `CMSG_USE_ITEM`. /// /// **INFERRED, and this is the whole of the evidence.** wow-re has not carved `CGItem::Use`'s /// charter branch, so the *position* of this arm and the opcode it sends are reasoned rather /// than read: /// /// - 1.12's shipped FrameXML special-cases a charter **nowhere** — `UseContainerItem` and /// `UseAction` are entirely generic — so whatever opens the window is engine-side. /// - The charter template (entry 5863) has no ON_USE spell, no `StartQuest`, and /// `InventoryType = 0`, so *every* other arm of this fork declines it and the click currently /// reaches [`Self::Nothing`] and sends nothing at all. Right-clicking a charter in 1.12 /// plainly does something, so an arm must exist. /// - `CMSG_PETITION_SHOW_SIGNATURES` is the only opcode that opens the window, and vmangos's /// `HandleUseItemOpcode` has no charter branch — a `CMSG_USE_ITEM` here would be answered /// with nothing. /// - The reference already keys other charter behaviour on this exact flag bit: the item /// tooltip's `ITEM_SIGNABLE` line and its enchant-line suppression both test `0x2000` /// (wow-re `system/ui/scratch/tooltip-content-law.md:485-505`). /// /// The arm sits directly after the quest fork. Its **order relative to the quest fork is /// unobservable** — no 1.12 item is both a charter and a quest starter — so nothing here rests /// on it; a byte read of `0x5d8d00` settles both the position and the opcode. ShowPetition { item: u64 }, } /// [`ItemUseRoute`]'s decision. `guid: None` (the instance never resolved) cannot address a /// questgiver, so it falls through to the ordinary path — the same fallback the equip fork makes. /// /// `aura_cancels` is the toggle predicate applied to the item's ON_USE spell id — the caller's /// [`crate::ui_action::toggle::active_action_toggle`], passed in so this stays a pure function of /// the click. The **order is the reference's**: the quest offer forks first (`0x5d8dcc`, well /// above), then the toggle scan (`0x5d9157`), then the cast tail (`0x5d9249`). pub(crate) fn item_use_route(it: ItemUse, aura_cancels: impl Fn(u32) -> bool) -> ItemUseRoute { if let Some(npc) = it.guid.filter(|_| it.start_quest != 0) { return ItemUseRoute::QuestOffer { npc, quest: it.start_quest, }; } // The charter arm (decision 1672) — see `ItemUseRoute::ShowPetition` for its evidence and for // why its position here is unobservable. A charter with no resolved instance cannot be // addressed, so it falls through to the ordinary path exactly as the quest fork does. if let Some(item) = it.guid.filter(|_| it.is_charter) { return ItemUseRoute::ShowPetition { item }; } match it.use_spell { Some(spell) if aura_cancels(spell) => ItemUseRoute::ToggleCancel(spell), Some(spell) => ItemUseRoute::Cast(spell), None => ItemUseRoute::Nothing, } } /// **The item-use fork's cast tail** — the only place a `CMSG_USE_ITEM` leaves benilla, and the /// seam where an item use enters the *one* cast ladder. /// /// The reference has no separate item-send path at all. `CGItem::Use 0x5d8d00`'s ordinary tail /// (`5d9249`–`5d9258`) calls `0x6e5a90`, whose whole 54-byte body is `call 0x6e4b60` — /// **`TryCast`**, the same function the spellbook, `/cast` and the action bar's SPELL arm run, /// with the **item as an ordinary argument** (`ret 0xc`). Inside TryCast that argument is read /// exactly twice, and neither read skips a gate: `6e4d76` computes a display flag ahead of the /// IsCasting check, and `6e4f33` forwards the item to the requirement validator `0x6094f0`. The /// commit `SendCast 0x6e54f0` then picks the opcode from it (`0x6e57d8 push 0xab`). So an item /// use takes the whole ladder — cooldown, GCD, in-flight, mounted, moving, form, reagents, target /// bind, range — and [`crate::ui_action::CastLadder::send`] is where all of that lives (decision /// 0914; verified at the bytes in wow-re's `system/ui/scratch/disasm-full.txt`, corroborated by /// its `action-item-slot.md` §8 and `cursor-system.md` §8.4a). /// /// Decision 0908 put only the in-flight rung here — the fix for the director's B200, where /// double-clicking a mount shipped a second `CMSG_USE_ITEM`, vmangos answered it /// `SPELL_FAILED_SPELL_IN_PROGRESS`, and the failure (spell-id-keyed, naming the *same* spell as /// the running cast) red-faded the running bar while the cast completed anyway. 0914 finishes the /// job: the rest of the ladder was still bypassed, so a bag click ignored cooldowns, a mounted or /// moving item click sent a doomed packet, and the bar's ITEM arm carried a private duplicate of /// the cooldown rung the bag and doll clicks never got. /// /// The **toggle-cancel** arm is this seam's other half, and it is why clicking your mount item /// while mounted dismounts you rather than drawing "You are mounted". `CGItem::Use` carries a /// separately-compiled twin of the action button's spell-branch toggle, `0x5d9157`–`0x5d9246`, /// sitting *above* the cast tail: /// /// ```text /// 5d915a edi = OnUseSpellId(item) ; 0x5d8c80, the same block scan use_spell reads /// 5d917c SpellRec[edi] + 0x1d8 (ActiveIconID) != 0 else fall through to the cast /// 5d9197 for slot in 0..0x30: ; UNIT_FIELD_AURA[48], block +0xa4 /// 5d919a aura[slot] == edi /// 5d91b7 && AURAFLAGS nibble bit0 set ; block +0x164, 2 slots per byte /// 5d91c5 -> 5d9234: CancelAura(edi) (0x6e7040 -> CMSG_CANCEL_AURA 0x136), return false /// ``` /// /// It is the identical predicate to `0x4e55f0` — [`crate::ui_action::toggle::active_action_toggle`] /// — just reached with the *item's* spell instead of the slot's, so the one predicate serves both /// (wow-re `shapeshift-plaincast-toggle.md`'s own `0x6e7040` call-site census lists `0x5d9237` /// under `0x5d8d00` as "action button, container-**item** branch"). /// /// Returns whether anything left for the server — `false` for the reference's silent no-op. pub(crate) fn send_item_use( it: ItemUse, ctx: &crate::ui_action::cast_target::CastContext, ladder: &mut crate::ui_action::CastLadder, script: &mut benilla_ui::script::UiScript, gate: &mut crate::ui_bind_confirm::BindGate, suppress: bool, ) -> bool { // The toggle predicate, resolved once against the caster's live aura slots. Both inputs are // already here: the spell's ActiveIconID column and the caster's descriptor. let aura_cancels = |spell: u32| { let Some(d) = ladder.spells.as_ref().and_then(|s| s.catalog.get(spell)) else { return false; }; ctx.rel .self_store .is_some_and(|store| crate::ui_action::toggle::active_action_toggle(spell, d, store)) }; match item_use_route(it, aura_cancels) { ItemUseRoute::ToggleCancel(spell) => { debug!("ui_items: item use {spell} re-pressed — its aura cancels, no cast"); let _ = ladder .commands .0 .send(crate::net::ClientCommand::CancelAura { spell_id: spell }); true } ItemUseRoute::QuestOffer { npc, quest } => { let _ = ladder .commands .0 .send(crate::net::ClientCommand::QuestgiverQuery { npc, quest }); true } ItemUseRoute::ShowPetition { item } => { let _ = ladder .commands .0 .send(crate::net::ClientCommand::PetitionShowSignatures { item }); true } // **The bind-on-use deferral** (`0x5d91d3`-`0x5d91f2`, decision 1750), and its POSITION is // half the law. The reference's bind arm is the last rung of `0x5d8d00`: every arm above — // the gift, the quest offer, the petition, the readable, the charge/slot rungs and the aura // toggle — has already claimed the click and exited before a bind question can be asked, so // re-pressing a bind-on-use trinket to cancel its aura must NOT ask you to bind it. // // **But it covers `Nothing` as well as `Cast`, and that is not an accident.** `0x5d91d3` // has five predecessors and four of them are on-use-spell lookup *failures* (`5d9166` // id < 0, `5d916e` id past the table, `5d917a` no Spell.dbc row, `5d9184` no ActiveIconID); // the reference asks the bind question even when the item has no usable on-use spell at // all. Gating this on `Cast(spell)` alone — which is where it was first written — would be // narrower than the reference, and wow-re said so in as many words. ItemUseRoute::Nothing | ItemUseRoute::Cast(_) if !suppress && it .guid .is_some_and(|g| gate.use_binds(&mut ladder.items, &ladder.commands, g)) => { gate.defer_use(script, it); false } ItemUseRoute::Nothing => { debug!( "ui_items: the item at wire {}/{} has no ON_USE block — nothing sent (TryCast's null-rec bail)", it.bag_index, it.slot ); false } ItemUseRoute::Cast(spell) => { ladder.send( spell, ctx, crate::ui_action::CastCommit::Item { bag_index: it.bag_index, slot: it.slot, entry: it.entry, spell_index: it.spell_index, on_object: it.on_object, }, ); true } } } /// The client's quality→color escape for an item link (`GetItemQualityColor`'s table) — shared by /// [`item_link`] and everything downstream of it (one table, no drifting twins). /// /// VERIFIED against the 1.12.1 binary (`WoW.exe` 5875): `0x52ad90` indexes the seven-pointer table /// at `0x854124` into the `|cffRRGGBB` literals at `0x8546dc`, and **clamps anything `>= 7` to /// index 1** (white) — which is what the catch-all arm below is, not a defensive default. pub(super) fn quality_color(quality: u32) -> &'static str { match quality { 0 => "ff9d9d9d", 2 => "ff1eff00", 3 => "ff0070dd", 4 => "ffa335ee", 5 => "ffff8000", 6 => "ffe6cc80", _ => "ffffffff", } } /// Build one item hyperlink — **the** item-link builder, the single owner of the escape shape. /// /// VERIFIED against the 1.12.1 binary: `0x52adb0` is `SStrPrintf(dst, 0x400, fmt, …)` over /// `fmt @0x8549c8 = "%s|Hitem:%d:%d:%d:%d|h[%s]|h%s"`, with the leading `%s` the [`quality_color`] /// escape, the four `%d` = (item id, enchant id, random-property id, suffix factor), and the /// trailing `%s` the `"|r"` reset (`0x844538`). Every link the client shows — bag, paperdoll, /// inspect, loot-roll announcement, "You receive loot" — comes out of this one function, so ours /// does too: five hand-rolled `format!` twins of this string is how one site (the receive line) /// silently shipped a bare, uncoloured name. pub(super) fn item_link_full( item_id: u32, enchant_id: u32, random_property_id: u32, suffix_factor: u32, name: &str, quality: u32, ) -> String { format!( "|c{}|Hitem:{item_id}:{enchant_id}:{random_property_id}:{suffix_factor}|h[{name}]|h|r", quality_color(quality) ) } /// [`item_link_full`] for a caller that has no enchant/random-property ids in hand. /// /// **Stated approximation (documented gap).** Bag, paperdoll and inspect items *do* carry an /// enchant and a random property on the wire; we do not thread either into the link yet, and the /// real client additionally appends the `ItemRandomProperties.dbc` suffix to the **name** /// (`0x5d8b00`) so a random-suffix green reads "Chipped Claw of the Bear". Both are one /// random-suffix arc, not per-call-site drift — which is why the zeros live here, once. pub(super) fn item_link(item_id: u32, name: &str, quality: u32) -> String { item_link_full(item_id, 0, 0, 0, name, quality) } /// `INVTYPE_AMMO` — the projectile/ammo inventory type (arrows, bullets). Loaded via /// `CMSG_SET_AMMO`, not the equip-swap wire (decision 0526); the equip drains fork on it. pub(super) const INVTYPE_AMMO: u32 = 24; /// The INVTYPE → live-API equip-slot(s) map decision 0208 phase 1b's "the fit rule" needs /// (`cursor::CursorItem`/`InvSlotView`/`ContainerSlot`'s `equip_slots`), transcribed from vmangos /// `ItemPrototype::GetAllowedEquipSlots` (`Objects/Item.cpp:577-696`) — the table /// `Player::FindEquipSlot` (`Objects/Player.cpp:8440-8479`) walks to answer "where can this go". /// Returns 1-based live ids (`GetInventorySlotInfo`'s own numbering, wire `EQUIPMENT_SLOT_*` + 1 /// — HeadSlot=1 … TabardSlot=19, the equipped-bag icons 20..23); empty = not equippable /// (consumables, quest items, armor tokens with no vanilla slot, …). /// /// Two named simplifications from the server's own function (0218 §4's residual: the client's /// terminal equip-fit check, `0x5da1d0`, was never byte-pinned — this is the best-available /// authority, corrected if a future pin disagrees): /// - **`INVTYPE_WEAPON` always offers BOTH main and off hand** (the server's `canDualWield` gate /// dropped): the real server only suggests the offhand slot when the class already knows dual /// wield, but getting that wrong here only over-permits a `CURSOR_UPDATE` highlight — the /// actual equip still round-trips through `SMSG_INVENTORY_CHANGE_FAILURE` /// (`EQUIP_ERR_CANT_DUAL_WIELD`) if the class can't. Simpler than threading class into every /// caller for a highlight-only consequence. /// - **`INVTYPE_RELIC` answers no slots** (the server's own table gates it per-class onto the /// ranged slot for Paladin/Druid/Shaman/Warlock librams/idols/totems): decision 0208 already /// established the relic slot is vanilla-UI-invisible (`UnitHasRelicSlot` always false, no /// relic slot ever shows on the 1.12 paper doll), so resolving this precisely drives no visible /// interaction — a named, harmless gap rather than threading class through for a slot nothing /// ever shows. pub(super) fn find_equip_slot(inventory_type: u32) -> Vec { // Live-API ids (`char_stats::SLOT_INFO`'s own numbering): wire `EQUIPMENT_SLOT_*` + 1. The // ammo slot is the client's own `GetInventorySlotInfo("AmmoSlot")` == 0 (not a real equip slot; // ammo loads by entry via `CMSG_SET_AMMO`, decision 0526) — it just names the fit-rule target. const AMMO: u8 = 0; const HEAD: u8 = 1; const NECK: u8 = 2; const SHOULDERS: u8 = 3; const BODY: u8 = 4; // the shirt slot (EQUIPMENT_SLOT_BODY) const CHEST: u8 = 5; const WAIST: u8 = 6; const LEGS: u8 = 7; const FEET: u8 = 8; const WRISTS: u8 = 9; const HANDS: u8 = 10; const FINGER1: u8 = 11; const FINGER2: u8 = 12; const TRINKET1: u8 = 13; const TRINKET2: u8 = 14; const BACK: u8 = 15; const MAINHAND: u8 = 16; const OFFHAND: u8 = 17; const RANGED: u8 = 18; const TABARD: u8 = 19; const BAG0: u8 = 20; const BAG1: u8 = 21; const BAG2: u8 = 22; const BAG3: u8 = 23; match inventory_type { 1 => vec![HEAD], // INVTYPE_HEAD 2 => vec![NECK], // INVTYPE_NECK 3 => vec![SHOULDERS], // INVTYPE_SHOULDERS 4 => vec![BODY], // INVTYPE_BODY (the shirt) 5 | 20 => vec![CHEST], // INVTYPE_CHEST / INVTYPE_ROBE (same slot) 6 => vec![WAIST], // INVTYPE_WAIST 7 => vec![LEGS], // INVTYPE_LEGS 8 => vec![FEET], // INVTYPE_FEET 9 => vec![WRISTS], // INVTYPE_WRISTS 10 => vec![HANDS], // INVTYPE_HANDS 11 => vec![FINGER1, FINGER2], // INVTYPE_FINGER 12 => vec![TRINKET1, TRINKET2], // INVTYPE_TRINKET 13 => vec![MAINHAND, OFFHAND], // INVTYPE_WEAPON (dual-wield simplified, see doc) 14 => vec![OFFHAND], // INVTYPE_SHIELD 15 => vec![RANGED], // INVTYPE_RANGED 16 => vec![BACK], // INVTYPE_CLOAK 17 => vec![MAINHAND], // INVTYPE_2HWEAPON 18 => vec![BAG0, BAG1, BAG2, BAG3], // INVTYPE_BAG 19 => vec![TABARD], // INVTYPE_TABARD 21 => vec![MAINHAND], // INVTYPE_WEAPONMAINHAND 22 => vec![OFFHAND], // INVTYPE_WEAPONOFFHAND 23 => vec![OFFHAND], // INVTYPE_HOLDABLE 24 => vec![AMMO], // INVTYPE_AMMO → the ammo slot (loaded via SET_AMMO) 25 => vec![RANGED], // INVTYPE_THROWN 26 => vec![RANGED], // INVTYPE_RANGEDRIGHT // INVTYPE_NON_EQUIP(0), INVTYPE_QUIVER(27), INVTYPE_RELIC(28, see doc), and anything past // MAX_INVTYPE(29): not equippable. _ => Vec::new(), } } /// The ItemSet.dbc catalog — the tooltip SET block's row source (name/members/bonuses/skill). #[derive(Resource)] pub(crate) struct ItemSets(pub(crate) benilla_formats::ItemSetCatalog); /// The ItemSubClass.dbc catalog — the slot|type line's alternate-proficiency and hidden-name /// gates ([`feed`]'s template resolve). #[derive(Resource)] pub(crate) struct ItemSubClasses(pub(crate) benilla_formats::ItemSubClassCatalog); /// The ItemBagFamily.dbc catalog — reason 16's `%s`, i.e. what a specialised bag accepts /// ("Only Arrows can be placed in that."). See [`feed`]'s `bag_family_name`, decision 0916. #[derive(Resource)] pub(crate) struct ItemBagFamilies(pub(crate) benilla_formats::ItemBagFamilyCatalog); /// The ItemClass.dbc catalog — what an item's class is *called* ("Weapon", "Container"), /// i.e. `GetItemInfo`'s `itemType`. See [`feed`]'s template resolve. #[derive(Resource)] pub(crate) struct ItemClasses(pub(crate) benilla_formats::ItemClassCatalog); /// Startup (after the MPQ chain opens): the item-tooltip DBCs. On failure a resource is simply /// absent — set items render without their SET block, subclass gates read as absent. fn load_item_dbcs(mut commands: Commands, world_assets: Option>) { use benilla_assets::LockRecover; let Some(world_assets) = world_assets else { return; }; let mut chain = world_assets.chain.lock_recover(); match benilla_formats::load_item_sets(&mut chain) { Ok(cat) => { info!("ui_items: ItemSet.dbc loaded ({} sets)", cat.len()); commands.insert_resource(ItemSets(cat)); } Err(e) => warn!("ui_items: ItemSet.dbc failed to load: {e:#}"), } match benilla_formats::load_item_sub_classes(&mut chain) { Ok(cat) => { info!("ui_items: ItemSubClass.dbc loaded ({} rows)", cat.len()); commands.insert_resource(ItemSubClasses(cat)); } Err(e) => warn!("ui_items: ItemSubClass.dbc failed to load: {e:#}"), } match benilla_formats::load_item_classes(&mut chain) { Ok(cat) => { info!("ui_items: ItemClass.dbc loaded ({} classes)", cat.len()); commands.insert_resource(ItemClasses(cat)); } Err(e) => warn!("ui_items: ItemClass.dbc failed to load: {e:#}"), } match benilla_formats::load_auction_houses(&mut chain) { Ok(cat) => { info!("ui_items: AuctionHouse.dbc loaded ({} houses)", cat.len()); commands.insert_resource(crate::ui_auction::AuctionHouses(cat)); } Err(e) => warn!("ui_items: AuctionHouse.dbc failed to load: {e:#}"), } match benilla_formats::load_item_bag_families(&mut chain) { Ok(cat) => { info!( "ui_items: ItemBagFamily.dbc loaded ({} families)", cat.len() ); commands.insert_resource(ItemBagFamilies(cat)); } Err(e) => warn!("ui_items: ItemBagFamily.dbc failed to load: {e:#}"), } } pub(crate) struct UiItemsPlugin; impl Plugin for UiItemsPlugin { fn build(&self, app: &mut App) { // The icon source — `ItemDisplayInfo.dbc` — is the `ItemDisplays` resource the equipment // renderer already loads (one parse serves the world and the bags). app.init_resource::() .init_resource::() // The soulbind confirmations' pending records (decision 1750) — the client's own // pending-equip array and its one bind-on-use cell. .init_resource::() .init_resource::() .init_resource::() // AFTER the chain opens — a bare Startup slot raced AssetSet::Open and, when it // won, silently skipped every item DBC (no ItemSets/ItemSubClasses resource for the // whole session: set tooltips lost their SET block, the crafting book its headers). // Exposed by 0446's header law; every other DBC loader already orders this way. .add_systems( Startup, load_item_dbcs.after(benilla_assets::AssetSet::Open), ) .add_systems( Update, ( // `.before(CooldownEvents)`: the slot cooldown triples must be in the VM // before `feed_action_state`'s synchronous `BAG_UPDATE_COOLDOWN` makes the // bag handlers re-read them (the set's own doc — else a fresh cooldown's // pie waits for the NEXT store change). feed_containers .in_set(UnitFeed) .before(crate::ui_action::CooldownEvents) .before(UiInput), // The shared item-tooltip store: answer stat asks before the input pass so a // re-hover the very next frame already sees them. feed_item_stats.in_set(UnitFeed).before(UiInput), feed_item_sets.in_set(UnitFeed).before(UiInput), // The roll table, pushed whole once per VM (1547) — before the input pass, so // the first hover of the session already resolves a drop's suffix lines. feed_random_properties.in_set(UnitFeed).before(UiInput), feed_player_req.in_set(UnitFeed).before(UiInput), // After the input pass, so a click's UseContainerItem goes out the same frame. drain_container_uses.after(UiInput), // The left-click pick/place/split drain — a queued move → CMSG_SWAP_INV_ITEM / // CMSG_SWAP_ITEM / CMSG_SPLIT_ITEM (doll↔bag/doll↔doll included, decision 0208 // phase 1b — same drain, EQUIPMENT_BAG rides the existing wire map). drain_container_moves.after(UiInput), // The delete-confirm popup's accept — a queued destroy → CMSG_DESTROYITEM. drain_container_destroys.after(UiInput), // AutoEquipCursorItem's queue (decision 0208 phase 1b) → CMSG_AUTOEQUIP_ITEM. drain_container_autoequips.after(UiInput), drain_bag_autostores.after(UiInput), // UseInventoryItem's queue (decision 0208 phase 1b) → CMSG_USE_ITEM against // the equipped position. drain_inventory_uses.after(UiInput), // EquipPendingItem/CancelPendingEquip/ConfirmBindOnUse — the soulbind // confirmations' answers (decision 1750). After the input pass like every // other drain, and after the drains whose deferrals it answers: a dialog // raised this frame is answered in a LATER one, so the order between them is // not load-bearing, but keeping it last matches the flow. drain::drain_bind_confirm_answers.after(UiInput), drain::drain_bind_on_use_confirms.after(UiInput), ), ); } } #[cfg(test)] mod tests { use super::{ find_equip_slot, item_use_route, keyring_size, wire_pos, ItemUse, ItemUseRoute, KEYRING_CONTAINER, }; use benilla_ui::script::EQUIPMENT_BAG; /// The keyring's level ladder, both ends of every rung — the reference's `GetKeyRingSize` /// (ContainerFrame.lua:773) and vmangos `GetMaxKeyringSize` (Player.h:985) agree on it exactly, /// which is what lets benilla compute the size instead of being told it. 60 is 1.12's cap, so /// the 16 rung is unreachable in play — it is here because both authorities carry it. #[test] fn keyring_size_walks_the_reference_ladder() { assert_eq!(keyring_size(1), 4); assert_eq!(keyring_size(39), 4, "the rung ends at 39"); assert_eq!(keyring_size(40), 8, "40 opens the second rung"); assert_eq!(keyring_size(49), 8); assert_eq!(keyring_size(50), 12); assert_eq!(keyring_size(60), 12, "the level cap still sits on 12"); assert_eq!(keyring_size(61), 16, "> 60, unreachable in 1.12"); } /// The keyring's wire mapping (decision 0765): its Lua slots are player-array slots 81.., so /// every one lands on the player's own grid — which is what makes keyring↔backpack moves ride /// the existing `CMSG_SWAP_INV_ITEM` branch with no drain change. Ranged at the wire's 16 /// addressable positions (vmangos `KEYRING_SLOT_END` 97), not the level-gated count. #[test] fn wire_pos_maps_the_keyring_onto_the_player_grid() { assert_eq!(wire_pos(KEYRING_CONTAINER, 1), Some((255, 81))); assert_eq!(wire_pos(KEYRING_CONTAINER, 16), Some((255, 96))); assert_eq!( wire_pos(KEYRING_CONTAINER, 17), None, "97 is past KEYRING_SLOT_END — not a position on this wire" ); assert_eq!(wire_pos(KEYRING_CONTAINER, 0), None); } /// The pure fork ([`item_use_route`], decisions 0664/0914), all four arms: a non-zero /// `StartQuest` diverts to `CMSG_QUESTGIVER_QUERY_QUEST` addressed to the ITEM's guid (arm #3, /// `0x5d8dd2` — it returns before the cast tail); an ON_USE spell whose aura is live on the /// caster **cancels** (`0x5d9234`) instead of casting; an ON_USE spell otherwise runs the /// ladder; **no** ON_USE block sends nothing at all, because `0x5d8c80` returns 0 and TryCast's /// null-rec bail (`6e4bac`) refuses spell id 0. That last arm is the one 0914 changed: we used /// to ship a `CMSG_USE_ITEM` vmangos answers `EQUIP_ERR_ITEM_NOT_FOUND` — a red "Item not /// found." on every right-click of a plain trade good, which the reference never shows. #[test] fn the_item_use_fork_routes_quest_offer_cast_and_nothing() { // "An Unsent Letter" (entry 2874, StartQuest 373 — live `mangos.item_template`): the item // guid is the questgiver, not the bag position. No quest-starter carries an on-use spell // (0 of the 215 live ones), but the quest arm wins even if one did. let letter = 0x4000_0000_0000_0BAD_u64; let it = |guid, start_quest, use_spell| ItemUse { entry: 0, guid, start_quest, bag_index: 255, slot: 23, spell_index: 0, use_spell, on_object: None, is_charter: false, }; let never = |_| false; assert_eq!( item_use_route(it(Some(letter), 373, None), never), ItemUseRoute::QuestOffer { npc: letter, quest: 373 } ); assert_eq!( item_use_route(it(Some(letter), 0, Some(8690)), never), ItemUseRoute::Cast(8690), "a hearthstone takes the cast tail" ); assert_eq!( item_use_route(it(Some(letter), 0, None), never), ItemUseRoute::Nothing, "no ON_USE block — the ref sends nothing" ); // No resolved instance (the template is still in flight): a query against guid 0 is // impossible, so the ordinary path runs — the equip fork's own fallback. assert_eq!( item_use_route(it(None, 373, Some(8690)), never), ItemUseRoute::Cast(8690) ); } /// **The charter arm** (decision 1672): an item whose template carries `ITEM_FLAG_CHARTER` /// opens the petition window instead of taking the cast tail. /// /// This is the arm's whole justification as a test: the live charter template (entry 5863) has /// **no** ON_USE spell, **no** `StartQuest` and `InventoryType = 0`, so without this arm the /// click reaches `Nothing` and sends absolutely nothing — a charter you can buy and never open. /// The `Nothing` case below is that pre-1672 behaviour, pinned beside the fix so the two are /// visibly one decision. #[test] fn a_charter_click_opens_the_petition_instead_of_casting() { let charter = 0x4000_0000_0000_5863_u64; let it = |guid, is_charter, use_spell| ItemUse { entry: 5863, guid, start_quest: 0, bag_index: 255, slot: 23, spell_index: 0, use_spell, on_object: None, is_charter, }; let never = |_| false; assert_eq!( item_use_route(it(Some(charter), true, None), never), ItemUseRoute::ShowPetition { item: charter }, "the charter opens its petition window" ); // Without the flag, the very same item is the reference's silent no-op — which is what a // charter click did before this arm existed. assert_eq!( item_use_route(it(Some(charter), false, None), never), ItemUseRoute::Nothing ); // No resolved instance: nothing to address the show-signatures with, so it falls through // exactly as the quest fork does on the same condition. assert_eq!( item_use_route(it(None, true, None), never), ItemUseRoute::Nothing ); assert_eq!( item_use_route(it(None, true, Some(8690)), never), ItemUseRoute::Cast(8690) ); } /// **The mount click, both ways** (the director, 08-03: "clicking the mount while mounted /// should dismount"). `CGItem::Use`'s toggle scan sits above the cast tail, so an item whose /// ON_USE spell is already live on the caster cancels its aura and never casts — and the same /// item clicked while the aura is *not* live takes the ordinary ladder. The predicate itself /// (ActiveIconID ≠ 0 ∧ a cancelable aura slot) is pinned in `ui_action::toggle`; here it is /// stubbed, so this pins the FORK and its ORDER. #[test] fn a_live_aura_makes_the_item_click_cancel_instead_of_cast() { const SUMMON_HORSE: u32 = 17462; let it = |start_quest, use_spell| ItemUse { entry: 0, guid: Some(0x4000_0000_0000_0BAD), start_quest, bag_index: 255, slot: 23, spell_index: 0, use_spell, on_object: None, is_charter: false, }; let mounted = |spell: u32| spell == SUMMON_HORSE; assert_eq!( item_use_route(it(0, Some(SUMMON_HORSE)), mounted), ItemUseRoute::ToggleCancel(SUMMON_HORSE), "mounted: the click dismounts — CMSG_CANCEL_AURA, no CMSG_USE_ITEM", ); assert_eq!( item_use_route(it(0, Some(SUMMON_HORSE)), |_| false), ItemUseRoute::Cast(SUMMON_HORSE), "not mounted: the very same click casts", ); // Order: the quest offer forks at `0x5d8dcc`, above the toggle scan at `0x5d9157`. assert_eq!( item_use_route(it(373, Some(SUMMON_HORSE)), mounted), ItemUseRoute::QuestOffer { npc: 0x4000_0000_0000_0BAD, quest: 373 }, ); // …and an item with no ON_USE block has nothing to cancel, whatever the predicate says. assert_eq!(item_use_route(it(0, None), |_| true), ItemUseRoute::Nothing); } /// A dozen representative `InventoryType`s (decision 0208 phase 1b's own ask), spanning the /// single-slot rows, the two-slot rows, the weapon rows' MAINHAND/OFFHAND split, and the /// not-equippable rows — cross-checked against vmangos /// `ItemPrototype::GetAllowedEquipSlots` (`Objects/Item.cpp:577-696`). #[test] fn find_equip_slot_matches_the_vmangos_table() { assert_eq!(find_equip_slot(1), vec![1], "HEAD"); assert_eq!(find_equip_slot(2), vec![2], "NECK"); assert_eq!(find_equip_slot(4), vec![4], "BODY (shirt)"); assert_eq!(find_equip_slot(5), vec![5], "CHEST"); assert_eq!(find_equip_slot(20), vec![5], "ROBE aliases CHEST"); assert_eq!(find_equip_slot(11), vec![11, 12], "FINGER, two slots"); assert_eq!(find_equip_slot(12), vec![13, 14], "TRINKET, two slots"); assert_eq!( find_equip_slot(13), vec![16, 17], "WEAPON offers both hands (dual-wield simplified)" ); assert_eq!(find_equip_slot(14), vec![17], "SHIELD -> off hand"); assert_eq!(find_equip_slot(15), vec![18], "RANGED"); assert_eq!(find_equip_slot(16), vec![15], "CLOAK -> back"); assert_eq!(find_equip_slot(17), vec![16], "2HWEAPON -> main hand only"); assert_eq!(find_equip_slot(19), vec![19], "TABARD"); assert_eq!(find_equip_slot(21), vec![16], "WEAPONMAINHAND"); assert_eq!(find_equip_slot(22), vec![17], "WEAPONOFFHAND"); assert_eq!(find_equip_slot(18), vec![20, 21, 22, 23], "BAG"); assert_eq!(find_equip_slot(24), vec![0], "AMMO -> the ammo slot (id 0)"); // Not equippable: no vanilla paper-doll slot, or a named deferral (quiver/relic). for t in [0u32, 27, 28, 100] { assert!(find_equip_slot(t).is_empty(), "inventory type {t}"); } } /// [`wire_pos`]'s [`EQUIPMENT_BAG`] branch — the doll-slot mapping decision 0208 phase 1b /// adds: live id `n` (1..=23) → the SAME player grid ([`benilla_protocol::messages:: /// BAG_PLAYER_INVENTORY`]) at wire slot `n - 1` — the 19 equipment slots plus the four /// equipped-bag icons (20..23 → wire 19..22, the drag-to-equip target); ammo (0) is refused, /// matching the engine's own `pickup_inventory_item` range guard. #[test] fn wire_pos_maps_equipment_bag_to_the_player_grid() { assert_eq!(wire_pos(EQUIPMENT_BAG, 1), Some((255, 0)), "HeadSlot"); assert_eq!(wire_pos(EQUIPMENT_BAG, 19), Some((255, 18)), "TabardSlot"); assert_eq!(wire_pos(EQUIPMENT_BAG, 16), Some((255, 15)), "MainHandSlot"); assert_eq!(wire_pos(EQUIPMENT_BAG, 0), None, "ammo — out of scope"); // The four equipped-bag icons map onto the wire's bag inventory slots (19..22). assert_eq!(wire_pos(EQUIPMENT_BAG, 20), Some((255, 19)), "Bag0Slot"); assert_eq!(wire_pos(EQUIPMENT_BAG, 23), Some((255, 22)), "Bag3Slot"); assert_eq!(wire_pos(EQUIPMENT_BAG, 24), None, "past the bag icons"); // Both a backpack move and a doll move land on the SAME wire bag (255) — the existing // drain_container_moves branch ("both ends 255 ⇒ CMSG_SWAP_INV_ITEM") already routes // doll↔backpack and doll↔doll correctly with no code of its own to add. assert_eq!( wire_pos(0, 1).map(|(b, _)| b), wire_pos(EQUIPMENT_BAG, 1).map(|(b, _)| b) ); } /// [`wire_pos`]'s bank arms (decision 0604): the 24 generic slots land on the player grid at /// wire 39..62; bank bags 5..=10 use their own player-array slot 63..68 as the wire bag byte /// (the equipped-bag rule); and the doll space carries the bank-bag *buttons* as live 64..69 /// (live id − 1 = wire slot, so bag-into-bank-slot drags ride the existing swap drain). #[test] fn wire_pos_maps_the_bank_spaces() { use super::BANK_CONTAINER; // The 24 generic slots: live 1..24 → (255, 39..62). assert_eq!(wire_pos(BANK_CONTAINER, 1), Some((255, 39))); assert_eq!(wire_pos(BANK_CONTAINER, 24), Some((255, 62))); assert_eq!(wire_pos(BANK_CONTAINER, 25), None, "past the vault"); assert_eq!(wire_pos(BANK_CONTAINER, 0), None); // Bank bags: container 5 is the bag in player-array slot 63, container 10 in 68. assert_eq!(wire_pos(5, 1), Some((63, 0))); assert_eq!(wire_pos(10, 36), Some((68, 35))); assert_eq!(wire_pos(11, 1), None, "past the bank bags"); // The bank-bag buttons in doll space: live 64..69 → wire 63..68; the gap 24..63 refuses. assert_eq!(wire_pos(EQUIPMENT_BAG, 64), Some((255, 63)), "BankBag1"); assert_eq!(wire_pos(EQUIPMENT_BAG, 69), Some((255, 68)), "BankBag6"); assert_eq!(wire_pos(EQUIPMENT_BAG, 63), None, "the doll-space gap"); assert_eq!(wire_pos(EQUIPMENT_BAG, 70), None, "past the bank bags"); } } /// [`find_item`] — the reference's inventory walk (decision 0666). Everything here is about /// **order**, because order is the whole finding: the walk it replaced never looked at equipment /// at all (an equipped trinket's action button was inert) and put the backpack ahead of the bags. #[cfg(test)] mod find_item_tests { use super::{find_item, ItemSearch}; use crate::items::Items; use benilla_protocol::ObjectFields; // Descriptor indices, raw (the module's own consts are private; the codebase's test idiom). const ENTRY: u16 = 3; // OBJECT_FIELD_ENTRY const CHARGES: u16 = 16; // ITEM_FIELD_SPELL_CHARGES[0] const NUM_SLOTS: u16 = 48; // CONTAINER_FIELD_NUM_SLOTS const SLOT_1: u16 = 50; // CONTAINER_FIELD_SLOT_1 (2 fields per guid) const INV_SLOT_HEAD: u16 = 486; // PLAYER_FIELD_INV_SLOT_HEAD (2 per guid, 23 slots) const PACK_SLOT_1: u16 = 532; // PLAYER_FIELD_PACK_SLOT_1 (2 per guid, 16 slots) const KEYRING_SLOT_1: u16 = 648; // PLAYER_FIELD_KEYRING_SLOT_1 (2 per guid, player slots 81..) const TRINKET: u32 = 12_930; const BAG: u32 = 4_500; /// A player whose slot array points at the given `(player-array index, guid)` pairs. Covers the /// three bands these tests use — equipment/bag buttons 0..22, backpack 23..38, keyring 81.. — /// each of which is its own descriptor array; the bank/buyback bands in between have no test /// that needs them. fn player(slots: &[(u16, u64)]) -> ObjectFields { let mut pairs = Vec::new(); for &(idx, guid) in slots { let base = if idx < 23 { INV_SLOT_HEAD + 2 * idx } else if idx < 81 { PACK_SLOT_1 + 2 * (idx - 23) } else { KEYRING_SLOT_1 + 2 * (idx - 81) }; pairs.push((base, guid as u32)); pairs.push((base + 1, (guid >> 32) as u32)); } ObjectFields::from_pairs(&pairs) } /// A plain item instance of `entry` (optionally with live charges). fn item(store: &mut Items, guid: u64, entry: u32, charges: Option) { let mut pairs = vec![(ENTRY, entry)]; if let Some(c) = charges { pairs.push((CHARGES, c as u32)); } store.insert_object(guid, ObjectFields::from_pairs(&pairs)); } /// A container instance holding `contents` at its own inner slots. fn bag(store: &mut Items, guid: u64, entry: u32, contents: &[(u8, u64)]) { let mut pairs = vec![(ENTRY, entry), (NUM_SLOTS, 16)]; for &(i, item_guid) in contents { pairs.push((SLOT_1 + 2 * u16::from(i), item_guid as u32)); pairs.push((SLOT_1 + 2 * u16::from(i) + 1, (item_guid >> 32) as u32)); } store.insert_object(guid, ObjectFields::from_pairs(&pairs)); } const ALL: ItemSearch = ItemSearch { equipment_only: false, live_charges_only: false, }; const WORN: ItemSearch = ItemSearch { equipment_only: true, live_charges_only: false, }; const CHARGED: ItemSearch = ItemSearch { equipment_only: false, live_charges_only: true, }; /// Equipment comes FIRST — the trinket case. A copy worn in trinket slot 13 wins over an /// identical copy sitting in the backpack, and the wire pair is the doll's `(255, 13)`. #[test] fn equipment_is_searched_before_everything_else() { let mut items = Items::default(); item(&mut items, 0xE1, TRINKET, None); item(&mut items, 0xB1, TRINKET, None); let store = player(&[(13, 0xE1), (23, 0xB1)]); assert_eq!( find_item(&store, &items, TRINKET, ALL), Some((255, 13, 0xE1)) ); } /// …and the equipment-only stage stops at the doll: the backpack copy is invisible to it. /// This is the stage that decides USE-in-place vs EQUIP. #[test] fn the_equipment_only_stage_ignores_the_bags() { let mut items = Items::default(); item(&mut items, 0xB1, TRINKET, None); let store = player(&[(23, 0xB1)]); assert_eq!(find_item(&store, &items, TRINKET, WORN), None); assert_eq!( find_item(&store, &items, TRINKET, ALL), Some((255, 23, 0xB1)) ); } /// A bag's CONTENTS come before the backpack (the walk recurses depth-first as it passes each /// container) — the leg the old backpack-first walk had backwards. #[test] fn bag_contents_precede_the_backpack() { let mut items = Items::default(); item(&mut items, 0xC1, TRINKET, None); item(&mut items, 0xB1, TRINKET, None); bag(&mut items, 0xBA, BAG, &[(2, 0xC1)]); let store = player(&[(19, 0xBA), (23, 0xB1)]); assert_eq!( find_item(&store, &items, TRINKET, ALL), Some((19, 2, 0xC1)), "bag 1's inner slot 2, addressed by the bag's own player-array index" ); } /// The bag OBJECT is a candidate in its own right, before its contents — a bag on the action /// bar is a real, placeable action (`InventoryType` 18 passes PlaceAction's filter). #[test] fn an_equipped_bag_is_found_as_itself() { let mut items = Items::default(); bag(&mut items, 0xBA, BAG, &[]); let store = player(&[(19, 0xBA)]); assert_eq!(find_item(&store, &items, BAG, ALL), Some((255, 19, 0xBA))); } /// The mode-`0x20` charge filter skips a SPENT copy and returns one with uses left — so a /// click on a charged item reaches a copy that still works. #[test] fn the_charge_filter_skips_a_spent_copy() { let mut items = Items::default(); item(&mut items, 0xB1, TRINKET, Some(0)); item(&mut items, 0xB2, TRINKET, Some(3)); let store = player(&[(23, 0xB1), (24, 0xB2)]); assert_eq!( find_item(&store, &items, TRINKET, ALL), Some((255, 23, 0xB1)), "without the filter the first copy wins, spent or not" ); assert_eq!( find_item(&store, &items, TRINKET, CHARGED), Some((255, 24, 0xB2)), "with it, the spent copy is skipped" ); } /// The keyring is the walk's LAST band (mode bit `0x40`) — a key that lives only there is still /// found, and its wire pair is the player array's own `(255, 81 + n)`. Before decision 0765 the /// walker stopped at the backpack, so a key on the action bar could never resolve its copy. #[test] fn a_key_in_the_keyring_is_found_last() { const KEY: u32 = 7_146; // The Scarlet Key let mut items = Items::default(); item(&mut items, 0xE1, KEY, None); let store = player(&[(81, 0xE1)]); assert_eq!(find_item(&store, &items, KEY, ALL), Some((255, 81, 0xE1))); // ...and a copy anywhere earlier still wins: the keyring really is last, not first. item(&mut items, 0xE2, KEY, None); let store = player(&[(81, 0xE1), (23, 0xE2)]); assert_eq!( find_item(&store, &items, KEY, ALL), Some((255, 23, 0xE2)), "the backpack copy precedes the keyring one" ); } /// `HasKey()` — the gate the whole keyring UI hangs off (decision 0765), and the one place the /// **bank** is searched. Byte-read from the reference's `0x48ae90`: predicate `BagFamily == 9`, /// mode `0x4f` = equipment | bag slots | backpack | BANK | keyring. So: an ordinary item is not /// a key wherever it sits; a key is a key wherever it sits, the bank included; and buyback — /// which has no mode bit at all — is never searched. #[test] fn has_key_finds_a_key_anywhere_the_reference_looks() { use super::{has_key, BAG_FAMILY_KEYS}; use crate::items::{test_template, Items}; use crate::net::NetCommands; const KEY: u32 = 7_146; // The Scarlet Key (bag_family 9, live mangos.item_template) const BREAD: u32 = 4_540; let (tx, _rx) = crossbeam_channel::unbounded(); let commands = NetCommands(tx); let mut items = Items::default(); let mut key_tpl = test_template("The Scarlet Key"); key_tpl.bag_family = BAG_FAMILY_KEYS; items.insert_template(KEY, Some(key_tpl)); items.insert_template(BREAD, Some(test_template("Tough Hunk of Bread"))); item(&mut items, 0xF1, KEY, None); item(&mut items, 0xF2, BREAD, None); // Nothing at all. assert!(!has_key(&player(&[]), &mut items, &commands)); // A non-key in the backpack is not a key. assert!(!has_key(&player(&[(23, 0xF2)]), &mut items, &commands)); // The director's own case: the key sitting in keyring slot 1. assert!(has_key(&player(&[(81, 0xF1)]), &mut items, &commands)); // And in the backpack, before it has been filed. assert!(has_key(&player(&[(23, 0xF1)]), &mut items, &commands)); // The BANK — reachable only because HasKey passes 0x4f rather than the walker's default // 0x47. `find_item` must NOT see the same copy (its mode omits 0x08). let mut banked = std::collections::HashMap::new(); banked.insert(39u16, 0xF1u64); let store = bank_player(&banked); assert!( has_key(&store, &mut items, &commands), "a key in the bank still gives you a keyring" ); assert_eq!( find_item(&store, &items, KEY, ALL), None, "...while the ordinary item search never reaches the bank" ); } /// A player with items in the BANK band (`PLAYER_FIELD_BANK_SLOT_1`), which `player` above /// deliberately does not cover. fn bank_player(slots: &std::collections::HashMap) -> ObjectFields { const BANK_SLOT_1: u16 = 564; // PLAYER_FIELD_BANK_SLOT_1 (2 per guid, player slots 39..62) let mut pairs = Vec::new(); for (&idx, &guid) in slots { let base = BANK_SLOT_1 + 2 * (idx - 39); pairs.push((base, guid as u32)); pairs.push((base + 1, (guid >> 32) as u32)); } ObjectFields::from_pairs(&pairs) } } /// [`count_of`]'s **scope** — decision 1158. The reference has one walker parameterised by a /// section mask, and the mask a caller passes is not cosmetic: the quest surfaces pass `8` (which /// the walker rewrites to `0x4F`) and so count **banked** copies; everything else passes `0` (→ /// `0x47`) and does not. Every case here is a slot band that separates the two scopes. #[cfg(test)] mod count_of_tests { use super::{count_of, InventoryScope}; use crate::items::Items; use benilla_protocol::ObjectFields; // Descriptor indices, raw (the module's own consts are private; the codebase's test idiom). const ENTRY: u16 = 3; // OBJECT_FIELD_ENTRY const STACK: u16 = 14; // ITEM_FIELD_STACK_COUNT const NUM_SLOTS: u16 = 48; // CONTAINER_FIELD_NUM_SLOTS const SLOT_1: u16 = 50; // CONTAINER_FIELD_SLOT_1 (2 fields per guid) const INV_SLOT_HEAD: u16 = 486; // player slots 0..22 const PACK_SLOT_1: u16 = 532; // player slots 23..38 const BANK_SLOT_1: u16 = 564; // player slots 39..62 const BANK_BAG_SLOT_1: u16 = 612; // player slots 63..68 const KEYRING_SLOT_1: u16 = 648; // player slots 81..112 const AMMO: u32 = 3_030; // the collect-quest item under test /// A player whose flat slot array points at the given `(absolute player slot, guid)` pairs — /// every band this module needs, each its own descriptor array on the wire. fn player(slots: &[(u16, u64)]) -> ObjectFields { let mut pairs = Vec::new(); for &(slot, guid) in slots { let base = match slot { 0..=22 => INV_SLOT_HEAD + 2 * slot, 23..=38 => PACK_SLOT_1 + 2 * (slot - 23), 39..=62 => BANK_SLOT_1 + 2 * (slot - 39), 63..=68 => BANK_BAG_SLOT_1 + 2 * (slot - 63), 81..=112 => KEYRING_SLOT_1 + 2 * (slot - 81), _ => panic!("slot {slot} is buyback or out of range"), }; pairs.push((base, guid as u32)); pairs.push((base + 1, (guid >> 32) as u32)); } ObjectFields::from_pairs(&pairs) } /// An item instance of `entry` holding `stack` copies. fn stack(items: &mut Items, guid: u64, entry: u32, stack: u32) { items.insert_object( guid, ObjectFields::from_pairs(&[(ENTRY, entry), (STACK, stack)]), ); } /// A container instance holding `contents` at its own inner slots. fn bag(items: &mut Items, guid: u64, contents: &[(u8, u64)]) { let mut pairs = vec![(ENTRY, 4_500), (NUM_SLOTS, 16)]; for &(slot, held) in contents { pairs.push((SLOT_1 + 2 * u16::from(slot), held as u32)); pairs.push((SLOT_1 + 2 * u16::from(slot) + 1, (held >> 32) as u32)); } items.insert_object(guid, ObjectFields::from_pairs(&pairs)); } /// The headline: bank the quest's items and a quest objective still counts them. This is the /// whole of the mask-`8` finding (wow-re `ui/scratch/quest-leaderboard-law.md` §3.1 — `8` is /// exactly the bit that *adds the bank*, and every one of the six mask-`8` call sites in the /// reference is a quest surface). #[test] fn a_quest_objective_counts_banked_copies_and_nothing_else_does() { let store = player(&[(23, 0xA1), (39, 0xB1)]); // one stack in the backpack, one in the bank let mut items = Items::default(); stack(&mut items, 0xA1, AMMO, 3); stack(&mut items, 0xB1, AMMO, 5); assert_eq!( count_of(&store, &items, AMMO, InventoryScope::QUEST_ITEMS), 8, "mask 0x4F sees the bank" ); assert_eq!( count_of(&store, &items, AMMO, InventoryScope::CARRIED), 3, "an action-bar/reagent count must NOT see the bank" ); } /// Bank BAGS are recursed into as well — the walker's container recursion is gated by mask bit /// `0x10`, which is clear in every mask any caller passes, and section gating applies only at /// the player's own root descriptor. #[test] fn a_quest_objective_counts_the_contents_of_bank_bags() { let store = player(&[(63, 0xBB)]); // a bag in bank-bag slot 1 let mut items = Items::default(); bag(&mut items, 0xBB, &[(0, 0xC1), (4, 0xC2)]); stack(&mut items, 0xC1, AMMO, 2); stack(&mut items, 0xC2, AMMO, 6); assert_eq!( count_of(&store, &items, AMMO, InventoryScope::QUEST_ITEMS), 8 ); assert_eq!(count_of(&store, &items, AMMO, InventoryScope::CARRIED), 0); } /// The other two bands `0x47` carries that benilla's own [`InventoryScope::CARRIED`] does not: /// worn gear and the keyring. Both are in the quest scope. #[test] fn the_quest_scope_also_reaches_worn_gear_and_the_keyring() { let store = player(&[(5, 0xE1), (81, 0xF1)]); // a worn copy and one in the keyring let mut items = Items::default(); stack(&mut items, 0xE1, AMMO, 1); stack(&mut items, 0xF1, AMMO, 1); assert_eq!( count_of(&store, &items, AMMO, InventoryScope::QUEST_ITEMS), 2 ); assert_eq!( count_of(&store, &items, AMMO, InventoryScope::CARRIED), 0, "benilla's pre-1158 count reached neither band — the named narrowing" ); } /// A carried bag's contents are in BOTH scopes — the change must not disturb the band every /// caller already relied on. #[test] fn carried_bags_are_unchanged_in_both_scopes() { let store = player(&[(19, 0xBA), (25, 0xA2)]); let mut items = Items::default(); bag(&mut items, 0xBA, &[(2, 0xC1)]); stack(&mut items, 0xC1, AMMO, 4); stack(&mut items, 0xA2, AMMO, 1); assert_eq!( count_of(&store, &items, AMMO, InventoryScope::QUEST_ITEMS), 5 ); assert_eq!(count_of(&store, &items, AMMO, InventoryScope::CARRIED), 5); } /// Buyback (slots 69–80) has no mask bit at all and is unreachable at every scope — an item /// sold to a vendor is gone from every count the client makes. #[test] fn buyback_is_never_counted() { // The buyback band has no accessor in the walk by construction; assert the neighbouring // bands still resolve so this is a real coverage statement, not a vacuous one. let store = player(&[(38, 0xA1), (68, 0xBB)]); let mut items = Items::default(); stack(&mut items, 0xA1, AMMO, 1); bag(&mut items, 0xBB, &[(0, 0xC1)]); stack(&mut items, 0xC1, AMMO, 1); assert_eq!( count_of(&store, &items, AMMO, InventoryScope::QUEST_ITEMS), 2, "last backpack slot + last bank bag, with buyback between them untouched" ); } }