Files
benilla-octo/crates/benilla-protocol/tests/movement.rs
T
SamandClaude Fable 5.1 71d1677205 the fire layer sounds, heartbeats blend, and freezes refuse the flinch
- the burning city was silent: the director, at a Stratholme pin (map 329, `3471.91 -3366.89 136.85`), reported the burning-building flame layer missing — on the reference it swells as you approach a building — and that is the ambient emitter pool's signature exactly, one channel per sound id positioned at whichever of its emitters is nearest the listener (1867): 88 placements of `StratholmeFireSmokeEmberM/S.m2`, WMO props of `stratholme_b.wmo` with zero render batches, each authoring one `$DSL(6437)` StratholmeFireSmokeEmberSLoop; measured at the pin, the pool's four channels were held by UndeadCampfireSmall / CauldronLoop / TorchLoop / SlimeWaterfall, every one past its own DistanceCutoff, 0 sounding, the fire the fifth entitled entry and withheld by the cap, on a frame reporting 1,004 parked anim hosts each still holding a registration; two independent defects behind one line: (1) `doodad_events::fire_doodad_anim_events` scanned every resident host — no distance, frustum or portal gate — while the four channels are arbitrated by ascending entry index, claim order, so a surplus registration is not harmless but silence; the reference's event scan `0x719370` is reached only from `0x7074b0`'s walk of `[CM2Scene+0x20]`, a worklist emptied every frame (`0x707680`) and refilled by `0x710b90(model, 1)` from the terrain drain `0x683f80`, which walks only the bucket the scene-walk cull `0x683700` appended to — frustum, horizon occlusion, and the radius-tiered horizontal fade whose `alpha ≤ 0` is a hard cutoff — so a culled doodad's `$DSL` never fires and it never takes one of the pool's 32 entries; our prose — at four sites — had read wow-re's "linkage" as "loaded", and the scanner now honours `DoodadAnimHost::active`, benilla's one spelling of that membership; which made the pin MORE silent, because `gate_doodad_anim`'s meshless branch built its own `EmitterFade { instance: None, room: None }` on the reasoning that a particles-only model is not a building's prop — Stratholme's fire is 88 of them — so it fell to `ExteriorGate::admits_sphere`, and a sealed room is `Windows([])`, which admits nothing; a placement now has ONE draw-set gate, built once by `spawn::fx::emitter_fade` and handed to its emitters, ribbons, glow lights and anim host alike (a transport's props pass `None` and get `EmitterFade::sphere`, the single spelling of the bare form), so a meshless WMO prop's bones, particles and sound freeze and resume together under its building's portal PVS — which wow-re then CONFIRMED: a WMO `MODD` prop takes the same drain as an ADT `MDDF` prop (`0x684155`), behind a per-frame portal gate (`0x6838f0` runs only for the groups the traversal reaches); (2) the RE round that fix dispatched came back with a correction: we counted the cap on ENTITLEMENT, an out-of-range entry keeping its slot because FMOD was supposedly still playing it attenuated to nothing, and the binary says otherwise — `0x461b40` returns `[+0xE04] != 0 && 0x7a5810(ch) && !0x7a5870(ch)`, and `0x7a5870` is bit `0x100000` of `[ch+0x38]`, the DistanceCutoff verdict maintained by a second system the pump never names: `0x7a5ca0` registers the channel into a cull list only when the cutoff is non-zero, and `0x7a5000`, run immediately before `FSOUND_Update`, stops it out of range and re-issues `FSOUND_Stream_PlayEx` back in — so a far, inaudible ambience holds none of the four, neither does one whose start failed, and a cutoff of 0 is never range-culled at all; `admitted()` becomes `cap_step()`, one step of the reference's own fold over ascending entry index — Skip / Service / Retire — with the counter advanced only by a channel read back live from the mixer (`source_kit_playing`), and the pump may service up to all 32 entries looking for four live ones, which is what keeps an out-of-range ambience positioned to start the instant it comes into range; the census names WHICH ids the cap withheld, an edge like any other — "1 withheld" was a count, and *which* is the only question a missing-layer report asks — and reports three states, `serviced [out of range]` costing the cap nothing versus `withheld`, never reached; and the event scan forgets its cursor across a park, since `0x719370`'s window is one frame wide off a clock that never stops and has no persisted cursor, so a culled doodad's missed markers are lost, never replayed — carrying the memory would have burst every `$DSO`/`$SND` the park skipped; retested at the pin: five entitled entries, four out of range, 6437 SOUNDING, nothing withheld, hosts in the animate set 28 → 184, and the log shows the entry claimed by a distant fire coming into view then following to the nearest record 150 ms later — the swell the director described; the Goldshire forge control is unchanged, BellowIN/BellowOut alternating through one entry, 1 sounding; `$DSO`/`$SND` one-shots from culled doodads stop too, equally faithful; and the variation re-roll stays ungated on purpose — gating it would re-roll a whole field's lightning on the frame you turned back, a look for the director to weigh (2059, 2065)
- a poured particle is born already aged: the spawn kernels' third argument is the SUBSTEP dt, not the lifespan — the burst loop pushes a literal 0 (`0x7b5600`), both continuous loops push dt (`0x7b5779` interpolated, `0x7b57e2` plain), and the kernels store `particle.age = U01 · w` (`0x7b88c4`/`0x7b88c7`, byte-identical across the plane, sphere and spline kernels) — and benilla birthed everything at 0.0, so the births sharing one frame arrived in lockstep; `birth_age` now seeds a poured particle at a random fraction of the frame it was born in, which is why a steady stream does not pulse at the frame rate, while a burst's N particles keep one clock and age as one body; read as the LIFESPAN instead, `w` would scatter a burst over `[0, lifespan)` — half the integrated brightness, half the mean radius, half every steady pool's population — and an audit here proposed exactly that reading, which wow-re's own measured count (N = rate × lifespan = 300, confirmed) had already ruled out and the bytes refute, so `a_burst_births_every_particle_at_age_zero` pins it; one named approximation: the reference substeps at a hard 0.1 s (`[0x81d828]`), bounding its dt by construction, and benilla does not substep, so the frame delta is clamped at 0.1 s in `birth_age` instead — identical below 0.1 s, which is every frame that is not a hitch
- the wound-flinch gate is the freeze node, and the swing report stays open: the director's report of a warrior in a many-mob brawl whose swings read as more suppressed and cut short by incoming impacts than the reference's had survived a full re-read of the victim side with every mechanism matching, and one reference gate benilla did not model had turned up on the way — the wound trigger `0x60ea70` refuses the flinch while any CEffect node on the victim's `+0xb4` list carries `+0x2c & 0x4` (`0x60eab6`); wow-re's cross-check pinned the bit's one setter image-wide, `0x6201d0`, the node the kit CharProc applier `0x60d7c0` creates for CharProcType 11: it saves the owner's playback rates (mount root, body key-bone 4, body root), overrides them with `CharParamZero` via `0x712910` and ORs the bit in, and the teardown `0x6203e0` restores them — the freeze node 0889 already models as `AuraNodes::head_anim_rate`, seen from the other end; while it lives every flinch is refused — melee, spell, missile, `$HIT`; in shipped data 17 kits carry a proc 11, 15 at rate 0.0, all referenced only as state kits — Freezing Trap, Ice Block, Petrify, Entangle, web wrap, the frozen-in-place family, and not the stuns (kits 349 / 33 carry none) — and the gate is the node's presence, kit 3071's rate of 1.0 closing it exactly like 0.0; a level-60 warrior brawl spawns none of them — Bloodrage, Berserker Rage, Enrage, Death Wish, Battle Shout, Demoralizing Shout, Sunder Armor, Heroic Strike, Rend, an incoming Renew or Power Word: Shield, the creature Strike/Cleave/Thunderclap family, a mage's Frost Nova (state kit 285) — so this gate does not explain the report; the lead is dead, verified, and the report stays open with the move trace (`WOW_MOVE_TRACE_TAGS=fct,anim`) now carrying every wound trigger, evict and expire beside every swing play and fast-path, so the per-swing flinch count and timing can be read off one run instead of guessed at; the driver refuses the wound while `AuraNodes::head_anim_rate()` is `Some` — the node's presence, not its rate — traced as `wound trigger id= REFUSED (a proc-11 rate node is attached)`; `KitPlay::wound` runs the kit tail's `[8,10]` branch for a discrete play and not for the impact hand-off's state stage (`0x60f383`: a state kit runs the base recompute only); 2058's `0x6ea280` hostility-classifier decode is CONFIRMED (`Targets & 0x100 → 1`, `& 0x80 → 2`, else the per-effect implicit-target tables `0x6ea338`/`0x6ea378`); tests: a rate-0 node and a rate-1.0 node both refuse the flinch and no node flinches, an instant harmful `SMSG_SPELL_GO` lays exactly one wound per hit target with a state kit naming CombatWound adding nothing, a helpful one lays none, and a missile arrival wounds regardless of hostility; named and not done: an effect model's own `$HIT` event flinching the unit it is attached to (`0x61f6f0`), and the reflect impact on the caster (`0x6e8cb0`) (2063)
- tag `0x26` is the teleport's, not the heartbeat's: wow-re's cross-check of the relay family — one client handler `0x603bb0` → `OnUnitMoveEvent 0x601580`, thirty opcodes — confirmed 2061's teleport claim and corrected an attribution this client had rested on since 0601: the queued move-event node's tag that `0x619030` (facing interp) and `0x619090` (position reconcile) both bail on (`cmp [eax+0xc], 0x26` at `0x61904b`) belongs to `MSG_MOVE_TELEPORT`, and the heartbeat's is `0x25`; benilla had excluded the heartbeat from both blends for two years and blended the teleport — which it never parsed, so nothing contradicted it; checked on this side before acting: `push 0x26` occurs at exactly two addresses in the movement region, `0x6186bd` and `0x618736`, inside functions whose only callers are `0x602f90` and `0x602fb0`, and `0x602fb0`'s other branch pushes `0xc7` = `MSG_MOVE_TELEPORT_ACK`; it was most wrong exactly where it mattered most — the heartbeat is the ~500 ms pulse a moving player sends, and a mover running straight sends nothing else carrying position (0617), so a watched player running in a straight line had their position snapped at 2 Hz instead of blended, a second cause, of our own, for the "2 Hz height staircase" 0626/1545 were built around (the ground clamp stays: our floor and the server's genuinely differ, and no blend fixes a missing collider); `reconciles()` is now `verb != Teleport`, and the three opcode-derived booleans this was heading toward are one closed `RelayVerb` enum — `Pose` / `Heartbeat` / `Teleport` / `Root(bool)` — so every receiver has to say what it does with each — nothing had forced a reader to enumerate what an opcode could mean, which is how the tag mistake lived as long as it did; two more corrections from the same cross-check: `MSG_MOVE_ROOT`/`MSG_MOVE_UNROOT` are opcode-authoritative — after the merge the client runs `SetRoot 0x7c7340` (`or 0x1000`, then the one-shot motion wipe `& 0xffe07f00`) or `ClearRoot 0x7c7370` unconditionally, the flags word getting no vote; vmangos always sends a word that already agrees (`MovementHandler.cpp:1070`), so it changes nothing today and stops the root depending on the server continuing to be careful — and a relayed flags word is MERGED, not assigned (`new = old ^ ((old ^ wire) & 0x75a07dff)` at `0x618de7`), which the self lane already did and the remote lane did not; still consequence-free, since nothing reads the one client-owned bit, ON_TRANSPORT, off `RemoteMotion::flags`, but lifted to `move_flags::merge_server_authored` so both lanes share one implementation; pinned both ways — the teleport skips, Heartbeat / Pose / Root all arm, and a deliberately contradictory `MSG_MOVE_ROOT` whose word says running roots the mover and wipes the direction bits anyway; open from the same round: the transport re-anchor (`0x618b30 → 0x7c6900`), which benilla reaches by a different route and is worth proving equivalent, and `MovementInfo +0x04`, which the reference neither parses nor zeroes and whose readers were not censused (2064)

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

843 lines
34 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, RelayVerb, 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());
}
/// **The observer leg of the movement-mode family, byte-exact** (decision 2061) — the six opcodes
/// that tell everyone *else* a player was rooted, levitated or blinked. Until this landed they hit
/// [`ServerPacket::Other`] and were dropped, so a watched player kept sliding through a root and
/// stood in the wrong place after a Blink until their next ordinary pose packet.
///
/// Four things are asserted, because each is how this leg differs from a sibling it is otherwise a
/// twin of:
///
/// 1. **The body is the ordinary relay shape** — `[packed guid][MovementInfo]`, no counter. That is
/// the whole difference from the ack'd `SMSG_FORCE_MOVE_ROOT` leg (`[packed guid][u32 counter]`)
/// and from `MSG_MOVE_TELEPORT_ACK` (`[packed guid][u32 counter][MovementInfo]`); reading a
/// counter that isn't there would shift the pose by four bytes and desync the rest of the read.
/// 2. **Apply/unapply rides the flags word, not the opcode.** Only root splits in two; hover,
/// feather-fall and water-walk use ONE opcode for both directions, because vmangos runs
/// `SetHoverReal` &co. *before* the broadcast (`MovementHandler.cpp:626-638`, `:743-744`) so the
/// `m_movementInfo` it sends already carries the bit's new state.
/// 3. **The whole word reaches the app**, which is what makes (2) work: `UnitMove.flags` is the
/// wire word verbatim, and Levitate — feather fall + hover + water walk at once (decision 1706)
/// — arrives as three packets whose flags words accumulate server-side.
/// 4. **`MSG_MOVE_TELEPORT` is tagged as a teleport and nothing else is.** The pose is a
/// discontinuity; the app's pre-fire reconcile has to know not to glide the mover into it.
#[test]
fn observer_move_mode_family_parses_golden() {
// Packed guid 0xAA (mask 0x01, one byte) + a 28-byte MovementInfo with no conditional tails:
// flags, time 12345, pos (1, 2, 3), orientation 0.5, fall_time 0. 30 bytes total — FOUR SHORT
// of `MSG_MOVE_TELEPORT_ACK`'s same-content body, which is the point of the length assertion.
let info = |flags: u32| {
let mut b = hx("01aa");
b.extend_from_slice(&flags.to_le_bytes());
b.extend_from_slice(&hx("393000000000803f00000040000040400000003f00000000"));
b
};
const ROOT: u32 = 0x0000_1000;
const WATER_WALK: u32 = 0x1000_0000;
const FEATHER_FALL: u32 = 0x2000_0000;
const HOVER: u32 = 0x4000_0000;
// (opcode, the flags word the server would have written into it, the opcode's own verb)
let expected = [
(messages::opcode::MSG_MOVE_ROOT, ROOT, RelayVerb::Root(true)),
(messages::opcode::MSG_MOVE_UNROOT, 0, RelayVerb::Root(false)),
(
messages::opcode::MSG_MOVE_WATER_WALK,
WATER_WALK,
RelayVerb::Pose,
),
// The same opcode, the other direction — the bit is simply absent, and the verb is still
// `Pose`: for these three the word is the whole message.
(messages::opcode::MSG_MOVE_WATER_WALK, 0, RelayVerb::Pose),
(
messages::opcode::MSG_MOVE_FEATHER_FALL,
FEATHER_FALL,
RelayVerb::Pose,
),
(messages::opcode::MSG_MOVE_HOVER, HOVER, RelayVerb::Pose),
// Levitate grants all three at once (decision 1706) — one word, three bits.
(
messages::opcode::MSG_MOVE_HOVER,
HOVER | FEATHER_FALL | WATER_WALK,
RelayVerb::Pose,
),
(messages::opcode::MSG_MOVE_TELEPORT, 0, RelayVerb::Teleport),
];
for (op, flags, expected_verb) in expected {
let body = info(flags);
assert_eq!(body.len(), 30, "the relay shape, with no counter dword");
let packet = messages::parse_server(op, &body)
.unwrap_or_else(|e| panic!("{op:#06x} must parse as an ordinary relay: {e}"));
match &packet {
ServerPacket::PlayerMove {
guid: 0xAA,
opcode,
flags: f,
position,
..
} => {
assert_eq!(*opcode, op);
assert_eq!(*f, flags, "the whole flags word survives for {op:#06x}");
// Four bytes off and this would read (2.0, 3.0, 0.5) — the counter-eating bug.
assert_eq!(
(position.x, position.y, position.z),
(1.0, 2.0, 3.0),
"the pose starts right after the guid for {op:#06x}"
);
}
other => panic!(
"expected a relayed PlayerMove for {op:#06x}, got {}",
other.name()
),
}
match decode(packet).as_slice() {
[SessionEvent::UnitMove {
guid: 0xAA,
flags: f,
verb,
..
}] => {
assert_eq!(*f, flags);
assert_eq!(
*verb, expected_verb,
"the opcode's verb, and only for the three that have one ({op:#06x})"
);
}
other => panic!("expected one UnitMove event for {op:#06x}, got {other:?}"),
}
}
// The numbers themselves (vmangos `Opcodes_1_12_1.h`; cross-checked against the client's own
// name table). `MSG_MOVE_TELEPORT` (197) is NOT `MSG_MOVE_TELEPORT_ACK` (199) — the pair this
// family is easiest to get wrong on, and the reason (1)'s length assertion exists.
assert_eq!(messages::opcode::MSG_MOVE_TELEPORT, 197);
assert_eq!(messages::opcode::MSG_MOVE_TELEPORT_ACK, 199);
assert_eq!(messages::opcode::MSG_MOVE_ROOT, 236);
assert_eq!(messages::opcode::MSG_MOVE_UNROOT, 237);
assert_eq!(messages::opcode::MSG_MOVE_HOVER, 247);
assert_eq!(messages::opcode::MSG_MOVE_FEATHER_FALL, 688);
assert_eq!(messages::opcode::MSG_MOVE_WATER_WALK, 689);
}