Files
benilla-octo/crates/benilla-protocol/tests/movement.rs
T
SamandClaude Opus 5 c48d8c6b7e other players walk the world, and NPCs speak your name
- Other players walk the world. A watched player's motion between server updates now runs through
  the same grounded movement resolve as your own character — same capsule, same colliders, same
  step-up and ground election — so they follow terrain every frame instead of every packet, and
  stop at a wall instead of walking into it and being snapped back. Their jumps are swept against
  the world too, and a standstill jump steered mid-air reports that steer, the one mid-air press
  the reference client broadcasts rather than defers.
- The move relay rebuilt: relayed moves ride a per-unit chain on real time, a due move never jumps
  ahead of its unit's queue, and SMSG_COMPRESSED_MOVES — the batched carrier the server sends under
  load — is decoded rather than skipped. Our own send path now matches the reference client's:
  facing streams while moving, Nagle is off so each packet is one write, and the strafe axis nets so
  left and right can never ride the same packet.
- NPC and quest text speaks the whole $-macro grammar, carved from the reference bytes — your name,
  class and race, the gendered forms, and the server's live world states — and the same
  substitution feeds gossip, quest text, the quest log and unit frames. Greetings are a weighted
  draw, rolled per NPC per open, and a quest giver with no quest to give is no longer clickable,
  where it used to open a dialog holding nothing but a placeholder greeting.
- Spell tooltips finish the reference's line law: cast time and cooldown behind the real passive
  gate, the required tool or form, the reagents block, the chance-to-X line, a multi-bit
  equipped-item requirement named properly instead of by raw bits, and a buff's dispel class.
- The UI layout engine got a dense, reusable anchor solver, and a change gate that skips the resolve
  entirely on frames where no layout input moved — in practice, almost every idle frame. Pets are
  named by their pet number rather than a creature-template entry, so the client asks the query the
  server can actually answer. And the asset extractor grew a stray-geometry census for tracking
  down geometry that renders where it should not.

This snapshot replaces the six dated `sync` commits that stood here before it, whose subjects said
nothing but the date. If you cloned before now: `git fetch && git reset --hard origin/main`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 14:57:54 +07:00

494 lines
20 KiB
Rust

//! 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 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());
}