- 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>
843 lines
34 KiB
Rust
843 lines
34 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, 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);
|
||
}
|