Files
benilla-octo/crates/benilla-protocol/tests/update_object.rs
T
SamandClaude Fable 5 2ad0043523 benilla: initial public release
A from-scratch World of Warcraft 1.12.1 client in Rust + Bevy.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 14:27:01 +07:00

449 lines
20 KiB
Rust

//! `SMSG_UPDATE_OBJECT` / `SMSG_DESTROY_OBJECT` wire tests (mirrors `src/messages/update_object.rs`):
//! object creates (unit/gameobject/item/container), out-of-range removal, destroy, and the sparse
//! `ObjectFields` Values-update decode (unit state/power/customization, virtual items + sheath,
//! inventory slots). 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, EntityKind, SessionEvent};
use benilla_protocol::messages;
use benilla_protocol::wire::write_packed_guid;
use common::hx;
#[test]
fn update_object_fixture_decodes() {
// A real serialized SMSG_UPDATE_OBJECT body: Unit create (guid 0xAA, displayid 50, scale 1.5, a
// living position) + GameObject create (guid 0xBB, displayid 99, scale 2.0, pos 100/200/300) +
// out-of-range [0x10, 0x20].
let body = hx("03000000000301aa03200000000000000000cdd70bc6357e04c3f90fa74200000040000000000000803f0000e040000090400000000000000000db0f4940051700000000000000000000000000000008000000aa00000000000000090000000000c03f320000000301bb05000117810700bb000000000000002100000000000040630000000000c84200004843000096430000803f040200000001100120");
let packet = messages::parse_server(messages::opcode::SMSG_UPDATE_OBJECT, &body).unwrap();
let events = decode(packet);
let unit = events
.iter()
.find_map(|e| match e {
SessionEvent::ObjectCreate {
guid: 0xAA,
kind,
display_id,
position,
scale,
speeds,
..
} => Some((*kind, *display_id, *position, *scale, *speeds)),
_ => None,
})
.expect("unit create");
assert_eq!(unit.0, EntityKind::Unit);
assert_eq!(unit.1, Some(50));
assert_eq!(unit.2, [-8949.95, -132.493, 83.5312]);
assert_eq!(unit.3, 1.5);
assert_eq!(unit.4.map(|s| s.walk), Some(1.0)); // speeds[0] = walk, from the LIVING block
let go = events
.iter()
.find_map(|e| match e {
SessionEvent::ObjectCreate {
guid: 0xBB,
kind,
display_id,
position,
scale,
speeds,
..
} => Some((*kind, *display_id, *position, *scale, *speeds)),
_ => None,
})
.expect("gameobject create");
assert_eq!(go.0, EntityKind::GameObject);
assert_eq!(go.1, Some(99));
assert_eq!(go.2, [100.0, 200.0, 300.0]);
assert_eq!(go.3, 2.0);
assert_eq!(go.4, None); // GameObject HAS_POSITION block carries no speeds
let removed = events
.iter()
.find_map(|e| match e {
SessionEvent::ObjectsRemoved(g) => Some(g.clone()),
_ => None,
})
.expect("out-of-range");
assert_eq!(removed, vec![0x10, 0x20]);
}
#[test]
fn destroy_object_decodes_to_object_destroyed() {
// SMSG_DESTROY_OBJECT: a plain u64 guid (vmangos `DestroyObject::AppendBodyTo` streams the raw
// ObjectGuid) — the corpse-decay / despawn teardown. Its own event, distinct from the OutOfRange
// update block: destroyed objects pop instantly, out-of-range ones fade.
let body = hx("efbeadde01000000");
let packet = messages::parse_server(messages::opcode::SMSG_DESTROY_OBJECT, &body).unwrap();
assert_eq!(packet.name(), "SMSG_DESTROY_OBJECT");
let destroyed = decode(packet)
.into_iter()
.find_map(|e| match e {
SessionEvent::ObjectDestroyed(guid) => Some(guid),
_ => None,
})
.expect("destroy");
assert_eq!(destroyed, 0x1_DEAD_BEEF);
}
#[test]
fn values_update_decodes_unit_state() {
// A hand-built Values-only update (update_type 0) for guid 0xAA carrying the unit descriptor
// fields HEALTH=22 (=100), MAXHEALTH=28 (=120), LEVEL=34 (=5), BYTES_0=36 (race 1/class 1/gender 0)
// — bits in 2 mask blocks, values in ascending field order. Verifies the `Object::Values` path
// (previously dropped) emits an ObjectValues delta whose fields decode via the named accessors.
let body = hx("01000000000001aa02000040101400000064000000780000000500000001010001");
let packet = messages::parse_server(messages::opcode::SMSG_UPDATE_OBJECT, &body).unwrap();
let fields = decode(packet)
.into_iter()
.find_map(|e| match e {
SessionEvent::ObjectValues { guid: 0xAA, fields } => Some(fields),
_ => None,
})
.expect("object values");
assert_eq!(
(
fields.unit_health(),
fields.unit_max_health(),
fields.unit_level(),
fields.unit_race(),
fields.unit_class(),
fields.unit_gender(),
),
(Some(100), Some(120), Some(5), Some(1), Some(1), Some(0))
);
// A non-player unit carries no PLAYER block, so the customization accessors stay `None`.
assert_eq!(fields.player_skin(), None);
assert_eq!(fields.player_facial_hair(), None);
}
#[test]
fn values_update_decodes_player_customization() {
// A Values-only update for player guid 0xBB carrying PLAYER_BYTES=193 (skin 0x10 / face 0x11 /
// hairStyle 0x12 / hairColor 0x13, packed b0..b3) and PLAYER_BYTES_2=194 (facialHair 0x14, b0).
// Field 193 lives in mask block 6 (bit 1), 194 in block 6 (bit 2) → 7 blocks, the first six empty.
// Pins the RF-0056 field indices + byte order against the decode.
let body = hx(
"01000000000001bb07000000000000000000000000000000000000000000000000060000001011121314000000",
);
let packet = messages::parse_server(messages::opcode::SMSG_UPDATE_OBJECT, &body).unwrap();
let fields = decode(packet)
.into_iter()
.find_map(|e| match e {
SessionEvent::ObjectValues { guid: 0xBB, fields } => Some(fields),
_ => None,
})
.expect("player object values");
assert_eq!(
(
fields.player_skin(),
fields.player_face(),
fields.player_hair_style(),
fields.player_hair_color(),
fields.player_facial_hair(),
),
(Some(0x10), Some(0x11), Some(0x12), Some(0x13), Some(0x14))
);
}
#[test]
fn values_update_decodes_unit_power() {
// A Values-only update for guid 0xAA carrying POWER1=23 (40), MAXPOWER1=29 (60), BYTES_0=36
// (race 1 / class 1 / gender 0 / power type 0 = mana). Pins the 1.12.1 power field indices
// (vmangos UpdateFields_1_12_1.h) + the bytes_0 power-type byte against the decode.
let body = hx("01000000000001aa020000802010000000280000003c00000001010000");
let packet = messages::parse_server(messages::opcode::SMSG_UPDATE_OBJECT, &body).unwrap();
let fields = decode(packet)
.into_iter()
.find_map(|e| match e {
SessionEvent::ObjectValues { guid: 0xAA, fields } => Some(fields),
_ => None,
})
.expect("object values");
assert_eq!(fields.unit_power_type(), 0);
assert_eq!(fields.unit_power(0), Some(40));
assert_eq!(fields.unit_max_power(0), Some(60));
// Other slots are absent; out-of-range slots are None by contract.
assert_eq!(fields.unit_power(1), None);
assert_eq!(fields.unit_max_power(5), None);
}
#[test]
fn values_update_decodes_unit_virtual_items_and_sheath() {
// A Values-only update for creature guid 0xCC carrying UNIT_VIRTUAL_ITEM_SLOT_DISPLAY slot 0
// (field 37) = 1234, UNIT_VIRTUAL_ITEM_INFO slot 0's pair (fields 40/41): dword0 packs
// class=2/subclass=7/material=1/invType=21, dword1 byte0 = sheath 3; and UNIT_FIELD_BYTES_2
// (field 164) byte0 = sheath state 1 (melee drawn). Field -> (block, bit): 37 -> (1,5),
// 40 -> (1,8), 41 -> (1,9), 164 -> (5,4) -- 6 mask blocks.
let mut body = hx("01000000000001cc06"); // count 1, no transport, VALUES, packed guid 0xCC, 6 blocks
let mut masks = [0u32; 6];
masks[1] = (1 << 5) | (1 << 8) | (1 << 9);
masks[5] = 1 << 4;
for m in masks {
body.extend_from_slice(&m.to_le_bytes());
}
let info_dword0 = 2u32 | (7 << 8) | (1 << 16) | (21 << 24); // class/subclass/material/invType
for v in [1234u32, info_dword0, 3u32, 1u32] {
body.extend_from_slice(&v.to_le_bytes());
}
let packet = messages::parse_server(messages::opcode::SMSG_UPDATE_OBJECT, &body).unwrap();
let fields = decode(packet)
.into_iter()
.find_map(|e| match e {
SessionEvent::ObjectValues { guid: 0xCC, fields } => Some(fields),
_ => None,
})
.expect("object values");
assert_eq!(fields.unit_virtual_item_display(0), Some(1234));
assert_eq!(fields.unit_virtual_item_display(1), None, "slot 1 unsent");
assert_eq!(
fields.unit_virtual_item_display(3),
None,
"slot out of range"
);
assert_eq!(fields.unit_virtual_item_info(0), Some((2, 7, 1, 21)));
assert_eq!(fields.unit_virtual_item_info(3), None, "slot out of range");
assert_eq!(fields.unit_virtual_item_sheath(0), Some(3));
assert_eq!(
fields.unit_virtual_item_sheath(3),
None,
"slot out of range"
);
assert_eq!(fields.unit_sheath_state(), Some(1));
}
#[test]
fn values_update_decodes_inventory_slots() {
// A Values-only update for guid 1 carrying PLAYER_FIELD_INV_SLOT 16 (the offhand, fields
// 518/519 — live-verified: One's shield guid landed exactly there) = guid 0xAB and
// PLAYER_FIELD_PACK_SLOT 0 (fields 532/533, live-verified) = guid 0xCD. Pins the CORRECTED
// 1.12.1 bases (vmangos enum arithmetic, NOT its stale hex comments: INV_SLOT_HEAD 486,
// PACK 532). 17 mask blocks; block 16 = bits 6,7 (518,519) + 20,21 (532,533).
let mut body = hx("010000000000010111"); // count 1, no transport, VALUES, packed guid 1, 17 blocks
for block in 0..17u32 {
let mask: u32 = if block == 16 { 0x0030_00C0 } else { 0 };
body.extend_from_slice(&mask.to_le_bytes());
}
for v in [0xABu32, 0, 0xCD, 0] {
body.extend_from_slice(&v.to_le_bytes());
}
let packet = messages::parse_server(messages::opcode::SMSG_UPDATE_OBJECT, &body).unwrap();
let fields = decode(packet)
.into_iter()
.find_map(|e| match e {
SessionEvent::ObjectValues { guid: 1, fields } => Some(fields),
_ => None,
})
.expect("values event");
assert_eq!(fields.player_inv_slot(16), Some(0xAB));
assert_eq!(fields.player_pack_slot(0), Some(0xCD));
assert_eq!(fields.player_pack_slot(3), None, "unsent slot reads None");
assert_eq!(fields.player_bank_slot(0), None);
assert_eq!(fields.container_slot(36), None, "out of range");
}
#[test]
fn item_and_container_creates_decode() {
// Two position-less creates in one packet — an item and a bag — exactly as vmangos streams our
// inventory at login: CREATE_OBJECT, packed guid, TypeId 1/2, movement block = UPDATEFLAG_ALL
// (0x10) + its constant u32 1 (wire-verified `Object::BuildMovementUpdate`), then the mask.
// These used to be dropped whole by the create placement gate; now they're ItemCreate events.
// Container fields pin the CORRECTED bases (enum arithmetic, not the stale hex comments —
// the same 6-low drift as the PLAYER block): NUM_SLOTS = ITEM_END = 48, SLOT_1 = 50.
let mut body = hx("0200000000");
// Item guid 0x42, entry 4660 (field 3), stack count 5 (field 14): 1 mask block, bits 3+14.
body.extend_from_slice(&hx("0201420110010000000108400000"));
body.extend_from_slice(&4660u32.to_le_bytes());
body.extend_from_slice(&5u32.to_le_bytes());
// Container guid 0x43, entry 828 (field 3), num-slots 6 (field 48), slot-0 guid 0x42
// (fields 50/51): 2 mask blocks — block0 bit 3, block1 bits 16/18/19.
body.extend_from_slice(&hx("020143021001000000020800000000000d00"));
for v in [828u32, 6, 0x42, 0] {
body.extend_from_slice(&v.to_le_bytes());
}
let packet = messages::parse_server(messages::opcode::SMSG_UPDATE_OBJECT, &body).unwrap();
let events = decode(packet);
assert_eq!(events.len(), 2, "both creates decode, nothing spatial");
match &events[0] {
SessionEvent::ItemCreate {
guid: 0x42,
container: false,
fields,
} => {
assert_eq!(fields.object_entry(), Some(4660));
assert_eq!(fields.item_stack_count(), Some(5));
}
other => panic!("item create, got {other:?}"),
}
match &events[1] {
SessionEvent::ItemCreate {
guid: 0x43,
container: true,
fields,
} => {
assert_eq!(fields.object_entry(), Some(828));
assert_eq!(fields.container_num_slots(), Some(6));
assert_eq!(fields.container_slot(0), Some(0x42));
assert_eq!(fields.container_slot(1), None, "unsent slot reads None");
}
other => panic!("container create, got {other:?}"),
}
}
/// A transport GameObject's create (decision 0438 "the wire"): `UPDATE_FLAG_HAS_POSITION` (0x40) +
/// `UPDATE_FLAG_TRANSPORT` (0x02), the latter's tail being the server's pathProgress ms anchor
/// (vmangos `Object.cpp:590-605`). The mask carries `GAMEOBJECT_DISPLAYID` only — **no**
/// `GAMEOBJECT_POS_*` fields, exactly what vmangos sends for a boat/zeppelin (it puts the stationary
/// spawn point in the movement block's `HAS_POSITION` slot instead) — pinning both the new
/// `transport_progress` surfacing AND the create-placement fallback to the movement block's position
/// (`create_placement`, `events/decode.rs`) in one fixture.
#[test]
fn gameobject_create_surfaces_transport_progress_and_position_fallback() {
// HIGH_MO_TRANSPORT (0x1FC0) with the Menethil-Theramore ferry's real template entry (176495)
// riding the full low 32 bits (guid.rs `entry()` — MO_TRANSPORT has no 24-bit entry slot).
let guid: u64 = 176_495 | (0x1FC0u64 << 48);
let mut body = 1u32.to_le_bytes().to_vec(); // amount_of_objects
body.push(0); // has_transport (unused by the parser)
body.push(2); // update_type: CREATE_OBJECT
write_packed_guid(guid, &mut body).unwrap();
body.push(5); // TypeId::GameObject
// Movement block: HAS_POSITION | TRANSPORT.
body.push(0x40 | 0x02);
body.extend_from_slice(&5000.0f32.to_le_bytes()); // stationary pos.x
body.extend_from_slice(&6000.0f32.to_le_bytes()); // stationary pos.y
body.extend_from_slice(&15.0f32.to_le_bytes()); // stationary pos.z
body.extend_from_slice(&2.0f32.to_le_bytes()); // orientation
body.extend_from_slice(&123_456u32.to_le_bytes()); // UPDATE_FLAG_TRANSPORT tail: pathProgress ms
// Mask: 1 block, GAMEOBJECT_DISPLAYID (field 8) only.
body.push(1);
body.extend_from_slice(&(1u32 << 8).to_le_bytes());
body.extend_from_slice(&3015u32.to_le_bytes()); // transportship.wmo's displayId
let packet = messages::parse_server(messages::opcode::SMSG_UPDATE_OBJECT, &body).unwrap();
let events = decode(packet);
match events.as_slice() {
[SessionEvent::ObjectCreate {
guid: g,
kind,
display_id,
position,
orientation,
speeds,
transport_progress,
transport,
..
}] => {
assert_eq!(*g, guid);
assert_eq!(*kind, EntityKind::GameObject);
assert_eq!(*display_id, Some(3015));
assert_eq!(
*position,
[5000.0, 6000.0, 15.0],
"falls back to the movement block's HAS_POSITION pose — GAMEOBJECT_POS_* is absent"
);
assert_eq!(*orientation, 2.0);
assert_eq!(
*speeds, None,
"a GameObject's HAS_POSITION block carries no speeds"
);
assert_eq!(
*transport_progress,
Some(123_456),
"UPDATE_FLAG_TRANSPORT's u32 tail is the pathProgress ms anchor"
);
assert_eq!(
*transport, None,
"a HAS_POSITION block (not LIVING) carries no ON_TRANSPORT rider tail"
);
}
other => panic!("expected one ObjectCreate, got {other:?}"),
}
}
/// A unit's `LIVING` block with `MOVEFLAG_ON_TRANSPORT` (0x0200_0000): the rider pose tail
/// `[u64 transport guid][local x,y,z][local o]` (`update_object/movement.rs`'s `MovementBlock::read`,
/// mirroring `read_movement_info`'s relay parse). VERIFIES the tail lands in `ObjectCreate::transport`
/// and the 6 movement speeds that follow still parse from the right offset (the alignment the old
/// discard-to-stay-aligned code depended on, now proven by consuming the values instead of dropping
/// them).
#[test]
fn unit_living_block_surfaces_on_transport_rider_pose() {
// The Org-UC zeppelin's template entry (176244), same MO_TRANSPORT composition as the ferry above.
let transport_guid: u64 = 176_244 | (0x1FC0u64 << 48);
let mut body = 1u32.to_le_bytes().to_vec();
body.push(0); // has_transport
body.push(2); // update_type: CREATE_OBJECT
write_packed_guid(0xAA, &mut body).unwrap();
body.push(3); // TypeId::Unit
body.push(0x20); // movement block: LIVING only
let flags: u32 = 0x0200_0000; // MOVEFLAG_ON_TRANSPORT (1.12 bit 25, vmangos MovementInfo.h)
body.extend_from_slice(&flags.to_le_bytes());
body.extend_from_slice(&0u32.to_le_bytes()); // timestamp
body.extend_from_slice(&100.0f32.to_le_bytes()); // living pos.x (world, NOT local)
body.extend_from_slice(&200.0f32.to_le_bytes()); // living pos.y
body.extend_from_slice(&5.0f32.to_le_bytes()); // living pos.z
body.extend_from_slice(&1.0f32.to_le_bytes()); // living orientation
body.extend_from_slice(&transport_guid.to_le_bytes()); // ON_TRANSPORT tail: FULL u64 guid
body.extend_from_slice(&2.0f32.to_le_bytes()); // local x
body.extend_from_slice(&3.0f32.to_le_bytes()); // local y
body.extend_from_slice(&0.5f32.to_le_bytes()); // local z
body.extend_from_slice(&0.25f32.to_le_bytes()); // local o
body.extend_from_slice(&0.0f32.to_le_bytes()); // fall_time (not swimming/jumping, no other tails)
for v in [2.5f32, 7.0, 4.5, 4.722_222_3, 2.5, std::f32::consts::PI] {
body.extend_from_slice(&v.to_le_bytes()); // the 6 speeds — must still land right after the tail
}
body.push(0); // mask: 0 blocks (nothing to interpret beyond the pose)
let packet = messages::parse_server(messages::opcode::SMSG_UPDATE_OBJECT, &body).unwrap();
let events = decode(packet);
match events.as_slice() {
[SessionEvent::ObjectCreate {
guid: 0xAA,
kind: EntityKind::Unit,
position,
orientation,
speeds,
transport_progress,
transport,
..
}] => {
assert_eq!(*position, [100.0, 200.0, 5.0], "the unit's own world pose");
assert_eq!(*orientation, 1.0);
assert_eq!(
transport,
&Some(benilla_protocol::messages::TransportPose {
guid: transport_guid,
pos: benilla_protocol::wire::Vector3d {
x: 2.0,
y: 3.0,
z: 0.5,
},
orientation: 0.25,
}),
"the ON_TRANSPORT tail surfaces as the rider's local pose"
);
assert_eq!(
transport_progress, &None,
"no UPDATE_FLAG_TRANSPORT on a rider create"
);
assert_eq!(
speeds.map(|s| s.walk),
Some(2.5),
"the 6 speeds still parse right after the transport tail"
);
}
other => panic!("expected one ObjectCreate, got {other:?}"),
}
}