diff --git a/crates/benilla-app/src/net/objects.rs b/crates/benilla-app/src/net/objects.rs index e70af0c9..cc9d9589 100644 --- a/crates/benilla-app/src/net/objects.rs +++ b/crates/benilla-app/src/net/objects.rs @@ -21,8 +21,9 @@ use super::motion::{ trace_create_spline, trace_move_snap, wire_yaw, write_pose, SplineStopped, ROOT_APPLY_WIPE, }; use super::{ - merge_store_fields, FieldChanged, Guid, GuidIndex, NetCommands, NetEntity, NetHandlerApp, - ObjectStore, RemoteMotion, SelfGuid, SpeedChangeMessage, Spline, UnitMoveModes, UnitSpeeds, + merge_store_fields, ActiveMover, FieldChanged, Guid, GuidIndex, NetCommands, NetEntity, + NetHandlerApp, ObjectStore, RemoteMotion, SelfGuid, SpeedChangeMessage, Spline, UnitMoveModes, + UnitSpeeds, }; /// `SMSG_GAMEOBJECT_CUSTOM_ANIM`, bridged to [`crate::go_anim`], which rejects `anim_id >= 4` and @@ -84,6 +85,7 @@ pub(crate) struct Scene<'w, 's> { self_guid: Res<'w, SelfGuid>, real: Res<'w, Time>, transforms: Query<'w, 's, &'static mut Transform>, + movers: Query<'w, 's, (), With>, stores: Query<'w, 's, &'static mut ObjectStore>, countdowns: Query<'w, 's, &'static mut crate::items::Countdowns>, remote: Query<'w, 's, &'static mut RemoteMotion>, @@ -223,6 +225,7 @@ fn on_object_move(In(ev): In, mut sc: Scene) { &mut sc.commands, &sc.index, &mut sc.transforms, + &sc.movers, ); } } @@ -310,8 +313,14 @@ fn on_object_destroyed(In(ev): In, mut sc: Scene) { /// OUT_OF_RANGE takes the same deactivate as DESTROY in the reference, so a member walking out of /// range also gets the snapshot and `CMSG_REQUEST_PARTY_MEMBER_STATS`. +/// +/// The active player is never released: the reference compares each listed guid with +/// `ClntObjMgrGetActivePlayer` (`0x465f39` → `0x468550`, `0x465f3e`, `0x465f42`) and jumps past +/// `0x464920` (`0x465f45` → `0x465f54`), the deactivate included; a player's guid is outside the +/// second round's types (`0x465f19`, `0x465f28`), so no later pass reaches it. fn on_objects_removed(In(ev): In, mut sc: Scene) { - if let SessionEvent::ObjectsRemoved(guids) = ev { + if let SessionEvent::ObjectsRemoved(mut guids) = ev { + guids.retain(|g| sc.self_guid.0 != Some(*g)); for guid in &guids { let store = sc.index.0.get(guid).and_then(|e| sc.stores.get(*e).ok()); crate::ui_party::net::member_deactivated(*guid, &mut sc.group, store, &sc.net); @@ -532,7 +541,7 @@ fn object_create( trace_create_spline(guid, spline.as_ref()); let walk = spline.and_then(create_spline); if let Some(&e) = index.0.get(&guid) { - // A re-create is a fresh snapshot: refresh identity and pose, drop any extrapolation. + // A re-create is a fresh snapshot: refresh identity and a unit's pose, drop any extrapolation. commands.entity(e).insert(net).remove::(); // The set is replaced whole; a `SMSG_FORCE_*_SPEED_CHANGE` in the same tick lands on top, // since this insert applies before the next packet's handler (`apply::seam_tests`). @@ -564,7 +573,11 @@ fn object_create( commands.entity(e).remove::(); } } - write_pose(commands, transforms, e, position, placement); + // Only a unit takes the block's movement (type-mask bit 3, `0x46638c`); any other type + // reads the position and drops it (`0x4664c3`..`0x46656b`), so it stays where it stands. + if matches!(kind, EntityKind::Unit | EntityKind::Player) { + write_pose(commands, transforms, e, position, placement); + } // The reference refreshes a live guid in place, notifying field watchers like any delta. merge_fields(commands, stores, edges, e, guid, fields); } else { @@ -636,7 +649,10 @@ fn item_create( } } -/// An `SMSG_UPDATE_OBJECT` movement block: a new pose that supersedes any active path. +/// An `SMSG_UPDATE_OBJECT` movement block: a new pose that supersedes any active path. The unit +/// this client moves, our player or a possessed unit ([`ActiveMover`]), drops it: the reference +/// compares the block's guid with the active mover before it looks the object up (`0x4670a5`, +/// `0x4670ae`, `0x4670b5`), and returns on a match (`0x4670bb`). fn object_move( guid: u64, position: [f32; 3], @@ -644,8 +660,12 @@ fn object_move( commands: &mut Commands, index: &GuidIndex, transforms: &mut Query<&mut Transform>, + movers: &Query<(), With>, ) { if let Some(&e) = index.0.get(&guid) { + if movers.contains(e) { + return; + } commands.entity(e).remove::(); // A yaw only: the GameObjects that get one are transports, which their tick then owns. write_pose(commands, transforms, e, position, wire_yaw(orientation)); @@ -1131,6 +1151,9 @@ fn speed_changed( } } +#[cfg(test)] +mod update_tests; + #[cfg(test)] mod tests { use super::*; diff --git a/crates/benilla-app/src/net/objects/update_tests.rs b/crates/benilla-app/src/net/objects/update_tests.rs new file mode 100644 index 00000000..d888b1c6 --- /dev/null +++ b/crates/benilla-app/src/net/objects/update_tests.rs @@ -0,0 +1,144 @@ +//! How `SMSG_UPDATE_OBJECT` blocks apply to the streamed world, through the handler table. +use super::*; +use benilla_assets::coords::wow_to_bevy; + +const ME: u64 = 0x0000_0000_0000_0007; +const MOB: u64 = 0xF130_0000_1234_0001; + +fn client() -> App { + crate::game_plugins::schedule_tests::headless_client() +} + +/// An indexed entity for `guid`, as a create leaves it. +fn indexed(world: &mut World, guid: u64) -> Entity { + let e = world.spawn((Guid(guid), Transform::default())).id(); + world.resource_mut::().0.insert(guid, e); + e +} + +/// An out-of-range block lists the active player among others: the others are released, the +/// player is not (`0x465f39`..`0x465f45` skip `0x464920`). +#[test] +fn an_out_of_range_block_never_releases_the_active_player() { + let mut app = client(); + let world = app.world_mut(); + world.resource_mut::().0 = Some(ME); + let me = indexed(world, ME); + let mob = indexed(world, MOB); + + super::super::handlers::dispatch(world, vec![SessionEvent::ObjectsRemoved(vec![MOB, ME])]); + + let index = &world.resource::().0; + assert_eq!(index.get(&ME), Some(&me), "the player stays indexed"); + assert!(world.get::(me).is_some(), "and is not torn down"); + assert!(world.get::(me).is_none(), "nor faded out"); + assert!( + !index.contains_key(&MOB), + "control: another guid is released" + ); + assert!(world.get::(mob).is_some()); +} + +fn moved(guid: u64) -> SessionEvent { + SessionEvent::ObjectMove { + guid, + position: [10.0, 20.0, 30.0], + orientation: 0.0, + } +} + +/// A movement block names the unit this client moves, our player or a possessed unit, and is +/// dropped; any other indexed unit takes the pose (`0x4670a5`..`0x4670bb`). With control lost +/// the mover global is zero (`0x5fa600`), so the block applies. +#[test] +fn a_movement_block_for_the_unit_we_move_is_dropped() { + let mut app = client(); + let world = app.world_mut(); + let driven = indexed(world, ME); + world.entity_mut(driven).insert(ActiveMover); + let possessed = indexed(world, 0xF130_0000_0000_0002); + world.entity_mut(possessed).insert(ActiveMover); + let other = indexed(world, MOB); + let lost = indexed(world, 0xF130_0000_0000_0003); + + super::super::handlers::dispatch( + world, + vec![ + moved(ME), + moved(0xF130_0000_0000_0002), + moved(MOB), + moved(0xF130_0000_0000_0003), + ], + ); + + let at = |world: &World, e: Entity| world.get::(e).unwrap().translation; + assert_eq!(at(world, driven), Vec3::ZERO, "the player is not moved"); + assert_eq!(at(world, possessed), Vec3::ZERO, "nor the possessed unit"); + let pose = wow_to_bevy([10.0, 20.0, 30.0]); + assert_eq!( + at(world, other), + pose, + "control: another unit takes the pose" + ); + assert_eq!(at(world, lost), pose, "control: an unsteered unit takes it"); +} + +fn create(guid: u64, kind: EntityKind, position: [f32; 3]) -> SessionEvent { + SessionEvent::ObjectCreate { + guid, + kind, + display_id: None, + position, + orientation: 0.0, + scale: 1.0, + speeds: None, + mover: None, + transport_progress: None, + transport: None, + spline: None, + fields: ObjectFields::default(), + } +} + +/// A create of a known guid moves only a unit (type-mask bit 3, `0x46638c`); a GameObject's +/// block position is read and dropped (`0x4664c3`), so it keeps its place. +#[test] +fn a_recreate_re_poses_units_only() { + const GO: u64 = 0xF110_0000_0000_0009; + let mut app = client(); + let (tx, _rx) = crossbeam_channel::unbounded(); + app.insert_resource(NetCommands(tx)); + let world = app.world_mut(); + let at = |world: &World, guid: u64| { + let e = world.resource::().0[&guid]; + world.get::(e).unwrap().translation + }; + super::super::handlers::dispatch( + world, + vec![ + create(MOB, EntityKind::Unit, [1.0, 2.0, 3.0]), + create(GO, EntityKind::GameObject, [1.0, 2.0, 3.0]), + ], + ); + let first = wow_to_bevy([1.0, 2.0, 3.0]); + assert_eq!( + at(world, MOB), + first, + "control: the first create places both" + ); + assert_eq!(at(world, GO), first); + + super::super::handlers::dispatch( + world, + vec![ + create(MOB, EntityKind::Unit, [10.0, 20.0, 30.0]), + create(GO, EntityKind::GameObject, [10.0, 20.0, 30.0]), + ], + ); + assert_eq!( + at(world, MOB), + wow_to_bevy([10.0, 20.0, 30.0]), + "a unit takes the new pose" + ); + assert_eq!(at(world, GO), first, "a GameObject stays where it stands"); +} diff --git a/crates/benilla-protocol/src/events/decode.rs b/crates/benilla-protocol/src/events/decode.rs index ec8abe93..7c70b496 100644 --- a/crates/benilla-protocol/src/events/decode.rs +++ b/crates/benilla-protocol/src/events/decode.rs @@ -1,6 +1,6 @@ //! The [`ServerPacket`] to [`SessionEvent`] mapping: pure functions of the packet, no state. -use crate::messages::{CastOutcome, MovementBlock, Object, ObjectFields, ObjectType, ServerPacket}; +use crate::messages::{CastOutcome, Object, ObjectFields, ObjectType, ServerPacket}; use crate::wire::Vector3d; use super::{EntityKind, MoveSpeeds, SessionEvent}; @@ -1124,9 +1124,10 @@ fn decode_objects(objects: Vec) -> Vec { }); continue; } - if let Some((position, orientation)) = - create_placement(object_type, &mask, &movement) - { + // The block's own position, for every type: the GameObject constructor copies it + // over `GAMEOBJECT_POS_X`..`FACING` (`0x5f7046`..`0x5f706b`), so the descriptor's + // copy never places anything. + if let Some((position, orientation)) = movement.position { let display = display_id(object_type, &mask); let scale = object_scale(object_type, &mask); let speeds = movement.speeds.map(MoveSpeeds::from_wire); @@ -1212,18 +1213,3 @@ fn object_scale(t: ObjectType, mask: &ObjectFields) -> f32 { }; raw.filter(|s| *s > 0.0).unwrap_or(1.0) } - -/// A create's pose: a GameObject's `GAMEOBJECT_POS_*` when sent, else the movement block, since -/// vmangos never sets those fields on a transport. Gate on `gameobject_pos_sent`, not `.or`: a -/// create reads absent fields as zero, so `gameobject_position()` is `Some` even when unsent. -fn create_placement( - object_type: ObjectType, - mask: &ObjectFields, - movement: &MovementBlock, -) -> Option<(Vector3d, f32)> { - if object_type == ObjectType::GameObject && mask.gameobject_pos_sent() { - mask.gameobject_position() - } else { - movement.position - } -} diff --git a/crates/benilla-protocol/src/messages/update_object/movement.rs b/crates/benilla-protocol/src/messages/update_object/movement.rs index 876d3411..42d82e98 100644 --- a/crates/benilla-protocol/src/messages/update_object/movement.rs +++ b/crates/benilla-protocol/src/messages/update_object/movement.rs @@ -146,13 +146,17 @@ impl MovementBlock { speeds = Some(s); if flags & MOVEMENT_FLAG_SPLINE_ENABLED != 0 { let spline_flags = read_u32_le(r)?; - // The final facing (`packet_builder.cpp:162-167`); a unit on a path faces along it. + // The final facing, three independent reads in the reference's order: a point + // (`0x47f0bf`), a guid (`0x47f0e8`), an angle (`0x47f0f9`). vmangos writes at most + // one (`packet_builder.cpp:162-167`); a unit on a path faces along it. + if spline_flags & SPLINE_FLAG_FINAL_POINT != 0 { + let _ = Vector3d::read(r)?; + } + if spline_flags & SPLINE_FLAG_FINAL_TARGET != 0 { + let _ = read_u64_le(r)?; + } if spline_flags & SPLINE_FLAG_FINAL_ANGLE != 0 { let _ = read_f32_le(r)?; - } else if spline_flags & SPLINE_FLAG_FINAL_TARGET != 0 { - let _ = read_u64_le(r)?; - } else if spline_flags & SPLINE_FLAG_FINAL_POINT != 0 { - let _ = Vector3d::read(r)?; } let time_passed_ms = read_u32_le(r)?; let duration_ms = read_u32_le(r)?; diff --git a/crates/benilla-protocol/tests/update_object.rs b/crates/benilla-protocol/tests/update_object.rs index 1d770b17..8fbca541 100644 --- a/crates/benilla-protocol/tests/update_object.rs +++ b/crates/benilla-protocol/tests/update_object.rs @@ -9,8 +9,9 @@ use common::hx; #[test] fn update_object_fixture_decodes() { - // A unit create (0xAA), a gameobject create (0xBB) and an out-of-range block [0x10, 0x20]. - let body = hx("03000000000301aa03200000000000000000cdd70bc6357e04c3f90fa74200000040000000000000803f0000e040000090400000000000000000db0f4940051700000000000000000000000000000008000000aa00000000000000090000000000c03f320000000301bb05000117810700bb000000000000002100000000000040630000000000c84200004843000096430000803f040200000001100120"); + // A unit create (0xAA), a gameobject create (0xBB, a HAS_POSITION block at (100, 200, 300), as + // vmangos sends one) and an out-of-range block [0x10, 0x20]. + let body = hx("03000000000301aa03200000000000000000cdd70bc6357e04c3f90fa74200000040000000000000803f0000e040000090400000000000000000db0f4940051700000000000000000000000000000008000000aa00000000000000090000000000c03f320000000301bb05400000c84200004843000096430000803f0117810700bb000000000000002100000000000040630000000000c84200004843000096430000803f040200000001100120"); let packet = messages::parse_server(messages::opcode::SMSG_UPDATE_OBJECT, &body).unwrap(); let events = decode(packet); @@ -597,6 +598,109 @@ fn unit_living_block_surfaces_the_walk_it_is_already_on() { } } +/// The GameObject constructor copies the create block's position over `GAMEOBJECT_POS_X`..`FACING` +/// (`0x5f7046`..`0x5f706b`), so a create whose descriptor and block disagree is placed by the block. +#[test] +fn gameobject_create_is_placed_by_the_block_over_its_position_fields() { + let mut body = 1u32.to_le_bytes().to_vec(); + body.push(0); // has_transport + body.push(2); // CREATE_OBJECT + write_packed_guid(0xBB, &mut body).unwrap(); + body.push(5); // TypeId::GameObject + body.push(0x40); // HAS_POSITION + for v in [5000.0f32, 6000.0, 15.0, 2.0] { + body.extend_from_slice(&v.to_le_bytes()); // the block's pose + } + // Fields 8 (GAMEOBJECT_DISPLAYID) and 15..=18 (GAMEOBJECT_POS_X, _Y, _Z, FACING) in block 0. + body.push(1); + body.extend_from_slice(&((1u32 << 8) | (0xF << 15)).to_le_bytes()); + body.extend_from_slice(&3015u32.to_le_bytes()); + for v in [100.0f32, 200.0, 300.0, 1.0] { + body.extend_from_slice(&v.to_le_bytes()); // the descriptor's pose + } + + let packet = messages::parse_server(messages::opcode::SMSG_UPDATE_OBJECT, &body).unwrap(); + match decode(packet).as_slice() { + [SessionEvent::ObjectCreate { + position, + orientation, + display_id, + .. + }] => { + assert_eq!(*display_id, Some(3015), "control: the fields were sent"); + assert_eq!(*position, [5000.0, 6000.0, 15.0], "the block wins"); + assert_eq!(*orientation, 2.0); + } + other => panic!("expected one ObjectCreate, got {other:?}"), + } +} + +/// The create spline's final-facing bits are tested one by one, each field read in this order when +/// its bit is set: point (`0x10000`, 12 bytes, `0x47f0bf`), guid (`0x20000`, 8, `0x47f0e8`), angle +/// (`0x40000`, 4, `0x47f0f9`). The id, timing and path behind them land only if every set bit took +/// its bytes. Flags `0x50000` is the golden: the point (1, 2, 3), then the angle 1.5. +#[test] +fn create_spline_reads_each_final_facing_field_its_bit_names() { + // A unit create with a LIVING block on a spline: head and move flags, pose, speeds, then + // `spline flags`, the facing fields, timing (3000, 12000, id 42, 5 nodes), the 5 nodes and the + // final destination, and a descriptor behind it (UNIT_FIELD_DISPLAYID 1234). + fn create(spline_flags: &str, facing: &str) -> Vec { + hx(&[ + concat!("01000000", "00", "02", "01aa", "03", "20"), + concat!("00004000", "00000000"), + concat!("000060410000a0410000f04100000000", "00000000"), + "000020400000e04000009040721c974000002040db0f4940", + spline_flags, + facing, + "b80b0000e02e00002a00000005000000", + concat!("000000000000a0410000f041", "000020410000a0410000f041"), + concat!("0000a0410000a0410000f041", "0000a041000020420000f041"), + concat!("0000a041000020420000f041", "0000a041000020420000f041"), + "050000000000000000000000000000000008000000d2040000", + ] + .concat()) + } + let point = "0000803f0000004000004040"; + let guid = "1122334455667788"; + let angle = "0000c03f"; + for (name, flags, facing) in [ + ("point and angle", "00000500", [point, angle].concat()), + ("point and guid", "00000300", [point, guid].concat()), + ("guid and angle", "00000600", [guid, angle].concat()), + ("all three", "00000700", [point, guid, angle].concat()), + ("the point alone", "00000100", point.to_string()), + ("the guid alone", "00000200", guid.to_string()), + ("the angle alone", "00000400", angle.to_string()), + ("none", "00000000", String::new()), + ] { + let packet = messages::parse_server( + messages::opcode::SMSG_UPDATE_OBJECT, + &create(flags, &facing), + ) + .unwrap(); + match decode(packet).as_slice() { + [SessionEvent::ObjectCreate { + guid: 0xAA, + display_id, + spline, + .. + }] => { + let s = spline.as_ref().expect("the spline"); + assert_eq!(s.id, 42, "{name}: the id is read where the facing ends"); + assert_eq!(s.time_passed_ms, 3_000, "{name}"); + assert_eq!(s.duration_ms, 12_000, "{name}"); + assert_eq!(s.path.len(), 3, "{name}"); + assert_eq!( + *display_id, + Some(1234), + "{name}: the fields behind it parse" + ); + } + other => panic!("{name}: expected one ObjectCreate, got {other:?}"), + } + } +} + #[test] fn unit_living_block_without_the_spline_flag_carries_no_walk() { let mut body = 1u32.to_le_bytes().to_vec();