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