Files
benilla-octo/crates/benilla-protocol/tests/movement.rs
T
SamandClaude Fable 5.1 0f88324605 the action bars' empty slots go ghost, and a rooted creature stays put
- picking a spell up off the bar no longer blacks out every empty slot: `UI-Quickslot` is a FILLED 60%-black plate, not a hollow ring (decoded off the shipped BLPs — Quickslot2's interior is RGBA 0,0,0,0, Quickslot's 0,0,0,153), and the reference dims it to half alpha in `ActionButton_ShowGrid`, so an empty well now ghosts at 30% black where it read as a hole; the wow-re cross-check then found the reference never UN-dims it (a 3-argument `SetVertexColor` preserves the current alpha at `0x79abd0`, and `SetNormalTexture` reuses the region), so in the real client the first drag leaves every button's border at half alpha for the rest of the session — benilla ends the dim on purpose and records it as the divergence it is (1782)
- the bag bar is the reference's own `MainMenuBarBagButtons.xml` (window 3 of the stock-first migration), and the swap found a real bug rather than a move: the reference's `ItemAnim_OnEvent` matches a push against the button's inventory-slot id (20..23) and benilla emitted its own container numbering (1..4), so all four bag-push cards would have silently never played against the stock body — `push_container` now emits the reference's selector (`bag + 1`), which is what an addon expects anyway; beside it, a readiness probe that asks the LOADER, not a grep, which of the 85 stock windows load off the player's chain today (58 do; the 27 that don't name their own blocker)
- a rooted, hovering, water-walking or walking creature reached benilla as an unhandled opcode: the twelve `SMSG_SPLINE_MOVE_*` (the observer twin of the ack'd force family — bare packed guid, no ack, any unit) now decode into one MOVEMENTFLAGS word per unit, which benilla kept for our mover and for a relayed player and not at all for a creature, the one body this family is ever addressed to; ROOT wipes the direction bits so the dead-reckon stops walking a rooted body on for its next heartbeat (`0x7c7340`), HOVER clears a yard off the floor without climbing a body already inside that yard (`0x636e81`), WATERWALK elects the liquid surface against the terrain, SAFE_FALL's terminal is 7.0 not 60.148 (`0x7c5d23`), and SET_RUN_MODE's bool is inverted against the bit, folded at the parse; the inspect card names a unit's granted modes in words (`granted rooted+hover`) so a creature resting a yard off the floor reads as intended rather than broken

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 16:57:55 +07:00

722 lines
29 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.
//! Player-movement relay + teleport-ack wire tests: the `[packed guid][MovementInfo]` relay shape
//! that rebroadcasts other players' `MSG_MOVE_*` locomotion (including the jump ballistic tail), and
//! `MSG_MOVE_TELEPORT_ACK`'s server-form pose. Split out of the former `tests/messages.rs` — see
//! `tests/common` for the shared fixtures and methodology note.
mod common;
use benilla_protocol::events::{decode, SessionEvent};
use benilla_protocol::messages::{self, MovementInfo, TransportPose};
use benilla_protocol::wire::{write_packed_guid, Vector3d};
use benilla_protocol::ServerPacket;
use common::hx;
#[test]
fn movement_relay_decodes_to_unit_move() {
// A relayed player move: [packed mover guid][MovementInfo]. Packed guid 0xAA = mask 0x01 + byte
// 0xAA; the MovementInfo body is the same 28-byte FORWARD info pinned in `client_bodies_golden`
// (flags 0x1, time, pos, orientation 1.25, fall_time). The server rebroadcasts with the mover's
// opcode — here MSG_MOVE_START_FORWARD — which is how another player's walking reaches us.
let body = hx("01aa0100000004030201cdd70bc6357e04c3f90fa7420000a03f00000000");
match messages::parse_server(messages::opcode::MSG_MOVE_START_FORWARD, &body).unwrap() {
ServerPacket::PlayerMove {
guid,
opcode,
flags,
position,
orientation,
jump,
..
} => {
assert_eq!(guid, 0xAA);
assert_eq!(opcode, messages::opcode::MSG_MOVE_START_FORWARD);
assert_eq!(flags, 0x1);
assert_eq!(
(position.x, position.y, position.z),
(-8949.95, -132.493, 83.5312)
);
assert_eq!(orientation, 1.25);
assert_eq!(jump, None, "a non-jumping relay carries no jump tail");
}
p => panic!("expected PlayerMove, got {}", p.name()),
}
// The whole relay family routes through the same body shape — spot-check a couple more opcodes and
// the fan-out into a SessionEvent the ECS consumes.
for op in [
messages::opcode::MSG_MOVE_HEARTBEAT,
messages::opcode::MSG_MOVE_SET_FACING,
messages::opcode::MSG_MOVE_STOP,
] {
let events = decode(messages::parse_server(op, &body).unwrap());
assert!(
matches!(
events.as_slice(),
[SessionEvent::UnitMove {
guid: 0xAA,
orientation,
flags: 0x1,
..
}] if *orientation == 1.25
),
"opcode {op:#06x} should decode to one UnitMove, got {events:?}"
);
}
}
#[test]
fn jump_relay_round_trips_the_ballistic_tail() {
// A jumping MovementInfo (JUMPING 0x2000 | FORWARD) carries the ballistic launch tail. Round-trip it
// through write → parse to pin the wire layout: `fall_time` is a u32 (ms), and the jump tail order is
// `zspeed, cosAngle, sinAngle, xyspeed` — cos *before* sin (VERIFIED vmangos `MovementInfo::Read`).
let mi = MovementInfo {
flags: 0x2000 | 0x1,
timestamp: 0x0102_0304,
position: Vector3d {
x: 1.0,
y: 2.0,
z: 3.0,
},
orientation: 0.5,
transport: None, // not on a transport — no transport tail is written
pitch: 0.0, // not swimming — no pitch tail is written
fall_time: 250,
jump: Some(messages::JumpInfo {
zspeed: 7.955_547,
cos_angle: 0.25,
sin_angle: 0.75,
xy_speed: 7.0,
}),
};
let mut body = hx("01aa"); // [packed guid 0xAA][MovementInfo] — the relay shape.
body.extend_from_slice(&messages::movement(&mi));
match messages::parse_server(messages::opcode::MSG_MOVE_JUMP, &body).unwrap() {
ServerPacket::PlayerMove {
flags,
fall_time,
jump,
..
} => {
assert_eq!(flags, 0x2001);
assert_eq!(fall_time, 250, "fall_time is a u32 ms count, not an f32");
let j = jump.expect("a JUMPING relay carries the ballistic tail");
assert_eq!(j.zspeed, 7.955_547);
assert_eq!(
j.cos_angle, 0.25,
"cosAngle is the 2nd jump float (before sin)"
);
assert_eq!(j.sin_angle, 0.75, "sinAngle is the 3rd jump float");
assert_eq!(j.xy_speed, 7.0);
}
p => panic!("expected PlayerMove, got {}", p.name()),
}
}
/// A relayed rider on a transport: `MOVEFLAG_ON_TRANSPORT | MOVEFLAG_SWIMMING | MOVEFLAG_JUMPING` all
/// set, so the transport pose, swim pitch, `fall_time`, and jump tail all ride the same packet — the
/// exact regression the old discard comment warned about (`messages/movement.rs`'s prior "the
/// transport ... tail is parsed to stay aligned but discarded"): a wrong transport-tail length would
/// misalign every tail after it. `write()` never serializes a transport tail (benilla doesn't set the
/// flag outbound — riding is decision 0438 phase 2), so this fixture is hand-built to the exact byte
/// order `read_movement_info` implements.
#[test]
fn movement_relay_surfaces_transport_pose_and_keeps_the_rest_aligned() {
// An elevator (HIGH_TRANSPORT, 0xF120): template entry 900, DB spawn low guid 4242 — the standard
// entry layout (guid.rs `entry()`).
let transport_guid: u64 = 4242 | (900u64 << 24) | (0xF120u64 << 48);
let mut body = Vec::new();
write_packed_guid(0xAA, &mut body).unwrap(); // mover guid
let flags: u32 = 0x0200_0000 | 0x20_0000 | 0x2000 | 0x1; // ON_TRANSPORT | SWIMMING | JUMPING | FORWARD
body.extend_from_slice(&flags.to_le_bytes());
body.extend_from_slice(&0x1122_3344u32.to_le_bytes()); // timestamp
body.extend_from_slice(&10.0f32.to_le_bytes()); // position.x
body.extend_from_slice(&20.0f32.to_le_bytes()); // position.y
body.extend_from_slice(&30.0f32.to_le_bytes()); // position.z
body.extend_from_slice(&0.75f32.to_le_bytes()); // orientation
body.extend_from_slice(&transport_guid.to_le_bytes()); // ON_TRANSPORT tail: FULL u64 guid
body.extend_from_slice(&1.0f32.to_le_bytes()); // local x
body.extend_from_slice(&2.0f32.to_le_bytes()); // local y
body.extend_from_slice(&3.0f32.to_le_bytes()); // local z
body.extend_from_slice(&0.5f32.to_le_bytes()); // local o
body.extend_from_slice(&(-0.3f32).to_le_bytes()); // SWIMMING tail: pitch
body.extend_from_slice(&999u32.to_le_bytes()); // fall_time (u32 — must land right after pitch)
body.extend_from_slice(&7.9f32.to_le_bytes()); // JUMPING tail: zspeed
body.extend_from_slice(&0.6f32.to_le_bytes()); // cos_angle
body.extend_from_slice(&0.8f32.to_le_bytes()); // sin_angle
body.extend_from_slice(&5.0f32.to_le_bytes()); // xy_speed
let want_transport = TransportPose {
guid: transport_guid,
pos: Vector3d {
x: 1.0,
y: 2.0,
z: 3.0,
},
orientation: 0.5,
};
match messages::parse_server(messages::opcode::MSG_MOVE_HEARTBEAT, &body).unwrap() {
ServerPacket::PlayerMove {
orientation,
transport,
fall_time,
jump,
..
} => {
assert_eq!(
orientation, 0.75,
"position/orientation land before the tail, unaffected"
);
assert_eq!(
transport,
Some(want_transport),
"the ON_TRANSPORT tail surfaces as the rider's local pose"
);
assert_eq!(
fall_time, 999,
"fall_time still lands right after the transport + swim-pitch tails"
);
let j =
jump.expect("the jump tail still parses after the transport/pitch/fall_time run");
assert_eq!(j.zspeed, 7.9);
assert_eq!(j.cos_angle, 0.6);
assert_eq!(j.sin_angle, 0.8);
assert_eq!(j.xy_speed, 5.0);
}
p => panic!("expected PlayerMove, got {}", p.name()),
}
// The same fixture through the SessionEvent fan-out: UnitMove carries the same transport pose and
// the same post-tail fields.
let events =
decode(messages::parse_server(messages::opcode::MSG_MOVE_HEARTBEAT, &body).unwrap());
match events.as_slice() {
[SessionEvent::UnitMove {
transport,
fall_time,
jump,
..
}] => {
assert_eq!(transport, &Some(want_transport));
assert_eq!(*fall_time, 999);
assert!(jump.is_some());
}
other => panic!("expected one UnitMove, got {other:?}"),
}
}
#[test]
fn teleport_ack_parses_pose() {
// MSG_MOVE_TELEPORT_ACK (server→client form): [packed guid][counter u32][MovementInfo]. Exercises
// the refactored read_movement_info (non-transport path) end to end.
let body = hx("01aa070000000100000004030201cdd70bc6357e04c3f90fa7420000a03f00000000");
match messages::parse_server(messages::opcode::MSG_MOVE_TELEPORT_ACK, &body).unwrap() {
ServerPacket::Teleport {
guid,
counter,
position,
orientation,
} => {
assert_eq!(guid, 0xAA);
assert_eq!(counter, 7);
assert_eq!(
(position.x, position.y, position.z),
(-8949.95, -132.493, 83.5312)
);
assert_eq!(orientation, 1.25);
}
p => panic!("expected Teleport, got {}", p.name()),
}
}
/// The force-speed-change family, byte-exact both directions (a new wire body never lands without
/// a golden — method): the SMSG body is `[packed guid][u32 counter][f32 speed]` (vmangos
/// `SendSpeedChangeToController`, 5875 branch), and the ACK body is `[u64 FULL guid][u32 counter]
/// [MovementInfo][f32 speed]` (`MoveSpeedAck::ReadFromWorldPacket` — a plain `ObjectGuid`
/// extraction is a raw u64, `ObjectGuid.cpp:180`, NOT packed like the SMSG's).
#[test]
fn force_speed_change_parses_and_ack_body_golden() {
use benilla_protocol::messages::SpeedKind;
// SMSG_FORCE_RUN_SPEED_CHANGE: packed guid 8 (mask 0x01, one byte), counter 7, speed 14.0.
let body = hx("01080700000000006041");
let packet =
messages::parse_server(messages::opcode::SMSG_FORCE_RUN_SPEED_CHANGE, &body).unwrap();
match &packet {
ServerPacket::ForceSpeedChange {
guid: 8,
kind: SpeedKind::Run,
counter: 7,
speed,
} => assert_eq!(*speed, 14.0),
_ => panic!("expected ForceSpeedChange (run, guid 8, counter 7)"),
}
match decode(packet).as_slice() {
[SessionEvent::ForceSpeedChange {
guid: 8,
kind: SpeedKind::Run,
counter: 7,
speed,
}] => assert_eq!(*speed, 14.0),
other => panic!("expected one ForceSpeedChange event, got {other:?}"),
}
// Every kind maps to its own SMSG opcode (the walk/swim-back/turn-rate trio lives in the
// 730-735 block, not the 226-231 run).
for (op, kind) in [
(
messages::opcode::SMSG_FORCE_WALK_SPEED_CHANGE,
SpeedKind::Walk,
),
(
messages::opcode::SMSG_FORCE_SWIM_SPEED_CHANGE,
SpeedKind::Swim,
),
(
messages::opcode::SMSG_FORCE_TURN_RATE_CHANGE,
SpeedKind::TurnRate,
),
] {
match messages::parse_server(op, &body).unwrap() {
ServerPacket::ForceSpeedChange { kind: k, .. } => assert_eq!(k, kind),
_ => panic!("expected ForceSpeedChange for opcode {op:#06x}"),
}
}
// The ACK body golden: full 8-byte guid, counter, a stationary MovementInfo (flags 0 => no
// conditional tails), then the echoed speed.
let info = MovementInfo {
flags: 0,
timestamp: 12345,
position: Vector3d {
x: 1.0,
y: 2.0,
z: 3.0,
},
orientation: 0.5,
transport: None,
pitch: 0.0,
fall_time: 42,
jump: None,
};
assert_eq!(
messages::force_speed_ack(8, 7, &info, 14.0),
hx("08000000000000000700000000000000393000000000803f00000040000040400000003f2a00000000006041"),
"CMSG_FORCE_*_SPEED_CHANGE_ACK body"
);
// The ack opcode per kind — the run ack is 227, the walk ack lives in the high block (731).
assert_eq!(SpeedKind::Run.ack_opcode(), 0x00E3);
assert_eq!(SpeedKind::Walk.ack_opcode(), 0x02DB);
assert_eq!(SpeedKind::TurnRate.ack_opcode(), 0x02DF);
}
/// **The knockback handshake, byte-exact both ways** (decision 1702).
///
/// The two halves are one test because the second is only correct *relative to* the first: the ack's
/// jump tail has to be the arriving quad, and the two packets order those same four floats
/// differently. `SMSG_MOVE_KNOCK_BACK` is direction-first (`vcos, vsin, speedXY, speedZ`); the
/// `MovementInfo` jump tail is `zspeed, cos, sin, xyspeed`. Read one in the other's order and every
/// field is still a plausible float, the arc flies off at a wrong angle, and
/// `FindPendingMovementKnockbackChange` rejects the ack — so this is exactly the transposition a
/// type alone cannot catch.
///
/// `speedZ` is negative here because it is **down-positive**: this is a knockback that throws the
/// body *upward* at 12 yd/s (wow-re: `0x7c61f0` stores the wire value verbatim into the same
/// `CMovement+0xa0` a land jump seeds with `0xc0fe93d8` = −7.955547).
#[test]
fn knock_back_parses_and_ack_body_golden() {
// packed guid 8 (mask 0x01, one byte), counter 7, then vcos 0.6, vsin 0.8, speedXY 25, speedZ −12.
let body = hx("0108070000009a99193fcdcc4c3f0000c841000040c1");
let packet = messages::parse_server(messages::opcode::SMSG_MOVE_KNOCK_BACK, &body).unwrap();
let launch = match &packet {
ServerPacket::KnockBack {
guid: 8,
counter: 7,
launch,
} => *launch,
other => panic!(
"expected KnockBack (guid 8, counter 7), got {}",
other.name()
),
};
assert_eq!(launch.cos_angle, 0.6, "first float on the wire is vcos");
assert_eq!(launch.sin_angle, 0.8, "second is vsin");
assert_eq!(launch.xy_speed, 25.0, "third is the horizontal speed");
assert_eq!(
launch.zspeed, -12.0,
"fourth is speedZ, down-positive — negative throws the body up"
);
match decode(packet).as_slice() {
[SessionEvent::KnockBack {
guid: 8,
counter: 7,
launch: got,
}] => assert_eq!(*got, launch),
other => panic!("expected one KnockBack event, got {other:?}"),
}
// The ACK body golden: a **full** 8-byte guid (packed on the way in, never on the way out), the
// echoed counter, and a `MovementInfo` carrying `MOVEFLAG_JUMPING` (0x2000) — without which the
// jump tail is not serialized at all and the server has nothing to match against.
let info = MovementInfo {
flags: 0x2000,
timestamp: 12345,
position: Vector3d {
x: 1.0,
y: 2.0,
z: 3.0,
},
orientation: 0.5,
transport: None,
pitch: 0.0,
fall_time: 0,
jump: Some(launch),
};
assert_eq!(
messages::knock_back_ack(8, 7, &info),
hx("08000000000000000700000000200000393000000000803f00000040000040400000003f00000000000040c19a99193fcdcc4c3f0000c841"),
"CMSG_MOVE_KNOCK_BACK_ACK body"
);
assert_eq!(messages::opcode::SMSG_MOVE_KNOCK_BACK, 0x00EF);
assert_eq!(messages::opcode::CMSG_MOVE_KNOCK_BACK_ACK, 0x00F0);
}
/// **Somebody else's knockback reaches us, and replays as an arc** (decision 1702, correcting
/// decision 0277's "no observer-side knockback signal exists in 1.12 at all").
///
/// `MSG_MOVE_KNOCK_BACK` is an ordinary `[packed guid][MovementInfo]` relay with the launch quad
/// appended — and the appendix is redundant by construction, because the server builds that
/// `MovementInfo` from the victim's own ack, whose jump tail already carries the quad. So the
/// trailing four floats go unread and the arc still replays: this test's assertion is that the tail
/// the reader *does* reach is the launch, not that the packet was consumed to the last byte.
#[test]
fn an_observed_knockback_relays_the_arc() {
// packed guid 0xAA, a JUMPING MovementInfo whose tail is (zspeed −12, cos 0.6, sin 0.8, xy 25),
// then the same quad again in the SMSG's own direction-first order.
let body = hx("01aa00200000393000000000803f00000040000040400000003f00000000000040c19a99193fcdcc4c3f0000c8419a99193fcdcc4c3f0000c841000040c1");
let packet = messages::parse_server(messages::opcode::MSG_MOVE_KNOCK_BACK, &body).unwrap();
match &packet {
ServerPacket::PlayerMove {
guid: 0xAA,
flags: 0x2000,
jump: Some(j),
..
} => {
assert_eq!(j.zspeed, -12.0, "the relayed arc rises, down-positive");
assert_eq!((j.cos_angle, j.sin_angle, j.xy_speed), (0.6, 0.8, 25.0));
}
other => panic!("expected a relayed PlayerMove, got {}", other.name()),
}
match decode(packet).as_slice() {
[SessionEvent::UnitMove { guid: 0xAA, .. }] => {}
other => panic!("expected one UnitMove event, got {other:?}"),
}
assert_eq!(messages::opcode::MSG_MOVE_KNOCK_BACK, 0x00F1);
}
/// The OBSERVER speed legs, byte-exact (decision 0441 — how an observed unit's mounted speed
/// reaches us; no counter, no ack on either): `SMSG_SPLINE_SET_*_SPEED` is `[packed guid]
/// [f32 speed]` (vmangos `SendSpeedChangeToAll` + the mid-spline observer branch), and
/// `MSG_MOVE_SET_*_SPEED` is `[packed guid][MovementInfo][f32 speed]`
/// (`SendSpeedChangeToObservers`, finalized branch) — a speed change AND a fresh pose, decoding
/// to a `UnitMove` + `SpeedChanged` pair.
#[test]
fn observer_speed_legs_parse_golden() {
use benilla_protocol::messages::SpeedKind;
// SMSG_SPLINE_SET_RUN_SPEED: packed guid 0xAA (mask 0x01, one byte), speed 14.0.
let body = hx("01aa00006041");
let packet =
messages::parse_server(messages::opcode::SMSG_SPLINE_SET_RUN_SPEED, &body).unwrap();
match &packet {
ServerPacket::SplineSpeedChange {
guid: 0xAA,
kind: SpeedKind::Run,
speed,
} => assert_eq!(*speed, 14.0),
p => panic!(
"expected SplineSpeedChange (run, guid 0xAA), got {}",
p.name()
),
}
match decode(packet).as_slice() {
[SessionEvent::SpeedChanged {
guid: 0xAA,
kind: SpeedKind::Run,
speed,
}] => assert_eq!(*speed, 14.0),
other => panic!("expected one SpeedChanged event, got {other:?}"),
}
// Each kind maps to its own opcode — spot-check the walk (769) and swim-back (770) slots,
// whose order differs from the FORCE family's.
for (op, kind) in [
(
messages::opcode::SMSG_SPLINE_SET_WALK_SPEED,
SpeedKind::Walk,
),
(
messages::opcode::SMSG_SPLINE_SET_SWIM_BACK_SPEED,
SpeedKind::SwimBack,
),
] {
match messages::parse_server(op, &body).unwrap() {
ServerPacket::SplineSpeedChange { kind: k, .. } => assert_eq!(k, kind),
p => panic!("expected SplineSpeedChange for {op:#06x}, got {}", p.name()),
}
}
// MSG_MOVE_SET_RUN_SPEED: [packed guid 0xAA][the shared 28-byte FORWARD MovementInfo golden]
// [speed 14.0] — decodes to the pose + the speed, in one drain.
let body = hx("01aa0100000004030201cdd70bc6357e04c3f90fa7420000a03f0000000000006041");
let packet = messages::parse_server(messages::opcode::MSG_MOVE_SET_RUN_SPEED, &body).unwrap();
match &packet {
ServerPacket::MoveSetSpeed {
guid: 0xAA,
kind: SpeedKind::Run,
flags: 0x1,
orientation,
speed,
..
} => {
assert_eq!(*orientation, 1.25);
assert_eq!(*speed, 14.0);
}
p => panic!("expected MoveSetSpeed (run, guid 0xAA), got {}", p.name()),
}
match decode(packet).as_slice() {
[SessionEvent::UnitMove {
guid: 0xAA,
flags: 0x1,
position,
..
}, SessionEvent::SpeedChanged {
guid: 0xAA,
kind: SpeedKind::Run,
speed,
}] => {
assert_eq!(position, &[-8949.95, -132.493, 83.5312]);
assert_eq!(*speed, 14.0);
}
other => panic!("expected UnitMove + SpeedChanged, got {other:?}"),
}
// SMSG_MOUNTRESULT / SMSG_DISMOUNTRESULT: one u32 code (OK = 10 / 3).
match decode(
messages::parse_server(messages::opcode::SMSG_MOUNTRESULT, &hx("0a000000")).unwrap(),
)
.as_slice()
{
[SessionEvent::MountResult {
mount: true,
code: 10,
}] => {}
other => panic!("expected MountResult(mount, 10), got {other:?}"),
}
match decode(
messages::parse_server(messages::opcode::SMSG_DISMOUNTRESULT, &hx("03000000")).unwrap(),
)
.as_slice()
{
[SessionEvent::MountResult {
mount: false,
code: 3,
}] => {}
other => panic!("expected MountResult(dismount, 3), got {other:?}"),
}
// SMSG_MOUNTSPECIAL_ANIM: one raw u64 guid, NOT packed (vmangos
// `HandleMountSpecialAnimOpcode` writes `data << GetObjectGuid()` — full 8 bytes).
match decode(
messages::parse_server(
messages::opcode::SMSG_MOUNTSPECIAL_ANIM,
&hx("0400000000000040"),
)
.unwrap(),
)
.as_slice()
{
[SessionEvent::MountSpecial {
guid: 0x4000_0000_0000_0004,
}] => {}
other => panic!("expected MountSpecial(player 4), got {other:?}"),
}
}
/// `SMSG_COMPRESSED_MOVES` — the batch carrier vmangos switches a session onto once it has been
/// sent 300 movement packets in ten seconds (decision 0624). The fixture is built exactly as
/// `MovementData::AddPacket` + `BuildPacket` do: `[u32 uncompressed size][zlib(records)]`, each
/// record `[u8 size][u16 opcode][body]` with `size` counting the opcode's two bytes. The two
/// records carry the same relay bodies pinned by `movement_relay_decodes_to_unit_move` above, so
/// what this adds is the *framing* — that a batched move decodes identically to a loose one.
#[test]
fn compressed_moves_unwraps_to_the_same_events_as_loose_relays() {
let body = hx("42000000780153d8cac0b88a91818181859989f1ec75ee63a6752c877ff22f77626058600f146650b805540062602a38005600002f2610d9");
let events =
decode(messages::parse_server(messages::opcode::SMSG_COMPRESSED_MOVES, &body).unwrap());
// Both records, in wire order, as ordinary moves — order is load-bearing: the remote-replay
// queue reconstructs a mover's arc from it (decision 0618).
match events.as_slice() {
[SessionEvent::UnitMove {
guid: 0xAA,
flags: 0x1,
..
}, SessionEvent::UnitMove {
guid: 0xAA,
flags: 0x0,
..
}] => {}
other => panic!(
"expected the moving then the idle relay for mover 0xAA, in that order, got {other:?}"
),
}
// A batched move must be byte-identical to the same move arriving loose.
let loose = decode(
messages::parse_server(
messages::opcode::MSG_MOVE_START_FORWARD,
&hx("01aa0100000004030201cdd70bc6357e04c3f90fa7420000a03f00000000"),
)
.unwrap(),
);
assert_eq!(format!("{:?}", events[0]), format!("{:?}", loose[0]));
}
/// A misframed batch must fail **loudly** (one `Poll::Skipped` naming the inner opcode), never
/// silently drop the moves it carries — the exact silence that made the runaway unattributable for
/// three rounds of fixes (decision 0624).
#[test]
fn a_misframed_batch_errors_rather_than_dropping_moves() {
// A HEARTBEAT record claiming a 40-byte body with only 4 bytes left in the stream.
let truncated = hx("070000007801d37ac700020006c10119");
assert!(messages::parse_server(messages::opcode::SMSG_COMPRESSED_MOVES, &truncated).is_err());
// A record whose size (1) cannot even hold the opcode that follows it — misframing, not an
// empty-body packet, and it must not underflow into a huge body length.
let undersized = hx("030000007801637cc7000001e200f0");
assert!(messages::parse_server(messages::opcode::SMSG_COMPRESSED_MOVES, &undersized).is_err());
}
/// **The observer movement-mode family, byte-exact** (decision 1780) — the `SMSG_SPLINE_MOVE_*`
/// twelve. Three things are asserted because all three are how this family differs from the ack'd
/// `SMSG_FORCE_*` one it is otherwise a twin of:
///
/// 1. **The body is a bare packed guid** — parsing a *four-byte-shorter* body than the FORCE family's
/// must succeed, so a trailing counter is not being read. Reading one would desync every
/// subsequent packet in the same TCP read.
/// 2. **All twelve opcodes are handled**, including `SMSG_SPLINE_MOVE_ROOT`'s out-of-band `0x31A`
/// (the reference's dispatcher range is `0x304..=0x31A`, root deliberately at the top).
/// 3. **The run/walk pair is inverted** — `SET_RUN_MODE` *clears* `MOVEFLAG_WALK_MODE`, because the
/// reference's `0x617e80` hands the opcode's bool to `SetRunMode 0x7c71c0`, whose argument is
/// *run*. Getting this backwards would walk every running creature in view.
#[test]
fn spline_move_mode_family_parses_golden() {
use benilla_protocol::messages::SplineMode;
// Packed guid 0xAA (mask 0x01, one byte) and NOTHING else — four bytes shorter than the
// FORCE family's `[packed guid][u32 counter]`.
let body = hx("01aa");
assert_eq!(body.len(), 2, "the whole body is one packed guid");
let expected = [
(
messages::opcode::SMSG_SPLINE_MOVE_ROOT,
SplineMode::Root,
true,
),
(
messages::opcode::SMSG_SPLINE_MOVE_UNROOT,
SplineMode::Root,
false,
),
(
messages::opcode::SMSG_SPLINE_MOVE_WATER_WALK,
SplineMode::WaterWalk,
true,
),
(
messages::opcode::SMSG_SPLINE_MOVE_LAND_WALK,
SplineMode::WaterWalk,
false,
),
(
messages::opcode::SMSG_SPLINE_MOVE_FEATHER_FALL,
SplineMode::FeatherFall,
true,
),
(
messages::opcode::SMSG_SPLINE_MOVE_NORMAL_FALL,
SplineMode::FeatherFall,
false,
),
(
messages::opcode::SMSG_SPLINE_MOVE_SET_HOVER,
SplineMode::Hover,
true,
),
(
messages::opcode::SMSG_SPLINE_MOVE_UNSET_HOVER,
SplineMode::Hover,
false,
),
(
messages::opcode::SMSG_SPLINE_MOVE_START_SWIM,
SplineMode::Swimming,
true,
),
(
messages::opcode::SMSG_SPLINE_MOVE_STOP_SWIM,
SplineMode::Swimming,
false,
),
// The inversion: the WALK opcode sets the bit, the RUN opcode clears it.
(
messages::opcode::SMSG_SPLINE_MOVE_SET_WALK_MODE,
SplineMode::WalkMode,
true,
),
(
messages::opcode::SMSG_SPLINE_MOVE_SET_RUN_MODE,
SplineMode::WalkMode,
false,
),
];
for (op, mode, apply) in expected {
let packet = messages::parse_server(op, &body)
.unwrap_or_else(|e| panic!("{op:#06x} must parse from a bare packed guid: {e}"));
match &packet {
ServerPacket::SplineMoveMode {
guid: 0xAA,
mode: m,
apply: a,
} => assert_eq!(
(*m, *a),
(mode, apply),
"{} decoded to the wrong mode/direction",
packet.name()
),
p => panic!("expected SplineMoveMode for {op:#06x}, got {}", p.name()),
}
match decode(packet).as_slice() {
[SessionEvent::SplineMoveMode {
guid: 0xAA,
mode: m,
apply: a,
}] => assert_eq!((*m, *a), (mode, apply)),
other => panic!("expected one SplineMoveMode event for {op:#06x}, got {other:?}"),
}
}
// The flag bits are the same word the ack'd family writes — the two families are one state.
assert_eq!(SplineMode::Root.flag(), messages::MoveMode::Root.flag());
assert_eq!(
SplineMode::WaterWalk.flag(),
messages::MoveMode::WaterWalk.flag()
);
assert_eq!(
SplineMode::FeatherFall.flag(),
messages::MoveMode::FeatherFall.flag()
);
assert_eq!(SplineMode::Hover.flag(), messages::MoveMode::Hover.flag());
}