Files
benilla-octo/crates/benilla-protocol/tests/items.rs
T
SamandClaude Fable 5 94de741cb1 /target, /assist and /follow, and right click opens a clam
- /target and /assist find units by name like the original, and /follow walks you behind your target
- right click opens clams and lockboxes, and the tooltip promises it
- the freeze thaws clean, poses release instead of sticking, and you drop to neutral standing before it grips
- unit frame art layers over the bars like the original

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-02 18:02:44 +07:00

493 lines
22 KiB
Rust

//! Item wire tests (mirrors `src/messages/items.rs`): the query request/response roundtrip,
//! inventory-change-failure (level refusal vs. plain), the GM-command system chat shape, and the
//! full `SMSG_ITEM_QUERY_SINGLE_RESPONSE` field walk (hit + undiscovered miss). Split out of the
//! former `tests/messages.rs` — see `tests/common` for the shared fixtures and methodology note.
mod common;
use benilla_protocol::events::{decode, SessionEvent};
use benilla_protocol::messages;
use benilla_protocol::ServerPacket;
use common::hx;
#[test]
fn item_query_wire() {
// CMSG_ITEM_QUERY_SINGLE: entry u32 + full item guid (vmangos QueryItem::ReadFromWorldPacket).
assert_eq!(messages::item_query(117, 0), hx("750000000000000000000000"));
// CMSG_USE_ITEM (vmangos UseItem::ReadFromWorldPacket): bagIndex, slot, spellSlot, then a
// self-shaped target block (u16 mask 0). Backpack slot 1 = bag 255 + player-array slot 23.
assert_eq!(messages::use_item(255, 23, 0, None), hx("ff17000000"));
// The KEY-IN-A-LOCK form (decision 0769): the same three bytes, then a SpellCastTargets with
// mask TARGET_FLAG_GAMEOBJECT|TARGET_FLAG_LOCKED (0x0800|0x4000 = 0x4800) and the object's
// PACKED guid. This is the packet the real client sends for a key (wow-re cursor-system.md
// §8.4: sender 0x6e54f0's item arm -> 0x6e57d8 push 0xab) and the only one vmangos will open a
// KEY-slot lock for (Spell::CanOpenLock requires m_CastItem, Spell.cpp:7892).
//
// Keyring slot 1 = bag 255 + player-array slot 81 (0x51). The guid 0xF110000C1F00A3B2 has two
// zero bytes (indices 2 and 5), so it packs to mask 0xDB + the other six, low byte first.
assert_eq!(
messages::use_item(255, 81, 0, Some(0xF110_000C_1F00_A3B2)),
hx("ff51000048dbb2a31f0c10f1"),
);
// The packed form really does drop zero bytes: guid 1 is mask 0x01 + a single byte, and the
// spellSlot ordinal rides in the third byte.
assert_eq!(
messages::use_item(255, 81, 2, Some(1)),
hx("ff510200480101")
);
// CMSG_AUTOEQUIP_ITEM (vmangos AutoEquipItem::ReadFromWorldPacket): bagIndex, slot.
assert_eq!(messages::auto_equip_item(255, 25), hx("ff19"));
// CMSG_OPEN_ITEM (vmangos OpenItem::ReadFromWorldPacket, Server/Packets/Spell.cpp:19-23):
// bagIndex, slot — and NOTHING else (no spell ordinal, no targets block; opening is not a
// cast). The same two bytes as auto-equip under a different opcode: this is the fork a
// clam/lockbox/gift takes instead of USE_ITEM, and the only one the server answers with
// SendLoot on the item's own guid. Backpack slot 3 = bag 255 + player-array slot 25; an item
// inside an equipped bag addresses that bag's own player-array slot (19..22) + inner slot.
assert_eq!(messages::open_item(255, 25), hx("ff19"));
assert_eq!(messages::open_item(19, 0), hx("1300"));
// SMSG_INVENTORY_CHANGE_FAILURE (vmangos InventoryChangeFailure::AppendBodyTo): the level
// refusal (reason 1) carries a u32 required level before the guids; others don't.
let level_fail = messages::parse_server(
messages::opcode::SMSG_INVENTORY_CHANGE_FAILURE,
&hx("010a000000420000000000004000000000000000000000"),
)
.unwrap();
match level_fail {
ServerPacket::InventoryChangeFailure {
reason,
required_level,
item_guid,
} => {
assert_eq!((reason, required_level), (1, Some(10)));
assert_eq!(item_guid, 0x4000_0000_0000_0042);
}
other => panic!("level refusal, got {}", other.name()),
}
let plain_fail = messages::parse_server(
messages::opcode::SMSG_INVENTORY_CHANGE_FAILURE,
&hx("14420000000000004000000000000000000000"),
)
.unwrap();
match plain_fail {
ServerPacket::InventoryChangeFailure {
reason,
required_level,
..
} => assert_eq!((reason, required_level), (0x14, None)),
other => panic!("plain refusal, got {}", other.name()),
}
// SMSG_MESSAGECHAT, the system shape (type 0x0A: guid, len-prefixed text incl. NUL, tag) —
// the GM-command feedback channel (live-verified: '.additem' answers exactly this shape).
let sys = messages::parse_server(
messages::opcode::SMSG_MESSAGECHAT,
&hx("0a0000000000000000000000000600000068656c6c6f0000"),
)
.unwrap();
match sys {
ServerPacket::MessageChat(m) => {
assert_eq!((m.chat_type, m.text.as_str()), (0x0a, "hello"));
assert_eq!((m.sender_guid, m.sender_name, m.channel), (0, None, None));
}
other => panic!("system chat, got {}", other.name()),
}
}
/// `SMSG_ITEM_QUERY_SINGLE_RESPONSE`, hit: the full 1.12.1 item template, byte-exact (VERIFIED
/// field order vmangos `HandleItemQuerySingleOpcode`, `ItemHandler.cpp:269-415`; every
/// `SUPPORTED_CLIENT_BUILD` conditional there is included for build 5875) — decision 0274 P1's
/// tooltip builder needs every line. A main-hand sword exercising every filtered/mirrored shape at
/// once: 2 of 5 damage blocks, 3 of 10 stats, 2 of 6 resistances, 2 of 5 spells (one ON_USE trigger
/// 0, one ON_EQUIP trigger 1), a nonempty description, and real requirement/durability values.
/// Every field carries a distinct value so a transposed or misaligned read fails the assert below.
#[test]
fn item_query_response_full_weapon_golden() {
use benilla_protocol::messages::{ItemDamage, ItemInfo, ItemSpellEntry, ItemUseSpell};
let mut body = 12_345u32.to_le_bytes().to_vec(); // entry
body.extend_from_slice(&2u32.to_le_bytes()); // class: weapon
body.extend_from_slice(&7u32.to_le_bytes()); // subclass: sword
body.extend_from_slice(b"Verified Blade of Testing\0");
body.extend_from_slice(&[0, 0, 0]); // name2..4: empty cstrings
body.extend_from_slice(&6303u32.to_le_bytes()); // displayInfoID
body.extend_from_slice(&4u32.to_le_bytes()); // quality: epic
body.extend_from_slice(&66u32.to_le_bytes()); // flags
body.extend_from_slice(&4200u32.to_le_bytes()); // buyPrice
body.extend_from_slice(&1050u32.to_le_bytes()); // sellPrice
body.extend_from_slice(&21u32.to_le_bytes()); // inventoryType: WEAPONMAINHAND
body.extend_from_slice(&(-1i32).to_le_bytes()); // AllowableClass: all classes
body.extend_from_slice(&3i32.to_le_bytes()); // AllowableRace
body.extend_from_slice(&60u32.to_le_bytes()); // ItemLevel
body.extend_from_slice(&55u32.to_le_bytes()); // RequiredLevel
body.extend_from_slice(&164u32.to_le_bytes()); // RequiredSkill
body.extend_from_slice(&225u32.to_le_bytes()); // RequiredSkillRank
body.extend_from_slice(&5209u32.to_le_bytes()); // RequiredSpell
body.extend_from_slice(&3u32.to_le_bytes()); // RequiredHonorRank
body.extend_from_slice(&2u32.to_le_bytes()); // RequiredCityRank
body.extend_from_slice(&69u32.to_le_bytes()); // RequiredReputationFaction
body.extend_from_slice(&4u32.to_le_bytes()); // RequiredReputationRank
body.extend_from_slice(&5u32.to_le_bytes()); // MaxCount
body.extend_from_slice(&1u32.to_le_bytes()); // Stackable
body.extend_from_slice(&0u32.to_le_bytes()); // ContainerSlots
// 10x ItemStat { type, value } — 3 nonzero (one negative value proves signed reads), 7 empty
// (dropped by the nonzero filter).
let stat_slots: [(u32, i32); 10] = [
(4, 15), // STRENGTH +15
(7, 20), // STAMINA +20
(5, -3), // INTELLECT -3
(0, 0),
(0, 0),
(0, 0),
(0, 0),
(0, 0),
(0, 0),
(0, 0),
];
for (stat_type, stat_value) in stat_slots {
body.extend_from_slice(&stat_type.to_le_bytes());
body.extend_from_slice(&stat_value.to_le_bytes());
}
// 5x Damage { min f32, max f32, type u32 } — 2 real blocks (physical + a secondary Fire line),
// 3 empty (dropped by the `max > 0` filter). Block 0 also mirrors into dmg_min/dmg_max/dmg_type.
let dmg_slots: [(f32, f32, u32); 5] = [
(12.0, 22.0, 0), // physical
(3.0, 7.0, 2), // Fire
(0.0, 0.0, 0),
(0.0, 0.0, 0),
(0.0, 0.0, 0),
];
for (min, max, school) in dmg_slots {
body.extend_from_slice(&min.to_le_bytes());
body.extend_from_slice(&max.to_le_bytes());
body.extend_from_slice(&school.to_le_bytes());
}
body.extend_from_slice(&15u32.to_le_bytes()); // Armor
// Resistances: Holy/Fire/Nature/Frost/Shadow/Arcane — 2 of 6 nonzero.
for r in [0i32, 8, 0, 0, 12, 0] {
body.extend_from_slice(&r.to_le_bytes());
}
body.extend_from_slice(&2800u32.to_le_bytes()); // Delay (ms)
body.extend_from_slice(&2u32.to_le_bytes()); // AmmoType
body.extend_from_slice(&1.5f32.to_le_bytes()); // RangedModRange
// 5x Spell block { SpellId, SpellTrigger, SpellCharges, Cooldown, Category, CategoryCooldown }
// — slot 0 ON_USE (a lone -1 use-cooldown next to a resolved category pair, the mixed shape
// vmangos can send), slot 1 ON_EQUIP (a plain passive, no cooldown), slots 2-4 the server's own
// unresolved-slot sentinel (0,0,0,-1,0,-1).
let spell_slots: [(u32, u32, i32, i32, u32, i32); 5] = [
(17_251, 0, 0, -1, 4, 60_000),
(671, 1, 0, 0, 0, 0),
(0, 0, 0, -1, 0, -1),
(0, 0, 0, -1, 0, -1),
(0, 0, 0, -1, 0, -1),
];
for (spell_id, trigger, charges, cooldown_ms, category, category_cooldown_ms) in spell_slots {
body.extend_from_slice(&spell_id.to_le_bytes());
body.extend_from_slice(&trigger.to_le_bytes());
body.extend_from_slice(&charges.to_le_bytes());
body.extend_from_slice(&cooldown_ms.to_le_bytes());
body.extend_from_slice(&category.to_le_bytes());
body.extend_from_slice(&category_cooldown_ms.to_le_bytes());
}
body.extend_from_slice(&2u32.to_le_bytes()); // Bonding: BIND_WHEN_EQUIPPED
body.extend_from_slice(b"A blade of pure verification.\0"); // Description
body.extend_from_slice(&333u32.to_le_bytes()); // PageText
body.extend_from_slice(&7u32.to_le_bytes()); // LanguageID
body.extend_from_slice(&2u32.to_le_bytes()); // PageMaterial
body.extend_from_slice(&444u32.to_le_bytes()); // StartQuest
body.extend_from_slice(&12u32.to_le_bytes()); // LockID
body.extend_from_slice(&3u32.to_le_bytes()); // Material
body.extend_from_slice(&3u32.to_le_bytes()); // Sheath: one-handed
body.extend_from_slice(&87u32.to_le_bytes()); // RandomProperty
body.extend_from_slice(&18u32.to_le_bytes()); // Block
body.extend_from_slice(&25u32.to_le_bytes()); // ItemSet
body.extend_from_slice(&120u32.to_le_bytes()); // MaxDurability
body.extend_from_slice(&8u32.to_le_bytes()); // Area
body.extend_from_slice(&1u32.to_le_bytes()); // Map
body.extend_from_slice(&4u32.to_le_bytes()); // BagFamily
match messages::parse_server(messages::opcode::SMSG_ITEM_QUERY_SINGLE_RESPONSE, &body).unwrap()
{
ServerPacket::ItemQueryResponse { entry, info } => {
assert_eq!(entry, 12_345);
assert_eq!(
info,
Some(Box::new(ItemInfo {
class: 2,
subclass: 7,
name: "Verified Blade of Testing".into(),
display_info_id: 6303,
quality: 4,
flags: 66,
buy_price: 4200,
sell_price: 1050,
inventory_type: 21,
allowable_class: -1,
allowable_race: 3,
item_level: 60,
required_level: 55,
required_skill: 164,
required_skill_rank: 225,
required_spell: 5209,
required_honor_rank: 3,
required_city_rank: 2,
required_rep_faction: 69,
required_rep_rank: 4,
max_count: 5,
stackable: 1,
container_slots: 0,
stats: vec![(4, 15), (7, 20), (5, -3)],
damages: vec![
ItemDamage {
min: 12.0,
max: 22.0,
school: 0
},
ItemDamage {
min: 3.0,
max: 7.0,
school: 2
},
],
dmg_min: 12.0,
dmg_max: 22.0,
dmg_type: 0,
armor: 15,
resistances: [0, 8, 0, 0, 12, 0],
delay_ms: 2800,
ammo_type: 2,
ranged_mod_range: 1.5,
spell_charges_0: 0,
spells: vec![
ItemSpellEntry {
index: 0,
spell_id: 17_251,
trigger: 0,
charges: 0,
cooldown_ms: -1,
category: 4,
category_cooldown_ms: 60_000,
},
ItemSpellEntry {
index: 1,
spell_id: 671,
trigger: 1,
charges: 0,
cooldown_ms: 0,
category: 0,
category_cooldown_ms: 0,
},
],
use_spell: Some(ItemUseSpell {
spell_id: 17_251,
cooldown_ms: -1,
category: 4,
category_cooldown_ms: 60_000,
}),
bonding: 2,
description: "A blade of pure verification.".into(),
page_text: 333,
language_id: 7,
page_material: 2,
start_quest: 444,
lock_id: 12,
material: 3,
sheath: 3,
random_property: 87,
block: 18,
item_set: 25,
max_durability: 120,
area: 8,
map: 1,
bag_family: 4,
}))
);
}
other => panic!("item query hit, got {}", other.name()),
}
}
/// `SMSG_ITEM_QUERY_SINGLE_RESPONSE`, hit: a minimal consumable (a potion). The server zeroes
/// `subclass` for every `ITEM_CLASS_CONSUMABLE` row regardless of the DB value (VERIFIED vmangos
/// `ItemHandler.cpp:300`) — that's a server-side rule, so this fixture just carries the
/// already-zeroed wire byte; the parser itself has no class-conditional logic. What this exercises
/// instead is the "everything empty" path the weapon golden above only partly covers: `stats`,
/// `damages`, and 4 of 5 `spells` slots all read as real zeroed wire data that must land as **empty
/// `Vec`s**, not get pushed as garbage entries — while the one real ON_USE slot still resolves both
/// into `spells` and into the legacy `use_spell` accessor.
#[test]
fn item_query_response_consumable_all_zero_slots_are_empty_not_garbage() {
use benilla_protocol::messages::{ItemInfo, ItemSpellEntry, ItemUseSpell};
let mut body = 117u32.to_le_bytes().to_vec(); // entry
body.extend_from_slice(&0u32.to_le_bytes()); // class: consumable
body.extend_from_slice(&0u32.to_le_bytes()); // subclass: server-zeroed for consumables
body.extend_from_slice(b"Minor Healing Potion\0");
body.extend_from_slice(&[0, 0, 0]); // name2..4: empty cstrings
body.extend_from_slice(&1712u32.to_le_bytes()); // displayInfoID
body.extend_from_slice(&1u32.to_le_bytes()); // quality: common
body.extend_from_slice(&0u32.to_le_bytes()); // flags
body.extend_from_slice(&40u32.to_le_bytes()); // buyPrice
body.extend_from_slice(&10u32.to_le_bytes()); // sellPrice
body.extend_from_slice(&0u32.to_le_bytes()); // inventoryType: none
body.extend_from_slice(&(-1i32).to_le_bytes()); // AllowableClass: all classes
body.extend_from_slice(&(-1i32).to_le_bytes()); // AllowableRace: all races
body.extend_from_slice(&1u32.to_le_bytes()); // ItemLevel
for _ in 0..6 {
body.extend_from_slice(&0u32.to_le_bytes()); // RequiredLevel .. RequiredCityRank
}
body.extend_from_slice(&0u32.to_le_bytes()); // RequiredReputationFaction
body.extend_from_slice(&0u32.to_le_bytes()); // RequiredReputationRank
body.extend_from_slice(&20u32.to_le_bytes()); // MaxCount
body.extend_from_slice(&20u32.to_le_bytes()); // Stackable
body.extend_from_slice(&0u32.to_le_bytes()); // ContainerSlots
for _ in 0..20 {
body.extend_from_slice(&0u32.to_le_bytes()); // 10x ItemStat { type, value }: all empty
}
for _ in 0..5 {
body.extend_from_slice(&0f32.to_le_bytes());
body.extend_from_slice(&0f32.to_le_bytes());
body.extend_from_slice(&0u32.to_le_bytes()); // 5x Damage: all empty
}
body.extend_from_slice(&0u32.to_le_bytes()); // Armor
for _ in 0..6 {
body.extend_from_slice(&0u32.to_le_bytes()); // resistances: all empty
}
body.extend_from_slice(&0u32.to_le_bytes()); // Delay
body.extend_from_slice(&0u32.to_le_bytes()); // AmmoType
body.extend_from_slice(&0f32.to_le_bytes()); // RangedModRange
// Spell slot 0: the ON_USE heal; slots 1-4 the server's unresolved-slot sentinel.
body.extend_from_slice(&2024u32.to_le_bytes()); // SpellId
body.extend_from_slice(&0u32.to_le_bytes()); // SpellTrigger: ON_USE
body.extend_from_slice(&0u32.to_le_bytes()); // SpellCharges
body.extend_from_slice(&(-1i32).to_le_bytes()); // Cooldown: the spell's own
body.extend_from_slice(&0u32.to_le_bytes()); // Category
body.extend_from_slice(&(-1i32).to_le_bytes()); // CategoryCooldown: the spell's own
for _ in 0..4 {
body.extend_from_slice(&0u32.to_le_bytes()); // SpellId
body.extend_from_slice(&0u32.to_le_bytes()); // SpellTrigger
body.extend_from_slice(&0u32.to_le_bytes()); // SpellCharges
body.extend_from_slice(&(-1i32).to_le_bytes()); // Cooldown
body.extend_from_slice(&0u32.to_le_bytes()); // Category
body.extend_from_slice(&(-1i32).to_le_bytes()); // CategoryCooldown
}
body.extend_from_slice(&0u32.to_le_bytes()); // Bonding
body.push(0); // Description: empty cstring
for _ in 0..6 {
body.extend_from_slice(&0u32.to_le_bytes()); // PageText .. Material
}
body.extend_from_slice(&0u32.to_le_bytes()); // Sheath
body.extend_from_slice(&0u32.to_le_bytes()); // RandomProperty
body.extend_from_slice(&0u32.to_le_bytes()); // Block
body.extend_from_slice(&0u32.to_le_bytes()); // ItemSet
body.extend_from_slice(&0u32.to_le_bytes()); // MaxDurability: consumables have none
body.extend_from_slice(&0u32.to_le_bytes()); // Area
body.extend_from_slice(&0u32.to_le_bytes()); // Map
body.extend_from_slice(&0u32.to_le_bytes()); // BagFamily
match messages::parse_server(messages::opcode::SMSG_ITEM_QUERY_SINGLE_RESPONSE, &body).unwrap()
{
ServerPacket::ItemQueryResponse { entry, info } => {
assert_eq!(entry, 117);
assert_eq!(
info,
Some(Box::new(ItemInfo {
class: 0,
subclass: 0,
name: "Minor Healing Potion".into(),
display_info_id: 1712,
quality: 1,
flags: 0,
buy_price: 40,
sell_price: 10,
inventory_type: 0,
allowable_class: -1,
allowable_race: -1,
item_level: 1,
required_level: 0,
required_skill: 0,
required_skill_rank: 0,
required_spell: 0,
required_honor_rank: 0,
required_city_rank: 0,
required_rep_faction: 0,
required_rep_rank: 0,
max_count: 20,
stackable: 20,
container_slots: 0,
stats: Vec::new(),
damages: Vec::new(),
dmg_min: 0.0,
dmg_max: 0.0,
dmg_type: 0,
armor: 0,
resistances: [0; 6],
delay_ms: 0,
ammo_type: 0,
ranged_mod_range: 0.0,
spell_charges_0: 0,
spells: vec![ItemSpellEntry {
index: 0,
spell_id: 2024,
trigger: 0,
charges: 0,
cooldown_ms: -1,
category: 0,
category_cooldown_ms: -1,
}],
use_spell: Some(ItemUseSpell {
spell_id: 2024,
cooldown_ms: -1,
category: 0,
category_cooldown_ms: -1,
}),
bonding: 0,
description: String::new(),
page_text: 0,
language_id: 0,
page_material: 0,
start_quest: 0,
lock_id: 0,
material: 0,
sheath: 0,
random_property: 0,
block: 0,
item_set: 0,
max_durability: 0,
area: 0,
map: 0,
bag_family: 0,
}))
);
}
other => panic!("item query hit, got {}", other.name()),
}
}
/// `SMSG_ITEM_QUERY_SINGLE_RESPONSE`, miss: the lone entry with the top bit set (vmangos
/// undiscovered/unknown-entry branch) — the same shape as the creature miss.
#[test]
fn item_query_response_miss() {
let fail = messages::parse_server(
messages::opcode::SMSG_ITEM_QUERY_SINGLE_RESPONSE,
&hx("d2040080"),
)
.unwrap();
match decode(fail).pop().unwrap() {
SessionEvent::ItemTemplate { entry, info } => {
assert_eq!((entry, info), (1234, None));
}
_ => panic!("item query miss event"),
}
}