e93c2e2129
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>
974 lines
39 KiB
Rust
974 lines
39 KiB
Rust
//! `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",
|
||
¬ify_create,
|
||
"move 0x6000000000000042",
|
||
¬ify_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",
|
||
¬ify_create,
|
||
"move 0x6000000000000042",
|
||
¬ify_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(),
|
||
]
|
||
);
|
||
}
|