Files
benilla-octo/crates/benilla-protocol/src/messages/items.rs
T
SamandClaude Opus 5.5 9c49f10882 items are objects in the one index, and three more packets are answered
- an item is an object in the one index, and its enchant and lifetime countdowns are its own (2334, 2340)
- the server's stand state applies, a bag equipped into a bag slot fires BAG_OPEN, and the inspect echo is dropped on purpose (2339)
- the schedule census counts declared orders only, and the architecture walls gate leaks rather than the API (2337, 2338)
- one queue for every event into the UI is not built: the reference orders its events per mechanism, not by the wire (2341)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 12:16:11 +07:00

1022 lines
51 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.
//! Item messages — the T2 container groundwork (decision 0068's tier ladder: Bagnon needs item
//! identity), widened to the **full** 1.12.1 item template (decision 0274 P1: the tooltip builder
//! needs every line the real client can render). The 1.12 wire carries no item *templates* in
//! descriptors — like unit names, they answer a query pair: `CMSG_ITEM_QUERY_SINGLE` (entry + guid)
//! → `SMSG_ITEM_QUERY_SINGLE_RESPONSE` (VERIFIED vmangos `HandleItemQuerySingleOpcode`; opcodes
//! 86/88 `Opcodes_1_12_1.h`).
//!
//! [`ItemInfo`] now carries the response whole: identity (class/subclass/name — 4 name slots, the
//! server sends 1 + 3 empties — displayInfoID, quality, inventoryType, sheath), the buy/sell
//! economy, every requirement gate (level/skill/spell/honor rank/city rank/reputation,
//! allowable class/race), stacking (maxCount/stackable/containerSlots), the full 10-slot stat
//! block, all 5 damage blocks (block 0 also mirrors into the legacy `dmg_min`/`dmg_max`/`dmg_type`
//! fields existing consumers already key on), armor plus the 6-wide resistance run, ranged data,
//! all 5 spell-trigger slots (the first ON_USE slot still surfaces separately as `use_spell` — the
//! client's own cooldown-scan key), bonding, description, page/lock/material/random-property/set,
//! durability, and the area/map/bagFamily tail (VERIFIED field order vmangos
//! `HandleItemQuerySingleOpcode`, `ItemHandler.cpp:269-415`; every `SUPPORTED_CLIENT_BUILD`
//! conditional in that function evaluates *included* for build 5875). A **miss** (undiscovered/
//! unknown entry) is the lone `u32` of `entry | 0x8000_0000`, the same shape as the creature miss.
use std::io;
use crate::wire::{read_cstring, read_f32_le, read_i32_le, read_u32_le, read_u64_le, read_u8};
/// A full item-template answer (decision 0274 P1: the tooltip builder's source of truth; every
/// field the wire carries, none discarded). (`PartialEq` only: several fields are wire floats —
/// the damage bounds and `ranged_mod_range`.)
#[derive(Debug, Clone, PartialEq)]
pub struct ItemInfo {
pub class: u32,
pub subclass: u32,
pub name: String,
/// `ItemDisplayInfo.dbc` key — the icon/model resolve.
pub display_info_id: u32,
/// 0 poor … 6 artifact (the RF-55 quality-color table's index).
pub quality: u32,
/// `ItemPrototypeFlags` bitmask (conjured, lootable, indestructible, wrapper, no-equip-cooldown,
/// …) — the tooltip's "Unique"/no-sell/no-disenchant lines key on bits here.
pub flags: u32,
/// `BuyPrice` — what a vendor charges per [`crate::messages::VendorItem::buy_count`]-sized
/// stack, in copper.
pub buy_price: u32,
/// `SellPrice` — what a vendor pays per unit, in copper (the bag tooltip's money row while a
/// merchant is open; 0 = unsellable → the "No sell price" line).
pub sell_price: u32,
/// `InventoryType` — the equip-slot family (1 head, 21/22 main/off-hand weapon, …); drives
/// which paperdoll slot an item can go in and which visual-item field it feeds.
pub inventory_type: u32,
/// `AllowableClass` — a class bitmask; the all-bits-set sentinel (`-1`) means no class
/// restriction, so this stays signed rather than reading as the unsigned `0xFFFF_FFFF`.
pub allowable_class: i32,
/// `AllowableRace` — the same bitmask shape as [`Self::allowable_class`], races instead.
pub allowable_race: i32,
/// `ItemLevel` — the repair-cost formula's `DurabilityCosts.dbc` row key (also a tooltip line).
pub item_level: u32,
/// `RequiredLevel` — the tooltip's "Requires Level N" line; 0 = no level requirement.
pub required_level: u32,
/// `RequiredSkill` — id from `SkillLine.dbc`; 0 = no skill requirement.
pub required_skill: u32,
/// `RequiredSkillRank` — the skill value [`Self::required_skill`] must meet or exceed.
pub required_skill_rank: u32,
/// `RequiredSpell` — id from `Spell.dbc`; the item is unusable without knowing this spell.
pub required_spell: u32,
/// `RequiredHonorRank`/`RequiredCityRank` — two more requirement gates the wire carries; the
/// tooltip's requirement-line law for these two is unverified (folds in with decision 0274's
/// §5 line-order dispatch).
pub required_honor_rank: u32,
pub required_city_rank: u32,
/// `RequiredReputationFaction` — id from `Faction.dbc`; 0 = no reputation requirement.
pub required_rep_faction: u32,
/// `RequiredReputationRank` — the wire's own gate: the server sends 0 whenever
/// [`Self::required_rep_faction`] is 0, even if the row has a nonzero rank (VERIFIED vmangos
/// `ItemHandler.cpp:321-322`).
pub required_rep_rank: u32,
/// `MaxCount` — the account-wide cap this item enforces (0 = uncapped); the tooltip's "Unique"
/// family of lines derive from this and [`Self::flags`].
pub max_count: u32,
/// `Stackable` — the max stack size a single slot can hold (1 = doesn't stack).
pub stackable: u32,
/// `ContainerSlots` — nonzero only for bag items (the number of slots the bag itself grants).
pub container_slots: u32,
/// The 10-slot `ItemStat` block, `(type, value)`, **filtered to nonzero entries** (type or
/// value nonzero) in wire order — the tooltip's "+N Stat" lines (`ItemModType` at this build:
/// 0 mana, 1 health, 3 agility, 4 strength, 5 intellect, 6 spirit, 7 stamina).
pub stats: Vec<(u32, i32)>,
/// The 5-slot `Damage` block, **filtered to entries with `max > 0`**, in wire order —
/// secondary damage lines (e.g. a Fiery weapon's bonus Fire line) beyond the primary
/// [`Self::dmg_min`]/[`Self::dmg_max`]/[`Self::dmg_type`], which always mirror block 0 whether
/// or not it clears this filter.
pub damages: Vec<ItemDamage>,
/// Damage block 0's per-hit minimum (the tooltip's "X - Y Damage" line; 0 for non-weapons) —
/// kept mirrored from `damages` block 0 for existing consumers.
pub dmg_min: f32,
/// Damage block 0's per-hit maximum.
pub dmg_max: f32,
/// Damage block 0's school (0 physical, 1 Holy … 6 Arcane — the tooltip's school suffix).
pub dmg_type: u32,
/// `Armor` — the first slot of the wire's 7-wide resistance run.
pub armor: u32,
/// The remaining 6 slots of the resistance run, in wire order: `[holy, fire, nature, frost,
/// shadow, arcane]` (`int32` on the wire in vmangos's own `ItemPrototype` — a template's
/// resistance can't go negative in practice, but the sign rides along).
pub resistances: [i32; 6],
/// Attack delay in milliseconds (the tooltip's "Speed" = delay / 1000).
pub delay_ms: u32,
/// `AmmoType` — the projectile family a ranged weapon consumes (0 none, 2 arrow, 3 bullet).
pub ammo_type: u32,
/// `RangedModRange` — a ranged weapon's range multiplier; the tooltip never shows this raw, it
/// feeds the range formula.
pub ranged_mod_range: f32,
/// The 5-slot `ItemSpell` block, **filtered to entries with `spell_id != 0`**, in wire order —
/// every "Use:"/"Equip:"/"Chance on hit:" trigger line the tooltip can render. Each entry
/// carries its own [`ItemSpellEntry::index`], since this vector's positions are not the
/// template's block ordinals.
pub spells: Vec<ItemSpellEntry>,
/// Spell **block 0**'s `SpellCharges` word, raw and unfiltered — the reference's
/// `template+0x144`, and the sole input to [`Self::has_finite_charges`].
pub spell_charges_0: i32,
/// The first ON_USE (`SpellTrigger == 0`) spell block — what a right-click/action-bar use
/// casts, and the key the item's cooldown tracks (the client's own 5-slot scan: spell id > 0,
/// trigger == 0 — wow-re `wave-cooldown.md` `GetItemCooldown 0x6e2ed0`). `None` for items with
/// no use effect. A stored view onto [`Self::spells`] (rather than a derived accessor) so
/// existing cooldown/tooltip consumers reading `.use_spell` are untouched.
pub use_spell: Option<ItemUseSpell>,
/// `Bonding` — `ItemBondingType` (0 none … 4 quest-bind); the tooltip's "Binds when picked
/// up"/"equipped"/"used" line.
pub bonding: u32,
/// The item's flavor text (the tooltip's italic line under the stat block); empty = none.
pub description: String,
/// `PageText` — a readable item's `PageText.wdb` id (0 = not a book/readable).
pub page_text: u32,
/// `LanguageID` — id from `Languages.dbc`; which in-game language a readable's text renders in.
pub language_id: u32,
/// `PageMaterial` — id from `PageTextMaterial.dbc`; the book-frame background/texture.
pub page_material: u32,
/// `StartQuest` — a quest-starter item's quest id (0 = doesn't start a quest).
pub start_quest: u32,
/// `LockID` — id from `Lock.dbc`; nonzero means the item (a chest/junkbox) needs picking/keying
/// open.
pub lock_id: u32,
/// `Material` — id from `Material.dbc`; drives the item's equip/drop/footstep sound set.
pub material: u32,
/// `Sheath` — the holster style a drawn weapon of this type renders with (vmangos
/// `ItemPrototype::Sheath`; the same vocabulary as [`super::update_object::ObjectFields`]'s
/// virtual-item sheath byte).
pub sheath: u32,
/// `RandomProperty` — id from `ItemRandomProperties.dbc`; a "of the Whale"-style suffix roll
/// (the concrete roll lives on the item *instance*, not the template — this is just which
/// property table applies).
pub random_property: u32,
/// `Block` — a shield's block value (two `u32`s past `Sheath`, after `RandomProperty`).
pub block: u32,
/// `ItemSet` — id from `ItemSet.dbc`; 0 = not part of a set.
pub item_set: u32,
/// `MaxDurability` — 0 for items without durability (never repairable).
pub max_durability: u32,
/// `Area` — id from `AreaTable.dbc`; a zone-bound item's required zone (0 = anywhere).
pub area: u32,
/// `Map` — id from `Map.dbc`; a map-bound item's required map (0 = anywhere).
pub map: u32,
/// `BagFamily` — which specialised container an item belongs in (quiver 1, ammo pouch 2, soul
/// bag 3, herb 6, enchanting 7, engineering 8, **keys 9**; 0 = an ordinary item / ordinary bag).
/// An **enum, not a bitmask**, on this wire: 1.12 tests it for equality (vmangos
/// `ItemPrototype.h`'s `enum BagFamily`, and the reference's own `HasKey` `0x48ae90` compares
/// `template+0x1d0 == 9`) — it only became a mask in 2.x. `9` is what routes an item into the
/// keyring, and what [`crate::ObjectFields::player_keyring_slot`]'s slots hold.
pub bag_family: u32,
}
impl ItemInfo {
/// Does this item carry **finite charges**? The reference's
/// `template+0x144 != 0 && template+0x144 != -1` (wow-re `action-item-slot.md` §8.2) — the
/// gate on the use path's mode-`0x20` inventory search, which skips spent copies so a click
/// reaches one that still works. `-1` is the "unlimited" sentinel, `0` "no charges at all".
pub fn has_finite_charges(&self) -> bool {
self.spell_charges_0 != 0 && self.spell_charges_0 != -1
}
/// The **block ordinal** (0..4) of the first ON_USE spell — the third byte of `CMSG_USE_ITEM`
/// (wow-re `action-item-slot.md` §8.3). `None` for an item with no on-use spell. Almost
/// always 0, but an item whose block 0 is an ON_EQUIP proc and whose on-use sits in block 1
/// needs the real index or the server casts the wrong block.
pub fn use_spell_index(&self) -> Option<u8> {
self.spells.iter().find(|s| s.trigger == 0).map(|s| s.index)
}
/// Is this item **consumable** in the sense the action bar's Count fontstring means?
/// `IsConsumableAction 0x4e5250`, byte-read (`4e52b9`–`4e52ea`), after it has resolved the
/// slot's item template:
///
/// ```text
/// [rec+0x2c] InventoryType == 0x18 (AMMO) or 0x19 (THROWN) -> true (4e52b9-4e52c4)
/// OR ∃ i∈[0,5): SpellId[i] [rec+0x11c+4i] != 0 (4e52d0)
/// ∧ SpellTrigger[i] [rec+0x130+4i] == 0 (ON_USE) (4e52d7)
/// ∧ SpellCharges[i] [rec+0x144+4i] < 0 (destroy-on-use) (4e52dc)
/// otherwise -> false
/// ```
///
/// **Negative** charges — not merely finite ones — are the test: the sign is vmangos's own
/// "the ITEM is consumed once the charges run out" convention, so a potion (`-1`) counts and a
/// wand-like item with positive charges does not. Nothing here looks at `Class`: a mount
/// (`Class` 15 Miscellaneous, `InventoryType` 0, on-use spell with `SpellCharges` 0) is **not**
/// consumable, which is why the reference shows no stack number under a mount on the bar.
///
/// [`Self::spells`] already drops the `SpellId == 0` blocks, so iterating it is the `4e52d5`
/// skip.
pub fn is_consumable(&self) -> bool {
const INVTYPE_AMMO: u32 = 0x18;
const INVTYPE_THROWN: u32 = 0x19;
matches!(self.inventory_type, INVTYPE_AMMO | INVTYPE_THROWN)
|| self.spells.iter().any(|s| s.trigger == 0 && s.charges < 0)
}
/// Can this item be placed on an action-bar slot? `PlaceAction`'s only item filter, byte-read
/// (wow-re `action-item-slot.md` §5, `4e6571`–`4e6598`): **an on-use spell OR equippable**.
/// No quality, bind, class/subclass, container or level test exists anywhere on that path — a
/// bag (`InventoryType` 18) IS placeable; a grey trade good with neither is silently refused.
pub fn placeable_on_action_bar(&self) -> bool {
self.use_spell.is_some() || self.inventory_type != 0
}
/// Does the tooltip put the green `<Right Click to Open>` line on this instance?
/// `instance_flags` is the item object's `ITEM_FIELD_FLAGS`; a template-only view (a
/// hyperlink, a merchant row) passes 0 and, being object-less, gets no line at all — the
/// reference's own gate 1 at `0x52e2e0`.
///
/// VERIFIED wow-re `right-click-open.md` §1.4 (`0x52e2f8`–`0x52e321`), re-derived by a §5 pair
/// 2026-08-02: the template LOOTABLE bit [`ITEM_FLAG_LOOTABLE`] behind its **lock sub-gate** —
/// a [`ItemInfo::lock_id`] item earns the line only once the instance carries
/// [`ITEM_DYNFLAG_UNLOCKED`] — **or** a wrapped gift ([`ITEM_FLAG_WRAPPER`] on the template
/// plus [`ITEM_DYNFLAG_WRAPPED`] on the instance).
///
/// **This is deliberately NOT the send condition** ([`Self::opens_loot`]). The line is a
/// promise, and the reference only makes it when opening will actually work; the *click*
/// tests the bare template bit and lets the server refuse a still-locked box with its own
/// error line. Decision 0896 — an earlier reading of ours collapsed the two, which would have
/// silently swallowed the click on every locked junkbox.
pub fn shows_open_line(&self, instance_flags: u32) -> bool {
let lootable = self.flags & ITEM_FLAG_LOOTABLE != 0
&& (self.lock_id == 0 || instance_flags & ITEM_DYNFLAG_UNLOCKED != 0);
lootable || self.unwraps_gift(instance_flags)
}
/// Does a right-click on this instance send `CMSG_OPEN_ITEM` to **unwrap a gift**? The first
/// arm of the reference's use dispatcher (`0x5d8d00` #2, `0x5d8d92`/`0x5d8d9d` → the `0x5edd60`
/// emitter): template [`ITEM_FLAG_WRAPPER`] **and** instance [`ITEM_DYNFLAG_WRAPPED`].
///
/// It is a separate predicate from [`Self::opens_loot`] because it sits at a different point
/// in the dispatcher's order — *before* the quest-starter and readable arms, where the loot
/// arm sits after them. A wrapper template whose instance is *not* wrapped takes the
/// begin-wrap cursor path instead ([`Self::begins_gift_wrap`]) — local, no packet.
pub fn unwraps_gift(&self, instance_flags: u32) -> bool {
self.flags & ITEM_FLAG_WRAPPER != 0 && instance_flags & ITEM_DYNFLAG_WRAPPED != 0
}
/// Does a right-click on this instance **arm the gift-wrap cursor**? The *other* side of the
/// same fork [`Self::unwraps_gift`] tests (`0x5d8d00` arm 2): template [`ITEM_FLAG_WRAPPER`]
/// with the instance's [`ITEM_DYNFLAG_WRAPPED`] **clear** — a piece of wrapping paper rather
/// than a wrapped present.
///
/// **Nothing is sent on this click.** `0x5d8dba` calls `0x5edea0`, whose only three acts are
/// `LockItem 0x4953e0` on the paper, `SetCursorBaseMode(2)` and `CursorSetMode(2)` — a purely
/// local arm (wow-re `object-layer/scratch/gift-wrap-law.md`, §5-carved). What completes it is
/// the next **left**-click on a container slot, which sends [`wrap_item`]. Decision 1934.
pub fn begins_gift_wrap(&self, instance_flags: u32) -> bool {
self.flags & ITEM_FLAG_WRAPPER != 0 && instance_flags & ITEM_DYNFLAG_WRAPPED == 0
}
/// Does a right-click on this template send `CMSG_OPEN_ITEM` to **loot it open**? The
/// reference's arm #8 (`0x5d8f7c: test al,4` → the `0x5edc80` emitter): a **bare** template
/// [`ITEM_FLAG_LOOTABLE`] test.
///
/// VERIFIED wow-re `right-click-open.md` §3, positively *and* negatively — no `LockID`
/// (`[rec+0x1ac]`) operand exists anywhere on the send path, checked against a positive
/// control. So a **still-locked junkbox does send the packet**, and the server answers
/// `EQUIP_ERR_ITEM_LOCKED`, which is where the player's "Item is locked" line comes from.
/// Refusing locally instead would eat the click in silence.
pub fn opens_loot(&self) -> bool {
self.flags & ITEM_FLAG_LOOTABLE != 0
}
}
/// `ITEM_FLAG_LOOTABLE` — the **template** bit that makes an item right-clickable into a loot
/// window (a clam, a lockbox, a Gnomish Mind Control Cap's box). vmangos
/// `ItemPrototype.h:66`, whose own comment names the client behaviour this drives: "It or lockid
/// set enable for client show 'Right click to open'".
pub const ITEM_FLAG_LOOTABLE: u32 = 0x0000_0004;
/// `ITEM_FLAG_WRAPPER` — the template bit for gift wrapping (vmangos `ItemPrototype.h:73`); paired
/// with [`ITEM_DYNFLAG_WRAPPED`] on the instance it makes the wrapped present openable.
pub const ITEM_FLAG_WRAPPER: u32 = 0x0000_0200;
/// `ITEM_DYNFLAG_UNLOCKED` — the **instance** (`ITEM_FIELD_FLAGS`) bit a lockbox gains once it has
/// been picked/keyed open; until then a `LockID` item refuses to open (vmangos
/// `HandleOpenItemOpcode`'s `EQUIP_ERR_ITEM_LOCKED`).
pub const ITEM_DYNFLAG_UNLOCKED: u32 = 0x0000_0004;
/// `ITEM_DYNFLAG_WRAPPED` — the instance bit on a gift-wrapped item; opening it unwraps to the
/// present (vmangos swaps the entry from `character_gifts` rather than sending loot).
pub const ITEM_DYNFLAG_WRAPPED: u32 = 0x0000_0008;
/// One `Damage` block ([`ItemInfo::damages`] — block 0 is also mirrored into
/// [`ItemInfo::dmg_min`]/[`ItemInfo::dmg_max`]/[`ItemInfo::dmg_type`]).
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct ItemDamage {
pub min: f32,
pub max: f32,
/// 0 physical, 1 Holy … 6 Arcane (`Resistances.dbc` id).
pub school: u32,
}
/// One item-template spell block ([`ItemInfo::spells`]) — the full 6-word wire shape, not just the
/// resolved ON_USE cooldown pair ([`ItemUseSpell`]). `charges`: positive = consumed only while
/// charges last, negative = the item itself is consumed once charges run out (vmangos
/// `ItemPrototype::_ItemSpell::SpellCharges`). The cooldown pair is **server-resolved** the same way
/// as [`ItemUseSpell`]'s (VERIFIED vmangos `ItemHandler.cpp:354-391`).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ItemSpellEntry {
/// Which of the template's **five** spell blocks this is (0..4). Not the index in
/// [`ItemInfo::spells`] — that vector drops empty blocks, and the wire's own
/// `CMSG_USE_ITEM` spell byte is this block ordinal (wow-re `action-item-slot.md` §8.3: the
/// reference scans `SpellId[5]`/`SpellTrigger[5]` for the block it is casting and sends its
/// position).
pub index: u8,
pub spell_id: u32,
/// `ItemSpelltriggerType`: 0 ON_USE, 1 ON_EQUIP, 2 CHANCE_ON_HIT.
pub trigger: u32,
pub charges: i32,
/// Use-cooldown ms; negative = the spell's own `RecoveryTime`.
pub cooldown_ms: i32,
/// Shared-cooldown category (potions 4, …); the wire's resolved value.
pub category: u32,
/// Category cooldown ms; negative = the spell's own `CategoryRecoveryTime`.
pub category_cooldown_ms: i32,
}
/// The first ON_USE spell block ([`ItemInfo::use_spell`]) — a resolved-cooldown view of whichever
/// [`ItemSpellEntry`] has `trigger == 0`. The cooldown pair is **server-resolved** (VERIFIED vmangos
/// `ItemHandler.cpp:354-380`: the `item_template` override when its value is `>= 0`, else the
/// spell's own `RecoveryTime`/`Category`/`CategoryRecoveryTime`) — but a lone `-1` can still ride
/// next to a set override, so the fields stay signed and a negative means "use the spell's own
/// Spell.dbc value" (the client's `>= 0` pick in `StartCooldown 0x6e2c60`, wow-re
/// `wave-cooldown.md`).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ItemUseSpell {
pub spell_id: u32,
/// Use-cooldown ms; negative = the spell's own `RecoveryTime`.
pub cooldown_ms: i32,
/// Shared-cooldown category (potions 4, …); the wire's resolved value.
pub category: u32,
/// Category cooldown ms; negative = the spell's own `CategoryRecoveryTime`.
pub category_cooldown_ms: i32,
}
/// Read `SMSG_ITEM_QUERY_SINGLE_RESPONSE` → `(entry, Some(head))`, or `(entry, None)` on a miss
/// (VERIFIED field order vmangos `HandleItemQuerySingleOpcode`, `ItemHandler.cpp:269-415`).
pub(super) fn read_item_query_response(r: &mut &[u8]) -> io::Result<(u32, Option<ItemInfo>)> {
let entry = read_u32_le(r)?;
if entry & 0x8000_0000 != 0 {
return Ok((entry & 0x7FFF_FFFF, None));
}
let class = read_u32_le(r)?;
let subclass = read_u32_le(r)?;
let name = read_cstring(r)?;
for _ in 0..3 {
let _ = read_cstring(r)?; // name2..name4 — the server sends empties
}
let display_info_id = read_u32_le(r)?;
let quality = read_u32_le(r)?;
let flags = read_u32_le(r)?;
let buy_price = read_u32_le(r)?;
let sell_price = read_u32_le(r)?;
let inventory_type = read_u32_le(r)?;
let allowable_class = read_i32_le(r)?;
let allowable_race = read_i32_le(r)?;
let item_level = read_u32_le(r)?;
let required_level = read_u32_le(r)?;
let required_skill = read_u32_le(r)?;
let required_skill_rank = read_u32_le(r)?;
let required_spell = read_u32_le(r)?;
let required_honor_rank = read_u32_le(r)?;
let required_city_rank = read_u32_le(r)?;
let required_rep_faction = read_u32_le(r)?;
let required_rep_rank = read_u32_le(r)?;
let max_count = read_u32_le(r)?;
let stackable = read_u32_le(r)?;
let container_slots = read_u32_le(r)?;
// 10x ItemStat { type, value } — kept only where either half is nonzero (an all-zero slot is a
// genuinely unused one), wire order preserved.
let mut stats = Vec::new();
for _ in 0..10 {
let stat_type = read_u32_le(r)?;
let stat_value = read_i32_le(r)?;
if stat_type != 0 || stat_value != 0 {
stats.push((stat_type, stat_value));
}
}
// 5x Damage { min f32, max f32, type u32 }, wire order. Block 0 is the tooltip's primary
// damage line — always mirrored into the legacy dmg_min/dmg_max/dmg_type fields for existing
// consumers, whether or not it clears the `max > 0` filter below (a non-weapon's block 0 is a
// real 0/0/0, not a missing value).
let dmg_min = read_f32_le(r)?;
let dmg_max = read_f32_le(r)?;
let dmg_type = read_u32_le(r)?;
let mut damages = Vec::new();
if dmg_max > 0.0 {
damages.push(ItemDamage {
min: dmg_min,
max: dmg_max,
school: dmg_type,
});
}
for _ in 0..4 {
let min = read_f32_le(r)?;
let max = read_f32_le(r)?;
let school = read_u32_le(r)?;
if max > 0.0 {
damages.push(ItemDamage { min, max, school });
}
}
// Armor is its own field; the remaining 6-wide resistance run (Holy/Fire/Nature/Frost/
// Shadow/Arcane) lands in `resistances` in wire order.
let armor = read_u32_le(r)?;
let holy_res = read_i32_le(r)?;
let fire_res = read_i32_le(r)?;
let nature_res = read_i32_le(r)?;
let frost_res = read_i32_le(r)?;
let shadow_res = read_i32_le(r)?;
let arcane_res = read_i32_le(r)?;
let resistances = [
holy_res, fire_res, nature_res, frost_res, shadow_res, arcane_res,
];
let delay_ms = read_u32_le(r)?;
let ammo_type = read_u32_le(r)?;
let ranged_mod_range = read_f32_le(r)?;
// 5x Spell block { SpellId, SpellTrigger, SpellCharges, Cooldown, Category, CategoryCooldown }
// (VERIFIED vmangos `ItemHandler.cpp:354-391`) — the server always writes all six words; a slot
// with no resolvable spell sends the sentinel 0,0,0,-1,0,-1. Kept in `spells` wherever
// `spell_id != 0`; the first ON_USE (trigger 0) slot also surfaces as `use_spell` — the
// client's own 5-slot scan.
let mut spells = Vec::new();
let mut use_spell = None;
// Block 0's charges, kept RAW — whether the item has finite charges is the reference's
// `template+0x144 != 0 && != -1` test on exactly this word ([`ItemInfo::has_finite_charges`],
// wow-re `action-item-slot.md` §8.2), which reads block 0 even when block 0 carries no spell
// and so never reaches `spells` below.
let mut spell_charges_0 = 0;
for block in 0..5u8 {
let spell_id = read_u32_le(r)?;
let trigger = read_u32_le(r)?;
let charges = read_i32_le(r)?;
let cooldown_ms = read_i32_le(r)?;
let category = read_u32_le(r)?;
let category_cooldown_ms = read_i32_le(r)?;
if block == 0 {
spell_charges_0 = charges;
}
if spell_id != 0 {
spells.push(ItemSpellEntry {
index: block,
spell_id,
trigger,
charges,
cooldown_ms,
category,
category_cooldown_ms,
});
if use_spell.is_none() && trigger == 0 {
use_spell = Some(ItemUseSpell {
spell_id,
cooldown_ms,
category,
category_cooldown_ms,
});
}
}
}
let bonding = read_u32_le(r)?;
let description = read_cstring(r)?;
let page_text = read_u32_le(r)?;
let language_id = read_u32_le(r)?;
let page_material = read_u32_le(r)?;
let start_quest = read_u32_le(r)?;
let lock_id = read_u32_le(r)?;
let material = read_u32_le(r)?;
let sheath = read_u32_le(r)?;
let random_property = read_u32_le(r)?;
let block = read_u32_le(r)?;
let item_set = read_u32_le(r)?;
let max_durability = read_u32_le(r)?;
let area = read_u32_le(r)?;
let map = read_u32_le(r)?;
let bag_family = read_u32_le(r)?;
Ok((
entry,
Some(ItemInfo {
class,
subclass,
name,
display_info_id,
quality,
flags,
buy_price,
sell_price,
inventory_type,
allowable_class,
allowable_race,
item_level,
required_level,
required_skill,
required_skill_rank,
required_spell,
required_honor_rank,
required_city_rank,
required_rep_faction,
required_rep_rank,
max_count,
stackable,
container_slots,
stats,
damages,
dmg_min,
dmg_max,
dmg_type,
armor,
resistances,
delay_ms,
ammo_type,
ranged_mod_range,
spells,
spell_charges_0,
use_spell,
bonding,
description,
page_text,
language_id,
page_material,
start_quest,
lock_id,
material,
sheath,
random_property,
block,
item_set,
max_durability,
area,
map,
bag_family,
}),
))
}
/// Body of `CMSG_ITEM_QUERY_SINGLE` (vmangos `QueryItem::ReadFromWorldPacket`): the template
/// `entry` + a full 8-byte item guid (0 when asking about a template with no instance in hand) —
/// the exact shape of the creature query.
pub fn item_query(entry: u32, guid: u64) -> Vec<u8> {
let mut body = Vec::with_capacity(12);
body.extend_from_slice(&entry.to_le_bytes());
body.extend_from_slice(&guid.to_le_bytes());
body
}
/// The wire's "the player's own inventory" bag index (`INVENTORY_SLOT_BAG_0`): with it, `slot`
/// addresses the player descriptor's item array directly — equipment 0–18, bag slots 19–22, the
/// backpack 23–38 (VERIFIED vmangos `Player.h` slot enums; the same 23-slot base the descriptor's
/// `PACK_SLOT_1` offset encodes).
pub const BAG_PLAYER_INVENTORY: u8 = 255;
/// The backpack's first player-array slot (`INVENTORY_SLOT_ITEM_START`).
pub const SLOT_PACK_FIRST: u8 = 23;
/// The first equipped-bag player-array slot (`INVENTORY_SLOT_BAG_START`; bags occupy 19–22).
pub const SLOT_BAG_FIRST: u8 = 19;
/// `TARGET_FLAG_GAMEOBJECT` — the cast-target bit that carries a GO guid (vmangos
/// `SpellDefines.h`; its `SpellCastTargets::read` is the only bit here that consumes bytes).
const TARGET_FLAG_GAMEOBJECT: u16 = 0x0800;
/// `TARGET_FLAG_UNIT` — a bound unit target: the same bit, and the same packed guid, a
/// `CMSG_CAST_SPELL` writes. In the real client ONE block builder serves both opcodes, so one bit
/// table serves both here.
const TARGET_FLAG_UNIT: u16 = 0x0002;
/// `TARGET_FLAG_DEST_LOCATION` — a ground point, the same bit and the same three `f32` WoW coords
/// a ground-targeted `CMSG_CAST_SPELL` writes ([`super::spells::cast_spell_at_dest`]).
const TARGET_FLAG_DEST_LOCATION: u16 = 0x0040;
/// `TARGET_FLAG_SOURCE_LOCATION` — the dest bit's twin one place down, and the same three `f32`
/// ([`super::spells::cast_spell_at_source`]). Both come out of the one binder `BindLocation
/// 0x6e60f0` (decision 2218).
const TARGET_FLAG_SOURCE_LOCATION: u16 = 0x0020;
/// `TARGET_FLAG_ITEM` — a bound *item* target, the same bit and the same packed guid an
/// item-targeted `CMSG_CAST_SPELL` writes (`super::spells::cast_spell_on_item`). One block
/// builder, two opcodes (decision 0923).
const TARGET_FLAG_ITEM: u16 = 0x0010;
/// The `SpellCastTargets` block a `CMSG_USE_ITEM` carries — built by the *same* code as a
/// `CMSG_CAST_SPELL`'s. `SendCast 0x6e54f0` picks the opcode from its item-vs-caster discriminator
/// (`0x6e57d8 push 0xab` when the pending-cast block's guid is the item's, `push 0x12e` when it is
/// the caster's) and then writes the one targets block `ArmCast 0x6e5250` bound — item or not
/// (wow-re `action-item-slot.md` §8: "body `{u8 bagIndex, u8 slot, u8 spell_index}` + the
/// cast-targets block"; `cursor-system.md` §8.4a for the split).
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub enum UseItemTarget {
/// Mask 0 (`TARGET_FLAG_SELF`) — the implicit self-cast an ordinary consumable sends, whose
/// target the server resolves itself. What 81% of the 1.12 on-use items bind to.
#[default]
SelfImplicit,
/// `TARGET_FLAG_UNIT` + the packed guid — an item whose spell binds a unit exactly as a
/// spell's does: a bandage, a soulstone, an offensive trinket.
Unit(u64),
/// `TARGET_FLAG_GAMEOBJECT` + the packed guid — the **key-in-a-lock** case (decision 0769):
/// opening a locked door or chest with a key is not a spell cast, it is *using the key at the
/// object*. It matters that this is USE_ITEM and not a bare cast: `Spell::CanOpenLock` honours
/// a `Lock.dbc` KEY slot **only** when `m_CastItem` is set (`Spell.cpp:7892`), which only this
/// packet supplies. The mask is `0x0800` alone — `TARGET_FLAG_LOCKED` is a *targeting-word*
/// bit that `BindTarget 0x6e5b40` consumes and never writes to the wire (decision 0939,
/// correcting 0769; see [`super::spells::cast_spell_gameobject`] for the census).
Object(u64),
/// `TARGET_FLAG_DEST_LOCATION` + three `f32` WoW coords — the targeting-cursor commit for a
/// **thrown** item: dynamite, grenades, bombs, the Goblin Mortar (46 of the 1.12 on-use item
/// spells, decision 0914). Same block a ground-targeted spell writes; only the opcode differs.
Dest([f32; 3]),
/// `TARGET_FLAG_ITEM` + the packed guid — the targeting cursor's **item** commit: a poison, a
/// sharpening stone, a weapon oil, an enchanting scroll applied to the item you clicked in a
/// bag or on the paper doll (decision 0923). The reference reaches it through the very same
/// `BindTarget 0x6e5b40` a unit goes through (`0x495d60` @ `496056`), so the block is the same
/// block; only which bit is set differs.
Item(u64),
/// `TARGET_FLAG_SOURCE_LOCATION` + three `f32` WoW coords — [`Self::Dest`]'s twin, one bit
/// over: `BindLocation 0x6e60f0`'s bit-5 arm writes the clicked point to `SPELLCAST+0x30` and
/// ORs `0x0020` into the wire mask, where its bit-6 arm writes `+0x3c` and ORs `0x0040`. Three
/// shipped items reach it — Martin Fury (17), 192 and 5417, all carrying spell 265 "Area Death
/// (TEST)" (decision 2218). vmangos reads this triple **before** the dest one.
Source([f32; 3]),
}
/// Body of `CMSG_USE_ITEM` (VERIFIED vmangos `UseItem::ReadFromWorldPacket` + opcode 171
/// `Opcodes_1_12_1.h`): `bagIndex` (a bag's player-array slot 19–22, or [`BAG_PLAYER_INVENTORY`]
/// with an absolute `slot`), `slot` (0-based within the bag), `spellSlot` (which of the template's
/// 5 spell effects — 0, the "use" effect, for a plain use), then a `SpellCastTargets` block
/// ([`UseItemTarget`]).
pub fn use_item(bag_index: u8, slot: u8, spell_slot: u8, target: UseItemTarget) -> Vec<u8> {
let mut body = Vec::with_capacity(5);
body.push(bag_index);
body.push(slot);
body.push(spell_slot);
let guid = match target {
UseItemTarget::SelfImplicit => {
body.extend_from_slice(&0u16.to_le_bytes());
return body;
}
UseItemTarget::Unit(guid) => {
body.extend_from_slice(&TARGET_FLAG_UNIT.to_le_bytes());
guid
}
UseItemTarget::Object(guid) => {
body.extend_from_slice(&TARGET_FLAG_GAMEOBJECT.to_le_bytes());
guid
}
UseItemTarget::Item(guid) => {
body.extend_from_slice(&TARGET_FLAG_ITEM.to_le_bytes());
guid
}
UseItemTarget::Dest(dest) => {
body.extend_from_slice(&TARGET_FLAG_DEST_LOCATION.to_le_bytes());
for c in dest {
body.extend_from_slice(&c.to_le_bytes());
}
return body;
}
UseItemTarget::Source(src) => {
body.extend_from_slice(&TARGET_FLAG_SOURCE_LOCATION.to_le_bytes());
for c in src {
body.extend_from_slice(&c.to_le_bytes());
}
return body;
}
};
crate::wire::write_packed_guid(guid, &mut body).expect("write to Vec cannot fail");
body
}
/// Body of `CMSG_OPEN_ITEM` (VERIFIED vmangos `OpenItem::ReadFromWorldPacket`,
/// `Server/Packets/Spell.cpp` + `.h:36-45`; opcode 172 `Opcodes_1_12_1.h:175`): `bagIndex`, `slot`
/// — the same two bytes and the same bag addressing as [`use_item`], and nothing else (no spell
/// ordinal, no targets block: opening is not a cast).
///
/// The right-click fork for an [`ItemInfo::openable`] item. The server answers on the **item's own
/// guid**: `SendLoot(item, LOOT_CORPSE)` for a lootable, or — for a wrapped gift — an entry swap
/// and no window at all.
pub fn open_item(bag_index: u8, slot: u8) -> Vec<u8> {
vec![bag_index, slot]
}
/// Body of `CMSG_WRAP_ITEM` (VERIFIED vmangos `WrapItem::ReadFromWorldPacket`,
/// `Server/Packets/Item.cpp:121-127` + `.h:191-201`; opcode 467 `Opcodes_1_12_1.h:468`):
/// `giftBag`, `giftSlot`, `itemBag`, `itemSlot` — all `uint8`, the **paper's** position first and
/// the target's second, the same bag addressing as [`use_item`].
///
/// The server refuses every ineligible target with an `EQUIP_ERR_*` of its own
/// (`HandleWrapItemOpcode`: equipped, a bag, soulbound, stackable, unique, already wrapped, or
/// mid-cast) — so a refusal reaches the player as an `SMSG_INVENTORY_CHANGE_FAILURE` line, not as
/// a silently eaten click. On success the *target* item keeps its guid and takes the paper's
/// `WrappedGift` entry, gains `ITEM_FIELD_GIFTCREATOR` and `ITEM_DYNFLAG_WRAPPED`, and one paper
/// is destroyed — all as ordinary field updates, no answering packet of its own.
pub fn wrap_item(gift_bag: u8, gift_slot: u8, item_bag: u8, item_slot: u8) -> Vec<u8> {
vec![gift_bag, gift_slot, item_bag, item_slot]
}
/// Body of `CMSG_AUTOEQUIP_ITEM` (VERIFIED vmangos `AutoEquipItem::ReadFromWorldPacket`,
/// `Server/Packets/Item.cpp:17-21` + `.h:31-39`; opcode 266 `Opcodes_1_12_1.h:269`): source
/// `srcbag`/`srcslot` (both `uint8`), the same bag addressing as [`use_item`]. The real client
/// sends this — not USE_ITEM — when the clicked bag item is *equippable* (the equip-vs-use fork is
/// client-side); the server picks the destination slot itself. Refusals answer
/// `SMSG_INVENTORY_CHANGE_FAILURE`.
pub fn auto_equip_item(bag_index: u8, slot: u8) -> Vec<u8> {
vec![bag_index, slot]
}
/// Body of `CMSG_AUTOSTORE_BAG_ITEM` (VERIFIED vmangos `AutoStoreBagItem::ReadFromWorldPacket`,
/// `Server/Packets/Item.cpp:23-28` + `.h:41-49`; opcode 267 `Opcodes_1_12_1.h:270`): `srcbag`,
/// `srcslot`, `dstbag` — all `uint8`. "Auto-store this item into that bag, server picks the slot."
/// Builder only tonight (backpack-internal moves take [`swap_inv_item`]); no UI path yet.
pub fn auto_store_bag_item(src_bag: u8, src_slot: u8, dst_bag: u8) -> Vec<u8> {
vec![src_bag, src_slot, dst_bag]
}
/// Body of `CMSG_SWAP_ITEM` (VERIFIED vmangos `SwapItem::ReadFromWorldPacket`,
/// `Server/Packets/Item.cpp:30-36` + `.h:51-61`; opcode 268 `Opcodes_1_12_1.h:271`): `dstbag`,
/// `dstslot`, `srcbag`, `srcslot` — all `uint8`, **destination FIRST**. The general bag↔bag move
/// (either endpoint an equipped bag). Builder only tonight; the windowed backpack's internal moves
/// go out as [`swap_inv_item`].
pub fn swap_item(dst_bag: u8, dst_slot: u8, src_bag: u8, src_slot: u8) -> Vec<u8> {
vec![dst_bag, dst_slot, src_bag, src_slot]
}
/// Body of `CMSG_SWAP_INV_ITEM` (VERIFIED vmangos `SwapInvItem::ReadFromWorldPacket`,
/// `Server/Packets/Item.cpp:38-42` + `.h:63-72`; opcode 269 `Opcodes_1_12_1.h:272`): `srcslot`,
/// `dstslot` — two `uint8` player-array slots, both implicitly on the player itself
/// (`INVENTORY_SLOT_BAG_0`). This is the wire for a backpack-internal pick/place/swap: both slots
/// are `INVENTORY_SLOT_ITEM_START`+i (see [`SLOT_PACK_FIRST`]). An empty destination is still a
/// swap on this wire — the server treats it as a move.
pub fn swap_inv_item(src_slot: u8, dst_slot: u8) -> Vec<u8> {
vec![src_slot, dst_slot]
}
/// Body of `CMSG_SPLIT_ITEM` (VERIFIED vmangos `SplitItem::ReadFromWorldPacket`,
/// `Server/Packets/Item.cpp:44-51` + `.h:74-85`; opcode 270 `Opcodes_1_12_1.h:273`): `srcbag`,
/// `srcslot`, `dstbag`, `dstslot`, `count` — all `uint8`. Builder only: the UI split dialog is out
/// of scope, but the wire is pinned so a later stack-split slice has a byte-exact starting point.
pub fn split_item(src_bag: u8, src_slot: u8, dst_bag: u8, dst_slot: u8, count: u8) -> Vec<u8> {
vec![src_bag, src_slot, dst_bag, dst_slot, count]
}
/// Body of `CMSG_DESTROYITEM` (VERIFIED vmangos `Packets/Item.cpp:59-68`; opcode 273
/// `Opcodes_1_12_1.h`): `bag`, `slot`, `count` (0 = the whole stack — matches [`split_item`]'s
/// count and the app's `container_destroys` triple), then THREE more `uint8`s the server reads
/// off the wire and discards — the real client sends them, so the body stays 6 bytes rather than
/// a shorter, non-matching one. Decision 0216 §3: the delete-confirm popup's `OnAccept`
/// (`DeleteCursorItem`).
pub fn destroy_item(bag: u8, slot: u8, count: u8) -> Vec<u8> {
vec![bag, slot, count, 0, 0, 0]
}
/// Body of `CMSG_SET_AMMO` (VERIFIED wow-re `cursor-dragdrop-slots.md`: the client's auto-equip
/// sender `0x5e1480` forks ammo-class → opcode `0x268`, body `{itemEntry}` (a single `u32`); the
/// vmangos handler `HandleSetAmmoOpcode` reads the same lone `uint32` entry). Unlike every other
/// item CMSG this is NOT a `(bag, slot)` address — ammo is loaded by item *entry*, and the stack
/// stays put in the bag (`PLAYER_AMMO_ID` just references it). The server refuses a mismatch
/// (`EQUIP_ERR_ONLY_AMMO_CAN_GO_HERE` &c.) via `SMSG_INVENTORY_CHANGE_FAILURE`. Decision 0526.
pub fn set_ammo(entry: u32) -> Vec<u8> {
entry.to_le_bytes().to_vec()
}
/// Read `SMSG_INVENTORY_CHANGE_FAILURE` (VERIFIED vmangos `InventoryChangeFailure::AppendBodyTo`):
/// `u8 reason` (`InventoryResult`; 0 = OK, no tail), then — only when failed — a `u32` required
/// level *iff* `reason == 1` (`CANT_EQUIP_LEVEL_I`), the two full item guids, and the bag slot.
///
/// The trailing `u8` is **the destination bag's absolute player slot**, not a subslot: vmangos
/// declares it *"slot of target bag that has storing condition (can be InventorySlots or
/// BankBagSlots)"* and fills it with `bagSlot = bag` at each `CanStoreItem` refusal
/// (`Player.cpp:8899`ff), where `bag` is `INVENTORY_SLOT_BAG_0` (255, the player's own array) or
/// an equipped bag's slot. The reference reads it the same way — its reason-16 helper
/// `0x5ede00` bails on `slot == 0xFF` and otherwise indexes the player's slot array by it (wow-re
/// `inventory-change-failure-display.md` §6). It is the `%s` source of *"Only Arrows can be
/// placed in that."*; see `benilla::ui_items::feed`.
///
/// Returns `(reason, required_level, item_guid, bag_slot)`.
pub(super) fn read_inventory_change_failure(
r: &mut &[u8],
) -> io::Result<(u8, Option<u32>, u64, u8)> {
let reason = read_u8(r)?;
if reason == 0 {
return Ok((0, None, 0, 0));
}
let required_level = if reason == 1 {
Some(read_u32_le(r)?)
} else {
None
};
let item_guid = read_u64_le(r)?;
let _item2 = read_u64_le(r)?;
let bag_slot = read_u8(r)?;
Ok((reason, required_level, item_guid, bag_slot))
}
/// Read `SMSG_ITEM_TIME_UPDATE` (VERIFIED vmangos `Item::SendTimeUpdate`,
/// `Objects/Item.cpp:1096-1106`): the item guid, then the remaining **seconds**
/// (`ITEM_FIELD_DURATION`, which vmangos counts down in seconds — `Item::UpdateDuration`,
/// `Item.cpp:243-257`). No trailing player guid on this one, unlike its enchant sibling: the
/// packet is `(8 + 4)` bytes and vmangos sizes it exactly so.
///
/// Sent on world enter for every carried duration item (`Player::SendItemDurations`,
/// `Player.cpp:12007`) and again whenever one is created or its duration changes
/// (`Player.cpp:19782-19785`).
pub(super) fn read_item_time(r: &mut &[u8]) -> io::Result<(u64, u32)> {
let item_guid = read_u64_le(r)?;
let seconds = read_u32_le(r)?;
Ok((item_guid, seconds))
}
/// Read `SMSG_ITEM_ENCHANT_TIME_UPDATE` (VERIFIED vmangos
/// `WorldPackets::Item::ItemEnchantTimeUpdate::AppendBodyTo`, `Server/Packets/Item.cpp:161-169`):
/// item guid, enchant **slot**, remaining **seconds**, then the owning player's guid (present from
/// build 1.10.2 up, so always in 1.12). Returns `(item_guid, slot, seconds)`; the trailing player
/// guid is dropped — the packet only ever concerns our own items and the item guid already names
/// which one.
///
/// `seconds == 0` means expired, and the reference stores that as **no timer** (`0x5d9cc0` writes
/// a `0` deadline whenever `seconds <= 0`), not as "0 seconds left".
/// Read `SMSG_OPEN_CONTAINER` → the item guid. VERIFIED vmangos `Player::SendOpenContainer`
/// (`Objects/Player.cpp:11707`) → `WorldPackets::Item::OpenContainer`, whose whole body is the item
/// guid; the one sender is `HandleAutoEquipItemOpcode` (`ItemHandler.cpp:227`) when the destination
/// is a bag slot. The reference fires `BAG_OPEN(containerId)` from it and nothing else (2339).
pub(super) fn read_open_container(r: &mut &[u8]) -> io::Result<u64> {
read_u64_le(r)
}
/// Read `SMSG_INSPECT` → the echoed target guid. VERIFIED vmangos `HandleInspectOpcode`
/// (`MiscHandler.cpp:957`) → `WorldPackets::Misc::InspectResponse`, one `u64`. The reference's
/// handler `0x5e7d70` reads it and does nothing with it (2339; decision 0631's call).
pub(super) fn read_inspect(r: &mut &[u8]) -> io::Result<u64> {
read_u64_le(r)
}
pub(super) fn read_item_enchant_time(r: &mut &[u8]) -> io::Result<(u64, u32, u32)> {
let item_guid = read_u64_le(r)?;
let slot = read_u32_le(r)?;
let seconds = read_u32_le(r)?;
Ok((item_guid, slot, seconds))
}
#[cfg(test)]
mod tests {
use super::*;
// The `SMSG_ITEM_QUERY_SINGLE_RESPONSE` parse goldens (hit + miss, byte-exact against the
// vmangos field order) live in `tests/items.rs` — one home, no drifting twins.
// Byte-exact encode goldens — the item-move CMSG bodies (VERIFIED field order + widths against
// vmangos `Server/Packets/Item.cpp` `ReadFromWorldPacket`s; every field is a `uint8`).
#[test]
fn open_container_decodes() {
// The whole body is the bag's guid (VERIFIED vmangos `Player::SendOpenContainer` →
// `WorldPackets::Item::OpenContainer::AppendBodyTo` writes only `itemGuid`); decision 2339.
let body = 0x4000_0000_0012_3456u64.to_le_bytes();
match crate::messages::parse_server(crate::messages::opcode::SMSG_OPEN_CONTAINER, &body)
.unwrap()
{
crate::messages::ServerPacket::OpenContainer { item } => {
assert_eq!(item, 0x4000_0000_0012_3456);
}
other => panic!("expected OpenContainer, got {}", other.name()),
}
}
#[test]
fn inspect_decodes_and_is_ignored() {
// One u64, the echoed target (VERIFIED vmangos `Misc::InspectResponse::AppendBodyTo`), and
// `decode()` turns it into nothing — the reference's handler discards it too (2339, 0631).
let body = 0x0000_0000_0000_0007u64.to_le_bytes();
let packet =
crate::messages::parse_server(crate::messages::opcode::SMSG_INSPECT, &body).unwrap();
assert!(matches!(
packet,
crate::messages::ServerPacket::Inspect { guid: 7 }
));
assert!(crate::events::decode(packet).is_empty());
}
#[test]
fn auto_equip_item_body() {
// 266: srcbag, srcslot (Item.cpp:17-21).
assert_eq!(auto_equip_item(255, 30), vec![255, 30]);
}
#[test]
fn wrap_item_body_paper_first() {
// 467: giftbag, giftslot, itembag, itemslot (Item.cpp:121-127) — the PAPER's pair leads.
assert_eq!(wrap_item(255, 23, 255, 24), vec![255, 23, 255, 24]);
}
#[test]
fn auto_store_bag_item_body() {
// 267: srcbag, srcslot, dstbag (Item.cpp:23-28).
assert_eq!(auto_store_bag_item(255, 30, 19), vec![255, 30, 19]);
}
#[test]
fn swap_item_body_destination_first() {
// 268: dstbag, dstslot, srcbag, srcslot — destination pair FIRST (Item.cpp:30-36).
assert_eq!(swap_item(19, 3, 255, 30), vec![19, 3, 255, 30]);
}
#[test]
fn swap_inv_item_body() {
// 269: srcslot, dstslot (Item.cpp:38-42). Backpack slot 1↔2 = player-array 23↔24.
assert_eq!(swap_inv_item(23, 24), vec![23, 24]);
}
#[test]
fn set_ammo_body() {
// 616 (0x268): a lone little-endian u32 item entry (wow-re cursor-dragdrop-slots.md).
assert_eq!(set_ammo(0x0001_6b74), vec![0x74, 0x6b, 0x01, 0x00]);
}
#[test]
fn split_item_body() {
// 270: srcbag, srcslot, dstbag, dstslot, count (Item.cpp:44-51).
assert_eq!(split_item(255, 23, 255, 24, 5), vec![255, 23, 255, 24, 5]);
}
#[test]
fn destroy_item_body() {
// 273: bag, slot, count, then three ignored trailing bytes (Item.cpp:59-68).
assert_eq!(destroy_item(255, 23, 0), vec![255, 23, 0, 0, 0, 0]);
}
// SMSG_INVENTORY_CHANGE_FAILURE parse — both branches of the conditional `requiredLevel u32`
// (VERIFIED vmangos `InventoryChangeFailure::AppendBodyTo`, `Item.cpp:198-209`;
// EQUIP_ERR_CANT_EQUIP_LEVEL_I = 1, `Objects/ItemDefines.h`).
#[test]
fn inventory_failure_ok_reason_is_bare() {
// reason 0 (EQUIP_ERR_OK) ships no tail.
let buf = [0u8];
let mut r = &buf[..];
assert_eq!(
read_inventory_change_failure(&mut r).unwrap(),
(0, None, 0, 0)
);
}
#[test]
fn inventory_failure_level_branch_reads_the_u32() {
// reason 1 (CANT_EQUIP_LEVEL_I): requiredLevel u32, item1Guid u64, item2Guid u64, bagSlot u8.
let mut buf = Vec::new();
buf.push(1u8); // reason
buf.extend_from_slice(&40u32.to_le_bytes()); // requiredLevel
buf.extend_from_slice(&0x1122_3344_5566_7788u64.to_le_bytes()); // item1
buf.extend_from_slice(&0u64.to_le_bytes()); // item2
buf.push(7); // bagSlot
let mut r = &buf[..];
assert_eq!(
read_inventory_change_failure(&mut r).unwrap(),
(1, Some(40), 0x1122_3344_5566_7788, 7)
);
}
/// `SMSG_ITEM_ENCHANT_TIME_UPDATE`'s body, byte-exact against vmangos's writer: guid, slot,
/// seconds, then the player guid we drop. Pinned because a wrong width here would silently
/// park a garbage deadline (decision 0920).
#[test]
fn item_enchant_time_reads_guid_slot_seconds() {
let mut buf = Vec::new();
buf.extend_from_slice(&0x4000_0000_0000_00f7u64.to_le_bytes()); // itemGuid
buf.extend_from_slice(&1u32.to_le_bytes()); // slot 1 = TEMP
buf.extend_from_slice(&600u32.to_le_bytes()); // seconds
buf.extend_from_slice(&0x0000_0000_0000_0001u64.to_le_bytes()); // playerGuid (dropped)
let mut r = &buf[..];
assert_eq!(
read_item_enchant_time(&mut r).unwrap(),
(0x4000_0000_0000_00f7, 1, 600)
);
// The trailing player guid stays unread — the reader consumes exactly its three fields.
assert_eq!(r.len(), 8);
}
/// `SMSG_ITEM_TIME_UPDATE`'s body: guid then seconds, and **nothing after** — the enchant
/// twin's trailing player guid is not on this packet (vmangos sizes it `8 + 4`). Pinned
/// because the two share a handler and it would be easy to copy the wrong tail.
#[test]
fn item_time_reads_guid_then_seconds() {
let mut buf = Vec::new();
buf.extend_from_slice(&0x4000_0000_0000_00f7u64.to_le_bytes()); // itemGuid
buf.extend_from_slice(&1800u32.to_le_bytes()); // seconds
let mut r = &buf[..];
assert_eq!(
read_item_time(&mut r).unwrap(),
(0x4000_0000_0000_00f7, 1800)
);
assert!(r.is_empty(), "the body is exactly 12 bytes");
}
#[test]
fn inventory_failure_nonlevel_branch_has_no_u32() {
// Any failed reason != 1 skips requiredLevel: item1Guid u64, item2Guid u64, bagSlot u8.
let mut buf = Vec::new();
buf.push(3u8); // reason (ITEM_DOESNT_GO_TO_SLOT) — no requiredLevel
buf.extend_from_slice(&0xDEAD_BEEF_0000_0001u64.to_le_bytes()); // item1
buf.extend_from_slice(&0u64.to_le_bytes()); // item2
buf.push(0); // bagSlot
let mut r = &buf[..];
assert_eq!(
read_inventory_change_failure(&mut r).unwrap(),
(3, None, 0xDEAD_BEEF_0000_0001, 0)
);
}
}