Files
Sam SungandClaude Opus 5 b42cd7ef6b Update packets: self release, mover blocks, spline facing, placement (#1324)
Five fixes to how `SMSG_UPDATE_OBJECT` is applied, each against the client's code:

- An out-of-range block never releases the active player (0x465f39–0x465f45).
- A movement block for the unit we move, the player or a possessed unit, is dropped (0x4670a5–0x4670bb).
- A create spline reads its three final-facing fields independently, in the order point, GUID, angle (0x47f0bf, 0x47f0e8, 0x47f0f9), so a server that sets two no longer breaks the packet.
- A game object is placed by its create block's position over its position fields (0x5f7046), and a re-create re-poses units only (0x46638c).
- The leave-world sweep's comment names the destroy path alone.

Byte-exact fixtures for the spline; each change has a test that fails without it.

Fixes #1307
Fixes #1308
Fixes #1309
Fixes #1310
Fixes #1311

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

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
(cherry picked from commit b816ff2f6054f496b7fb169a8acbd430323a98d3)

# Conflicts:
#	crates/benilla-app/src/ui_script/sweep.rs

Absorbed into the octo fork. Our tree has no ui_script/sweep.rs (upstream added it after our last sync), and the only change to it here was a comment; the file is dropped rather than orphaned. The five packet fixes apply unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
2026-10-25 23:00:32 -06:00

771 lines
31 KiB
Rust
Raw Permalink 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 common::hx;
#[test]
fn update_object_fixture_decodes() {
// A unit create (0xAA), a gameobject create (0xBB, a HAS_POSITION block at (100, 200, 300), as
// vmangos sends one) and an out-of-range block [0x10, 0x20].
let body = hx("03000000000301aa03200000000000000000cdd70bc6357e04c3f90fa74200000040000000000000803f0000e040000090400000000000000000db0f4940051700000000000000000000000000000008000000aa00000000000000090000000000c03f320000000301bb05400000c84200004843000096430000803f0117810700bb000000000000002100000000000040630000000000c84200004843000096430000803f040200000001100120");
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(), 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 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); // 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
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,
..
}] => {
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:?}"),
}
}
/// `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,
..
}] => {
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,
..
}] => {
assert_eq!(*orientation, 2.0);
assert_eq!(
mover,
&Some(benilla_protocol::MoverState { flags, pitch: -0.4 }),
"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(&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(&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, .. }] => {
assert_eq!(
mover,
&Some(benilla_protocol::MoverState { flags, pitch: 0.0 }),
"no tail on the wire reads as a level pitch, not a misparse"
);
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:?}"),
}
}
/// `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,
..
}] => {
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,
..
}] => {
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.
#[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";
for (name, flags, facing) in [
("point and angle", "00000500", [point, angle].concat()),
("point and guid", "00000300", [point, guid].concat()),
("guid and angle", "00000600", [guid, angle].concat()),
("all three", "00000700", [point, guid, angle].concat()),
("the point alone", "00000100", point.to_string()),
("the guid alone", "00000200", guid.to_string()),
("the angle alone", "00000400", angle.to_string()),
("none", "00000000", String::new()),
] {
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,
..
}] => {
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!(
*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, .. }] => 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)"
);
}