Files
benilla-octo/crates/benilla-protocol/tests/movement.rs
T
SamandClaude Fable 5.1 0d5ff18fa2 the player picks the realm, and a harmful spell flinches its victim
- the player picks the realm: benilla connected to whichever realm the auth server listed first — not a policy but the absence of one — with the reference's Change Realm button drawn permanently disabled and the character screen's banner reading "Connecting…", a string 1.12 does not have, invented because the realm only ever arrived as a passenger on the character list (`CharacterSelect.lua` l.66 ends that ladder with `CharSelectRealmName:Hide()` — no name, no banner); `read_realm_list` had read the flags byte, the category byte and the realm id into `_` locals and stringified the population on arrival, and all four are load-bearing; the IO thread gains a third park — logon → which realm? → world handshake → which character — publishing `SessionEvent::RealmList` and blocking on `RealmRequest::{Enter, Refresh, Abandon}` exactly as at the other two, `Enter` carrying the realm's name and not its index since the list is re-requested every 5 s (`RealmList.lua`'s `REALM_LIST_REFRESH_TIME`) off a realmd socket `Logon` now keeps; Change Realm hands the logon forward (`HeldLogon`) so a different realm costs a world dial, not a re-authentication, and a deliberate ask arms `Realms::forced` for exactly one list, because the realm remembered from last time is precisely the realm you are standing on and asking to leave — without it the button re-entered the same realm and appeared to do nothing; the auto-answer ladder is a pure `auto_answer` — `WOW_REALM`, then the remembered `realmName` CVar (which `set_realm_name` had poked into its slot in place, never reaching `cvar_changes`, so the persisted CVar whose whole job is "the last realm connected to" was absent from disk forever; it writes through `set_from_engine` now), then an unattended run takes the first realm that is up, every arm requiring the realm to be up — and the screen is `RealmList.xml` rebuilt on the same 640×512 HelpFrame plate the AddOns list uses, eighteen rows at the authored 20 px pitch, four sortable columns; the Population word is a BAND, not the float: `realm_load_stats` (0x46e510) takes the mean and `sqrt(Σ(pop − mean)² / (n − 1)) · 0.6`, `realm_load_classify` (0x46ec60) reads the sentinels first (0x20 → Recommended, 0x40 → New, 0x80 → Full) then strict `pop < mean − stddev` → Low, `pop > mean + stddev` → High, else Medium — so the same realm reads High on a quiet list and Medium on a busy one, and a lone realm is banded against the `n ≤ 1` short circuit's literal 1.0, so a local vmangos realm at 0.06 reads Low, not Medium; then wow-re's cross-check of `RealmList.cpp [0x46e3bf, 0x46fa05)` confirmed 0x01 = invalid / 0x02 = offline and that the real client does auto-connect by `realmName` without raising the list (`0x5ab800` resolves the stored name against the fresh list, its success leg calling no `SetStatus`), and corrected four things the first cut shipped: the statistics are GLOBAL — `0x46e510` walks the flat all-categories array unconditionally (`0x46e5ee fadd [esi+0x12c]`), the per-category bucketing in the same loop a side effect and never a bound, so scoping them to the tab made the same realm read a different word per tab; Recommended/New/Full never travel as flag bits at all — the parser recognises magic populations by bit pattern and rewrites them, 600.0 → 0.0 + 0x20, 200.0 → 0.001 + 0x40, 400.0 → 8.0 + 0x80, and it has to happen in `read_realm_list` before anything averages, since one realm advertised Recommended arriving as 600.0 drags the global mean past every other row; pvp/rp is a `Cfg_Configs.dbc` join on the type dword at `[realm+0x04]`, and the shipped rows make types 3 and 5 PvP and 7 RP where the 1/6/8 match had all three Normal; and the default sort is `[characters, load, name, mode]`, all ascending, written once per process by `0x46e300` — a four-key fallthrough where "ascending" characters means higher count first, a column click being move-to-front (`0x46e9b0`) that keeps the column's direction unless it was already in front — with the reference's non-antisymmetric characters comparator (its tie-breaking `strcmp` result clobbered one instruction later at `0x46e846`) deliberately not reproduced; `RealmWizard`, the `REALM_IS_FULL` confirm and the category tab strip are deliberately not built, each for a stated reason (2056, 2062)
- a re-played effect REPLACES the same record at the same attach point: the director's report that the reference draws far fewer impact effects when many mobs beat on one warrior — and named the star burst on both hands on every crit — is not a limiter but a replace, recorded in wow-re for weeks: `CEffect::AddEffect` (0x61fdd0), the path every kit slot and the melee blood spurt take, opens with `0x6208e0(owner, rec, tag)`, which walks the owner's `+0xb4` list and destroys every node carrying the same `SpellVisualEffectName` record at the same attach tag (bounded `[0x6208e0, 0x62092c]`), skipping `tag == -1`; benilla replaced only persistent instances, on the narrower `(spell_id, class)` key of the spell-id reap `0x614150`, and pushed every one-shot unconditionally — a gap seen and scoped to one caller four times (0850, 0927, 1149, 1518) without being generalised — so five attackers landing swings on one body held five blood spurts at `(UnitBlood, 0xf)` where the reference holds one, linear in the attacker count on additively-blended emitters where the reference's is flat, and near-identical in a 1v1, which is why it survived; the sharpest case is Flurry (12966–12970), applied on every critical strike, whose visual 2759 hangs record 416 — `Spells\Strike_Impact_Chest.mdx`, the gold chest-impact flare reused at the hand tags 0x15/0x16 — on both hands via cast kit 3010 over a persistent `BloodLust_State_Hand` glow from state kit 3009, and every crit stacked another pair; `spell_fx::replace_same_slot` now runs per slot at every Begin for persistent and self-terminating instances alike, keyed on the RECORD id threaded through the fx lane (`FxSlot { tag, effect, path }`, the hardcoded map becoming name → (id, path)) and not the path, because two rows may name one .mdx and the reference lets those coexist, with both exclusions kept — the world plant and an already-decaying node — and four tests pinning the key's two halves and both; the other half of the report, that the burst is too large and too long, is a separate mechanism and is not touched — and the instrument that would have measured it was lying: `m2part` read a burst emitter's `peak_rate()` as its particle count, while the reference fires on the rising edge of `enabled != 0 && rate > 0` sampled on one clock, and `Strike_Impact_Chest.m2`'s gold flare steps its gate 1 → 0 on the very keyframe its rate steps 0 → 50, so the two are never true together and that emitter emits NOTHING; the tool printed "burst of ~50 particles, life 0.50s" and a diagnosis started 2.7× over on count and 1.7× over on duration off that one line; `EmitTiming::first_burst` walks the slot's keyed span at 60 Hz and returns the first instant the predicate holds or `None`, the dump prints the real count and time or `NEVER FIRES`, and a real-asset test pins that model's emitter #0 as never and #1 as exactly 30 (2057)
- a harmful spell's impact flinches its victim: benilla had one spell-side wound path, `play_kit`'s `[8,10]` branch — the kit player's own `0x60f3ad` test — carrying the KIT's anim id, so a spell whose impact kit names no wound anim, which is nearly every one (Fireball, Frostbolt, Shadow Bolt, Sunder Armor, a mob's Strike), twitched nothing, and a kit naming CombatCritical played 10 where the client plays 9 or 8; `0x60ea70` has six callers, not the three an earlier sweep found (`grep "call 0x60ea70"` over the full objdump was the one command it never ran): the missile hand-off `0x61dc50` lays the flinch before the impact kit for every living target (`0x61dc74`, HEALTH > 0; the already-dead arm `0x60ea30` plays Death/SwimDeath instead), the instant-hit ImpactKit loop `0x6e8bf0` lays it after the kit when `0x6ea280(SpellRec) == 2` (`0x6e8c89`), the reflect kit `0x6e8cb0` does the same on the caster, and every spell-side call pushes severity 0, so the id is always `0x60ea70`'s own `(severity, engaged)` pick — CombatWound(9) engaged, StandWound(8) otherwise — never the kit column; `0x6ea280` is the spell's hostility class and `== 2` ⇔ the spell targets enemies: Targets bit `0x100` → never, `0x80` → yes, else `EffectImplicitTargetA[i]` then `B[i]` for effects 0..3 indexed into the byte tables `0x6ea338`/`0x6ea378`, whose case-0 ids `{2, 6, 15, 16, 24, 28, 53, 54}` are exactly the modern enum's `*_ENEMY` set; so `WoundAnim { entity }` loses its id and the driver resolves every spell edge as `wound_anim(0, engaged)`, `play_impact` takes an `ImpactPlay` with `flinch` — the instant hits pass `SpellDisplay::is_harmful()`, a missile arrival passes true — `SpellDisplay` gains `effect_implicit_target_a/b` (columns 82–84 / 85–87) and `is_harmful()`, and the flinch is laid as the same secondary-blend overlay as a melee wound, over a cast, a swing or a run, never replacing them; pinned against the real 5875 `Spell.dbc` — Fireball, Charge and Sunder Armor harmful through A, Frost Nova through B alone, Renew, Healing Touch, Arcane Intellect and Battle Shout not — and the driver never picks CombatCritical for a spell edge; `WOW_MOVE_TRACE` gains `fct wound trigger/evict/expire` lines, since the whole flinch lifecycle was invisible in a trace; and the report this was chased under — the warrior's swings reading as cut short by incoming impacts in a many-mob brawl — stays open, with one reference gate found on the way that benilla does not model: `0x60ea70` aborts while any attached CEffect on the victim carries `+0x2c & 0x4` (`0x60eab6`), and which kit-spawned nodes carry that bit is the question dispatched to wow-re (2058)
- other players' roots, levitation and blinks now relay to onlookers: a report that a nearby player getting rooted, water-walking or Blinking slid or stood in the wrong place until their next movement packet was exactly right — a movement mode has three legs and benilla carried two, the mover's own acked handshake (`SMSG_FORCE_MOVE_ROOT → CMSG_FORCE_MOVE_ROOT_ACK`, 0866) and the server-driven `SMSG_SPLINE_MOVE_*` twelve (1780), while the third, what observers are told once the mover acks (vmangos `MovementPacketSender.h:30-60`, broadcast by `SendMovementFlagChangeToObservers` / `SendTeleportToObservers`), fell through `parse_server` to `ServerPacket::Other` and was dropped — so a rooted player kept sliding for the ~500 ms to their next heartbeat, a Levitate on someone else was never rendered, and a Blink left them standing at the old spot; six opcodes — `MSG_MOVE_ROOT`/`UNROOT` (236/237), `MSG_MOVE_HOVER` (247), `MSG_MOVE_FEATHER_FALL` (688), `MSG_MOVE_WATER_WALK` (689), `MSG_MOVE_TELEPORT` (197) — all the ordinary `[packed guid][MovementInfo]` shape and all sharing the client's one relay handler `0x603bb0` with the 22 already carried (wow-re's opcode-handler table has thirty rows on it; the remaining two are cheat opcodes vmangos never constructs), so admitting them to `is_movement_relay` is the whole fix for five: apply/unapply rides the flags word rather than the opcode, since vmangos runs `SetHoverReal` &co. before the broadcast and the word on the wire already carries the bit's new state, and `apply_move` already commits it wholesale into the union the ground resolve reads for hover's offset and the water-walk plane — the pose the packet carries beside the mode is half the value, a rooted player's stopping place being news too, and the root arrives already post-wipe with `MOVEFLAG_ROOT` forced back on, so no wipe of ours is invented; the teleport is a relay in shape and a discontinuity in meaning, so `RelayMove::reconciles()` excludes it from the pre-fire reconcile beside the heartbeat, for the opposite reason — the heartbeat because the dead-reckon has already converged, the teleport because there is nothing to converge to, and lerping toward a Blink's destination would drag the mover across the twenty yards the Blink exists to skip, sweeping the shared capsule through every wall between — while it still rides the unit's relay chain rather than snapping at arrival, because replay is paced against the sender's own stamps and applying it early would put the player at the destination while their walk to the launch point was still replaying; a `tp` tag joins `hb`/`tr` on the `rly` trace line; and parse and decode of all six are pinned against golden bodies asserted four bytes shorter than the counter-carrying `MSG_MOVE_TELEPORT_ACK` form, so a counter is provably not being read (2061)

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

836 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, 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, is-a-teleport)
let expected = [
(messages::opcode::MSG_MOVE_ROOT, ROOT, false),
(messages::opcode::MSG_MOVE_UNROOT, 0, false),
(messages::opcode::MSG_MOVE_WATER_WALK, WATER_WALK, false),
// The same opcode, the other direction — the bit is simply absent.
(messages::opcode::MSG_MOVE_WATER_WALK, 0, false),
(messages::opcode::MSG_MOVE_FEATHER_FALL, FEATHER_FALL, false),
(messages::opcode::MSG_MOVE_HOVER, HOVER, false),
// Levitate grants all three at once (decision 1706) — one word, three bits.
(
messages::opcode::MSG_MOVE_HOVER,
HOVER | FEATHER_FALL | WATER_WALK,
false,
),
(messages::opcode::MSG_MOVE_TELEPORT, 0, true),
];
for (op, flags, is_teleport) 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,
teleport,
heartbeat,
..
}] => {
assert_eq!(*f, flags);
assert_eq!(
*teleport, is_teleport,
"only MSG_MOVE_TELEPORT is a discontinuity ({op:#06x})"
);
assert!(!*heartbeat, "none of these is the periodic pulse");
}
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);
}