From 82026ceb7cf026c16b230c889bf3338c5222ff9b Mon Sep 17 00:00:00 2001 From: Mathih13 Date: Thu, 15 Oct 2026 10:08:57 +0200 Subject: [PATCH 1/2] feat(module): relay pitch and acknowledged movement controls Add the pitch opcodes to the relayed movement set and name the observer relays the Gateway records after a client acknowledges a root, unroot or water walk. MSG_MOVE_SET_PITCH carries only the pose, so the motion queue coalesces it like a heartbeat. A durable test covers that coalescing. Co-Authored-By: Claude Opus 5.5 --- crates/lyracore-shared/src/opcodes.rs | 25 ++++++++- module/src/motion.rs | 18 +++++-- module/tests/pitch_motion.rs | 76 +++++++++++++++++++++++++++ 3 files changed, 113 insertions(+), 6 deletions(-) create mode 100644 module/tests/pitch_motion.rs diff --git a/crates/lyracore-shared/src/opcodes.rs b/crates/lyracore-shared/src/opcodes.rs index 12670f902..e28ed3737 100644 --- a/crates/lyracore-shared/src/opcodes.rs +++ b/crates/lyracore-shared/src/opcodes.rs @@ -48,13 +48,20 @@ pub mod movement { pub const MSG_MOVE_START_TURN_LEFT: u32 = 0x0BC; pub const MSG_MOVE_START_TURN_RIGHT: u32 = 0x0BD; pub const MSG_MOVE_STOP_TURN: u32 = 0x0BE; + pub const MSG_MOVE_START_PITCH_UP: u32 = 0x0BF; + pub const MSG_MOVE_START_PITCH_DOWN: u32 = 0x0C0; + pub const MSG_MOVE_STOP_PITCH: u32 = 0x0C1; pub const MSG_MOVE_SET_RUN_MODE: u32 = 0x0C2; pub const MSG_MOVE_SET_WALK_MODE: u32 = 0x0C3; pub const MSG_MOVE_FALL_LAND: u32 = 0x0C9; pub const MSG_MOVE_START_SWIM: u32 = 0x0CA; pub const MSG_MOVE_STOP_SWIM: u32 = 0x0CB; pub const MSG_MOVE_SET_FACING: u32 = 0x0DA; + pub const MSG_MOVE_SET_PITCH: u32 = 0x0DB; + pub const MSG_MOVE_ROOT: u32 = 0x0EC; + pub const MSG_MOVE_UNROOT: u32 = 0x0ED; pub const MSG_MOVE_HEARTBEAT: u32 = 0x0EE; + pub const MSG_MOVE_WATER_WALK: u32 = 0x2B1; pub const SLICE_MOVE_OPCODES: &[u32] = &[ MSG_MOVE_START_FORWARD, @@ -67,18 +74,30 @@ pub mod movement { MSG_MOVE_START_TURN_LEFT, MSG_MOVE_START_TURN_RIGHT, MSG_MOVE_STOP_TURN, + MSG_MOVE_START_PITCH_UP, + MSG_MOVE_START_PITCH_DOWN, + MSG_MOVE_STOP_PITCH, MSG_MOVE_SET_RUN_MODE, MSG_MOVE_SET_WALK_MODE, MSG_MOVE_FALL_LAND, MSG_MOVE_START_SWIM, MSG_MOVE_STOP_SWIM, MSG_MOVE_SET_FACING, + MSG_MOVE_SET_PITCH, MSG_MOVE_HEARTBEAT, ]; + /// Observer relays recorded after a client acknowledgement; the client never sends them. + pub const ACKNOWLEDGED_MOVE_OPCODES: &[u32] = + &[MSG_MOVE_ROOT, MSG_MOVE_UNROOT, MSG_MOVE_WATER_WALK]; + pub fn is_slice_move(opcode: u32) -> bool { SLICE_MOVE_OPCODES.contains(&opcode) } + + pub fn is_relayed_move(opcode: u32) -> bool { + is_slice_move(opcode) || ACKNOWLEDGED_MOVE_OPCODES.contains(&opcode) + } } #[cfg(test)] @@ -93,7 +112,11 @@ mod tests { "0x{opcode:03X} should be a slice move opcode" ); } - assert_eq!(movement::SLICE_MOVE_OPCODES.len(), 17); + assert_eq!(movement::SLICE_MOVE_OPCODES.len(), 21); + for &opcode in movement::ACKNOWLEDGED_MOVE_OPCODES { + assert!(movement::is_relayed_move(opcode)); + assert!(!movement::is_slice_move(opcode)); + } assert!(!movement::is_slice_move(world::CMSG_PLAYER_LOGIN)); assert!(!movement::is_slice_move(world::SMSG_UPDATE_OBJECT as u32)); assert!(!movement::is_slice_move(world::CMSG_AUTH_SESSION)); diff --git a/module/src/motion.rs b/module/src/motion.rs index 788880dd8..132999d55 100644 --- a/module/src/motion.rs +++ b/module/src/motion.rs @@ -33,7 +33,7 @@ pub(crate) fn is_discrete(opcode: u16) -> bool { use lyracore_shared::opcodes::movement as m; !matches!( opcode as u32, - m::MSG_MOVE_HEARTBEAT | m::MSG_MOVE_SET_FACING + m::MSG_MOVE_HEARTBEAT | m::MSG_MOVE_SET_FACING | m::MSG_MOVE_SET_PITCH ) } @@ -251,14 +251,22 @@ mod tests { const STOP: u16 = m::MSG_MOVE_STOP as u16; #[test] - fn only_heartbeat_and_facing_are_coalescible() { - for &op in m::SLICE_MOVE_OPCODES { + fn only_heartbeat_facing_and_pitch_are_coalescible() { + for &op in m::SLICE_MOVE_OPCODES + .iter() + .chain(m::ACKNOWLEDGED_MOVE_OPCODES) + { let discrete = is_discrete(op as u16); - let expected = op != m::MSG_MOVE_HEARTBEAT && op != m::MSG_MOVE_SET_FACING; + let expected = ![ + m::MSG_MOVE_HEARTBEAT, + m::MSG_MOVE_SET_FACING, + m::MSG_MOVE_SET_PITCH, + ] + .contains(&op); assert_eq!( discrete, expected, "0x{op:03X}: a discrete transition is anything a peer animates off; only \ - HEARTBEAT and SET_FACING carry nothing a later packet does not" + HEARTBEAT, SET_FACING and SET_PITCH carry nothing a later packet does not" ); } } diff --git a/module/tests/pitch_motion.rs b/module/tests/pitch_motion.rs new file mode 100644 index 000000000..5a63d1e4c --- /dev/null +++ b/module/tests/pitch_motion.rs @@ -0,0 +1,76 @@ +mod support; + +use std::time::Duration; + +use support::{poll_until, Standalone}; + +const CHARACTER_GUID: u64 = 1; +const START_FORWARD: u32 = lyracore_shared::opcodes::movement::MSG_MOVE_START_FORWARD; +const SET_PITCH: u32 = lyracore_shared::opcodes::movement::MSG_MOVE_SET_PITCH; + +#[test] +#[ignore = "requires the SpacetimeDB 2.7.1 CLI and Wasm toolchain"] +fn a_pitch_change_coalesces_behind_a_staged_transition() { + let mut standalone = Standalone::start("pitch-motion"); + standalone.publish_module(); + standalone.assert_call("claim_operator", &[]); + standalone.assert_call("install_guid_range", &["0"]); + standalone.assert_call("debug_spawn_player_entity", &[&CHARACTER_GUID.to_string()]); + // The first movement arms the publisher; stop it only after it has published. + move_with(&standalone, SET_PITCH, 1); + assert!( + poll_until(Duration::from_secs(5), || published_seq(&standalone) + .is_some()), + "scheduled publisher did not create the public motion row" + ); + standalone.assert_sql("DELETE FROM game_motion_publish_schedule"); + let published = published_seq(&standalone); + + move_with(&standalone, START_FORWARD, 2); + move_with(&standalone, SET_PITCH, 3); + assert_eq!( + staged_opcode(&standalone), + Some(START_FORWARD.to_string()), + "the staged transition stays queued" + ); + assert_eq!( + published_seq(&standalone), + published, + "a pitch change behind a transition must not publish in the movement transaction" + ); +} + +fn published_seq(standalone: &Standalone) -> Option { + standalone + .query_rows(&format!( + "SELECT seq FROM game_entity_motion WHERE guid = {CHARACTER_GUID}" + )) + .first() + .and_then(|row| row.get("seq").cloned()) +} + +fn move_with(standalone: &Standalone, opcode: u32, move_time_ms: u32) { + let args = [ + support::actor(&CHARACTER_GUID.to_string()), + opcode.to_string(), + "[]".to_string(), + "100.0".to_string(), + "100.0".to_string(), + "20.0".to_string(), + "0.0".to_string(), + move_time_ms.to_string(), + ]; + standalone.assert_call( + "gw_movement_update", + &args.iter().map(String::as_str).collect::>(), + ); +} + +fn staged_opcode(standalone: &Standalone) -> Option { + standalone + .query_rows(&format!( + "SELECT opcode FROM game_entity_motion_pending WHERE guid = {CHARACTER_GUID}" + )) + .first() + .and_then(|row| row.get("opcode").cloned()) +} From 70708c7879b48898573b9efcbcef1aec689bcce7 Mon Sep 17 00:00:00 2001 From: Mathih13 Date: Thu, 15 Oct 2026 10:08:57 +0200 Subject: [PATCH 2/2] feat(gateway): extend movement controls and relay pitch to observers Build on the root rule from #696. The owning client's root now uses the build-5875 packed guid, and a dead body stays rooted until release. A ghost walks on water, world entry restores both, and creatures root for observers in spline form. The root receipt reaches peers as MSG_MOVE_ROOT, and the unroot and water-walk receipts relay too, all while no teleport is pending. Pitch relays to observers. A slow lowers backward running, at world entry and in a peer's CREATE too. Observers get other units' speeds in spline form. Movement acknowledgements use the build-5875 layout, so a transport tail no longer ends the World Session. Cheat, Game Master and minigame movement requests and the fall reset are ignored without decoding, and the inventory counts them as explicitly ignored. Co-Authored-By: Claude Opus 5.5 --- docs/benilla-compatibility.md | 60 ++++- gateway/examples/benilla_inventory.rs | 17 +- gateway/src/codec/combat/subscriptions.rs | 100 ++----- gateway/src/codec/entity/subscriptions.rs | 37 +++ .../entity/subscriptions/packet_tests.rs | 35 +++ gateway/src/codec/movement.rs | 254 +++++++++++++++++- gateway/src/codec/movement/subscriptions.rs | 195 +++++++++++++- .../movement/subscriptions/control_tests.rs | 210 +++++++++++++++ gateway/src/codec/movement_info.rs | 186 ++++++++----- gateway/src/codec/tests.rs | 44 ++- gateway/src/stdb/aoi.rs | 2 + gateway/src/stdb/subscriptions.rs | 31 +++ gateway/src/world/benilla_tests/durable.rs | 33 +++ gateway/src/world/handlers/mod.rs | 2 + gateway/src/world/handlers/movement.rs | 22 ++ gateway/src/world/handlers/unavailable.rs | 3 - gateway/src/world/mod.rs | 41 ++- gateway/src/world/movement_tests.rs | 247 +++++++++++++++++ .../world/test_support/world_fake/session.rs | 6 + gateway/src/world/tests.rs | 21 +- 20 files changed, 1347 insertions(+), 199 deletions(-) create mode 100644 gateway/src/codec/movement/subscriptions/control_tests.rs create mode 100644 gateway/src/world/handlers/movement.rs create mode 100644 gateway/src/world/movement_tests.rs diff --git a/docs/benilla-compatibility.md b/docs/benilla-compatibility.md index 94bd1adb6..cc566bbc8 100644 --- a/docs/benilla-compatibility.md +++ b/docs/benilla-compatibility.md @@ -97,21 +97,59 @@ enabled in the normal suite. The real Module scenario also checks late-observer transport attachment. Speed, root, hover, feather-fall, water-walk, knockback, teleport and spline completion -acknowledgements keep the World Session usable. This does not establish boat -simulation, transport membership or acknowledgement counter enforcement. +acknowledgements keep the World Session usable. The Gateway reads the speed, +root, knockback, hover, feather-fall and water-walk acknowledgements with the +build-5875 layout too, so a transport tail does not end the session. This does +not establish boat simulation, transport membership or acknowledgement counter +enforcement. + +## Movement controls + +Pitch changes (`MSG_MOVE_START_PITCH_UP`, `_DOWN`, `MSG_MOVE_STOP_PITCH` and +`MSG_MOVE_SET_PITCH`) relay to observers like other movement. + +A stun, root or polymorph aura, and a dead body before release, root the +Character's own client with `SMSG_FORCE_MOVE_ROOT`. The last of them to end sends +`SMSG_FORCE_MOVE_UNROOT`. A ghost walks on water. World entry restores both +states. When the client acknowledges a root, unroot or water walk, observers +receive `MSG_MOVE_ROOT`, `MSG_MOVE_UNROOT` or `MSG_MOVE_WATER_WALK` with the +acknowledged pose. Observers see a creature's stun, root or polymorph as +`SMSG_SPLINE_MOVE_ROOT` and its end as `SMSG_SPLINE_MOVE_UNROOT`. + +An acknowledgement that arrives after a same-map teleport and before the +client's `MSG_MOVE_TELEPORT_ACK` is not relayed. Its pose may be the old +position: a release acknowledges the unroot at the corpse. + +Root, stun and polymorph follow `lyracore_shared::aura::holds_in_place`, the +rule the Module's movement gate also uses. The importer gives confuse, pacify +and charm the polymorph mechanic, so they root the client too. Telling them +apart needs a distinct mechanic in the imported spell data. + +A slow also lowers backward running, through +`SMSG_FORCE_RUN_BACK_SPEED_CHANGE`, at world entry and in a peer's CREATE. +Observers receive another unit's run, backward run and swim speeds as +`SMSG_SPLINE_SET_*_SPEED`, players included. cmangos relays a player's +`MSG_MOVE_SET_*_SPEED` after the owner's acknowledgement instead. The motion +row holds only a MovementInfo body, so it cannot carry that relay's speed. + +Cheat, Game Master and minigame movement requests and `CMSG_MOVE_FALL_RESET` +are ignored without decoding. They never move the Character. ### Root receipts The client moves independently of the Module. The Gateway sends a root packet for a root, stun or -polymorph and sends an unroot packet only after the last hold ends. The Module drops steering -samples while held, but accepts stop receipts, turns and landings so fall damage still applies. - -`gateway/src/world/mod.rs::root_receipt_stop` relays the current Character's root or unroot receipt -as a stop. Peers otherwise retain the last run. A root receipt clears steering flags so the Module -accepts the reported stop position. The pinned library's `clear_*` methods mask with -`reverse_bits`, so the Gateway clears the bits in the encoded bytes instead. The typed reader skips -the build-5875 transport tail; the encoder refuses that incomplete value, so receipts on a -transport are not relayed. +polymorph and sends an unroot packet only after the last hold ends. The library writes +`SMSG_FORCE_MOVE_ROOT` and `SMSG_SPLINE_MOVE_ROOT` with a full guid, which the client misreads, so +the Gateway writes the packed guid itself. The Module drops steering samples while held, but accepts +root receipts, stops, turns and landings so fall damage still applies. + +`gateway/src/codec/movement.rs::acknowledged_move` turns the current Character's root, unroot and +water-walk receipts into `MSG_MOVE_ROOT`, `MSG_MOVE_UNROOT` and `MSG_MOVE_WATER_WALK` relays for +peers, as cmangos does. Peers otherwise retain the last run. A root receipt clears steering flags +so the Module accepts the reported position. The pinned library's `clear_*` methods mask with +`reverse_bits`, so the Gateway clears the bits in the encoded bytes instead. The Gateway reads the +receipts with the build-5875 layout, so receipts on a transport relay too. A receipt between a +same-map teleport and the client's `MSG_MOVE_TELEPORT_ACK` is not relayed. Movement interruption uses client flags, including jumping, rather than a change in stored position. A delayed stop can update the stored position after Shadowmeld begins without ending diff --git a/gateway/examples/benilla_inventory.rs b/gateway/examples/benilla_inventory.rs index d82f2373a..76a4f5f08 100644 --- a/gateway/examples/benilla_inventory.rs +++ b/gateway/examples/benilla_inventory.rs @@ -241,7 +241,13 @@ fn row( let dispatch = lyra_name.and_then(|name| lyra.dispatch.get(name)); let status = if direction == "gateway_to_client" { "source_evidence_only" - } else if lyra_name.is_some_and(|name| lyra.ignored.contains(name)) { + } else if lyra_name.is_some_and(|name| lyra.ignored.contains(name)) + // The reader drops these by number before decoding: `is_silent_movement_request`. + || matches!( + opcode, + 0x0008 | 0x0009 | 0x00c6 | 0x00d9 | 0x00e1 | 0x02ad | 0x02ca | 0x02f8 + ) + { "explicitly_ignored" } else if dispatch.is_some() { "dispatch_pattern_present" @@ -383,6 +389,15 @@ mod tests { ); } + #[test] + fn requests_dropped_by_number_are_explicitly_ignored() { + let none = SourceScan::default(); + for opcode in [0x0008, 0x02ca, 0x02f8] { + let row = row("client_to_gateway", opcode, &[], &none, &none); + assert_eq!(row["handling"], "explicitly_ignored", "0x{opcode:04X}"); + } + } + #[test] fn inventory_finds_movement_relay_selector_patterns() { let syntax = syn::parse_file( diff --git a/gateway/src/codec/combat/subscriptions.rs b/gateway/src/codec/combat/subscriptions.rs index 7810604ad..871670820 100644 --- a/gateway/src/codec/combat/subscriptions.rs +++ b/gateway/src/codec/combat/subscriptions.rs @@ -12,7 +12,6 @@ use spacetimedb_sdk::Table; use std::collections::HashSet; use std::sync::{Arc, Mutex}; use wow_world_messages::vanilla::opcodes::ServerOpcodeMessage; -use wow_world_messages::vanilla::{SMSG_FORCE_MOVE_ROOT, SMSG_FORCE_MOVE_UNROOT}; pub(crate) use lyracore_shared::spell::taxonomy::A_STEALTH; @@ -440,9 +439,7 @@ pub(crate) fn aura_insert_outbound( out.push(o); } if let Some(current) = current { - if let Some(o) = root_packet(current.iter().cloned(), row, self_guid) { - out.push(o); - } + out.extend(movement_outbound(coord, ¤t, row, self_guid)); if let Some(o) = swim_speed_packet(current.iter().cloned(), row, self_guid) { out.push(o); } @@ -492,9 +489,7 @@ pub(crate) fn aura_update_outbound( } } if let Some(current) = current { - if let Some(o) = root_packet(current.iter().cloned(), row, self_guid) { - out.push(o); - } + out.extend(movement_outbound(coord, ¤t, row, self_guid)); if let Some(o) = swim_speed_packet(current.iter().cloned(), row, self_guid) { out.push(o); } @@ -529,9 +524,7 @@ pub(crate) fn aura_delete_outbound( if visible { let current = view.auras.on_target(shard, row.target_guid); out.push(aura_sync(current.iter().cloned(), row.target_guid)); - if let Some(o) = root_packet(current.iter().cloned(), row, self_guid) { - out.push(o); - } + out.extend(movement_outbound(coord, ¤t, row, self_guid)); if let Some(o) = swim_speed_packet(current.iter().cloned(), row, self_guid) { out.push(o); } @@ -598,6 +591,16 @@ pub(crate) fn aura_snapshot_outbound( return out; } out.push(aura_sync(current.iter().cloned(), target_guid)); + let changed: Vec<&Aura> = current.iter().chain(previous).collect(); + out.extend( + crate::codec::movement::subscriptions::aura_movement_from_cache( + coord, + ¤t, + &changed, + target_guid, + viewer.self_guid, + ), + ); if target_guid != viewer.self_guid { return out; } @@ -606,12 +609,6 @@ pub(crate) fn aura_snapshot_outbound( .iter() .filter_map(|row| aura_duration_packet(row, viewer.self_guid)), ); - out.extend( - current - .iter() - .chain(previous) - .find_map(|row| root_packet(current.iter().cloned(), row, viewer.self_guid)), - ); out.extend( current .iter() @@ -701,6 +698,21 @@ pub(crate) fn aura_duration_packet(row: &Aura, self_guid: u64) -> Option Vec { + crate::codec::movement::subscriptions::aura_movement_from_cache( + coord, + current, + &[changed], + changed.target_guid, + self_guid, + ) +} + pub(crate) fn run_speed_packet( auras: impl Iterator, changed: &Aura, @@ -730,35 +742,6 @@ pub(crate) fn run_speed_packet( )) } -/// The client moves independently; overlapping holds must not send an early unroot. -pub(crate) fn root_packet( - mut auras: impl Iterator, - changed: &Aura, - self_guid: u64, -) -> Option { - if changed.target_guid != self_guid - || !lyracore_shared::aura::holds_in_place(changed.eff_kind, changed.eff_p_0) - { - return None; - } - let guid = wow_world_messages::Guid::new(self_guid); - let held = auras.any(|aura| { - aura.target_guid == self_guid - && lyracore_shared::aura::holds_in_place(aura.eff_kind, aura.eff_p_0) - }); - Some(Outbound::One(if held { - ServerOpcodeMessage::SMSG_FORCE_MOVE_ROOT(Box::new(SMSG_FORCE_MOVE_ROOT { - guid, - counter: 0, - })) - } else { - ServerOpcodeMessage::SMSG_FORCE_MOVE_UNROOT(Box::new(SMSG_FORCE_MOVE_UNROOT { - guid, - counter: 0, - })) - })) -} - pub(crate) fn swim_speed_packet( auras: impl Iterator, changed: &Aura, @@ -1135,33 +1118,6 @@ pub(crate) mod tests { assert!(aura_duration_packet(&expired, self_guid).is_none()); } - #[test] - fn root_packet_holds_the_owner_until_the_last_hold_ends() { - use lyracore_shared::spell::taxonomy::{A_CONTROL, M_FEAR, M_ROOT, M_STUN}; - let guid = 1; - let root = aura(guid, A_CONTROL, M_ROOT, 0, 0, 8_000_000); - let stun = aura(guid, A_CONTROL, M_STUN, 0, 0, 4_000_000); - let rooted = |current: Vec, changed: &Aura| match root_packet( - current.into_iter(), - changed, - guid, - ) { - Some(Outbound::One(ServerOpcodeMessage::SMSG_FORCE_MOVE_ROOT(_))) => true, - Some(Outbound::One(ServerOpcodeMessage::SMSG_FORCE_MOVE_UNROOT(_))) => false, - _ => panic!("expected a root or unroot packet"), - }; - assert!(rooted(vec![root.clone()], &root)); - assert!( - rooted(vec![root.clone()], &stun), - "the root outlives the stun" - ); - assert!(!rooted(vec![], &root)); - - let fear = aura(guid, A_CONTROL, M_FEAR, 0, 0, 8_000_000); - assert!(root_packet(vec![fear.clone()].into_iter(), &fear, guid).is_none()); - assert!(root_packet(vec![root.clone()].into_iter(), &root, guid + 1).is_none()); - } - #[test] fn run_speed_packet_uses_the_strongest_move_bonus_on_self_only() { let self_guid = 1u64; diff --git a/gateway/src/codec/entity/subscriptions.rs b/gateway/src/codec/entity/subscriptions.rs index ba017ad59..195c0a4b9 100644 --- a/gateway/src/codec/entity/subscriptions.rs +++ b/gateway/src/codec/entity/subscriptions.rs @@ -114,6 +114,7 @@ pub(crate) fn peer_create_outbound( if let Some(mut living) = movement2.update_flag.get_living().cloned() { if let wow_world_messages::vanilla::MovementBlock_UpdateFlag_Living::Living { running_speed, + backwards_running_speed, swimming_speed, .. } = &mut living @@ -126,6 +127,8 @@ pub(crate) fn peer_create_outbound( } *swimming_speed = lyracore_shared::aura::swim_speed(speeds::SWIM, speed_auras.clone()); + *backwards_running_speed = + crate::codec::movement::subscriptions::run_back_speed(speed_auras.clone()); } movement2.update_flag = movement2.update_flag.clone().set_living(living); } @@ -398,9 +401,17 @@ pub(crate) fn relay_entity_update( let mut out: Vec = entity_update_to_outbound_with_dynamic_flags(old, new, dynamic_flags) .into_iter() + .map(|message| observed_speed(message, viewer.self_guid)) .map(Outbound::One) .collect(); if new.guid == viewer.self_guid { + out.extend( + crate::codec::movement::subscriptions::self_entity_movement_outbound( + old, + new, + &view.auras.on_target(shard, new.guid), + ), + ); if let Some(is_ghost) = ghost_transition(old.player_flags, new.player_flags, GHOST_PLAYER_FLAG) { @@ -433,6 +444,18 @@ pub(crate) fn relay_entity_update( out } +/// Observers get the spline speed form; see docs/benilla-compatibility.md#movement-controls. +fn observed_speed(message: ServerOpcodeMessage, self_guid: u64) -> ServerOpcodeMessage { + match message { + ServerOpcodeMessage::SMSG_FORCE_RUN_SPEED_CHANGE(change) + if change.guid.guid() != self_guid => + { + codec::build_spline_speed(change.guid.guid(), codec::ObservedSpeed::Run, change.speed) + } + message => message, + } +} + pub(crate) fn loot_tag_flags_after_membership_change( coord: &Coordinator, viewer: &Viewer, @@ -1075,6 +1098,20 @@ pub(crate) mod tests { entity.dynamic_flags = 0x42; assert_eq!(project_flags(7, &entity, None, None, &[], &[], &[]), 0x42); } + + #[test] + fn a_peer_speed_change_reaches_observers_in_spline_form() { + let forced = || { + ServerOpcodeMessage::SMSG_FORCE_RUN_SPEED_CHANGE(Box::new( + codec::build_force_run_speed(2, 11.2), + )) + }; + assert_eq!( + observed_speed(forced(), 1), + codec::build_spline_speed(2, codec::ObservedSpeed::Run, 11.2) + ); + assert_eq!(observed_speed(forced(), 2), forced()); + } } #[cfg(test)] diff --git a/gateway/src/codec/entity/subscriptions/packet_tests.rs b/gateway/src/codec/entity/subscriptions/packet_tests.rs index 634f71fe9..0fe9d0a1a 100644 --- a/gateway/src/codec/entity/subscriptions/packet_tests.rs +++ b/gateway/src/codec/entity/subscriptions/packet_tests.rs @@ -753,6 +753,41 @@ fn peer_create_uses_the_remaining_ghost_speed_and_restores_baseline_after_remova } } +// A 40% slow: 7.0 * 0.6 = 4.2 forward and 4.5 * 0.6 = 2.7 backward. +#[test] +fn peer_create_carries_a_slowed_backward_speed() { + use lyracore_shared::spell::taxonomy::{A_MOD_SPEED, SPEED_MOVE}; + use wow_world_messages::vanilla::{MovementBlock_UpdateFlag_Living, Object}; + let mut entity = player_entity(); + entity.unit_bytes_0 = 0x0000_0101; + let view = entity_view(entity, 0); + let slow = aura( + view.guid, + A_MOD_SPEED, + i32::from(SPEED_MOVE), + -40, + 0, + i64::MAX, + ); + let out = peer_create_outbound(&view, &[], &[slow], None).unwrap(); + let Outbound::One(ServerOpcodeMessage::SMSG_UPDATE_OBJECT(create)) = &out[0] else { + panic!("peer CREATE"); + }; + let Object::CreateObject2 { movement2, .. } = &create.objects[0] else { + panic!("Character CREATE"); + }; + let MovementBlock_UpdateFlag_Living::Living { + running_speed, + backwards_running_speed, + .. + } = movement2.update_flag.get_living().unwrap() + else { + panic!("living movement"); + }; + assert!((*running_speed - 4.2).abs() < 1e-5); + assert!((*backwards_running_speed - 2.7).abs() < 1e-5); +} + #[test] fn peer_create_preserves_gm_run_speed_without_movement_auras() { use wow_world_messages::vanilla::{MovementBlock_UpdateFlag_Living, Object}; diff --git a/gateway/src/codec/movement.rs b/gateway/src/codec/movement.rs index 0fa261448..5b738315c 100644 --- a/gateway/src/codec/movement.rs +++ b/gateway/src/codec/movement.rs @@ -1,5 +1,10 @@ use super::*; -use wow_world_messages::vanilla::SMSG_FORCE_SWIM_SPEED_CHANGE; +use wow_world_messages::vanilla::{ + MSG_MOVE_SET_PITCH_Server, MSG_MOVE_START_PITCH_DOWN_Server, MSG_MOVE_START_PITCH_UP_Server, + MSG_MOVE_STOP_PITCH_Server, SMSG_FORCE_RUN_BACK_SPEED_CHANGE, SMSG_FORCE_SWIM_SPEED_CHANGE, + SMSG_MOVE_LAND_WALK, SMSG_MOVE_WATER_WALK, SMSG_SPLINE_SET_RUN_BACK_SPEED, + SMSG_SPLINE_SET_RUN_SPEED, SMSG_SPLINE_SET_SWIM_SPEED, +}; pub fn relayed_move_opcode(msg: &ClientOpcodeMessage) -> Option { use movement_opcodes as m; @@ -14,17 +19,45 @@ pub fn relayed_move_opcode(msg: &ClientOpcodeMessage) -> Option { ClientOpcodeMessage::MSG_MOVE_START_TURN_LEFT(_) => m::MSG_MOVE_START_TURN_LEFT, ClientOpcodeMessage::MSG_MOVE_START_TURN_RIGHT(_) => m::MSG_MOVE_START_TURN_RIGHT, ClientOpcodeMessage::MSG_MOVE_STOP_TURN(_) => m::MSG_MOVE_STOP_TURN, + ClientOpcodeMessage::MSG_MOVE_START_PITCH_UP(_) => m::MSG_MOVE_START_PITCH_UP, + ClientOpcodeMessage::MSG_MOVE_START_PITCH_DOWN(_) => m::MSG_MOVE_START_PITCH_DOWN, + ClientOpcodeMessage::MSG_MOVE_STOP_PITCH(_) => m::MSG_MOVE_STOP_PITCH, ClientOpcodeMessage::MSG_MOVE_SET_RUN_MODE(_) => m::MSG_MOVE_SET_RUN_MODE, ClientOpcodeMessage::MSG_MOVE_SET_WALK_MODE(_) => m::MSG_MOVE_SET_WALK_MODE, ClientOpcodeMessage::MSG_MOVE_FALL_LAND(_) => m::MSG_MOVE_FALL_LAND, ClientOpcodeMessage::MSG_MOVE_START_SWIM(_) => m::MSG_MOVE_START_SWIM, ClientOpcodeMessage::MSG_MOVE_STOP_SWIM(_) => m::MSG_MOVE_STOP_SWIM, ClientOpcodeMessage::MSG_MOVE_SET_FACING(_) => m::MSG_MOVE_SET_FACING, + ClientOpcodeMessage::MSG_MOVE_SET_PITCH(_) => m::MSG_MOVE_SET_PITCH, ClientOpcodeMessage::MSG_MOVE_HEARTBEAT(_) => m::MSG_MOVE_HEARTBEAT, _ => return None, }) } +/// Observer relay for an acknowledgement; see docs/benilla-compatibility.md#root-receipts. +pub fn acknowledged_move(msg: &ClientOpcodeMessage) -> Option<(u64, u32, MovementInfo)> { + use movement_opcodes as m; + const FORWARD_BACKWARD_STRAFE: u8 = 0xF; + let (guid, opcode, info) = match msg { + ClientOpcodeMessage::CMSG_FORCE_MOVE_ROOT_ACK(ack) => { + (ack.guid, m::MSG_MOVE_ROOT, &ack.info) + } + ClientOpcodeMessage::CMSG_FORCE_MOVE_UNROOT_ACK(ack) => { + (ack.guid, m::MSG_MOVE_UNROOT, &ack.info) + } + ClientOpcodeMessage::CMSG_MOVE_WATER_WALK_ACK(ack) => { + (ack.guid, m::MSG_MOVE_WATER_WALK, &ack.info) + } + _ => return None, + }; + // The library clears flags with reverse_bits, so clear steering in the encoded bytes. + let mut bytes = movement_info_to_bytes(info).ok()?; + if opcode == m::MSG_MOVE_ROOT { + bytes[0] &= !FORWARD_BACKWARD_STRAFE; + } + Some((guid.guid(), opcode, bytes_to_movement_info(&bytes).ok()?)) +} + /// The typed transport layout belongs to later clients; use the build-5875 codec. #[cfg_attr(not(test), allow(dead_code))] pub fn build_movement_relay( @@ -77,6 +110,21 @@ pub fn build_movement_relay( info, })) } + m::MSG_MOVE_START_PITCH_UP => { + ServerOpcodeMessage::MSG_MOVE_START_PITCH_UP(Box::new(MSG_MOVE_START_PITCH_UP_Server { + guid, + info, + })) + } + m::MSG_MOVE_START_PITCH_DOWN => ServerOpcodeMessage::MSG_MOVE_START_PITCH_DOWN(Box::new( + MSG_MOVE_START_PITCH_DOWN_Server { guid, info }, + )), + m::MSG_MOVE_STOP_PITCH => { + ServerOpcodeMessage::MSG_MOVE_STOP_PITCH(Box::new(MSG_MOVE_STOP_PITCH_Server { + guid, + info, + })) + } m::MSG_MOVE_SET_RUN_MODE => { ServerOpcodeMessage::MSG_MOVE_SET_RUN_MODE(Box::new(MSG_MOVE_SET_RUN_MODE_Server { guid, @@ -113,6 +161,12 @@ pub fn build_movement_relay( info, })) } + m::MSG_MOVE_SET_PITCH => { + ServerOpcodeMessage::MSG_MOVE_SET_PITCH(Box::new(MSG_MOVE_SET_PITCH_Server { + guid, + info, + })) + } m::MSG_MOVE_HEARTBEAT => { ServerOpcodeMessage::MSG_MOVE_HEARTBEAT(Box::new(MSG_MOVE_HEARTBEAT_Server { guid, @@ -131,7 +185,7 @@ pub fn build_movement_relay_raw( mover_guid: u64, info_bytes: &[u8], ) -> Option<(u16, Vec)> { - if !movement_opcodes::is_slice_move(opcode) || info_bytes.len() < MIN_MOVEMENT_INFO_LEN { + if !movement_opcodes::is_relayed_move(opcode) || info_bytes.len() < MIN_MOVEMENT_INFO_LEN { return None; } // 9 = the largest packed guid, including its mask byte. @@ -211,6 +265,70 @@ pub fn build_force_swim_speed(guid: u64, speed: f32) -> SMSG_FORCE_SWIM_SPEED_CH } } +pub fn build_force_run_back_speed(guid: u64, speed: f32) -> SMSG_FORCE_RUN_BACK_SPEED_CHANGE { + SMSG_FORCE_RUN_BACK_SPEED_CHANGE { + guid: Guid::new(guid), + move_event: 0, + speed, + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ObservedSpeed { + Run, + RunBack, + Swim, +} + +/// Observers get this form for players too; see docs/benilla-compatibility.md#movement-controls. +pub fn build_spline_speed(guid: u64, kind: ObservedSpeed, speed: f32) -> ServerOpcodeMessage { + let guid = Guid::new(guid); + match kind { + ObservedSpeed::Run => { + ServerOpcodeMessage::SMSG_SPLINE_SET_RUN_SPEED(Box::new(SMSG_SPLINE_SET_RUN_SPEED { + guid, + speed, + })) + } + ObservedSpeed::RunBack => ServerOpcodeMessage::SMSG_SPLINE_SET_RUN_BACK_SPEED(Box::new( + SMSG_SPLINE_SET_RUN_BACK_SPEED { guid, speed }, + )), + ObservedSpeed::Swim => { + ServerOpcodeMessage::SMSG_SPLINE_SET_SWIM_SPEED(Box::new(SMSG_SPLINE_SET_SWIM_SPEED { + guid, + speed, + })) + } + } +} + +/// 0x00E8/0x00EA, packed guid and counter; the client misreads the library's full guid. +pub fn build_force_move_root_raw(guid: u64, rooted: bool) -> (u16, Vec) { + let mut body = Vec::with_capacity(13); + super::values::write_packed_guid_u64(&mut body, guid); + body.extend_from_slice(&0u32.to_le_bytes()); + (if rooted { 0x00E8 } else { 0x00EA }, body) +} + +/// 0x031A or 0x0304: packed guid only. The library writes a full guid for the root form. +pub fn build_spline_move_root_raw(guid: u64, rooted: bool) -> (u16, Vec) { + let mut body = Vec::with_capacity(9); + super::values::write_packed_guid_u64(&mut body, guid); + (if rooted { 0x031A } else { 0x0304 }, body) +} + +pub fn build_move_water_walk(guid: u64, water_walk: bool) -> ServerOpcodeMessage { + let guid = Guid::new(guid); + if water_walk { + ServerOpcodeMessage::SMSG_MOVE_WATER_WALK(Box::new(SMSG_MOVE_WATER_WALK { + guid, + counter: 0, + })) + } else { + ServerOpcodeMessage::SMSG_MOVE_LAND_WALK(Box::new(SMSG_MOVE_LAND_WALK { guid, counter: 0 })) + } +} + pub fn build_monster_move( mover_guid: u64, start: Vector3d, @@ -368,4 +486,136 @@ mod ground_path_tests { } } +#[cfg(test)] +mod control_tests { + use super::*; + + /// The plaintext frame a client receives: big-endian size of opcode and body, then the opcode. + fn framed(opcode: u16, body: &[u8]) -> Vec { + let mut frame = ((2 + body.len()) as u16).to_be_bytes().to_vec(); + frame.extend_from_slice(&opcode.to_le_bytes()); + frame.extend_from_slice(body); + frame + } + + fn typed(message: ServerOpcodeMessage) -> Vec { + let mut frame = Vec::new(); + message.write_unencrypted_server(&mut frame).unwrap(); + frame + } + + // Packed guid (mask 0x03, then 0x02 0x01) and a zero counter. + #[test] + fn forced_root_and_unroot_carry_a_packed_guid_and_the_counter() { + let body = [0x03, 0x02, 0x01, 0, 0, 0, 0]; + let (opcode, raw) = build_force_move_root_raw(0x0102, true); + assert_eq!( + framed(opcode, &raw), + [0x00, 0x09, 0xE8, 0x00, 0x03, 0x02, 0x01, 0, 0, 0, 0] + ); + let (opcode, raw) = build_force_move_root_raw(0x0102, false); + assert_eq!((opcode, raw.as_slice()), (0x00EA, body.as_slice())); + } + + // A creature guid packs to mask 0xC1 and its three nonzero bytes. + #[test] + fn spline_root_and_unroot_carry_only_the_packed_guid() { + let (opcode, raw) = build_spline_move_root_raw(0xF130_0000_0000_0042, true); + assert_eq!( + framed(opcode, &raw), + [0x00, 0x06, 0x1A, 0x03, 0xC1, 0x42, 0x30, 0xF1] + ); + let (opcode, raw) = build_spline_move_root_raw(0xF130_0000_0000_0042, false); + assert_eq!( + framed(opcode, &raw), + [0x00, 0x06, 0x04, 0x03, 0xC1, 0x42, 0x30, 0xF1] + ); + } + + #[test] + fn water_walk_and_land_walk_carry_a_packed_guid_and_the_counter() { + assert_eq!( + typed(build_move_water_walk(7, true)), + [0x00, 0x08, 0xDE, 0x00, 0x01, 0x07, 0, 0, 0, 0] + ); + assert_eq!( + typed(build_move_water_walk(7, false)), + [0x00, 0x08, 0xDF, 0x00, 0x01, 0x07, 0, 0, 0, 0] + ); + } + + // 2.25 is 0x40100000 and 3.5 is 0x40600000. + #[test] + fn speed_changes_carry_the_speed_after_the_packed_guid() { + assert_eq!( + typed(ServerOpcodeMessage::SMSG_FORCE_RUN_BACK_SPEED_CHANGE( + Box::new(build_force_run_back_speed(7, 2.25)) + )), + [0x00, 0x0C, 0xE4, 0x00, 0x01, 0x07, 0, 0, 0, 0, 0x00, 0x00, 0x10, 0x40] + ); + for (kind, opcode) in [ + (ObservedSpeed::Run, [0xFE, 0x02]), + (ObservedSpeed::RunBack, [0xFF, 0x02]), + (ObservedSpeed::Swim, [0x00, 0x03]), + ] { + assert_eq!( + typed(build_spline_speed(7, kind, 3.5)), + [0x00, 0x08, opcode[0], opcode[1], 0x01, 0x07, 0x00, 0x00, 0x60, 0x40] + ); + } + } + + /// A pose on a transport, in the build-5875 layout the library cannot read. + fn transport_pose() -> Vec { + let mut pose = 0x0200_0000u32.to_le_bytes().to_vec(); + pose.extend_from_slice(&5u32.to_le_bytes()); + pose.extend_from_slice(&[0; 16]); + pose.extend_from_slice(&0xF120_0000_0000_0012u64.to_le_bytes()); + pose.extend_from_slice(&[0; 16]); + pose.extend_from_slice(&0u32.to_le_bytes()); + pose + } + + #[test] + fn acknowledgements_read_the_build_5875_pose() { + let mut root = 9u64.to_le_bytes().to_vec(); + root.extend_from_slice(&3u32.to_le_bytes()); + root.extend_from_slice(&transport_pose()); + let message = read_movement_client(0x00E9, &root).unwrap().unwrap(); + let (mover, opcode, info) = acknowledged_move(&message).unwrap(); + assert_eq!((mover, opcode), (9, movement_opcodes::MSG_MOVE_ROOT)); + assert_eq!(movement_info_to_bytes(&info).unwrap(), transport_pose()); + + let mut run_back = root.clone(); + run_back.extend_from_slice(&2.25f32.to_le_bytes()); + match read_movement_client(0x00E5, &run_back).unwrap().unwrap() { + ClientOpcodeMessage::CMSG_FORCE_RUN_BACK_SPEED_CHANGE_ACK(ack) => { + assert_eq!((ack.guid.guid(), ack.movement_counter), (9, 3)); + assert_eq!(ack.new_speed, 2.25); + } + other => panic!("expected the run-back acknowledgement, got {other}"), + } + + let mut water_walk = root.clone(); + water_walk.extend_from_slice(&1u32.to_le_bytes()); + let message = read_movement_client(0x02D0, &water_walk).unwrap().unwrap(); + assert_eq!( + acknowledged_move(&message).map(|(_, opcode, _)| opcode), + Some(movement_opcodes::MSG_MOVE_WATER_WALK) + ); + } + + #[test] + fn a_truncated_or_padded_acknowledgement_is_refused() { + let mut root = 9u64.to_le_bytes().to_vec(); + root.extend_from_slice(&3u32.to_le_bytes()); + root.extend_from_slice(&transport_pose()); + for end in 0..root.len() { + assert!(read_movement_client(0x00E9, &root[..end]).is_err()); + } + root.push(0); + assert!(read_movement_client(0x00E9, &root).is_err()); + } +} + pub(crate) mod subscriptions; diff --git a/gateway/src/codec/movement/subscriptions.rs b/gateway/src/codec/movement/subscriptions.rs index 7e169f8e9..19cf14d8d 100644 --- a/gateway/src/codec/movement/subscriptions.rs +++ b/gateway/src/codec/movement/subscriptions.rs @@ -65,7 +65,7 @@ pub(crate) fn motion_outbound( MOTION_SENT.fetch_add(1, std::sync::atomic::Ordering::Relaxed); vec![Outbound::Raw { opcode, body }] } - None if lyracore_shared::opcodes::movement::is_slice_move(row.opcode as u32) => { + None if lyracore_shared::opcodes::movement::is_relayed_move(row.opcode as u32) => { log::warn!( "motion relay: malformed movement_info ({} bytes) for mover {}", row.movement_info.len(), @@ -77,6 +77,191 @@ pub(crate) fn motion_outbound( } } +/// The Module's movement gate uses the same rule. +fn holds_in_place(aura: &Aura) -> bool { + lyracore_shared::aura::holds_in_place(aura.eff_kind, aura.eff_p_0) +} + +fn moves_speed(aura: &Aura, speed: u8) -> bool { + aura.eff_kind == lyracore_shared::spell::taxonomy::A_MOD_SPEED + && aura.eff_p_0 == i32::from(speed) +} + +/// cmangos: a slow lowers backward running too. +fn slows(aura: &Aura) -> bool { + moves_speed(aura, lyracore_shared::spell::taxonomy::SPEED_MOVE) + && lyracore_shared::aura::stacked_amount(aura.amount, aura.stacks) < 0 +} + +fn aura_amounts(auras: &[Aura]) -> impl Iterator + '_ { + auras.iter().map(|aura| lyracore_shared::aura::AuraAmount { + kind: aura.eff_kind, + parameter: aura.eff_p_0, + amount: aura.amount, + stacks: aura.stacks, + }) +} + +/// Only slows apply to backward running, with the run fold's floor. +pub(crate) fn run_back_speed( + auras: impl IntoIterator, +) -> f32 { + use lyracore_shared::spell::taxonomy::{A_MOD_SPEED, SPEED_MOVE}; + let slow = auras + .into_iter() + .filter(|aura| aura.kind == A_MOD_SPEED && aura.parameter == i32::from(SPEED_MOVE)) + .map(|aura| lyracore_shared::aura::stacked_amount(aura.amount, aura.stacks)) + .filter(|amount| *amount < 0) + .sum(); + lyracore_shared::aura::move_speed_with_pct(lyracore_shared::constants::speeds::RUN_BACK, slow) +} + +/// Controls the owner's client must hold; see docs/benilla-compatibility.md#movement-controls. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct MoverControl { + pub(crate) rooted: bool, + pub(crate) water_walk: bool, +} + +impl MoverControl { + pub(crate) fn of(entity: Option<&WorldEntity>, auras: &[Aura]) -> Self { + const GHOST: u32 = lyracore_shared::constants::player_flags::GHOST; + let ghost = entity.is_some_and(|e| e.player_flags & GHOST != 0); + let body = entity.is_some_and(|e| e.dead) && !ghost; + Self { + rooted: body || auras.iter().any(holds_in_place), + water_walk: ghost, + } + } +} + +fn root_outbound(guid: u64, rooted: bool, own_mover: bool) -> Outbound { + let (opcode, body) = if own_mover { + codec::build_force_move_root_raw(guid, rooted) + } else { + codec::build_spline_move_root_raw(guid, rooted) + }; + Outbound::Raw { opcode, body } +} + +/// `changed` holds the changed rows (all rows on reconnect); `current` is the post-change set. +pub(crate) fn aura_movement_outbound( + entity: Option<&WorldEntity>, + current: &[Aura], + changed: &[&Aura], + target_guid: u64, + self_guid: u64, +) -> Vec { + const RUN: f32 = 7.0; // the base `run_speed_packet` and the CREATE speed block use + let touched = |rule: fn(&Aura) -> bool| changed.iter().any(|aura| rule(aura)); + let mut out = Vec::new(); + if target_guid == self_guid { + if touched(holds_in_place) { + let rooted = MoverControl::of(entity, current).rooted; + out.push(root_outbound(self_guid, rooted, true)); + } + if touched(slows) { + out.push(Outbound::One( + ServerOpcodeMessage::SMSG_FORCE_RUN_BACK_SPEED_CHANGE(Box::new( + codec::build_force_run_back_speed( + self_guid, + run_back_speed(aura_amounts(current)), + ), + )), + )); + } + return out; + } + if touched(holds_in_place) && matches!(target_guid >> 48, 0xF130 | 0xF140) { + let rooted = current.iter().any(holds_in_place); + out.push(root_outbound(target_guid, rooted, false)); + } + let spline = |kind, speed| Outbound::One(codec::build_spline_speed(target_guid, kind, speed)); + if touched(|aura| lyracore_shared::aura::moves_run_speed(aura.eff_kind, aura.eff_p_0)) { + let speed = lyracore_shared::aura::run_speed(RUN, aura_amounts(current)); + out.push(spline(codec::ObservedSpeed::Run, speed)); + } + if touched(slows) { + out.push(spline( + codec::ObservedSpeed::RunBack, + run_back_speed(aura_amounts(current)), + )); + } + if touched(|aura| moves_speed(aura, lyracore_shared::spell::taxonomy::SPEED_SWIM)) { + let speed = lyracore_shared::aura::swim_speed( + lyracore_shared::constants::speeds::SWIM, + aura_amounts(current), + ); + out.push(spline(codec::ObservedSpeed::Swim, speed)); + } + out +} + +pub(crate) fn aura_movement_from_cache( + coord: &crate::stdb::Coordinator, + current: &[Aura], + changed: &[&Aura], + target_guid: u64, + self_guid: u64, +) -> Vec { + let entity = (target_guid == self_guid) + .then(|| { + coord + .0 + .coord() + .conn + .db + .game_world_entity() + .guid() + .find(&target_guid) + }) + .flatten(); + aura_movement_outbound(entity.as_ref(), current, changed, target_guid, self_guid) +} + +pub(crate) fn self_entity_movement_outbound( + old: &WorldEntity, + new: &WorldEntity, + auras: &[Aura], +) -> Vec { + let before = MoverControl::of(Some(old), auras); + let after = MoverControl::of(Some(new), auras); + let mut out = Vec::new(); + if old.dead != new.dead || before.rooted != after.rooted { + out.push(root_outbound(new.guid, after.rooted, true)); + } + if before.water_walk != after.water_walk { + out.push(Outbound::One(codec::build_move_water_walk( + new.guid, + after.water_walk, + ))); + } + out +} + +/// The self CREATE starts unrooted, on land and at base backward speed. +pub(crate) fn world_entry_movement_outbound(entity: &WorldEntity, auras: &[Aura]) -> Vec { + let control = MoverControl::of(Some(entity), auras); + let mut out = Vec::new(); + if control.rooted { + out.push(root_outbound(entity.guid, true, true)); + } + if control.water_walk { + out.push(Outbound::One(codec::build_move_water_walk( + entity.guid, + true, + ))); + } + if auras.iter().any(slows) { + out.push(Outbound::One( + ServerOpcodeMessage::SMSG_FORCE_RUN_BACK_SPEED_CHANGE(Box::new( + codec::build_force_run_back_speed(entity.guid, run_back_speed(aura_amounts(auras))), + )), + )); + } + out +} + pub(crate) fn creature_leg_outbound( created: &Mutex>, row: &CreatureSpline, @@ -293,7 +478,10 @@ pub(crate) fn teleport_appeared(view: &WorldView, shard: ShardId, row: &Teleport return; }; let row = row.clone(); - enqueue(viewer.clone(), move |_| { + enqueue(viewer.clone(), move |viewer| { + if !row.cross_map { + viewer.gates.teleport_pending.store(true, Ordering::Release); + } crate::codec::movement::subscriptions::teleport_event_outbound(&row) }); } @@ -312,6 +500,9 @@ crate::stdb::relays::declare_relays! { } } +#[cfg(test)] +mod control_tests; + #[cfg(test)] mod packet_tests; diff --git a/gateway/src/codec/movement/subscriptions/control_tests.rs b/gateway/src/codec/movement/subscriptions/control_tests.rs new file mode 100644 index 000000000..2e5408f7e --- /dev/null +++ b/gateway/src/codec/movement/subscriptions/control_tests.rs @@ -0,0 +1,210 @@ +use super::*; +use crate::codec::combat::subscriptions::tests::aura; +use crate::codec::entity::subscriptions::tests::player_entity; +use lyracore_shared::spell::taxonomy::{ + A_CONTROL, A_MOD_SPEED, M_FEAR, M_POLY, M_ROOT, M_STUN, SPEED_MOVE, SPEED_SWIM, +}; + +const SELF: u64 = 1; +const CREATURE: u64 = 0xF130_0000_0000_0042; +const PEER: u64 = 2; + +fn raw(out: &Outbound) -> (u16, &[u8]) { + match out { + Outbound::Raw { opcode, body } => (*opcode, body), + _ => panic!("expected a raw packet"), + } +} + +fn stun(target: u64) -> Aura { + aura(target, A_CONTROL, M_STUN, 0, 0, i64::MAX) +} + +#[test] +fn a_stun_roots_its_own_client_until_the_last_immobilizer_ends() { + let stun = stun(SELF); + let root = aura(SELF, A_CONTROL, M_ROOT, 0, 0, i64::MAX); + let out = aura_movement_outbound(None, std::slice::from_ref(&stun), &[&stun], SELF, SELF); + assert_eq!(raw(&out[0]), (0x00E8, [0x01, 0x01, 0, 0, 0, 0].as_slice())); + let out = aura_movement_outbound(None, std::slice::from_ref(&root), &[&stun], SELF, SELF); + assert_eq!(raw(&out[0]).0, 0x00E8); + let out = aura_movement_outbound(None, &[], &[&root], SELF, SELF); + assert_eq!(raw(&out[0]).0, 0x00EA); +} + +#[test] +fn a_dead_body_stays_rooted_when_its_stun_ends() { + let stun = stun(SELF); + let body = WorldEntity { + dead: true, + ..player_entity() + }; + let out = aura_movement_outbound(Some(&body), &[], &[&stun], SELF, SELF); + assert_eq!(raw(&out[0]).0, 0x00E8); +} + +#[test] +fn fear_is_not_an_immobilizer() { + let fear = aura(SELF, A_CONTROL, M_FEAR, 0, 0, i64::MAX); + assert!( + aura_movement_outbound(None, std::slice::from_ref(&fear), &[&fear], SELF, SELF).is_empty() + ); +} + +#[test] +fn observers_see_a_creature_root_as_a_spline_root_and_a_player_root_not_at_all() { + let creature_stun = stun(CREATURE); + let out = aura_movement_outbound( + None, + std::slice::from_ref(&creature_stun), + &[&creature_stun], + CREATURE, + SELF, + ); + assert_eq!(raw(&out[0]).0, 0x031A); + let out = aura_movement_outbound(None, &[], &[&creature_stun], CREATURE, SELF); + assert_eq!(raw(&out[0]).0, 0x0304); + let peer_stun = stun(PEER); + assert!(aura_movement_outbound( + None, + std::slice::from_ref(&peer_stun), + &[&peer_stun], + PEER, + SELF + ) + .is_empty()); +} + +// A 40% slow: run 7.0 becomes 4.2 and run back 4.5 becomes 2.7. +#[test] +fn a_slow_changes_backward_running_for_the_owner_and_observers() { + let slow = aura(SELF, A_MOD_SPEED, i32::from(SPEED_MOVE), -40, 0, i64::MAX); + let out = aura_movement_outbound(None, std::slice::from_ref(&slow), &[&slow], SELF, SELF); + match &out[..] { + [Outbound::One(ServerOpcodeMessage::SMSG_FORCE_RUN_BACK_SPEED_CHANGE(change))] => { + assert!((change.speed - 2.7).abs() < 1e-5); + } + _ => panic!("expected one run-back change"), + } + let peer_slow = aura(PEER, A_MOD_SPEED, i32::from(SPEED_MOVE), -40, 0, i64::MAX); + let out = aura_movement_outbound( + None, + std::slice::from_ref(&peer_slow), + &[&peer_slow], + PEER, + SELF, + ); + match &out[..] { + [Outbound::One(ServerOpcodeMessage::SMSG_SPLINE_SET_RUN_SPEED(run)), Outbound::One(ServerOpcodeMessage::SMSG_SPLINE_SET_RUN_BACK_SPEED(back))] => + { + assert_eq!((run.guid.guid(), back.guid.guid()), (PEER, PEER)); + assert!((run.speed - 4.2).abs() < 1e-5); + assert!((back.speed - 2.7).abs() < 1e-5); + } + _ => panic!("expected run and run-back spline speeds"), + } +} + +// Sprint's +70% gives 11.9 and leaves backward running alone. +#[test] +fn observers_see_bonus_speeds_in_spline_form() { + let sprint = aura(PEER, A_MOD_SPEED, i32::from(SPEED_MOVE), 70, 0, i64::MAX); + let out = aura_movement_outbound(None, std::slice::from_ref(&sprint), &[&sprint], PEER, SELF); + match &out[..] { + [Outbound::One(ServerOpcodeMessage::SMSG_SPLINE_SET_RUN_SPEED(run))] => { + assert!((run.speed - 11.9).abs() < 1e-4); + } + _ => panic!("expected one run spline speed"), + } + let swim = aura(PEER, A_MOD_SPEED, i32::from(SPEED_SWIM), 50, 0, i64::MAX); + let out = aura_movement_outbound(None, &[], &[&swim], PEER, SELF); + match &out[..] { + [Outbound::One(ServerOpcodeMessage::SMSG_SPLINE_SET_SWIM_SPEED(speed))] => { + assert!((speed.speed - lyracore_shared::constants::speeds::SWIM).abs() < 1e-5); + } + _ => panic!("expected the base swim speed after the bonus ends"), + } + let own_sprint = aura(SELF, A_MOD_SPEED, i32::from(SPEED_MOVE), 70, 0, i64::MAX); + assert!(aura_movement_outbound(None, &[], &[&own_sprint], SELF, SELF).is_empty()); +} + +#[test] +fn death_roots_release_unroots_with_water_walk_and_resurrection_lands() { + const GHOST: u32 = lyracore_shared::constants::player_flags::GHOST; + let alive = player_entity(); + let body = WorldEntity { + dead: true, + ..alive.clone() + }; + let ghost = WorldEntity { + player_flags: GHOST, + ..body.clone() + }; + let out = self_entity_movement_outbound(&alive, &body, &[]); + assert_eq!(out.len(), 1); + assert_eq!(raw(&out[0]).0, 0x00E8); + let out = self_entity_movement_outbound(&body, &ghost, &[]); + assert_eq!(raw(&out[0]).0, 0x00EA); + assert!(matches!( + out[1], + Outbound::One(ServerOpcodeMessage::SMSG_MOVE_WATER_WALK(_)) + )); + let out = self_entity_movement_outbound(&ghost, &alive, &[]); + assert_eq!(raw(&out[0]).0, 0x00EA); + assert!(matches!( + out[1], + Outbound::One(ServerOpcodeMessage::SMSG_MOVE_LAND_WALK(_)) + )); + assert!(self_entity_movement_outbound(&alive, &alive, &[]).is_empty()); +} + +#[test] +fn world_entry_restores_only_the_controls_that_differ_from_the_default() { + let alive = player_entity(); + assert!(world_entry_movement_outbound(&alive, &[]).is_empty()); + let out = world_entry_movement_outbound(&alive, &[stun(SELF)]); + assert_eq!(raw(&out[0]).0, 0x00E8); + let ghost = WorldEntity { + dead: true, + player_flags: lyracore_shared::constants::player_flags::GHOST, + ..alive + }; + assert!(matches!( + world_entry_movement_outbound(&ghost, &[])[..], + [Outbound::One(ServerOpcodeMessage::SMSG_MOVE_WATER_WALK(_))] + )); +} + +// A 40% slow leaves 4.5 * 0.6 = 2.7 yards per second of backward running. +#[test] +fn world_entry_restores_a_slowed_backward_speed() { + let slow = aura(SELF, A_MOD_SPEED, i32::from(SPEED_MOVE), -40, 0, i64::MAX); + match &world_entry_movement_outbound(&player_entity(), &[slow])[..] { + [Outbound::One(ServerOpcodeMessage::SMSG_FORCE_RUN_BACK_SPEED_CHANGE(change))] => { + assert!((change.speed - 2.7).abs() < 1e-5); + } + _ => panic!("expected one run-back change"), + } +} + +#[test] +fn polymorph_roots_the_owner_and_a_creature() { + let polymorph = aura(SELF, A_CONTROL, M_POLY, 0, 0, i64::MAX); + let out = aura_movement_outbound( + None, + std::slice::from_ref(&polymorph), + &[&polymorph], + SELF, + SELF, + ); + assert_eq!(raw(&out[0]).0, 0x00E8); + let sheep = aura(CREATURE, A_CONTROL, M_POLY, 0, 0, i64::MAX); + let out = aura_movement_outbound( + None, + std::slice::from_ref(&sheep), + &[&sheep], + CREATURE, + SELF, + ); + assert_eq!(raw(&out[0]).0, 0x031A); +} diff --git a/gateway/src/codec/movement_info.rs b/gateway/src/codec/movement_info.rs index 714f70716..fe765de3d 100644 --- a/gateway/src/codec/movement_info.rs +++ b/gateway/src/codec/movement_info.rs @@ -151,86 +151,140 @@ pub fn create_with_movement( Ok(body) } +/// Vanilla acknowledgements: full mover guid, counter, pose, then `trailing` fixed bytes. +fn acknowledgement(body: &[u8], trailing: usize) -> Result<(Guid, u32, MovementInfo, [u8; 4])> { + ensure!( + body.len() >= 12 + trailing, + "short movement acknowledgement" + ); + let (head, rest) = body.split_at(12); + let (pose, tail) = rest.split_at(rest.len() - trailing); + let mut word = [0; 4]; + word[..trailing].copy_from_slice(tail); + Ok(( + Guid::new(u64::from_le_bytes(head[..8].try_into()?)), + u32::from_le_bytes(head[8..].try_into()?), + bytes_to_movement_info(pose)?, + word, + )) +} + +/// Read before the typed reader, which misreads the build-5875 transport tail. pub fn read_movement_client( opcode: u32, body: &[u8], ) -> Result> { - use super::{movement_opcodes, ClientOpcodeMessage}; + use super::{movement_opcodes, ClientOpcodeMessage as C}; use wow_world_messages::vanilla::*; - if !movement_opcodes::is_slice_move(opcode) { - return Ok(None); - } - let info = bytes_to_movement_info(body)?; - Ok(Some(match opcode { - movement_opcodes::MSG_MOVE_START_FORWARD => { - ClientOpcodeMessage::MSG_MOVE_START_FORWARD(Box::new(MSG_MOVE_START_FORWARD_Client { - info, - })) - } - movement_opcodes::MSG_MOVE_START_BACKWARD => { - ClientOpcodeMessage::MSG_MOVE_START_BACKWARD(Box::new(MSG_MOVE_START_BACKWARD_Client { - info, - })) - } - movement_opcodes::MSG_MOVE_STOP => { - ClientOpcodeMessage::MSG_MOVE_STOP(Box::new(MSG_MOVE_STOP_Client { info })) - } - movement_opcodes::MSG_MOVE_START_STRAFE_LEFT => { - ClientOpcodeMessage::MSG_MOVE_START_STRAFE_LEFT(Box::new( - MSG_MOVE_START_STRAFE_LEFT_Client { info }, - )) - } - movement_opcodes::MSG_MOVE_START_STRAFE_RIGHT => { - ClientOpcodeMessage::MSG_MOVE_START_STRAFE_RIGHT(Box::new( - MSG_MOVE_START_STRAFE_RIGHT_Client { info }, - )) - } - movement_opcodes::MSG_MOVE_STOP_STRAFE => { - ClientOpcodeMessage::MSG_MOVE_STOP_STRAFE(Box::new(MSG_MOVE_STOP_STRAFE_Client { + macro_rules! speed_ack { + ($variant:ident, $counter:ident) => {{ + let (guid, $counter, info, speed) = acknowledgement(body, 4)?; + C::$variant(Box::new($variant { + guid, + $counter, info, + new_speed: f32::from_le_bytes(speed), })) - } - movement_opcodes::MSG_MOVE_JUMP => { - ClientOpcodeMessage::MSG_MOVE_JUMP(Box::new(MSG_MOVE_JUMP_Client { info })) - } - movement_opcodes::MSG_MOVE_START_TURN_LEFT => { - ClientOpcodeMessage::MSG_MOVE_START_TURN_LEFT(Box::new( - MSG_MOVE_START_TURN_LEFT_Client { info }, - )) - } - movement_opcodes::MSG_MOVE_START_TURN_RIGHT => { - ClientOpcodeMessage::MSG_MOVE_START_TURN_RIGHT(Box::new( - MSG_MOVE_START_TURN_RIGHT_Client { info }, - )) - } - movement_opcodes::MSG_MOVE_STOP_TURN => { - ClientOpcodeMessage::MSG_MOVE_STOP_TURN(Box::new(MSG_MOVE_STOP_TURN_Client { info })) - } - movement_opcodes::MSG_MOVE_SET_RUN_MODE => { - ClientOpcodeMessage::MSG_MOVE_SET_RUN_MODE(Box::new(MSG_MOVE_SET_RUN_MODE_Client { + }}; + } + macro_rules! flag_ack { + ($variant:ident) => {{ + let (guid, movement_counter, info, apply) = acknowledgement(body, 4)?; + C::$variant(Box::new($variant { + guid, + movement_counter, info, + apply: u32::from_le_bytes(apply), })) - } - movement_opcodes::MSG_MOVE_SET_WALK_MODE => { - ClientOpcodeMessage::MSG_MOVE_SET_WALK_MODE(Box::new(MSG_MOVE_SET_WALK_MODE_Client { + }}; + } + macro_rules! pose_ack { + ($variant:ident) => {{ + let (guid, movement_counter, info, _) = acknowledgement(body, 0)?; + C::$variant(Box::new($variant { + guid, + movement_counter, info, })) + }}; + } + match opcode { + 0x00E3 => return Ok(Some(speed_ack!(CMSG_FORCE_RUN_SPEED_CHANGE_ACK, counter))), + 0x00E5 => { + return Ok(Some(speed_ack!( + CMSG_FORCE_RUN_BACK_SPEED_CHANGE_ACK, + movement_counter + ))) } - movement_opcodes::MSG_MOVE_FALL_LAND => { - ClientOpcodeMessage::MSG_MOVE_FALL_LAND(Box::new(MSG_MOVE_FALL_LAND_Client { info })) - } - movement_opcodes::MSG_MOVE_START_SWIM => { - ClientOpcodeMessage::MSG_MOVE_START_SWIM(Box::new(MSG_MOVE_START_SWIM_Client { info })) - } - movement_opcodes::MSG_MOVE_STOP_SWIM => { - ClientOpcodeMessage::MSG_MOVE_STOP_SWIM(Box::new(MSG_MOVE_STOP_SWIM_Client { info })) + 0x00E7 => return Ok(Some(speed_ack!(CMSG_FORCE_SWIM_SPEED_CHANGE_ACK, counter))), + 0x02DB => return Ok(Some(speed_ack!(CMSG_FORCE_WALK_SPEED_CHANGE_ACK, counter))), + 0x02DD => { + return Ok(Some(speed_ack!( + CMSG_FORCE_SWIM_BACK_SPEED_CHANGE_ACK, + counter + ))) } - movement_opcodes::MSG_MOVE_SET_FACING => { - ClientOpcodeMessage::MSG_MOVE_SET_FACING(Box::new(MSG_MOVE_SET_FACING_Client { info })) + 0x02DF => return Ok(Some(speed_ack!(CMSG_FORCE_TURN_RATE_CHANGE_ACK, counter))), + 0x00E9 => return Ok(Some(pose_ack!(CMSG_FORCE_MOVE_ROOT_ACK))), + 0x00EB => return Ok(Some(pose_ack!(CMSG_FORCE_MOVE_UNROOT_ACK))), + 0x00F0 => { + let (guid, counter, info, _) = acknowledgement(body, 0)?; + return Ok(Some(C::CMSG_MOVE_KNOCK_BACK_ACK(Box::new( + CMSG_MOVE_KNOCK_BACK_ACK { + guid, + counter, + info, + }, + )))); } - movement_opcodes::MSG_MOVE_HEARTBEAT => { - ClientOpcodeMessage::MSG_MOVE_HEARTBEAT(Box::new(MSG_MOVE_HEARTBEAT_Client { info })) + 0x00F6 => { + let (guid, counter, info, applied) = acknowledgement(body, 4)?; + return Ok(Some(C::CMSG_MOVE_HOVER_ACK(Box::new( + CMSG_MOVE_HOVER_ACK { + guid, + counter, + info, + is_applied: u32::from_le_bytes(applied), + }, + )))); } - _ => return Ok(None), + 0x02CF => return Ok(Some(flag_ack!(CMSG_MOVE_FEATHER_FALL_ACK))), + 0x02D0 => return Ok(Some(flag_ack!(CMSG_MOVE_WATER_WALK_ACK))), + _ => {} + } + if !movement_opcodes::is_slice_move(opcode) { + return Ok(None); + } + let info = bytes_to_movement_info(body)?; + macro_rules! moves { + ($($opcode:ident => $variant:ident),+ $(,)?) => { + match opcode { + $(movement_opcodes::$opcode => C::$opcode(Box::new($variant { info })),)+ + _ => return Ok(None), + } + }; + } + Ok(Some(moves! { + MSG_MOVE_START_FORWARD => MSG_MOVE_START_FORWARD_Client, + MSG_MOVE_START_BACKWARD => MSG_MOVE_START_BACKWARD_Client, + MSG_MOVE_STOP => MSG_MOVE_STOP_Client, + MSG_MOVE_START_STRAFE_LEFT => MSG_MOVE_START_STRAFE_LEFT_Client, + MSG_MOVE_START_STRAFE_RIGHT => MSG_MOVE_START_STRAFE_RIGHT_Client, + MSG_MOVE_STOP_STRAFE => MSG_MOVE_STOP_STRAFE_Client, + MSG_MOVE_JUMP => MSG_MOVE_JUMP_Client, + MSG_MOVE_START_TURN_LEFT => MSG_MOVE_START_TURN_LEFT_Client, + MSG_MOVE_START_TURN_RIGHT => MSG_MOVE_START_TURN_RIGHT_Client, + MSG_MOVE_STOP_TURN => MSG_MOVE_STOP_TURN_Client, + MSG_MOVE_START_PITCH_UP => MSG_MOVE_START_PITCH_UP_Client, + MSG_MOVE_START_PITCH_DOWN => MSG_MOVE_START_PITCH_DOWN_Client, + MSG_MOVE_STOP_PITCH => MSG_MOVE_STOP_PITCH_Client, + MSG_MOVE_SET_RUN_MODE => MSG_MOVE_SET_RUN_MODE_Client, + MSG_MOVE_SET_WALK_MODE => MSG_MOVE_SET_WALK_MODE_Client, + MSG_MOVE_FALL_LAND => MSG_MOVE_FALL_LAND_Client, + MSG_MOVE_START_SWIM => MSG_MOVE_START_SWIM_Client, + MSG_MOVE_STOP_SWIM => MSG_MOVE_STOP_SWIM_Client, + MSG_MOVE_SET_FACING => MSG_MOVE_SET_FACING_Client, + MSG_MOVE_SET_PITCH => MSG_MOVE_SET_PITCH_Client, + MSG_MOVE_HEARTBEAT => MSG_MOVE_HEARTBEAT_Client, })) } diff --git a/gateway/src/codec/tests.rs b/gateway/src/codec/tests.rs index 72ee5e285..655dde3b7 100644 --- a/gateway/src/codec/tests.rs +++ b/gateway/src/codec/tests.rs @@ -1220,18 +1220,20 @@ fn raw_movement_relay_is_byte_identical_to_the_typed_path() { } assert_eq!( compared, - 6 * 17 * 5, + 6 * 21 * 5, "every case × opcode × guid must have been compared" ); } +/// The acknowledged relays have no vanilla library type, so only the raw path writes them. #[test] fn raw_and_typed_movement_relays_accept_exactly_the_same_opcodes() { let info = movement_info_cases()[0].1.clone(); let stored = movement_info_to_bytes(&info).unwrap(); for opcode in 0u32..=0xFFFF { assert_eq!( - build_movement_relay(opcode, 7, info.clone()).is_some(), + build_movement_relay(opcode, 7, info.clone()).is_some() + || movement_opcodes::ACKNOWLEDGED_MOVE_OPCODES.contains(&opcode), build_movement_relay_raw(opcode, 7, &stored).is_some(), "the typed and raw movement relays disagree about opcode 0x{opcode:04X}" ); @@ -2950,16 +2952,18 @@ fn visible_item_index_boundary_stops_after_equipment_slot_18() { } #[test] -fn relayed_move_opcode_maps_all_seventeen_client_variants() { +fn relayed_move_opcode_maps_every_relayed_client_variant() { use movement_opcodes as m; use wow_world_messages::vanilla::{ MSG_MOVE_FALL_LAND_Client, MSG_MOVE_JUMP_Client, MSG_MOVE_SET_FACING_Client, - MSG_MOVE_SET_RUN_MODE_Client, MSG_MOVE_SET_WALK_MODE_Client, + MSG_MOVE_SET_PITCH_Client, MSG_MOVE_SET_RUN_MODE_Client, MSG_MOVE_SET_WALK_MODE_Client, MSG_MOVE_START_BACKWARD_Client, MSG_MOVE_START_FORWARD_Client, + MSG_MOVE_START_PITCH_DOWN_Client, MSG_MOVE_START_PITCH_UP_Client, MSG_MOVE_START_STRAFE_LEFT_Client, MSG_MOVE_START_STRAFE_RIGHT_Client, MSG_MOVE_START_SWIM_Client, MSG_MOVE_START_TURN_LEFT_Client, - MSG_MOVE_START_TURN_RIGHT_Client, MSG_MOVE_STOP_Client, MSG_MOVE_STOP_STRAFE_Client, - MSG_MOVE_STOP_SWIM_Client, MSG_MOVE_STOP_TURN_Client, MovementInfo_MovementFlags, + MSG_MOVE_START_TURN_RIGHT_Client, MSG_MOVE_STOP_Client, MSG_MOVE_STOP_PITCH_Client, + MSG_MOVE_STOP_STRAFE_Client, MSG_MOVE_STOP_SWIM_Client, MSG_MOVE_STOP_TURN_Client, + MovementInfo_MovementFlags, }; let info = MovementInfo { flags: MovementInfo_MovementFlags::empty(), @@ -3033,6 +3037,30 @@ fn relayed_move_opcode_maps_all_seventeen_client_variants() { })), m::MSG_MOVE_STOP_TURN, ), + ( + ClientOpcodeMessage::MSG_MOVE_START_PITCH_UP(Box::new( + MSG_MOVE_START_PITCH_UP_Client { info: info.clone() }, + )), + m::MSG_MOVE_START_PITCH_UP, + ), + ( + ClientOpcodeMessage::MSG_MOVE_START_PITCH_DOWN(Box::new( + MSG_MOVE_START_PITCH_DOWN_Client { info: info.clone() }, + )), + m::MSG_MOVE_START_PITCH_DOWN, + ), + ( + ClientOpcodeMessage::MSG_MOVE_STOP_PITCH(Box::new(MSG_MOVE_STOP_PITCH_Client { + info: info.clone(), + })), + m::MSG_MOVE_STOP_PITCH, + ), + ( + ClientOpcodeMessage::MSG_MOVE_SET_PITCH(Box::new(MSG_MOVE_SET_PITCH_Client { + info: info.clone(), + })), + m::MSG_MOVE_SET_PITCH, + ), ( ClientOpcodeMessage::MSG_MOVE_SET_RUN_MODE(Box::new(MSG_MOVE_SET_RUN_MODE_Client { info: info.clone(), @@ -3078,8 +3106,8 @@ fn relayed_move_opcode_maps_all_seventeen_client_variants() { ]; assert_eq!( cases.len(), - 17, - "the relayed opcode table has exactly 17 entries" + m::SLICE_MOVE_OPCODES.len(), + "every relayed client opcode has a case" ); for (msg, expected) in &cases { assert_eq!( diff --git a/gateway/src/stdb/aoi.rs b/gateway/src/stdb/aoi.rs index cd7539eff..ce02fbe12 100644 --- a/gateway/src/stdb/aoi.rs +++ b/gateway/src/stdb/aoi.rs @@ -5,6 +5,8 @@ pub(crate) static AOI_RECENTERS: AtomicU64 = AtomicU64::new(0); #[derive(Default)] pub(crate) struct ViewerGates { pub(crate) is_ghost: AtomicBool, + /// Set by a same-map teleport relay; cleared by the client's `MSG_MOVE_TELEPORT_ACK`. + pub(crate) teleport_pending: AtomicBool, } impl ViewerGates { diff --git a/gateway/src/stdb/subscriptions.rs b/gateway/src/stdb/subscriptions.rs index d19474170..76dd600da 100644 --- a/gateway/src/stdb/subscriptions.rs +++ b/gateway/src/stdb/subscriptions.rs @@ -89,6 +89,21 @@ impl PlayerSubscriptions { self.viewer.as_ref().map(|viewer| &viewer.member_stats) } + pub(crate) fn teleport_pending(&self) -> bool { + self.viewer + .as_ref() + .is_some_and(|viewer| viewer.gates.teleport_pending.load(Ordering::Acquire)) + } + + pub(crate) fn teleport_acknowledged(&self) { + if let Some(viewer) = &self.viewer { + viewer + .gates + .teleport_pending + .store(false, Ordering::Release); + } + } + pub fn aoi_update(&mut self, x: f32, y: f32) { let (Some(viewer), Some(view)) = (self.viewer.as_ref(), self.view.as_ref()) else { return; @@ -419,6 +434,22 @@ impl Coordinator { ))); } } + { + let guard = self.0.coord(); + let db = &guard.conn.db; + let entity = db.game_world_entity().guid().find(&self_guid); + let auras: Vec = db + .game_aura() + .iter() + .filter(|aura| aura.target_guid == self_guid) + .collect(); + drop(guard); + for out in entity.iter().flat_map(|entity| { + crate::codec::movement::subscriptions::world_entry_movement_outbound(entity, &auras) + }) { + let _ = tx.send(out); + } + } { let guard = self.0.coord(); let mut words: std::collections::HashMap = std::collections::HashMap::new(); diff --git a/gateway/src/world/benilla_tests/durable.rs b/gateway/src/world/benilla_tests/durable.rs index a822bcb6c..9c1f200e1 100644 --- a/gateway/src/world/benilla_tests/durable.rs +++ b/gateway/src/world/benilla_tests/durable.rs @@ -721,6 +721,39 @@ fn benilla_durable_bag_contents_remain_addressable_after_reconnect() { gateway.join().unwrap().unwrap(); } +#[test] +#[ignore = "requires SpacetimeDB 2.7.1 and the Wasm toolchain"] +fn benilla_durable_a_stun_roots_the_client_across_reconnect_until_it_ends() { + use messages::MoveMode; + let root = |apply: bool| move |packet: &ServerPacket| matches!(packet, ServerPacket::MoveMode { guid: 1, mode: MoveMode::Root, apply: actual, .. } if *actual == apply); + let realm = Realm::start("benilla-stun-root"); + let (mut client, gateway) = realm.connect("TEST"); + client.enter_world(); + realm + .standalone + .assert_call("debug_spawn_at_feet", &["1", "51000", "2"]); + let creature = client.created_entry(51000); + // Test Stun (50020) holds for ten minutes. + realm.standalone.assert_call( + "debug_force_cast_at", + &[&creature.to_string(), "50020", "1"], + ); + client.until("root from the stun", root(true)); + client.finish(); + gateway.join().unwrap().unwrap(); + + // Auras outlive the World Session. + let (mut client, gateway) = realm.connect("TEST"); + client.enter_world(); + client.until("root at world entry", root(true)); + realm + .standalone + .assert_sql("DELETE FROM game_aura WHERE target_guid = 1 AND spell_id = 50020"); + client.until("unroot when the stun ends", root(false)); + client.finish(); + gateway.join().unwrap().unwrap(); +} + #[test] #[ignore = "requires SpacetimeDB 2.7.1 and the Wasm toolchain"] fn benilla_durable_cancel_channelling_stops_the_active_channel() { diff --git a/gateway/src/world/handlers/mod.rs b/gateway/src/world/handlers/mod.rs index 2c01841c5..4b2889ab1 100644 --- a/gateway/src/world/handlers/mod.rs +++ b/gateway/src/world/handlers/mod.rs @@ -18,6 +18,7 @@ mod mail; mod meeting_stone; mod melee; mod member_stats; +mod movement; mod query; mod quest; mod taxi; @@ -76,6 +77,7 @@ pub(crate) use member_stats::MemberSnapshot; pub(crate) use member_stats::{ member_stats_tick, MemberPresence, MemberStats, MemberStatsRecord, MemberStatsStore, }; +pub(crate) use movement::is_silent_movement_request; pub(crate) use query::{NpcStore, Query}; pub(crate) use quest::{quest_giver_menu, Quest, QuestActionStore}; #[cfg(test)] diff --git a/gateway/src/world/handlers/movement.rs b/gateway/src/world/handlers/movement.rs new file mode 100644 index 000000000..63bf17533 --- /dev/null +++ b/gateway/src/world/handlers/movement.rs @@ -0,0 +1,22 @@ +/// Ignored without decoding; see docs/benilla-compatibility.md#movement-controls. +pub(crate) fn is_silent_movement_request(opcode: u32) -> bool { + const CMSG_WORLD_TELEPORT: u32 = 0x0008; + const CMSG_TELEPORT_TO_UNIT: u32 = 0x0009; + const MSG_MOVE_TELEPORT_CHEAT: u32 = 0x00C6; + const MSG_MOVE_TOGGLE_COLLISION_CHEAT: u32 = 0x00D9; + const CMSG_MOVE_SET_RAW_POSITION: u32 = 0x00E1; + const MSG_MOVE_TOGGLE_GRAVITY_CHEAT: u32 = 0x02AD; + const CMSG_MOVE_FALL_RESET: u32 = 0x02CA; + const CMSG_MINIGAME_MOVE: u32 = 0x02F8; + matches!( + opcode, + CMSG_WORLD_TELEPORT + | CMSG_TELEPORT_TO_UNIT + | MSG_MOVE_TELEPORT_CHEAT + | MSG_MOVE_TOGGLE_COLLISION_CHEAT + | CMSG_MOVE_SET_RAW_POSITION + | MSG_MOVE_TOGGLE_GRAVITY_CHEAT + | CMSG_MOVE_FALL_RESET + | CMSG_MINIGAME_MOVE + ) +} diff --git a/gateway/src/world/handlers/unavailable.rs b/gateway/src/world/handlers/unavailable.rs index 4a4758c19..bd092ee6c 100644 --- a/gateway/src/world/handlers/unavailable.rs +++ b/gateway/src/world/handlers/unavailable.rs @@ -232,12 +232,9 @@ pub(crate) fn is_control_receipt(msg: &ClientOpcodeMessage) -> bool { ClientOpcodeMessage::MSG_MOVE_TELEPORT_ACK(_) | ClientOpcodeMessage::CMSG_FORCE_RUN_BACK_SPEED_CHANGE_ACK(_) | ClientOpcodeMessage::CMSG_FORCE_SWIM_SPEED_CHANGE_ACK(_) - | ClientOpcodeMessage::CMSG_FORCE_MOVE_ROOT_ACK(_) - | ClientOpcodeMessage::CMSG_FORCE_MOVE_UNROOT_ACK(_) | ClientOpcodeMessage::CMSG_MOVE_KNOCK_BACK_ACK(_) | ClientOpcodeMessage::CMSG_MOVE_HOVER_ACK(_) | ClientOpcodeMessage::CMSG_MOVE_FEATHER_FALL_ACK(_) - | ClientOpcodeMessage::CMSG_MOVE_WATER_WALK_ACK(_) | ClientOpcodeMessage::CMSG_FORCE_WALK_SPEED_CHANGE_ACK(_) | ClientOpcodeMessage::CMSG_FORCE_SWIM_BACK_SPEED_CHANGE_ACK(_) | ClientOpcodeMessage::CMSG_FORCE_TURN_RATE_CHANGE_ACK(_) diff --git a/gateway/src/world/mod.rs b/gateway/src/world/mod.rs index 6c484774c..3cce7f556 100644 --- a/gateway/src/world/mod.rs +++ b/gateway/src/world/mod.rs @@ -781,6 +781,9 @@ fn run_world_session_with_queue_and_deadline( )?; continue; } + if handlers::is_silent_movement_request(hdr.opcode) { + continue; + } if let Some(replies) = handlers::raw_unavailable_outbound(hdr.opcode, &body)? { if matches!(conn.state, WorldState::InWorld(_)) { for reply in replies { @@ -947,13 +950,22 @@ fn dispatch( forward_movement(store, conn, fwd_opcode, &fwd_info)?; } } - } else { - if let Some(info) = root_receipt_stop(&msg, conn.self_guid()) { - let stop = lyracore_shared::opcodes::movement::MSG_MOVE_STOP; - for (fwd_opcode, fwd_info) in conn.move_coalesce.on_movement_now(stop, &info) { + } else if let Some((mover, opcode, info)) = codec::acknowledged_move(&msg) { + // A teleport outdates the pose; see docs/benilla-compatibility.md#root-receipts. + let teleporting = match &conn.state { + WorldState::InWorld(iw) => iw.subs.teleport_pending(), + _ => false, + }; + if conn.self_guid() == Some(mover) && !teleporting { + for (fwd_opcode, fwd_info) in conn.move_coalesce.on_movement_now(opcode, &info) { forward_movement(store, conn, fwd_opcode, &fwd_info)?; } - return Ok(()); + } + } else { + if let (ClientOpcodeMessage::MSG_MOVE_TELEPORT_ACK(_), WorldState::InWorld(iw)) = + (&msg, &conn.state) + { + iw.subs.teleport_acknowledged(); } if handlers::is_control_receipt(&msg) { return Ok(()); @@ -1001,25 +1013,6 @@ fn complete_reply( }) } -/// For receipt and transport constraints, see docs/benilla-compatibility.md#root-receipts. -fn root_receipt_stop(msg: &ClientOpcodeMessage, self_guid: Option) -> Option { - let (guid, info, rooted) = match msg { - ClientOpcodeMessage::CMSG_FORCE_MOVE_ROOT_ACK(ack) => (ack.guid, &ack.info, true), - ClientOpcodeMessage::CMSG_FORCE_MOVE_UNROOT_ACK(ack) => (ack.guid, &ack.info, false), - _ => return None, - }; - if Some(guid.guid()) != self_guid { - return None; - } - // The library clears flags with reverse_bits, so clear steering in the encoded bytes. - let mut bytes = codec::movement_info_to_bytes(info).ok()?; - if rooted { - const FORWARD_BACKWARD_STRAFE: u8 = 0xF; - bytes[0] &= !FORWARD_BACKWARD_STRAFE; - } - codec::bytes_to_movement_info(&bytes).ok() -} - fn forward_movement( store: &St, conn: &mut WorldConn, diff --git a/gateway/src/world/movement_tests.rs b/gateway/src/world/movement_tests.rs new file mode 100644 index 000000000..e59a1c29f --- /dev/null +++ b/gateway/src/world/movement_tests.rs @@ -0,0 +1,247 @@ +use super::*; +use lyracore_shared::opcodes::movement as m; +use std::io::Write; +use wow_world_messages::vanilla::{ + MSG_MOVE_HEARTBEAT_Client, MSG_MOVE_SET_PITCH_Client, MovementInfo, MovementInfo_MovementFlags, + Vector3d, CMSG_FORCE_MOVE_UNROOT_ACK, +}; + +fn in_world() -> ( + std::sync::Arc, + UnixStream, + EncrypterHalf, + std::thread::JoinHandle>, +) { + let (store, client, c_enc, _, server) = in_world_with(None); + (store, client, c_enc, server) +} + +fn in_world_with( + relay_view: Option>, +) -> ( + std::sync::Arc, + UnixStream, + EncrypterHalf, + DecrypterHalf, + std::thread::JoinHandle>, +) { + let store = std::sync::Arc::new({ + let base = tester_store(7); + WorldFake { + session: SessionState { + login_entity: Some(warrior_entity()), + relay_view, + ..base.session + }, + ..base + } + }); + let (mut client, server_end) = world_session_socket_pair(); + let server_store = store.clone(); + let server = std::thread::spawn(move || run_world_session(server_end, server_store)); + let (mut c_enc, mut c_dec) = client_handshake(&mut client, "TESTER", K); + CMSG_PLAYER_LOGIN { guid: Guid::new(1) } + .write_encrypted_client(&mut client, &mut c_enc) + .unwrap(); + drain_world_entry(&mut client, &mut c_dec); + (store, client, c_enc, c_dec, server) +} + +fn send_raw(client: &mut UnixStream, c_enc: &mut EncrypterHalf, opcode: u32, body: &[u8]) { + c_enc + .write_encrypted_client_header(&mut *client, 4 + body.len() as u16, opcode) + .unwrap(); + client.write_all(body).unwrap(); +} + +/// Build-5875 transport pose with pitch, which the library cannot decode. +fn swimming_on_transport(timestamp: u32) -> Vec { + let mut body = 0x0220_0000u32.to_le_bytes().to_vec(); // ON_TRANSPORT | SWIMMING + body.extend_from_slice(×tamp.to_le_bytes()); + for value in [-8950.0f32, -130.0, 83.0, 1.5] { + body.extend_from_slice(&value.to_le_bytes()); + } + body.extend_from_slice(&0xF120_0000_0000_0012u64.to_le_bytes()); + for value in [1.0f32, -2.5, 3.25, 0.5, 0.25] { + body.extend_from_slice(&value.to_le_bytes()); // relative pose, then pitch + } + body.extend_from_slice(&0u32.to_le_bytes()); // fall time + body +} + +fn standing(timestamp: u32) -> MovementInfo { + MovementInfo { + flags: MovementInfo_MovementFlags::empty(), + timestamp, + position: Vector3d { + x: -8950.0, + y: -130.0, + z: 83.0, + }, + orientation: 1.5, + fall_time: 0.0, + } +} + +fn recorded_opcodes(store: &WorldFake) -> Vec { + store + .session + .moves + .lock() + .unwrap() + .iter() + .map(|(opcode, ..)| *opcode) + .collect() +} + +#[test] +fn pitch_changes_reach_the_movement_relay_even_on_a_transport() { + let (store, mut client, mut c_enc, server) = in_world(); + send_raw( + &mut client, + &mut c_enc, + m::MSG_MOVE_START_PITCH_UP, + &swimming_on_transport(10), + ); + MSG_MOVE_SET_PITCH_Client { info: standing(20) } + .write_encrypted_client(&mut client, &mut c_enc) + .unwrap(); + drop(client); + server.join().unwrap().unwrap(); + assert_eq!( + recorded_opcodes(&store), + [m::MSG_MOVE_START_PITCH_UP, m::MSG_MOVE_SET_PITCH] + ); +} + +#[test] +fn acknowledged_root_and_unroot_relay_the_acknowledged_pose() { + let (store, mut client, mut c_enc, server) = in_world(); + let mut root_ack = 1u64.to_le_bytes().to_vec(); + root_ack.extend_from_slice(&0u32.to_le_bytes()); // movement counter + root_ack.extend_from_slice(&swimming_on_transport(10)); + send_raw(&mut client, &mut c_enc, 0x00E9, &root_ack); + CMSG_FORCE_MOVE_UNROOT_ACK { + guid: Guid::new(1), + movement_counter: 1, + info: standing(20), + } + .write_encrypted_client(&mut client, &mut c_enc) + .unwrap(); + drop(client); + server.join().unwrap().unwrap(); + assert_eq!( + recorded_opcodes(&store), + [m::MSG_MOVE_ROOT, m::MSG_MOVE_UNROOT] + ); + let moves = store.session.moves.lock().unwrap(); + assert_eq!(moves[0].5, 10, "the relay carries the acknowledged pose"); + assert_eq!( + store.session.move_bodies.lock().unwrap()[0], + swimming_on_transport(10), + "the stored body keeps the build-5875 transport tail" + ); +} + +fn root_ack(timestamp: u32) -> Vec { + let mut ack = 1u64.to_le_bytes().to_vec(); + ack.extend_from_slice(&0u32.to_le_bytes()); // movement counter + ack.extend_from_slice(&swimming_on_transport(timestamp)); + ack +} + +#[test] +fn an_acknowledged_pose_waits_for_the_client_to_apply_a_teleport() { + let view = std::sync::Arc::new(crate::stdb::world_view::WorldView::new(true)); + let (store, mut client, mut c_enc, mut c_dec, server) = in_world_with(Some(view.clone())); + crate::codec::movement::subscriptions::teleport_appeared( + &view, + 0, + &crate::stdb::bindings::TeleportEvent { + id: 1, + recipient_identity: spacetimedb_sdk::Identity::from_byte_array([0; 32]), + mover_guid: 1, + map_id: 0, + x: -9000.0, + y: -100.0, + z: 80.0, + orientation: 0.0, + created_micros: 0, + cross_map: false, + }, + ); + loop { + if let ServerOpcodeMessage::MSG_MOVE_TELEPORT_ACK(_) = + ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap() + { + break; + } + } + send_raw(&mut client, &mut c_enc, 0x00E9, &root_ack(10)); + wow_world_messages::vanilla::MSG_MOVE_TELEPORT_ACK_Client { + guid: Guid::new(1), + movement_counter: 0, + time: std::time::Duration::from_millis(15), + } + .write_encrypted_client(&mut client, &mut c_enc) + .unwrap(); + send_raw(&mut client, &mut c_enc, 0x00E9, &root_ack(20)); + drop(client); + server.join().unwrap().unwrap(); + let moves = store.session.moves.lock().unwrap(); + assert_eq!( + moves.len(), + 1, + "only the acknowledgement after the teleport is relayed" + ); + assert_eq!((moves[0].0, moves[0].5), (m::MSG_MOVE_ROOT, 20)); +} + +#[test] +fn an_acknowledgement_for_another_mover_records_nothing() { + let (store, mut client, mut c_enc, server) = in_world(); + CMSG_FORCE_MOVE_UNROOT_ACK { + guid: Guid::new(2), + movement_counter: 1, + info: standing(20), + } + .write_encrypted_client(&mut client, &mut c_enc) + .unwrap(); + drop(client); + server.join().unwrap().unwrap(); + assert!(recorded_opcodes(&store).is_empty()); +} + +#[test] +fn cheat_and_game_master_movement_requests_leave_the_position_unchanged() { + let (store, mut client, mut c_enc, server) = in_world(); + let world_teleport = { + // Time, an unknown map, a position and a facing: the library would refuse the map. + let mut body = 0u32.to_le_bytes().to_vec(); + body.extend_from_slice(&0xFFFFu32.to_le_bytes()); + body.extend_from_slice(&[0; 16]); + body + }; + let requests: [(u32, Vec); 8] = [ + (0x0008, world_teleport), + (0x0009, b"Bob\0".to_vec()), + (0x00C6, swimming_on_transport(1)), + (0x00D9, swimming_on_transport(1)), + (0x00E1, vec![0; 16]), + (0x02AD, swimming_on_transport(1)), + (0x02CA, swimming_on_transport(1)), + (0x02F8, vec![0; 13]), + ]; + for (opcode, body) in &requests { + send_raw(&mut client, &mut c_enc, *opcode, body); + } + MSG_MOVE_HEARTBEAT_Client { info: standing(30) } + .write_encrypted_client(&mut client, &mut c_enc) + .unwrap(); + drop(client); + server + .join() + .unwrap() + .expect("an ignored request must leave the World Session open"); + assert_eq!(recorded_opcodes(&store), [m::MSG_MOVE_HEARTBEAT]); +} diff --git a/gateway/src/world/test_support/world_fake/session.rs b/gateway/src/world/test_support/world_fake/session.rs index 7ac668c57..75db18bda 100644 --- a/gateway/src/world/test_support/world_fake/session.rs +++ b/gateway/src/world/test_support/world_fake/session.rs @@ -14,6 +14,7 @@ pub(crate) struct SessionState { pub(crate) session: Option, pub(crate) login_entity: Option, pub(crate) moves: std::sync::Mutex>, + pub(crate) move_bodies: std::sync::Mutex>>, pub(crate) client_commands: std::sync::Mutex>, pub(crate) client_command_error: Option ReducerCallError>, pub(crate) turn_in_reward_item: Option, @@ -85,6 +86,11 @@ impl SessionStore for WorldFake { info.orientation, info.timestamp, )); + self.session + .move_bodies + .lock() + .unwrap() + .push(codec::movement_info_to_bytes(info)?); if let Some(world) = &self.session.movement_world { world.update(actor.guid(), opcode, info)?; } diff --git a/gateway/src/world/tests.rs b/gateway/src/world/tests.rs index d4b387a7c..c727ebd04 100644 --- a/gateway/src/world/tests.rs +++ b/gateway/src/world/tests.rs @@ -72,6 +72,8 @@ mod mail_tests; mod melee_tests; #[path = "member_stats_tests.rs"] mod member_stats_tests; +#[path = "movement_tests.rs"] +mod movement_tests; #[path = "npc_tests.rs"] mod npc_tests; #[path = "party_mirror_tests.rs"] @@ -240,7 +242,7 @@ fn human_speaker() -> SpeakerFacts { } #[test] -fn a_root_receipt_relays_a_stop_without_steering_flags() { +fn a_root_receipt_relays_msg_move_root_without_steering_flags() { use wow_world_messages::vanilla::{ MovementInfo_MovementFlags, Vector3d, CMSG_FORCE_MOVE_ROOT_ACK, CMSG_FORCE_MOVE_UNROOT_ACK, }; @@ -267,16 +269,14 @@ fn a_root_receipt_relays_a_stop_without_steering_flags() { let bytes = codec::movement_info_to_bytes(info).unwrap(); u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]) }; - let stop = root_receipt_stop(&root(FORWARD_ROOT), Some(7)).expect("own root receipt"); + use lyracore_shared::opcodes::movement::{MSG_MOVE_ROOT, MSG_MOVE_UNROOT}; + let (mover, opcode, stop) = + codec::acknowledged_move(&root(FORWARD_ROOT)).expect("root receipt"); + assert_eq!((mover, opcode), (7, MSG_MOVE_ROOT)); assert_eq!(flags(&stop), 0x1000); - assert!(root_receipt_stop(&root(FORWARD_ROOT), Some(8)).is_none()); assert!( - root_receipt_stop( - &root(lyracore_shared::env::MOVEMENT_FLAG_ON_TRANSPORT), - Some(7) - ) - .is_none(), - "a build-5875 transport receipt has a tail the typed reader skipped" + codec::acknowledged_move(&root(lyracore_shared::env::MOVEMENT_FLAG_ON_TRANSPORT)).is_none(), + "a transport flag without its tail is refused" ); let unroot = @@ -285,6 +285,7 @@ fn a_root_receipt_relays_a_stop_without_steering_flags() { movement_counter: 0, info: info(0x1), })); - let resumed = root_receipt_stop(&unroot, Some(7)).expect("own unroot receipt"); + let (_, opcode, resumed) = codec::acknowledged_move(&unroot).expect("unroot receipt"); + assert_eq!(opcode, MSG_MOVE_UNROOT); assert_eq!(flags(&resumed), 0x1, "an unroot receipt keeps steering"); }