Files
benilla-octo/crates/benilla-app/src/ui_items/mod.rs
T
SamandClaude Fable 5 14bd5ea1a4 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>
2026-08-31 20:18:51 +07:00

1774 lines
88 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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<u32>,
/// 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<EquipError>);
/// 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<u64> {
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<String> {
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<T>(
store: &ObjectFields,
items: &Items,
scope: InventoryScope,
mut visit: impl FnMut(u8, u8, u64) -> Option<T>,
) -> Option<T> {
// 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<T>| {
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<u64>) {
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<u64>,
/// 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<u32>,
/// 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<u64>,
/// 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<u8> {
// 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<Res<benilla_assets::WorldAssets>>) {
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::<EquipErrors>()
.init_resource::<PendingItemOps>()
// The soulbind confirmations' pending records (decision 1750) — the client's own
// pending-equip array and its one bind-on-use cell.
.init_resource::<crate::ui_bind_confirm::PendingEquips>()
.init_resource::<crate::ui_bind_confirm::PendingBindOnUse>()
.init_resource::<LockClearedByFailure>()
// 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<i32>) {
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<u16, u64>) -> 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"
);
}
}