Files
moonwell-client-core/crates/benilla-protocol/tests/update_object.rs
T
Sam Sung e93c2e2129 Remote units dead-reckon from their create and each move's fire time (#2870)
In 1.12.1 every unit has mover state from its create (`0x619456` → `0x617b00`). A unit created moving is dead-reckoned from the create, and a move the tick fires mid-frame steps on from its fire time (`0x618d67`). benilla left such a unit standing until its first movement packet, and stepped a queued move over the whole frame.

Now every remote unit gets mover state at its create and is dead-reckoned from it and from each fire time. The facing system keys on the mover's bits and the unit's type (`0x600cd0`). A relay during a server path applies at once, and every monster move holds the spline that forces the next relay (`0x618c43`). The flush acts only on root and teleport (`0x618080`). A unit created airborne goes on along its arc, its start height rebuilt from the fall time.

Tests: one per behaviour, each failing on its parent.

Fixes #2853
Fixes #2852

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 07:31:22 +07:00

974 lines
39 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.
//! `SMSG_UPDATE_OBJECT` and `SMSG_DESTROY_OBJECT` wire tests (`src/messages/update_object.rs`).
mod common;
use benilla_protocol::events::{decode, EntityKind, SessionEvent};
use benilla_protocol::messages;
use benilla_protocol::wire::write_packed_guid;
use benilla_protocol::MonsterMoveFacing;
use common::hx;
#[test]
fn update_object_fixture_decodes() {
// An out-of-range block [0x10, 0x20], first as vmangos sends it (`UpdateData.cpp:143-155`),
// a unit create (0xAA) and a gameobject create (0xBB, a HAS_POSITION block at (100, 200,
// 300), as vmangos sends one).
let body = hx("03000000000402000000011001200301aa03200000000000000000cdd70bc6357e04c3f90fa74200000040000000000000803f0000e040000090400000000000000000db0f4940051700000000000000000000000000000008000000aa00000000000000090000000000c03f320000000301bb05400000c84200004843000096430000803f0117810700bb000000000000002100000000000040630000000000c84200004843000096430000803f");
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 raw u64 guid (vmangos `DestroyObject::AppendBodyTo`).
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() {
// Values update for 0xAA: fields 22 HEALTH 100, 28 MAXHEALTH 120, 34 LEVEL 5 and 36 BYTES_0
// (race 1, class 1, gender 0), in 2 mask blocks with values in ascending field order.
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))
);
assert_eq!(fields.player_skin(), None);
assert_eq!(fields.player_facial_hair(), None);
}
#[test]
fn values_update_decodes_player_customization() {
// Player 0xBB: PLAYER_BYTES (193) = skin, face, hairStyle, hairColor in b0..b3; PLAYER_BYTES_2
// (194) b0 = facialHair. Both sit in mask block 6 (bits 1, 2), after six empty blocks.
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() {
// Fields 23 POWER1 = 40, 29 MAXPOWER1 = 60, 36 BYTES_0 with power type 0 (mana) in b3 (vmangos
// `UpdateFields_1_12_1.h`).
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));
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() {
// Fields 37 (virtual item display 0), 40/41 (its info pair, sheath 3 in 41's b0) and 164
// (UNIT_FIELD_BYTES_2, b0 = sheath state 1, melee drawn).
let mut body = hx("01000000000001cc06"); // count 1, no transport, VALUES, 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() {
// INV_SLOT 16, the offhand (fields 518/519) = 0xAB; PACK_SLOT 0 (532/533) = 0xCD. The bases
// (INV_SLOT_HEAD 486, PACK 532) follow vmangos's enum arithmetic, not its stale hex comments.
let mut body = hx("010000000000010111"); // count 1, no transport, VALUES, 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() {
// Item and bag creates as vmangos streams them at login (movement block 0x10 + u32 1). Bases
// follow vmangos's enum arithmetic, not its stale hex comments: NUM_SLOTS 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(), 3, "both creates decode, nothing spatial");
assert!(matches!(events[2], SessionEvent::ObjectUpdateEnd));
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 boat's create: `HAS_POSITION` (0x40) + `TRANSPORT` (0x02), whose u32 tail is pathProgress ms
/// (vmangos `Object.cpp:590-605`); vmangos sends no `GAMEOBJECT_POS_*`, only the movement pose.
#[test]
fn gameobject_create_surfaces_transport_progress_and_position_fallback() {
// HIGH_MO_TRANSPORT (0x1FC0) guids hold the entry in the full low 32 bits; 176495 is the
// Grom'Gol-Undercity zeppelin.
let guid: u64 = 176_495 | (0x1FC0u64 << 48);
let mut body = 1u32.to_le_bytes().to_vec(); // amount_of_objects
body.push(0); // the header byte: no first pass
body.push(2); // update_type: CREATE_OBJECT
write_packed_guid(guid, &mut body).unwrap();
body.push(5); // TypeId::GameObject
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()); // 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,
..
}, SessionEvent::ObjectUpdateEnd] => {
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.map(|c| c.progress_ms),
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:?}"),
}
}
/// `MOVEFLAG_ON_TRANSPORT` (0x0200_0000) adds a rider tail to the `LIVING` block: u64 transport
/// guid, then local x, y, z and orientation.
#[test]
fn unit_living_block_surfaces_on_transport_rider_pose() {
// 176244 is the Moonspray, the Auberdine-Rut'theran boat.
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 (no swim or jump 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, 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,
..
}, SessionEvent::ObjectUpdateEnd] => {
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:?}"),
}
}
/// A swimming create's flags word and swim pitch both reach [`SessionEvent::ObjectCreate::mover`];
/// the reference re-authors the mover's live flags from this block (the `0x75a07dff` merge).
#[test]
fn unit_living_block_surfaces_the_swim_it_is_already_in() {
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(4); // TypeId::Player
body.push(0x20); // movement block: LIVING only
let flags: u32 = 0x20_0000 | 0x1; // MOVEFLAG_SWIMMING | MOVEFLAG_FORWARD
body.extend_from_slice(&flags.to_le_bytes());
body.extend_from_slice(&0u32.to_le_bytes()); // timestamp
body.extend_from_slice(&(-812.5f32).to_le_bytes()); // living pos.x
body.extend_from_slice(&(-566.0f32).to_le_bytes()); // living pos.y
body.extend_from_slice(&(-3.25f32).to_le_bytes()); // living pos.z (under the surface)
body.extend_from_slice(&2.0f32.to_le_bytes()); // living orientation
body.extend_from_slice(&(-0.4f32).to_le_bytes()); // the SWIMMING tail: pitch, nose down
body.extend_from_slice(&0.0f32.to_le_bytes()); // fall_time
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, right after the tail
}
body.push(0); // mask: 0 blocks
let packet = messages::parse_server(messages::opcode::SMSG_UPDATE_OBJECT, &body).unwrap();
match decode(packet).as_slice() {
[SessionEvent::ObjectCreate {
guid: 0xAA,
kind: EntityKind::Player,
orientation,
mover,
speeds,
..
}, SessionEvent::ObjectUpdateEnd] => {
assert_eq!(*orientation, 2.0);
assert_eq!(
mover,
&Some(benilla_protocol::MoverState {
flags,
pitch: -0.4,
time: 0,
fall_time: 0,
jump: None,
}),
"the live flags word and its swim-pitch tail both reach the app"
);
assert_eq!(
speeds.map(|s| s.walk),
Some(2.5),
"the 6 speeds still parse right after the swim-pitch tail"
);
}
other => panic!("expected one ObjectCreate, got {other:?}"),
}
}
/// Without SWIMMING there is no pitch tail; the mover still surfaces, with a level 0.0 pitch.
#[test]
fn a_dry_living_block_has_no_pitch_tail_and_reads_level() {
let mut body = 1u32.to_le_bytes().to_vec();
body.push(0);
body.push(2);
write_packed_guid(0xAB, &mut body).unwrap();
body.push(3); // TypeId::Unit
body.push(0x20); // LIVING
let flags: u32 = 0x1; // FORWARD, walking on dry land
body.extend_from_slice(&flags.to_le_bytes());
body.extend_from_slice(&123_456u32.to_le_bytes()); // timestamp
for v in [10.0f32, 20.0, 30.0, 0.5] {
body.extend_from_slice(&v.to_le_bytes()); // pos + orientation
}
body.extend_from_slice(&0.0f32.to_le_bytes()); // fall_time, no pitch tail before it
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());
}
body.push(0);
let packet = messages::parse_server(messages::opcode::SMSG_UPDATE_OBJECT, &body).unwrap();
match decode(packet).as_slice() {
[SessionEvent::ObjectCreate { mover, speeds, .. }, SessionEvent::ObjectUpdateEnd] => {
assert_eq!(
mover,
&Some(benilla_protocol::MoverState {
flags,
pitch: 0.0,
time: 123_456,
fall_time: 0,
jump: None,
}),
"no tail on the wire reads as a level pitch, and the stamp as the server wrote it"
);
assert_eq!(
speeds.map(|s| s.walk),
Some(2.5),
"the speeds land right after fall_time — the tail really was absent"
);
}
other => panic!("expected one ObjectCreate, got {other:?}"),
}
}
/// A create mid-jump carries its fall time and its `JUMPING` tail to the app, which the apply
/// copies to the mover (`0x618e1b` → `0x7c6490`); the speeds still follow the tail.
#[test]
fn a_living_block_mid_jump_surfaces_its_fall_and_launch() {
let mut body = 1u32.to_le_bytes().to_vec();
body.push(0);
body.push(2);
write_packed_guid(0xAC, &mut body).unwrap();
body.push(3); // TypeId::Unit
body.push(0x20); // LIVING
let flags: u32 = 0x2000 | 0x1; // JUMPING | FORWARD
body.extend_from_slice(&flags.to_le_bytes());
body.extend_from_slice(&0u32.to_le_bytes()); // timestamp
for v in [10.0f32, 20.0, 30.0, 0.5] {
body.extend_from_slice(&v.to_le_bytes()); // pos + orientation
}
body.extend_from_slice(&250u32.to_le_bytes()); // fall_time, ms
for v in [-7.955_547f32, 0.6, 0.8, 7.0] {
body.extend_from_slice(&v.to_le_bytes()); // zspeed, cos, sin, xy speed
}
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());
}
body.push(0);
let packet = messages::parse_server(messages::opcode::SMSG_UPDATE_OBJECT, &body).unwrap();
match decode(packet).as_slice() {
[SessionEvent::ObjectCreate { mover, speeds, .. }, SessionEvent::ObjectUpdateEnd] => {
assert_eq!(
mover,
&Some(benilla_protocol::MoverState {
flags,
pitch: 0.0,
time: 0,
fall_time: 250,
jump: Some(benilla_protocol::JumpInfo {
zspeed: -7.955_547,
cos_angle: 0.6,
sin_angle: 0.8,
xy_speed: 7.0,
}),
}),
);
assert_eq!(
speeds.map(|s| s.walk),
Some(2.5),
"the speeds follow the tail"
);
}
other => panic!("expected one ObjectCreate, got {other:?}"),
}
}
/// `MOVEFLAG_SPLINE_ENABLED` (0x0040_0000) adds a spline tail (vmangos `packet_builder.cpp:152`).
/// Its nodes are the internal control array `[phantom, p₀…pₙ, tail]`, since vmangos builds even a
/// linear spline through `InitCatmullRom` (`spline.cpp:52`); the decoded path drops the two ends.
#[test]
fn unit_living_block_surfaces_the_walk_it_is_already_on() {
let path = [
[10.0f32, 20.0, 30.0],
[20.0, 20.0, 30.0],
[20.0, 40.0, 30.0],
];
let nodes = [
[0.0f32, 20.0, 30.0], // phantom: 2·p₀ − p₁
path[0],
path[1],
path[2],
path[2], // tail: the destination again
];
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 = 0x0040_0000; // MOVEFLAG_SPLINE_ENABLED (vmangos MovementInfo.h:53)
body.extend_from_slice(&flags.to_le_bytes());
body.extend_from_slice(&0u32.to_le_bytes()); // timestamp
for v in [14.0f32, 20.0, 30.0, 0.0] {
body.extend_from_slice(&v.to_le_bytes()); // stored pose, synced to the spline every 400 ms
}
body.extend_from_slice(&0.0f32.to_le_bytes()); // fall_time
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
}
// The spline tail: flags, time_passed, duration, id, node count, the nodes, final destination.
body.extend_from_slice(&0u32.to_le_bytes()); // spline flags: ground walk, no dictated facing
body.extend_from_slice(&3_000u32.to_le_bytes()); // time_passed: a quarter of the ride is done
body.extend_from_slice(&12_000u32.to_le_bytes()); // duration
body.extend_from_slice(&42u32.to_le_bytes()); // spline id
body.extend_from_slice(&(nodes.len() as u32).to_le_bytes());
for n in nodes {
for f in n {
body.extend_from_slice(&f.to_le_bytes());
}
}
for f in path[2] {
body.extend_from_slice(&f.to_le_bytes()); // FinalDestination
}
// A descriptor field behind the spline: UNIT_FIELD_DISPLAYID (131) = block 4, bit 3.
body.push(5); // 5 mask blocks
for m in [0u32, 0, 0, 0, 1 << 3] {
body.extend_from_slice(&m.to_le_bytes());
}
body.extend_from_slice(&1234u32.to_le_bytes());
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,
display_id,
position,
spline,
..
}, SessionEvent::ObjectUpdateEnd] => {
let s = spline.as_ref().expect("the create block's live spline");
assert_eq!(
s.path, path,
"the two virtual control points are the server's, not waypoints"
);
assert_eq!(s.time_passed_ms, 3_000);
assert_eq!(s.duration_ms, 12_000);
assert_eq!(s.id, 42);
assert!(!s.flying, "no Flying bit ⇒ a ground walk");
assert!(!s.cyclic);
assert_eq!(
*position,
[14.0, 20.0, 30.0],
"the block's own pose is the server's last 400 ms sync, kept as the spawn pose"
);
assert_eq!(
*display_id,
Some(1234),
"the descriptor mask behind the spline still parses — the block was sized right"
);
}
other => panic!("expected one ObjectCreate, got {other:?}"),
}
}
/// The GameObject constructor copies the create block's position over `GAMEOBJECT_POS_X`..`FACING`
/// (`0x5f7046`..`0x5f706b`), so a create whose descriptor and block disagree is placed by the block.
#[test]
fn gameobject_create_is_placed_by_the_block_over_its_position_fields() {
let mut body = 1u32.to_le_bytes().to_vec();
body.push(0); // has_transport
body.push(2); // CREATE_OBJECT
write_packed_guid(0xBB, &mut body).unwrap();
body.push(5); // TypeId::GameObject
body.push(0x40); // HAS_POSITION
for v in [5000.0f32, 6000.0, 15.0, 2.0] {
body.extend_from_slice(&v.to_le_bytes()); // the block's pose
}
// Fields 8 (GAMEOBJECT_DISPLAYID) and 15..=18 (GAMEOBJECT_POS_X, _Y, _Z, FACING) in block 0.
body.push(1);
body.extend_from_slice(&((1u32 << 8) | (0xF << 15)).to_le_bytes());
body.extend_from_slice(&3015u32.to_le_bytes());
for v in [100.0f32, 200.0, 300.0, 1.0] {
body.extend_from_slice(&v.to_le_bytes()); // the descriptor's pose
}
let packet = messages::parse_server(messages::opcode::SMSG_UPDATE_OBJECT, &body).unwrap();
match decode(packet).as_slice() {
[SessionEvent::ObjectCreate {
position,
orientation,
display_id,
..
}, SessionEvent::ObjectUpdateEnd] => {
assert_eq!(*display_id, Some(3015), "control: the fields were sent");
assert_eq!(*position, [5000.0, 6000.0, 15.0], "the block wins");
assert_eq!(*orientation, 2.0);
}
other => panic!("expected one ObjectCreate, got {other:?}"),
}
}
/// The create spline's final-facing bits are tested one by one, each field read in this order when
/// its bit is set: point (`0x10000`, 12 bytes, `0x47f0bf`), guid (`0x20000`, 8, `0x47f0e8`), angle
/// (`0x40000`, 4, `0x47f0f9`). The id, timing and path behind them land only if every set bit took
/// its bytes. Flags `0x50000` is the golden: the point (1, 2, 3), then the angle 1.5. The facing
/// kept is the one the path's end reads first (`0x61a882`): the angle, then the guid, then the point.
#[test]
fn create_spline_reads_each_final_facing_field_its_bit_names() {
// A unit create with a LIVING block on a spline: head and move flags, pose, speeds, then
// `spline flags`, the facing fields, timing (3000, 12000, id 42, 5 nodes), the 5 nodes and the
// final destination, and a descriptor behind it (UNIT_FIELD_DISPLAYID 1234).
fn create(spline_flags: &str, facing: &str) -> Vec<u8> {
hx(&[
concat!("01000000", "00", "02", "01aa", "03", "20"),
concat!("00004000", "00000000"),
concat!("000060410000a0410000f04100000000", "00000000"),
"000020400000e04000009040721c974000002040db0f4940",
spline_flags,
facing,
"b80b0000e02e00002a00000005000000",
concat!("000000000000a0410000f041", "000020410000a0410000f041"),
concat!("0000a0410000a0410000f041", "0000a041000020420000f041"),
concat!("0000a041000020420000f041", "0000a041000020420000f041"),
"050000000000000000000000000000000008000000d2040000",
]
.concat())
}
let point = "0000803f0000004000004040";
let guid = "1122334455667788";
let angle = "0000c03f";
let (spot, target, turn) = (
MonsterMoveFacing::Spot([1.0, 2.0, 3.0]),
MonsterMoveFacing::Target(0x8877_6655_4433_2211),
MonsterMoveFacing::Angle(1.5),
);
for (name, flags, facing, want) in [
("point and angle", "00000500", [point, angle].concat(), turn),
("point and guid", "00000300", [point, guid].concat(), target),
("guid and angle", "00000600", [guid, angle].concat(), turn),
("all three", "00000700", [point, guid, angle].concat(), turn),
("the point alone", "00000100", point.to_string(), spot),
("the guid alone", "00000200", guid.to_string(), target),
("the angle alone", "00000400", angle.to_string(), turn),
("none", "00000000", String::new(), MonsterMoveFacing::None),
] {
let packet = messages::parse_server(
messages::opcode::SMSG_UPDATE_OBJECT,
&create(flags, &facing),
)
.unwrap();
match decode(packet).as_slice() {
[SessionEvent::ObjectCreate {
guid: 0xAA,
display_id,
spline,
..
}, SessionEvent::ObjectUpdateEnd] => {
let s = spline.as_ref().expect("the spline");
assert_eq!(s.id, 42, "{name}: the id is read where the facing ends");
assert_eq!(s.time_passed_ms, 3_000, "{name}");
assert_eq!(s.duration_ms, 12_000, "{name}");
assert_eq!(s.path.len(), 3, "{name}");
assert_eq!(s.facing, want, "{name}: the facing the path ends on");
assert_eq!(
*display_id,
Some(1234),
"{name}: the fields behind it parse"
);
}
other => panic!("{name}: expected one ObjectCreate, got {other:?}"),
}
}
}
#[test]
fn unit_living_block_without_the_spline_flag_carries_no_walk() {
let mut body = 1u32.to_le_bytes().to_vec();
body.push(0); // has_transport
body.push(2); // CREATE_OBJECT
write_packed_guid(0xAB, &mut body).unwrap();
body.push(3); // TypeId::Unit
body.push(0x20); // LIVING
body.extend_from_slice(&0u32.to_le_bytes()); // no move flags
body.extend_from_slice(&0u32.to_le_bytes()); // timestamp
for v in [1.0f32, 2.0, 3.0, 0.5] {
body.extend_from_slice(&v.to_le_bytes());
}
body.extend_from_slice(&0.0f32.to_le_bytes()); // fall_time
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());
}
body.push(0); // no descriptor fields
let packet = messages::parse_server(messages::opcode::SMSG_UPDATE_OBJECT, &body).unwrap();
match decode(packet).as_slice() {
[SessionEvent::ObjectCreate { spline, .. }, SessionEvent::ObjectUpdateEnd] => {
assert!(spline.is_none())
}
other => panic!("expected one ObjectCreate, got {other:?}"),
}
}
/// `PLAYER_VISIBLE_ITEM_<n>_0`, the public entry other players' gear renders from, sits at field
/// 258 (`PLAYER_VISIBLE_ITEM_1_CREATOR`, a 2-field guid) + 2 + 12·slot: 260, 272, 428, 476 here.
#[test]
fn values_update_decodes_player_visible_item_entries() {
let mut body: Vec<u8> = Vec::new();
body.extend_from_slice(&1u32.to_le_bytes()); // count 1
body.push(0); // no transport
body.push(0); // UPDATETYPE_VALUES
body.extend_from_slice(&hx("01dd")); // packed guid 0xDD
body.push(15); // 15 mask blocks
let mut masks = [0u32; 15];
masks[8] = (1 << 4) | (1 << 16);
masks[13] = 1 << 12;
masks[14] = 1 << 28;
for m in masks {
body.extend_from_slice(&m.to_le_bytes());
}
for v in [7365u32, 2196u32, 15406u32, 2504u32] {
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: 0xDD, fields } => Some(fields),
_ => None,
})
.expect("object values");
assert_eq!(fields.player_visible_item_entry(0), Some(7365), "HEAD");
assert_eq!(fields.player_visible_item_entry(1), Some(2196), "NECK");
assert_eq!(fields.player_visible_item_entry(14), Some(15406), "BACK");
assert_eq!(fields.player_visible_item_entry(18), Some(2504), "TABARD");
// An unsent slot and an out-of-range one both read None, never item 0.
assert_eq!(fields.player_visible_item_entry(2), None, "slot 2 unsent");
assert_eq!(
fields.player_visible_item_entry(19),
None,
"slot out of range (19 slots, 0..=18)"
);
}
/// Every block reaches the app, an empty values block and a near-objects block included (the
/// reference's handler acts on both: `0x46720f`, `0x467230`), and the packet's end closes them.
#[test]
fn an_update_packet_decodes_every_block_then_its_end() {
// Two blocks: values for 0xAA with no mask words, near objects [0x10].
let mut body = hx("0200000000");
body.extend_from_slice(&hx("0001aa00"));
body.extend_from_slice(&hx("05010000000110"));
let packet = messages::parse_server(messages::opcode::SMSG_UPDATE_OBJECT, &body).unwrap();
match decode(packet).as_slice() {
[SessionEvent::ObjectValues { guid: 0xAA, fields }, SessionEvent::ObjectsNear(near), SessionEvent::ObjectUpdateEnd] =>
{
assert!(fields.is_empty());
assert_eq!(near, &[0x10]);
}
other => panic!("{other:?}"),
}
}
/// A first-pass guid: its top four bits are 6 (`0x4660e6`).
const FIRST_SIX: u64 = 0x6000_0000_0000_0042;
/// One event as the pass tests compare it: the block's kind and guid, any other event whole.
fn shape(ev: &SessionEvent) -> String {
match ev {
SessionEvent::ObjectCreate { guid, .. } => format!("create {guid:#x}"),
SessionEvent::ItemCreate { guid, .. } => format!("item {guid:#x}"),
SessionEvent::ObjectValues { guid, .. } => format!("values {guid:#x}"),
SessionEvent::ObjectMove { guid, .. } => format!("move {guid:#x}"),
SessionEvent::ObjectsNear(guids) => format!("near {guids:x?}"),
SessionEvent::ObjectsRemoved(guids) => format!("out of range {guids:x?}"),
SessionEvent::ObjectUpdateEnd => "end".into(),
other => format!("{other:?}"),
}
}
fn update_shapes(body: &[u8]) -> Vec<String> {
let packet = messages::parse_server(messages::opcode::SMSG_UPDATE_OBJECT, body).unwrap();
decode(packet).iter().map(shape).collect()
}
/// Five blocks behind the header byte: values for 0xAA (entry 0x12); for the first-pass guid
/// [`FIRST_SIX`], a gameobject create at (100, 200, 300) with display 3015, a movement block and
/// values (entry 0x13); near objects [0x10, 0x8000_0000_0000_0011].
fn mixed_passes(header: &str) -> Vec<u8> {
hx(&[
"05000000",
header,
"0001aa010800000012000000",
"0281426005400000c84200004843000096430000803f0100010000c70b0000",
"01814260400000ca4200004843000096430000803f",
"00814260010800000013000000",
"05020000000110811180",
]
.concat())
}
/// The header byte runs a first pass over the blocks of top-nibble 6 and 8 guids, then a second
/// over the rest and every movement block (`0x46520e`, `0x465222`; the guid test `0x4660d8`-
/// `0x466105`, none in the movement handler `0x467050`). The notify pass reads every block in
/// packet order (`0x46522d`), so a first-pass block keeps its place there.
#[test]
fn the_header_byte_writes_top_nibble_six_and_eight_guids_first() {
let notify_create = format!("ObjectNotify {{ guid: {FIRST_SIX}, block: Create(GameObject) }}");
let notify_values = format!("ObjectNotify {{ guid: {FIRST_SIX}, block: Values([3]) }}");
assert_eq!(
update_shapes(&mixed_passes("01")),
[
"create 0x6000000000000042",
"values 0x6000000000000042",
"near [8000000000000011]",
"values 0xaa",
&notify_create,
"move 0x6000000000000042",
&notify_values,
"near [10]",
"end",
]
);
}
/// With the header byte clear only the second pass runs, so no pass writes a first-pass guid's
/// create, values or near entry; the notify pass still reads them (`0x465c50`, `0x465330`).
#[test]
fn a_clear_header_byte_writes_no_first_pass_block() {
let notify_create = format!("ObjectNotify {{ guid: {FIRST_SIX}, block: Create(GameObject) }}");
let notify_values = format!("ObjectNotify {{ guid: {FIRST_SIX}, block: Values([3]) }}");
assert_eq!(
update_shapes(&mixed_passes("00")),
[
"values 0xaa",
&notify_create,
"move 0x6000000000000042",
&notify_values,
"near [10]",
"end",
]
);
}
/// An out-of-range list releases only as the packet's first block, ahead of both passes
/// (`0x4651e1`); in any other place the notify pass reads past it (`0x465fd0`).
#[test]
fn an_out_of_range_list_decodes_only_as_the_first_block() {
// Values for 0xAA with no mask words, then out of range [0xAA].
assert_eq!(
update_shapes(&hx("02000000000001aa00040100000001aa")),
["values 0xaa", "end"]
);
assert_eq!(
update_shapes(&hx("0200000000040100000001aa0001aa00")),
["out of range [aa]", "values 0xaa", "end"]
);
// First, it goes ahead of the first pass too.
assert_eq!(
update_shapes(&hx("020000000104010000008142600081426000")),
[
"out of range [6000000000000042]".to_string(),
"values 0x6000000000000042".to_string(),
format!("ObjectNotify {{ guid: {FIRST_SIX}, block: Values([]) }}"),
"end".to_string(),
]
);
}