From 07213bc2804c984bee91525d5a265a60fce1b73b Mon Sep 17 00:00:00 2001 From: Sam Date: Fri, 14 Aug 2026 19:25:28 +0700 Subject: [PATCH] a possessed body is driven, not merely held MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - the active mover is a marker on the body in your hands: a mind-controlled creature drives with its own speeds and turn rate, and your identity never follows it - a control packet is about that body, not about you — a fear on the thing you drive no longer tears the possession down - your own turn rate reads live mover state, so speed and turn auras it previously ignored now apply Co-Authored-By: Claude Fable 5 --- crates/benilla-app/src/blob_shadow.rs | 4 +- crates/benilla-app/src/chat_bubble.rs | 4 +- crates/benilla-app/src/creature_anim/lod.rs | 12 +- crates/benilla-app/src/entities.rs | 10 +- crates/benilla-app/src/footprints.rs | 6 +- crates/benilla-app/src/minimap/blips/mod.rs | 4 +- crates/benilla-app/src/net.rs | 25 +- crates/benilla-app/src/net/motion/facing.rs | 6 +- crates/benilla-app/src/net/motion/remote.rs | 8 +- crates/benilla-app/src/player.rs | 143 ++++---- crates/benilla-app/src/player/camera.rs | 8 +- crates/benilla-app/src/player/embody.rs | 323 +++++++++++++++++++ crates/benilla-app/src/player/gait.rs | 57 +++- crates/benilla-app/src/player/server_ride.rs | 6 +- crates/benilla-app/src/player/state.rs | 65 +++- crates/benilla-app/src/player/wire_in.rs | 143 ++++++-- crates/benilla-app/src/player/world_focus.rs | 8 +- crates/benilla-app/src/sound/liquid_loop.rs | 6 +- crates/benilla-app/src/sound/mod.rs | 6 +- 19 files changed, 699 insertions(+), 145 deletions(-) create mode 100644 crates/benilla-app/src/player/embody.rs diff --git a/crates/benilla-app/src/blob_shadow.rs b/crates/benilla-app/src/blob_shadow.rs index a28db005..4dbdad19 100644 --- a/crates/benilla-app/src/blob_shadow.rs +++ b/crates/benilla-app/src/blob_shadow.rs @@ -50,7 +50,7 @@ use bevy::ecs::entity::{EntityHashMap, EntityHashSet}; use bevy::prelude::*; use crate::creature_anim::AnimData; -use crate::net::{NetEntity, SelfPlayer}; +use crate::net::{ActiveMover, NetEntity}; use crate::player::CameraControl; use benilla_world::decal::{DecalFrame, WorldDecal}; use benilla_world::model_fade::{fade_alpha, RenderFade}; @@ -184,7 +184,7 @@ fn update_shadows( ( &Transform, &ModelAnimations, - Has, + Has, Option<&crate::entities::mount::MountChild>, ), Without, diff --git a/crates/benilla-app/src/chat_bubble.rs b/crates/benilla-app/src/chat_bubble.rs index 9173ce0b..ec698cff 100644 --- a/crates/benilla-app/src/chat_bubble.rs +++ b/crates/benilla-app/src/chat_bubble.rs @@ -61,7 +61,7 @@ use benilla_ui::script::{pieces, Backdrop, Insets}; use benilla_ui::script::{JustifyH, JustifyV, Outline}; use crate::entities::{overhead_anchor, BoneAttach, OverheadFallback}; -use crate::net::{Guid, NetEntity, SelfGuid, SelfPlayer}; +use crate::net::{ActiveMover, Guid, NetEntity, SelfGuid}; use crate::ui_chat::{default_color, ChatEventKind}; use crate::ui_pass::{UiQuad, UiQuadAppend, UiQuads, UvRect}; use crate::ui_text::{layout_text_quads, measure_text, FontSpec, Justify, UiFontAtlas}; @@ -300,7 +300,7 @@ fn drive_bubbles( vplates: Res, self_guid: Res, units: Query<(Entity, &Guid, &Transform), With>, - self_q: Query<&Transform, With>, + self_q: Query<&Transform, With>, camera: Query<(&Camera, &Transform), With>, mut atlas: Option>, mut quads: ResMut, diff --git a/crates/benilla-app/src/creature_anim/lod.rs b/crates/benilla-app/src/creature_anim/lod.rs index f7ccfec1..cc5e1a21 100644 --- a/crates/benilla-app/src/creature_anim/lod.rs +++ b/crates/benilla-app/src/creature_anim/lod.rs @@ -53,7 +53,7 @@ use bevy::prelude::*; use benilla_assets::{WmoGroupNav, WmoModel, WmoPortalRef}; use crate::entities::mount::MountBody; -use crate::net::SelfPlayer; +use crate::net::ActiveMover; use crate::target::SelectionRadius; use benilla_world::view::WorldCamera; use benilla_world::wmo_portal::{UnitWmoRoom, WmoPortalInstance, WmoRoom}; @@ -77,7 +77,7 @@ const FALLBACK_RADIUS: f32 = 6.0; /// portal-PVS room test (decision 0739 — see the module doc's "room leg"). PostUpdate, /// before [`AnimationSystems`] — a wake drops the marker in time for the same frame's pose /// evaluation, so the re-appearing unit samples the absolute-clock pose with no stale -/// frame. Exempt: the self-avatar (the camera rides its attachment-17 pivot, and it must keep +/// frame. Exempt: the body we drive (the camera rides its attachment-17 pivot, and it must keep /// animating faded-out in first person) and its mount child. `WOW_NO_ANIM_LOD=1` disables /// parking — the live-probe A/B lever; `WOW_NO_ROOM_LOD=1` disables only the room leg. #[allow(clippy::type_complexity, clippy::too_many_arguments)] // one Bevy system's full input set @@ -90,7 +90,7 @@ pub(super) fn gate_rig_animation( &GlobalTransform, Option<&SelectionRadius>, Has, - Has, + Has, Option<&MountBody>, Option<&UnitWmoRoom>, ), @@ -104,7 +104,7 @@ pub(super) fn gate_rig_animation( // `RigPose`), so this query can never park the character pane. With, >, - self_hosts: Query>, + self_hosts: Query>, instances: Query<&WmoPortalInstance>, wmos: Res>, mut out_since: Local>, @@ -616,11 +616,11 @@ mod tests { let mut app = app(); spawn_camera(&mut app); let (behind, behind_joint) = spawn_rig(&mut app, Vec3::Z * 50.0); - app.world_mut().entity_mut(behind).insert(SelfPlayer); + app.world_mut().entity_mut(behind).insert(ActiveMover); advance(&mut app, PARK_AFTER_SECS + 0.2, 4); assert!( !app.world().entity(behind).contains::(), - "SelfPlayer never parks, wherever the camera points" + "the active mover never parks, wherever the camera points" ); // And its bones keep animating — the exemption is live evaluation, not just the marker. let before = bone(&app, behind_joint); diff --git a/crates/benilla-app/src/entities.rs b/crates/benilla-app/src/entities.rs index 8f88c716..aae1af15 100644 --- a/crates/benilla-app/src/entities.rs +++ b/crates/benilla-app/src/entities.rs @@ -448,7 +448,7 @@ fn publish_world_units( bodies: Query, viewers: Query<( Entity, - Has, + Has, Has, )>, ) { @@ -1117,7 +1117,7 @@ mod world_unit_tests { .world_mut() .spawn(( net(EntityKind::Player, 1.0), - crate::net::SelfPlayer, + crate::net::ActiveMover, collision_height::CollisionHeight(2.5), )) .id(); @@ -1164,14 +1164,14 @@ mod world_unit_tests { ); } - /// The viewer marker is reconciled, not stamped once: a `/logout` takes `SelfPlayer` away and + /// The viewer marker is reconciled, not stamped once: a `/logout` takes `ActiveMover` away and /// the marker has to go with it, or the next character is feathered as someone else's avatar. #[test] fn the_viewer_marker_follows_self_player_off_as_well_as_on() { let mut app = App::new(); let me = app .world_mut() - .spawn((net(EntityKind::Player, 1.0), crate::net::SelfPlayer)) + .spawn((net(EntityKind::Player, 1.0), crate::net::ActiveMover)) .id(); app.world_mut() .run_system_once(publish_world_units) @@ -1183,7 +1183,7 @@ mod world_unit_tests { app.world_mut() .entity_mut(me) - .remove::(); + .remove::(); app.world_mut() .run_system_once(publish_world_units) .expect("reconciler runs"); diff --git a/crates/benilla-app/src/footprints.rs b/crates/benilla-app/src/footprints.rs index 19c441ab..58f8ddab 100644 --- a/crates/benilla-app/src/footprints.rs +++ b/crates/benilla-app/src/footprints.rs @@ -49,7 +49,7 @@ use bevy::prelude::*; use crate::creature_anim::{footfall_side, move_flags, AnimSoundEvent, MovementState}; use crate::entities::{BoneAttach, Creatures}; -use crate::net::{NetEntity, ObjectStore, SelfPlayer}; +use crate::net::{ActiveMover, NetEntity, ObjectStore}; use crate::sound::footsteps::Footsteps; use benilla_assets::{AssetSet, LockRecover, WorldAssets}; use benilla_world::decal::{DecalFrame, WorldDecal}; @@ -149,7 +149,7 @@ fn load_ink( /// The spawner's ROOT-unit reads (see [`spawn_footprints`]'s `roots` param). type RootState = ( - Has, + Has, Option<&'static NetEntity>, Option<&'static ObjectStore>, Option<&'static MovementState>, @@ -169,7 +169,7 @@ fn spawn_footprints( // rider's SCALE_X — RE-corrected), and the state gates all read the root. parents: Query<&ChildOf>, roots: Query, - self_pos: Query<&Transform, With>, + self_pos: Query<&Transform, With>, joints: Query<&GlobalTransform>, footsteps: Option>, creatures: Option>, diff --git a/crates/benilla-app/src/minimap/blips/mod.rs b/crates/benilla-app/src/minimap/blips/mod.rs index 10346a0c..955bbc4f 100644 --- a/crates/benilla-app/src/minimap/blips/mod.rs +++ b/crates/benilla-app/src/minimap/blips/mod.rs @@ -66,7 +66,7 @@ use benilla_ui::script::UiScript; use crate::go_templates::GameObjectTemplates; use crate::names::NameCache; -use crate::net::{Guid, GuidIndex, NetCommands, NetEntity, ObjectStore, SelfPlayer}; +use crate::net::{ActiveMover, Guid, GuidIndex, NetCommands, NetEntity, ObjectStore, SelfPlayer}; use crate::ui_pass::{UiQuad, UiQuads, UvRect}; /// The landmark rank radius in yards (`0x8116cc` = 694.444, VERIFIED). @@ -154,7 +154,7 @@ pub(super) type TrackedCandidates<'w, 's> = Query< &'static GlobalTransform, Option<&'static ObjectStore>, ), - Without, + Without, >; /// The shared frame geometry the blip emitters draw in: the widget centre/side, the active diff --git a/crates/benilla-app/src/net.rs b/crates/benilla-app/src/net.rs index c057cf71..e448300b 100644 --- a/crates/benilla-app/src/net.rs +++ b/crates/benilla-app/src/net.rs @@ -182,11 +182,32 @@ pub(crate) struct UnitSpeeds(pub(crate) MoveSpeeds); #[derive(Component, Clone, Default)] pub(crate) struct ObjectStore(pub(crate) ObjectFields); -/// Marks our own player's streamed entity (guid == [`SelfGuid`]). The renderer skips it — the player -/// controller owns our avatar — but the controller reads its [`Transform`] to take control on login. +/// Marks our own player's streamed entity (guid == [`SelfGuid`]). This is **identity** — "my +/// character", the thing whose bags, auras, quest log and paper doll are mine. It is deliberately +/// *not* "the thing I steer": that is [`ActiveMover`], which normally sits on the same entity and +/// leaves it while you drive something else. #[derive(Component)] pub(crate) struct SelfPlayer; +/// **The one unit this client simulates locally and streams to the server** — the reference's +/// active-mover global (`ds:0xc4da98`/`0xc4da9c`, written only by `SetActiveMover 0x6006e0`), as a +/// marker on the entity that global names. +/// +/// Normally our own body, so it rides alongside [`SelfPlayer`]. While we hold somebody else's reins +/// — mind-controlling a creature, an Eye of Kilrogg — it moves to *that* entity and our own body +/// keeps none of it. The two are separate stores in the reference and separate markers here, for +/// the reason decision 0092 separates the camera eye from the active-player character: possession +/// moves what you *drive* without moving who you *are*. +/// +/// Exactly one entity carries it, and possibly none — a claimed mover whose object has not streamed +/// in yet is nobody, never a silent fallback to our own body. That distinction is load-bearing: +/// outbound `MSG_MOVE_*` carry no guid, so driving our body under a claimed creature's mover writes +/// our pose onto the creature. [`crate::player`] owns the marker's placement; everything that +/// simulates, animates from input, or streams a body reads `With`, and everything that +/// replays *server* motion reads `Without`. +#[derive(Component)] +pub(crate) struct ActiveMover; + // ── ECS state: resources ───────────────────────────────────────────────────────────────────────── /// The inbound event channel — drained each frame by [`apply_net_updates`]. diff --git a/crates/benilla-app/src/net/motion/facing.rs b/crates/benilla-app/src/net/motion/facing.rs index 9871046c..7c6d0514 100644 --- a/crates/benilla-app/src/net/motion/facing.rs +++ b/crates/benilla-app/src/net/motion/facing.rs @@ -6,7 +6,7 @@ use benilla_assets::coords::bevy_to_wow; use benilla_protocol::{EntityKind, MonsterMoveFacing}; use bevy::prelude::*; -use super::super::{GuidIndex, NetEntity, ObjectStore, SelfPlayer}; +use super::super::{ActiveMover, GuidIndex, NetEntity, ObjectStore}; use super::{yaw_of, RemoteMotion, Spline}; /// Radians/second an idle creature turns to face its target — a smooth turn, not a snap. Tunable @@ -75,7 +75,7 @@ pub(in crate::net) fn resolve_facing( /// stationary meleeing mob squares up on its victim even though the server sends **no** facing for it /// (vmangos `SetInFront` is server-only; verified by a live sniff — no `MONSTER_MOVE` arrives when you /// attack a standing mob). A moving unit (a [`Spline`]) faces its travel direction instead; a remote -/// player ([`RemoteMotion`]) and our own avatar ([`SelfPlayer`]) own their facing — all excluded. The +/// player ([`RemoteMotion`]) and the body we drive ([`ActiveMover`]) own their facing — all excluded. The /// turn is capped per frame at [`FACE_TARGET_TURN_RATE`], toward the horizontal bearing to the target. #[allow(clippy::type_complexity)] pub(in crate::net) fn face_target( @@ -84,7 +84,7 @@ pub(in crate::net) fn face_target( index: Res, candidates: Query< (Entity, &NetEntity, &ObjectStore), - (Without, Without, Without), + (Without, Without, Without), >, mut transforms: Query<&mut Transform>, // A remote player's latch belongs to the facing interp in [`super::remote`] — the cleanup diff --git a/crates/benilla-app/src/net/motion/remote.rs b/crates/benilla-app/src/net/motion/remote.rs index ed633dbe..65f25469 100644 --- a/crates/benilla-app/src/net/motion/remote.rs +++ b/crates/benilla-app/src/net/motion/remote.rs @@ -10,7 +10,7 @@ use bevy::time::Real; use crate::creature_anim::move_flags; use crate::player::{GRAVITY, TERMINAL_VELOCITY}; -use super::super::{SelfPlayer, UnitSpeeds}; +use super::super::{ActiveMover, UnitSpeeds}; use super::relay::{PendingMove, RelayChain, RelayMove}; use super::{yaw_of, Spline}; @@ -361,7 +361,7 @@ pub(in crate::net) fn drain_pending_moves( time: Res>, mut commands: Commands, mut landings: MessageWriter, - mut q: Query<(Entity, &mut RemoteMotion), (Without, Without)>, + mut q: Query<(Entity, &mut RemoteMotion), (Without, Without)>, ) { let now_ms = time.elapsed_secs_f64() * 1000.0; for (e, mut rm) in &mut q { @@ -376,7 +376,7 @@ pub(in crate::net) fn drain_pending_moves( /// *smoothly* between the sparse relay packets instead of snapping at the ~2 Hz heartbeat rate. The /// horizontal velocity is derived from the live `moveFlags` in the mover's facing frame at its run / /// run-back / swim speed; the `TURN_*` flags rotate the facing at `turn_rate`. A creature [`Spline`] -/// (server-authored path) and our own avatar ([`SelfPlayer`]) are excluded — they have their own +/// (server-authored path) and the body we drive ([`ActiveMover`]) are excluded — they have their own /// motion source. /// /// **The step is then resolved against the world** ([`crate::player::mover::grounded_step`], decision @@ -428,7 +428,7 @@ pub(in crate::net) fn extrapolate_remote_units( Has, Has, ), - (Without, Without), + (Without, Without), >, ) { use crate::creature_anim::{ease_strafe_yaw, strafe_body_offset, wrap_pi}; diff --git a/crates/benilla-app/src/player.rs b/crates/benilla-app/src/player.rs index 2ce01332..dbc7b570 100644 --- a/crates/benilla-app/src/player.rs +++ b/crates/benilla-app/src/player.rs @@ -22,7 +22,7 @@ use bevy::prelude::*; use bevy::window::{CursorOptions, PrimaryWindow}; use crate::creature_anim::{move_flags, wrap_pi, BodyTwist, MovementState}; -use crate::net::{ClientCommand, NetCommands, SelfPlayer, TeleportMessage, WorldportMessage}; +use crate::net::{ActiveMover, ClientCommand, NetCommands, TeleportMessage, WorldportMessage}; use crate::ui_script::InspectMode; use crate::ui_script::PointerOverUi; use benilla_assets::AssetSet; @@ -36,6 +36,8 @@ mod world_focus; // own `pub(super)` fields instead of widening them for a module outside. mod camera_saved; mod drunk; +// Which single unit the client embodies (decision 1277) — the `ActiveMover` marker's owner. +mod embody; mod follow; mod gait; @@ -248,14 +250,24 @@ impl Plugin for PlayerPlugin { Update, wire_in::release_on_session_end.in_set(WorldStage::Input), ) - // Mirror our body's collision height onto `Player` for the swim arm (decision 0645). - // A *continuous* sync rather than a one-shot at take-control, for the reason the - // take-control branch itself records: a cross-map worldport re-streams the `SelfPlayer` - // entity, so anything latched on that edge is lost on transfer. Before `control`, which - // is where the swim depth lines are evaluated. + // Which body the client drives at all (decision 1277). Strictly before everything that + // reads the marker — the controller, and the collision-height mirror below. .add_systems( Update, - mirror_self_collision_height + embody::maintain_active_mover + .in_set(WorldStage::Input) + .before(control) + .before(mirror_mover_collision_height), + ) + // Mirror the driven body's collision height onto `Player` for the swim arm (decision + // 0645). A *continuous* sync rather than a one-shot at take-control, for the reason the + // take-control branch itself records: a cross-map worldport re-streams the entity, so + // anything latched on that edge is lost on transfer — and a possession swaps it for a + // body of an entirely different size. Before `control`, which is where the swim depth + // lines are evaluated. + .add_systems( + Update, + mirror_mover_collision_height .in_set(WorldStage::Input) .before(control), ) @@ -290,15 +302,16 @@ impl Plugin for PlayerPlugin { } } -/// Mirror the streamed `SelfPlayer`'s [`crate::entities::CollisionHeight`] onto [`Player`] — the -/// avatar's swim depth lines are fractions of it (decision 0645), and the swim arm runs off the -/// resource, not the entity. One entity, one copy: no work until our body streams in, and it -/// re-syncs itself after a worldport re-streams that entity under a new one. -fn mirror_self_collision_height( +/// Mirror the driven body's [`crate::entities::CollisionHeight`] onto [`Player`] — its swim depth +/// lines are fractions of it (decision 0645), and the swim arm runs off the resource, not the +/// entity. One entity, one copy: no work until a body streams in, and it re-syncs itself after a +/// worldport re-streams that entity under a new one — or after a possession puts a different-sized +/// body in our hands. +fn mirror_mover_collision_height( mut player: ResMut, - self_player: Query<&crate::entities::CollisionHeight, With>, + mover: Query<&crate::entities::CollisionHeight, With>, ) { - if let Ok(&h) = self_player.single() { + if let Ok(&h) = mover.single() { if player.collision_height != h { player.collision_height = h; } @@ -386,10 +399,13 @@ fn control( // Avian's kinematic move-and-slide: sweeps the capsule against the streamed colliders (decision 0009). collide: benilla_world::collision::WorldCollision, mut cameras: Query<(&mut Transform, &mut FlyCam, &Camera)>, - // The streamed self entity: we read its server pose to take control, then drive its transform - // (feet position + facing) and feed its movement to the animation selector via `MovementState`. Its - // body model is attached by the entity renderer through the same path as any other player (0041). - mut self_player: Query< + // **The body in our hands** — normally our own streamed avatar, and a possessed creature while + // we hold its reins (decision 1277). We read its server pose to take control, then drive its + // transform (feet position + facing) and feed its movement to the animation selector via + // `MovementState`. Its model is attached by the entity renderer through the same path as any + // other unit (0041), which is exactly why a creature needs nothing special here: everything + // below reads the body's own pivot, speeds, scale and descriptor off the entity. + mut body: Query< ( Entity, &mut Transform, @@ -404,7 +420,7 @@ fn control( // (the ref's three `GetWeapon(0/1/2)` calls before the state walk, below). Option<&crate::creature_anim::Wielded>, ), - (With, Without), + (With, Without), >, window: Single<(&mut Window, &mut CursorOptions), With>, // Clicks go out here — left for the target picker, right for the context action (attack) — and @@ -432,7 +448,7 @@ fn control( (&Transform, &crate::net::Guid), ( With, - Without, + Without, Without, ), >, @@ -496,7 +512,7 @@ fn control( // movement flag and NOT an aura: the reference's `0x5145b0` computes `!STUNNED` straight off // `[[unit+0x110]+0xa0]` (`not eax; shr eax,0x12; and eax,1`) and `0x514755` consumes it to skip // the turn and pitch emitters outright. - let stunned = self_player + let stunned = body .single() .ok() .and_then(|(.., store, _, _, _, _)| { @@ -508,7 +524,7 @@ fn control( // fraction), and zero while stunned — the reference's wobble sits behind the same // input-allowed chain as the turn emitters (`0x60aa47` → `0x5145b0`). let drunk_wobble = { - let f = self_player + let f = body .single() .ok() .and_then(|(.., store, _, _, _, _)| store.and_then(|s| s.0.player_drunk_byte())) @@ -550,9 +566,13 @@ fn control( // zeroed, `0x514640` skips the whole tick at `51466c` — input is still *sampled*, the body turn // is skipped at `514474` — while the camera rotate at `514444` happens BEFORE the mover lookup // and so keeps working. A mind-controlled player can still look around; they just cannot turn - // or move their body. Same restore, for the same reason, and it covers holding somebody else's - // reins too: the turn belongs to whatever we are driving, which is not this body. - if stunned || player.control_lost || player.foreign_mover.is_some() { + // or move their body. Same restore, for the same reason — and `reseat` is the same condition + // once more, the frames where the mover global would not resolve at all. + // + // Holding *somebody else's* reins is emphatically not in this set: the turn belongs to whatever + // we are driving, and once the marker has caught up that is the creature, whose `Transform` is + // the one this `face_yaw` writes. + if stunned || player.control_lost || player.reseat { player.face_yaw = yaw_before_look; } let mouse_turned = player.face_yaw != yaw_before_look; @@ -600,26 +620,11 @@ fn control( &mut net.11, self_guid, transports, - self_player - .single() + body.single() .ok() .map(|(_, t, ..)| (t.translation, server_ride::yaw_of(t.rotation))), ); - // A mover the server took back owes it a parting pose (`CMSG_MOVE_NOT_ACTIVE_MOVER`), and this - // is the first point in the frame that has one. Without it the server never clears its - // confirmed-mover state and never re-broadcasts the stop, so observers keep the unit sliding on - // its last relayed pose. - if let Some(guid) = player.release_mover.take() { - let _ = net.0 .0.send(crate::net::ClientCommand::NotActiveMover { - guid, - flags: 0, - pos: benilla_assets::coords::bevy_to_wow(player.pos), - orientation: player.face_yaw.rem_euclid(std::f32::consts::TAU), - fall_time: 0, - }); - } - let flat = |v: Vec3| Vec3::new(v.x, 0.0, v.z).normalize_or_zero(); // The platform carry (decision 0438 phase 2): while attached to a transport, recompose the @@ -684,13 +689,18 @@ fn control( // The park below flushes one `MSG_MOVE_STOP`, which matters — we may have been mid-run at // the moment control was taken, and the server would otherwise keep extrapolating that run // for every observer. - // The same gate serves both ways of not driving our own body: the reins taken from us - // (mind-controlled), or the reins of something else in our hands. The second is the - // sharper one — outbound moves carry no guid, so with a foreign mover claimed, streaming - // our body's pose would land on the possessed unit and teleport it onto us. Standing our - // own character still is also what possession looks like from the outside. - if player.control_lost || player.foreign_mover.is_some() { - let pivot_h = self_player + // + // The same gate serves the other way of holding no reins: [`Player::reseat`], the window + // between mover guids where what we intend to drive and what carries `ActiveMover` have + // not yet met — the frame a grant lands, and every frame after it while the claimed unit + // has not streamed in. Driving during that window writes one body's pose onto another, + // because outbound moves carry no guid of their own. `apply_server_moves` above closes the + // window the moment a pose is there to adopt, so this is normally a single frame. + // + // Note this branch is deliberately NOT "we are possessing": once the marker has caught up, + // possession runs the *ordinary* controlled path below, on the creature (decision 1277). + if player.control_lost || player.reseat { + let pivot_h = body .single() .map(|(_, t, _, pivot, ..)| head_height(pivot, t.scale.x)) .unwrap_or(CAM_PIVOT_FALLBACK); @@ -715,7 +725,7 @@ fn control( return; } if player.server_riding { - let pivot_h = self_player + let pivot_h = body .single() .map(|(_, t, _, pivot, ..)| head_height(pivot, t.scale.x)) .unwrap_or(CAM_PIVOT_FALLBACK); @@ -848,6 +858,18 @@ fn control( // The net translate state — the reference's `flags & 0xf` (its four move bits), read off // the *net* axes, not the keys: W+S streams no direction bit and doesn't translate. let translating = fwd_axis != 0 || side_axis != 0; + // **The mover's own** six speeds, read once here for the whole frame — the turn below and + // the run/backpedal selection further down. All six live on the driven unit's `CMovement` + // and nothing in the reference's applied-input path ever reads *our* speeds when the mover + // is a different object (VERIFIED, decision 1278), so a possessed creature moves and turns + // at its own numbers for free: the component was on its entity all along. + let mover_speeds = body.single().ok().and_then(|q| q.7).map(|s| s.0); + // The 6th speed. Zero is the ctor state, not a rate — the client keeps no default of its + // own, so a unit whose create block has not landed falls back rather than freezing solid. + let turn_rate = mover_speeds + .map(|s| s.turn_rate) + .filter(|r| *r > 0.0) + .unwrap_or(TURN_RATE); // This frame's keyboard-turn rotation — `seat_camera` carries the camera by it rigidly // (char and camera turn as one on the reference; director's call, closing 0050's open // "camera follow on turn" feel item). @@ -860,8 +882,10 @@ fn control( if turn_right { turn -= 1.0; } - // 0.75× while also translating — the verified `flags & 0x200f` case. - let rate = TURN_RATE * if translating { TURN_RATE_MOVING } else { 1.0 }; + // 0.75× while translating **or falling** — the verified `flags & 0x200f` case, whose + // `0x2000` is FALLING (`0x7c5c73`). A jump mid-turn keeps the reduced rate. + let slowed = translating || player.airborne_since.is_some(); + let rate = turn_rate * if slowed { TURN_RATE_MOVING } else { 1.0 }; turn_delta = turn * rate * dt; player.face_yaw += turn_delta; } @@ -915,7 +939,7 @@ fn control( // observer's. `stand_pending` is the local commit (the client's `SetStandState` // applies immediately and sends, one setter — `0x6127b0`), overlaid on the echoed // byte until it lands so the pose never waits on the round-trip. - let stand_byte = self_player + let stand_byte = body .single() .ok() .and_then(|(.., store, _, _, _, _)| store.map(|s| s.0.unit_stand_state())) @@ -957,7 +981,7 @@ fn control( // wow-re `sheath-policy.md` §4): entering any stand-state ∉ {0 STAND, 2 SIT_CHAIR} // force-stows drawn weapons, through the anim layer's one setter. if s != 0 && s != 2 { - if let Ok((e, _, _, _, drv, _, _, _, _, _)) = self_player.single() { + if let Ok((e, _, _, _, drv, _, _, _, _, _)) = body.single() { if drv.and_then(|d| d.sheath_state()).unwrap_or(0) != 0 { net.3.write(crate::creature_anim::SheathRequest { entity: e, @@ -978,8 +1002,7 @@ fn control( // `sheath-policy.md`). No body model yet (no driver) drops the toggle, the client's own // refusal. if binds.fired(crate::bindings::cmd::TOGGLE_SHEATH) { - if let Ok((e, _, _, _, Some(drv), store, engaged, _, _, wielded)) = self_player.single() - { + if let Ok((e, _, _, _, Some(drv), store, engaged, _, _, wielded)) = body.single() { // The manual toggle's guard chain (decision 0080d) — the guards of the client's // 12-deep silent-refusal chain (`ToggleSheath` `0x5eb480`) whose states exist // today: dead · engaged in combat · not standing (`GetStandState() != 0` — @@ -1031,8 +1054,7 @@ fn control( // move us at the server's number). `$WOW_MOVE_SPEED` stays the absolute dev override // (backpedal keeps the vanilla 4.5/7.0 ratio under it); pre-create frames fall back the // same way. - let server_speeds = self_player.single().ok().and_then(|q| q.7).map(|s| s.0); - let (run_speed, run_back_speed) = match server_speeds { + let (run_speed, run_back_speed) = match mover_speeds { Some(s) if !move_speed.env_override => (s.run, s.run_back), _ => (move_speed.value, move_speed.value * RUN_BACK_RATIO), }; @@ -1111,7 +1133,7 @@ fn control( // (TU-F: Space is the Jump command; it is NOT a pitch or ascend input) — and never // reaches this walk-side gate. if want_jump && !moving && !swimming && player.airborne_since.is_none() { - if let Ok((e, .., store, _, _, _, _)) = self_player.single() { + if let Ok((e, .., store, _, _, _, _)) = body.single() { if store.is_some_and(|s| s.0.unit_mount_display_id() != 0) { want_jump = false; if !turning { @@ -1196,7 +1218,7 @@ fn control( // forward or strafe-only → swim; the backward bit `0x2` → `min(swimBack, swim)` — // byte-identical in template to the run arm's `min(runBack, run)`. Vanilla defaults // 4.722/2.5 (vmangos `baseMoveSpeed`). - let (swim_speed, swim_back_speed) = match server_speeds { + let (swim_speed, swim_back_speed) = match mover_speeds { Some(s) if !move_speed.env_override => (s.swim, s.swim_back), _ => (swim::SWIM_SPEED, swim::SWIM_BACK_SPEED), }; @@ -1531,6 +1553,7 @@ fn control( moving, airborne, turning || mouselook, + turn_rate, ); // Drive the streamed self entity: its transform is the avatar's pose (feet position + body @@ -1542,7 +1565,7 @@ fn control( // `CAM_PIVOT_FLOOR`). Read the avatar's model-local pivot + its live scale here (where we hold the // self entity); until the body attaches (no `CameraPivot` yet) fall back to a human neck height. let mut cam_pivot_height = CAM_PIVOT_FALLBACK; - if let Ok((entity, mut t, motion, pivot, .., twist, _)) = self_player.single_mut() { + if let Ok((entity, mut t, motion, pivot, .., twist, _)) = body.single_mut() { t.translation = player.pos; // The swim body pitch (TU-A, `0x60a110`→`0x710620`): while swimming AND moving fwd/back // the model root renders `Rz(yaw)·Ry(−pitch)` — in Bevy axes, the yaw then a nose-up diff --git a/crates/benilla-app/src/player/camera.rs b/crates/benilla-app/src/player/camera.rs index fad70c32..75173519 100644 --- a/crates/benilla-app/src/player/camera.rs +++ b/crates/benilla-app/src/player/camera.rs @@ -12,7 +12,7 @@ use bevy::window::{CursorGrabMode, CursorOptions}; use avian3d::prelude::*; -use crate::net::SelfPlayer; +use crate::net::ActiveMover; use benilla_assets::materials::WowModelMaterial; use benilla_world::interact::{WorldClick, WorldRightClick, WorldRightPress}; use benilla_world::model_fade::{ @@ -334,7 +334,7 @@ impl FlyCam { /// The per-model camera-pivot height in **model-local yards, pre-scale** — `attach17.z + 0.0972` (M2 /// attachment id 17) for a character, else `0.9 × vertex-box Z-extent`; the reference's camera-target /// height (`0x50cbc0`, wow-re `follow-camera`). Stamped on every modeled unit at attach -/// ([`crate::entities`]); `control` reads it off the [`SelfPlayer`], multiplies the live avatar scale, +/// ([`crate::entities`]); `control` reads it off the [`ActiveMover`], multiplies that body's live scale, /// and floors at [`CAM_PIVOT_FLOOR`] to get the world pivot the third-person camera looks at (and the /// first-person eye). `0.0` for a bounds-less display (→ floor). #[derive(Component, Clone, Copy)] @@ -680,7 +680,7 @@ pub(super) fn seat_camera( #[allow(clippy::type_complexity, clippy::too_many_arguments)] // one Bevy system's full input set pub(crate) fn apply_self_model_fade( rig: Res, - self_player: Query<(Entity, Option<&crate::aura_visual::AuraNodes>), With>, + self_player: Query<(Entity, Option<&crate::aura_visual::AuraNodes>), With>, children_of: Query<&Children>, mut parts: Query< ( @@ -1061,7 +1061,7 @@ mod tests { app.add_systems(Update, apply_self_model_fade); // The avatar: root -> joint (the eye-glow bone). Its card follows the joint. - let avatar = app.world_mut().spawn(SelfPlayer).id(); + let avatar = app.world_mut().spawn(ActiveMover).id(); let joint = app.world_mut().spawn(Transform::default()).id(); app.world_mut().entity_mut(avatar).add_child(joint); let eye_glow = app diff --git a/crates/benilla-app/src/player/embody.rs b/crates/benilla-app/src/player/embody.rs new file mode 100644 index 00000000..a3865cdb --- /dev/null +++ b/crates/benilla-app/src/player/embody.rs @@ -0,0 +1,323 @@ +//! **What the client embodies** — which single unit it simulates, animates from input, and streams. +//! +//! Decision 0092 gave this client two answers to "where am I?": the **camera eye** and the +//! **active-player character**. Possession forces a third, and the reference has had it all along +//! as its own global: the **active mover** (`ds:0xc4da98`, written only by `SetActiveMover +//! 0x6006e0`), which the input applier `0x514640` resolves at the top of every tick and *skips the +//! whole tick* when it does not resolve. The three are genuinely independent stores there — the +//! camera never consults the mover to pick its anchor, and neither of them touches "the active +//! player" (`ds:0xb41414`), which is invariant under both far sight and possession (VERIFIED, +//! wow-re `object-layer/scratch/farsight-and-client-control.md` §9). +//! +//! So benilla splits the *marker*, not the identity. [`SelfPlayer`] keeps meaning **my character** +//! — bags, auras, quest log, paper doll, the name over the head — and never moves. +//! [`ActiveMover`] means **the body in my hands**, and moves. Of ~150 `SelfPlayer` query sites the +//! large majority are the first kind and are untouched by possession; the ones that changed are +//! exactly those that simulate, animate from input, render the body the camera is inside, or +//! stream it. +//! +//! Three properties of the placement carry the weight: +//! +//! - **Forbidden is nobody too.** A body we are told we may not move — ours under a Mind Control, +//! or a possessed creature the moment it is feared — leaves the marker unplaced, mirroring the +//! reference's own zeroing of the mover global. That is what lets the *server* drive it: the +//! ordinary relayed-motion path skips whatever carries this marker, so a body that keeps it while +//! the controller refuses to drive it is driven by nobody at all, and stands frozen while the +//! server runs it around in front of everybody else. +//! - **Unresolvable is nobody, never a fallback.** A claimed grant whose object has not streamed in +//! leaves the marker unplaced, because the alternative — quietly leaving it on our own body — +//! drives our body under the creature's mover, and outbound `MSG_MOVE_*` carry no guid: the +//! server writes our pose onto the creature. That is the sharpest trap in this family (decision +//! 1269 §3), and here it is structurally impossible rather than guarded against. +//! - **The reins change hands by GUID, not by entity.** A cross-map worldport despawns and +//! re-streams our own body under a fresh entity while we never stop driving it, so keying the +//! handover on the entity would re-seize (and re-settle) on every zone transfer. + +use bevy::prelude::*; + +use super::follow::FollowState; +use super::state::Player; +use crate::creature_anim::MovementState; +use crate::net::{ActiveMover, GuidIndex, RemoteMotion, SelfGuid, SelfPlayer}; + +/// Keep [`ActiveMover`] on the one body we drive: the possessed unit while we hold its reins, our +/// own otherwise, and nobody at all while neither is streamed. +/// +/// Runs before the controller, which reads the marker to decide what — if anything — it is driving. +pub(super) fn maintain_active_mover( + mut commands: Commands, + mut player: ResMut, + mut follow: ResMut, + guids: (Res, Res), + self_body: Query>, + tagged: Query>, + mut driving: Local>, +) { + let (self_guid, index) = (&guids.0, &guids.1); + // Who we mean to drive, and which entity that is. Three answers, not two: + // + // - **Forbidden to move it** ([`Player::control_lost`]) — nobody. This is the reference's own + // rule rather than an optimisation: `0x5fa600` *zeroes the mover global* for the named unit, + // and the consequence is the whole point. A mind-controlled player's body stops being locally + // simulated and falls back to the ordinary relayed-motion path, so it visibly walks where its + // captor drives it instead of standing frozen while the server runs it around. The same for a + // possessed creature that has just been feared: the server's flee path drives it, and we watch + // through a camera that is still on it. + // - **A claimed foreign mover** answers by itself: either its object is streamed and it is the + // mover, or nothing is. + // - Otherwise our own body. + let want_guid = if player.control_lost { + None + } else { + player.foreign_mover.or(self_guid.0) + }; + let want = match (player.control_lost, player.foreign_mover) { + (true, _) => None, + (false, Some(guid)) => index.0.get(&guid).copied(), + (false, None) => self_body.iter().next(), + }; + + if *driving != want_guid { + *driving = want_guid; + if want_guid.is_some() { + // Whatever pose and momentum `Player` holds describe the body we just let go of, so the + // controller has to adopt the new one's before it drives anything. It also drives + // nothing at all until it has: see [`Player::reseat`]. + player.reseat = true; + // The reference tears the same thing down on the outgoing mover — `SetActiveMover + // 0x6006e0` calls `0x6103a0` → `0x60fb60(0, 1)`, cancelling click-to-move and follow. + // A follow that survived would steer the creature toward whoever our *character* was + // walking behind. + follow.stop(); + } + } + + let held = tagged.iter().next(); + if held == want { + return; + } + for e in &tagged { + commands.entity(e).remove::(); + // Hand the unit back to the ordinary remote path. [`MovementState`] is `unify`'s + // *top-precedence* leg, so one left behind would freeze the creature's animation on the + // last view we drove it with and shadow the relayed `MSG_MOVE_*` stream forever. Our own + // body keeps its own — `net::apply::tag_self_player` owns that one and never re-inserts it. + if !self_body.contains(e) { + commands.entity(e).remove::(); + } + } + if let Some(e) = want { + // Drop whatever server-replay state the unit carried at the instant we took it. While we + // drive it the server sends us no relays for it (vmangos excludes the mover's own session + // from the broadcast), so nothing accumulates — but a move already queued for a future + // apply would otherwise fire after we let go and yank the unit back to a pose it left. + commands + .entity(e) + .insert(ActiveMover) + .remove::(); + // A creature carries no controller-fed movement view — only our own avatar is given one, + // beside its `SelfPlayer` tag. Without it the possessed unit would walk with its idle + // animation playing: `control` writes this component, and `unify` reads it first. + if !self_body.contains(e) { + commands.entity(e).insert(MovementState::default()); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::net::RemoteMotion; + + fn harness() -> (App, Entity) { + let mut app = App::new(); + app.init_resource::() + .init_resource::() + .init_resource::() + .init_resource::() + .add_systems(Update, maintain_active_mover); + let me = app.world_mut().spawn(SelfPlayer).id(); + app.world_mut().resource_mut::().0 = Some(0xAA); + app.world_mut() + .resource_mut::() + .0 + .insert(0xAA, me); + (app, me) + } + + fn mover(app: &mut App) -> Option { + let mut q = app + .world_mut() + .query_filtered::>(); + q.iter(app.world()).next() + } + + /// A remote unit's replay state, minimally populated — only its presence is under test. + fn replaying() -> RemoteMotion { + RemoteMotion { + wow_pos: [0.0; 3], + orientation: 0.0, + flags: 0, + pitch: 0.0, + speed: 0.0, + vertical_velocity: 0.0, + jump_xy_vel: [0.0; 2], + fall_start_z: None, + pending: std::collections::VecDeque::new(), + relay: Default::default(), + last_apply_ms: 0.0, + last_apply_pos: [0.0; 3], + } + } + + /// The whole handover, and the two states a casual version gets wrong: a claim we cannot yet + /// resolve must leave the marker on **nobody**, and coming home must hand the creature back to + /// the remote path intact. + #[test] + fn a_claim_we_cannot_resolve_yet_moves_the_marker_to_nobody_not_to_our_own_body() { + let (mut app, me) = harness(); + app.update(); + assert_eq!(mover(&mut app), Some(me), "no claim → our own body"); + + // Claimed, but the creature has not streamed in. + app.world_mut().resource_mut::().foreign_mover = Some(0xBB); + app.update(); + assert_eq!( + mover(&mut app), + None, + "an unresolvable claim is NOBODY — leaving it on our body drives us under the \ + creature's mover, and outbound moves carry no guid of their own" + ); + assert!( + app.world().resource::().reseat, + "and the controller is told to drive nothing until it has a pose to adopt" + ); + + // It streams in. + let boar = app.world_mut().spawn(replaying()).id(); + app.world_mut() + .resource_mut::() + .0 + .insert(0xBB, boar); + app.update(); + assert_eq!(mover(&mut app), Some(boar)); + assert!( + app.world().entity(boar).contains::(), + "a creature carries no controller-fed movement view of its own — without one it walks \ + with its idle animation playing" + ); + assert!( + !app.world().entity(boar).contains::(), + "and its queued server replay is dropped, or it snaps back the moment we let go" + ); + + // Released. + app.world_mut().resource_mut::().foreign_mover = None; + app.update(); + assert_eq!(mover(&mut app), Some(me), "the reins come home"); + assert!( + !app.world().entity(boar).contains::(), + "`unify` reads this leg FIRST, so one left behind freezes the creature's animation on \ + the last view we drove it with, forever" + ); + } + + /// The one component this system must never take away. `tag_self_player` inserts our body's + /// [`MovementState`] beside the `SelfPlayer` tag and never inserts it again, so stripping it + /// here — as the creature leg does — would leave our own avatar animation-dead for the rest of + /// the session, and only after a possession. + #[test] + fn our_own_body_never_loses_its_movement_view_to_a_possession() { + let (mut app, me) = harness(); + app.world_mut() + .entity_mut(me) + .insert(MovementState::default()); + app.update(); + + app.world_mut().resource_mut::().foreign_mover = Some(0xBB); + let boar = app.world_mut().spawn(replaying()).id(); + app.world_mut() + .resource_mut::() + .0 + .insert(0xBB, boar); + app.update(); + app.world_mut().resource_mut::().foreign_mover = None; + app.update(); + + assert!( + app.world().entity(me).contains::(), + "our own body's movement view is `tag_self_player`'s to own, never this system's" + ); + } + + /// A cross-map worldport despawns and re-streams our own body under a fresh entity while we + /// never stop driving it. Keying the handover on the entity would re-seize — and re-settle — + /// on every zone transfer. + #[test] + fn re_streaming_our_own_body_moves_the_marker_without_calling_it_a_handover() { + let (mut app, me) = harness(); + app.update(); + app.world_mut().resource_mut::().reseat = false; + + app.world_mut().entity_mut(me).despawn(); + let reborn = app.world_mut().spawn(SelfPlayer).id(); + app.world_mut() + .resource_mut::() + .0 + .insert(0xAA, reborn); + app.update(); + + assert_eq!( + mover(&mut app), + Some(reborn), + "the marker follows the entity" + ); + assert!( + !app.world().resource::().reseat, + "but the mover GUID never changed, so this is not a handover: re-seizing here would \ + discard the worldport's own snap and re-run the settle" + ); + } + /// Being forbidden to move a body hands it back to the *server's* motion, and that is visible: + /// a mind-controlled player must be seen walking where their captor drives them. The marker + /// staying on our own body is what would freeze it — `control` refuses to drive it, and the + /// remote-replay path skips whatever carries the marker, so it would be driven by nobody at all. + #[test] + fn a_body_we_may_not_move_is_driven_by_nobody_so_the_server_can_drive_it() { + let (mut app, me) = harness(); + app.update(); + assert_eq!(mover(&mut app), Some(me)); + + // Mind-controlled: the server named us with allowMove = 0. + app.world_mut().resource_mut::().control_lost = true; + app.update(); + assert_eq!( + mover(&mut app), + None, + "the reins are nobody's — our body rejoins the relayed-motion path and walks where it \ + is driven, instead of standing frozen while the server runs it around" + ); + + // And a possessed creature we have just been forbidden to move behaves the same way. + app.world_mut().resource_mut::().foreign_mover = Some(0xBB); + let boar = app.world_mut().spawn(replaying()).id(); + app.world_mut() + .resource_mut::() + .0 + .insert(0xBB, boar); + app.update(); + assert_eq!(mover(&mut app), None, "held, but not ours to move"); + + // Control back: the marker returns, and the body under us is re-adopted — it moved while + // the server had it. + app.world_mut().resource_mut::().reseat = false; + app.world_mut().resource_mut::().control_lost = false; + app.update(); + assert_eq!(mover(&mut app), Some(boar)); + assert!( + app.world().resource::().reseat, + "the creature ran under the server's fear path; carrying our stale pose onto it would \ + snap it back to wherever we last drove it" + ); + } +} diff --git a/crates/benilla-app/src/player/gait.rs b/crates/benilla-app/src/player/gait.rs index f46dfb62..6a22274f 100644 --- a/crates/benilla-app/src/player/gait.rs +++ b/crates/benilla-app/src/player/gait.rs @@ -5,7 +5,7 @@ use crate::creature_anim::{ease_strafe_yaw, move_flags, strafe_body_offset, wrap_pi}; -use super::{Player, STATIONARY_CHASE_RATE, TURN_RATE}; +use super::{Player, STATIONARY_CHASE_RATE}; /// Advance `model_yaw` (the rendered body heading) one frame and return the **animation's** view of /// the move flags (the wire keeps the real key flags — observers integrate the facing from them): @@ -41,6 +41,9 @@ pub(super) fn drive_body_heading( moving: bool, airborne: bool, steering: bool, + // The mover's own turn rate (rad/s) — the release sweep is a multiple of it, so a possessed + // creature's body settles onto its aim at the creature's pace (decision 1278). + turn_rate: f32, ) -> u32 { let strafe_offset = if swimming { 0.0 @@ -58,7 +61,7 @@ pub(super) fn drive_body_heading( let delta = wrap_pi(player.face_yaw - player.model_yaw); let mut step = (delta.abs() - std::f32::consts::FRAC_PI_2).max(0.0); // the ceiling if !steering { - step += dt * TURN_RATE * STATIONARY_CHASE_RATE; // the release sweep + step += dt * turn_rate * STATIONARY_CHASE_RATE; // the release sweep } body_turn_step = step.min(delta.abs()).copysign(delta); player.model_yaw = wrap_pi(player.model_yaw + body_turn_step); @@ -75,3 +78,53 @@ pub(super) fn drive_body_heading( } anim_flags } + +#[cfg(test)] +mod tests { + use super::*; + + /// The release sweep is a multiple of the **mover's** turn rate, not of a constant. It is the + /// one place the rate reaches the *rendered* body rather than the aim, so a possessed creature + /// with a slow turn rate would otherwise snap its body around at a human's pace while its + /// facing crawled — the head and the body disagreeing about what they are attached to. + #[test] + fn the_standing_body_sweeps_back_at_the_movers_own_rate() { + let sweep = |turn_rate: f32| { + let mut player = Player { + face_yaw: 1.0, + model_yaw: 0.0, + ..Default::default() + }; + drive_body_heading(&mut player, 0, 0.01, false, false, false, false, turn_rate); + player.model_yaw + }; + // Below the 90° ceiling, the whole step is the sweep — so it scales linearly with the rate. + let slow = sweep(std::f32::consts::PI / 4.0); + let fast = sweep(std::f32::consts::PI); + assert!(slow > 0.0 && fast > slow); + assert!( + (fast / slow - 4.0).abs() < 1e-3, + "a 4× turn rate sweeps 4× as far in a frame: {slow} vs {fast}" + ); + // Steering freezes the chase whatever the rate — the byte rule, not a slow sweep. + let mut player = Player { + face_yaw: 1.0, + model_yaw: 0.0, + ..Default::default() + }; + drive_body_heading( + &mut player, + 0, + 0.01, + false, + false, + false, + true, + std::f32::consts::PI, + ); + assert_eq!( + player.model_yaw, 0.0, + "under the 90° ceiling a steering frame moves the body not at all" + ); + } +} diff --git a/crates/benilla-app/src/player/server_ride.rs b/crates/benilla-app/src/player/server_ride.rs index 3ee00731..300c5768 100644 --- a/crates/benilla-app/src/player/server_ride.rs +++ b/crates/benilla-app/src/player/server_ride.rs @@ -25,7 +25,7 @@ use benilla_assets::coords::bevy_to_wow; use bevy::prelude::*; use crate::creature_anim::{move_flags, BodyTwist, MovementState}; -use crate::net::{ClientCommand, NetCommands, SelfPlayer, Spline}; +use crate::net::{ActiveMover, ClientCommand, NetCommands, Spline}; use super::Player; @@ -54,7 +54,7 @@ pub(super) fn drive_self_ride( Option<&mut MovementState>, Option<&mut BodyTwist>, ), - With, + With, >, ) { // Only while we hold control (post-login). A free-fly detach (`F`) abandons any ride rather than @@ -186,7 +186,7 @@ mod tests { grounded: true, }, twist, - SelfPlayer, + ActiveMover, )) .id(); (app, entity, rx) diff --git a/crates/benilla-app/src/player/state.rs b/crates/benilla-app/src/player/state.rs index 8c1015e0..e430e26f 100644 --- a/crates/benilla-app/src/player/state.rs +++ b/crates/benilla-app/src/player/state.rs @@ -19,10 +19,20 @@ use bevy::prelude::*; /// ~36% shorter — no separate constant. pub(super) const RUN_BACK_RATIO: f32 = 4.5 / 7.0; -/// Character turn rate (rad/s) — how fast A/D rotate the avatar's facing when not mouse-looking (the -/// vanilla turn-vs-strafe model, VERIFIED wow-5875-re `0x7c4f30` heading integrate). It is the unit's -/// 6th movement speed (`CMovement+0x9c`, the server-seeded `TURN_RATE`; vanilla default ≈π rad/s), -/// reduced to 0.75× while also translating (the `flags & 0x200f` case). Decision 0050. +/// Turn rate (rad/s) **fallback** — how fast A/D rotate the facing when not mouse-looking, used +/// only until the mover's own sixth movement speed arrives. +/// +/// The live rate is per-unit and server-authoritative: `CMovement+0x9c`, last of six consecutive +/// speed floats on the **mover's own** `CMovement`, set by `0x7c6ff0` (which acks with +/// `CMSG_FORCE_TURN_RATE_CHANGE_ACK`, `0x2df`) and read for held turns by `GetYawRate 0x7c5c50`. +/// The base ctor `0x7c4850` *zeroes* all six, so the client keeps no default of its own — π is the +/// vanilla server's value, and this constant stands in only for frames before a create block lands. +/// VERIFIED wow-5875-re (`0x7c4f30` heading integrate; the speed block, decision 1278). +/// +/// Because it lives on the mover, a possessed creature turns at **its** rate, not ours. Reduced to +/// [`TURN_RATE_MOVING`] while translating **or falling** (`flags & 0x200f`), and the same cell is +/// the held-*pitch* rate — that integrator's keys are default-unbound, so we never bind it. +/// Decision 0050. pub(super) const TURN_RATE: f32 = std::f32::consts::PI; /// The mouselook swim-pitch clamp (radians) — **VERIFIED** ±89.0° = 1.5533431 (`0x8089d8` = @@ -468,9 +478,19 @@ pub(crate) struct Player { pub(super) follow_forward: bool, /// Free-fly (`F`): the camera moves on its own and the avatar/server position is frozen. pub(crate) detached: bool, - /// **The server has taken the reins off us** — `SMSG_CLIENT_CONTROL_UPDATE` naming our own guid - /// with `allowMove = 0`. Set while somebody is mind-controlling us; cleared when control comes - /// back (B211). + /// **The body in our hands may not be moved** — `SMSG_CLIENT_CONTROL_UPDATE` with + /// `allowMove = 0` about whatever we are currently driving. + /// + /// Two cases, one meaning (decision 1279). The packet names **us** while somebody is + /// mind-controlling us (B211); it names **the creature we are possessing** when that creature + /// becomes feared or confused, which vmangos sends from the fear/flee movement generators + /// without ending the possession at all. Both say the same thing — the reins are still where + /// they were, and nothing may move — and the reference collapses them the same way, by zeroing + /// the mover global for whichever unit the packet names when it is the one already being + /// driven. + /// + /// So this is **not** "someone is controlling me"; reading it that way is what made the second + /// case clear [`Player::foreign_mover`] and walk our own body around under a creature's mover. /// /// This is the *whole* of what stops a mind-controlled player walking away, and that is a /// verified property of the server rather than an assumption: vmangos never roots the victim, @@ -483,11 +503,6 @@ pub(crate) struct Player { /// (decision 0866), each with its own SMSG/ack pair and its own movement-flag bit; this owes no /// ack, carries no flag, and suppresses turning too — which root explicitly does not. pub(crate) control_lost: bool, - /// A mover the server just took back from us, still owed its `CMSG_MOVE_NOT_ACTIVE_MOVER`. - /// - /// Parked here rather than sent at the wire drain because the release carries a *pose*, and the - /// controller is the only thing that owns one. Cleared the frame it goes out. - pub(crate) release_mover: Option, /// **We hold somebody else's reins** — a unit the server handed us and we claimed as our mover /// (mind-controlling a creature, Eye of Kilrogg). `None` whenever the mover is our own body. /// @@ -497,10 +512,30 @@ pub(crate) struct Player { /// mover would teleport that creature onto us. Suppressing our body is also simply what /// possession looks like: your own character stands still while you drive the other thing. /// - /// Driving the held unit is the named residual (decision 1269): its capsule, speeds and - /// take-control edge are all still the avatar's alone, so for now the possessed unit stays put - /// rather than moving wrongly. + /// While this holds, [`crate::net::ActiveMover`] sits on that unit's entity — or on nothing at + /// all until it streams — and the controller drives *it*. Our own body then simulates nothing, + /// animates from nothing, and sends nothing, which is both the fix and what possession looks + /// like from the outside (decision 1277). pub(crate) foreign_mover: Option, + /// **The reins are between hands** — the body we drive has changed, and we drive *nothing* + /// until we have adopted its pose. + /// + /// Set when the mover guid changes (the control handshake in [`super::wire_in`], and + /// [`super::embody::maintain_active_mover`] when the marker follows it); cleared at the same + /// take-control edge that seizes our own body on login, once a streamed pose is actually there + /// to seize. Both halves matter: + /// + /// - **Adopt before driving** — the resource's `pos`/facing still describe the body we were + /// driving a moment ago, so the first streamed frame would otherwise report the creature + /// standing wherever we left our character. + /// - **Drive nothing while pending** — the handshake runs *inside* the controller, a frame + /// ahead of the marker that follows it, and the claimed unit may not have streamed in at all. + /// Either way there is a window where what we intend to drive and what carries the marker + /// disagree, and driving during it means writing one body's pose onto another. + /// + /// A flag rather than a direct write because the pose lives on the entity and the seize needs + /// the camera too — both of which the controller already holds and the marker's owner does not. + pub(crate) reseat: bool, /// Feet position in **Bevy** coords (converted to raw WoW only when sending to the server). pub(crate) pos: Vec3, /// Vertical velocity (yd/s, Bevy +Y up) for gravity/jump/fall. Integrated each frame; zeroed while diff --git a/crates/benilla-app/src/player/wire_in.rs b/crates/benilla-app/src/player/wire_in.rs index 9356ebee..c41775c9 100644 --- a/crates/benilla-app/src/player/wire_in.rs +++ b/crates/benilla-app/src/player/wire_in.rs @@ -21,8 +21,8 @@ use benilla_assets::coords::{bevy_to_wow, wow_to_bevy}; use crate::creature_anim::{move_flags, wrap_pi}; use crate::net::{ - ClientCommand, ClientControlMessage, Guid, MoveKind, MoveModeMessage, NetCommands, - SelfMoveMessage, SelfPlayer, SpeedChangeMessage, TeleportMessage, WorldportMessage, + ActiveMover, ClientCommand, ClientControlMessage, Guid, MoveKind, MoveModeMessage, NetCommands, + SelfMoveMessage, SpeedChangeMessage, TeleportMessage, WorldportMessage, }; use crate::transport::Transport; use benilla_world::world_map::CurrentMap; @@ -49,6 +49,32 @@ enum ControlVerdict { Released(u64), } +/// Give our own body back to the server: the stop, then the parting pose. +/// +/// `CMSG_MOVE_NOT_ACTIVE_MOVER` (`0x2D1`) is **only ever about our own character**. The reference +/// sends it from `SetActiveMover 0x6006e0` for the outgoing mover *when that mover was the local +/// player* (`0x5fa6d0`), and vmangos agrees from the other side: `HandleMoveNotActiveMover` rejects +/// any guid that is not the session's currently-confirmed mover, and rejects again if the guid names +/// something other than the player while the player's mover still is it. There is no such packet for +/// a creature we are handing back. +/// +/// Skipped while a server-authored spline owns the body: the reference returns early on that edge +/// (`0x619d50`), and vmangos would discard it anyway (`HasPendingSplineDone`). +fn yield_own_body(net_cmds: &NetCommands, player: &mut Player, self_guid: Option) { + movement_net::park_mover(&net_cmds.0, player); + let Some(me) = self_guid else { return }; + if player.server_riding { + return; + } + let _ = net_cmds.0.send(ClientCommand::NotActiveMover { + guid: me, + flags: 0, + pos: bevy_to_wow(player.pos), + orientation: player.face_yaw.rem_euclid(std::f32::consts::TAU), + fall_time: 0, + }); +} + /// Classify a control update. `self_guid` is `None` only before login has named us, where nothing /// can be "about me" yet. fn control_verdict(mover: u64, allow_move: bool, self_guid: Option) -> ControlVerdict { @@ -83,7 +109,7 @@ pub(super) fn apply_server_moves( self_guid: Option, transports: &Query< (&Transform, &Guid), - (With, Without, Without), + (With, Without, Without), >, self_pose: Option<(Vec3, f32)>, ) -> Vec { @@ -97,11 +123,27 @@ pub(super) fn apply_server_moves( // frozen server-side while our client cheerfully walks it around locally. for c in control_msgs.read() { match control_verdict(c.mover, c.allow_move, self_guid) { - ControlVerdict::Revoked => player.control_lost = true, + // Somebody is driving our body. Yielding it is not just bookkeeping: the parting + // `CMSG_MOVE_NOT_ACTIVE_MOVER` clears the server's `m_clientMoverGuid`, after which + // `GetConfirmedMover` answers null and vmangos **discards every movement packet we + // send**. That is a second, server-side belt on the immobility our own gate provides. + ControlVerdict::Revoked => { + player.control_lost = true; + yield_own_body(net_cmds, player, self_guid); + } ControlVerdict::Restored => { player.control_lost = false; player.foreign_mover = None; + player.reseat = true; // Re-claim ourselves as the mover — the same claim login makes. + // + // And send **nothing else**. A parting stop for the creature we were driving looks + // right and is a teleport: by the time this packet is written the server has + // already pointed `m_mover` back at us while `m_clientMoverGuid` still names the + // creature, so `GetConfirmedMover` falls through its mismatch branch to *us* — and + // a stop carrying the creature's pose would relocate our own character to wherever + // the creature was standing. The creature's own stop is the server's to send; it + // owns that unit again. let _ = net_cmds .0 .send(ClientCommand::SetActiveMover { guid: c.mover }); @@ -110,16 +152,35 @@ pub(super) fn apply_server_moves( // claim is what stops the controller streaming OUR body's pose under their guid — // outbound moves carry none of their own, so that would teleport them onto us. ControlVerdict::Granted(guid) => { + // Hand our own body over FIRST, while it is still the mover the server attributes + // to. One command channel, so this is genuinely ordered ahead of the claim — and + // it has to be, in both directions: vmangos rejects a + // `CMSG_MOVE_NOT_ACTIVE_MOVER` whose guid is not the mover it currently has + // confirmed, and a `MSG_MOVE_STOP` sent after the claim would carry our pose under + // the creature's guid. + yield_own_body(net_cmds, player, self_guid); + player.control_lost = false; player.foreign_mover = Some(guid); + player.reseat = true; let _ = net_cmds.0.send(ClientCommand::SetActiveMover { guid }); } - // The matching `CMSG_MOVE_NOT_ACTIVE_MOVER` rides the controller's own park, which - // owns the pose that has to go in it. + // "That unit may not move" — about a unit that is not us. Genuinely ambiguous on the + // wire, because vmangos sends it from two places: the unpossess path (right after the + // `Restored` that already took us home) and, while a possession is still very much in + // force, whenever the possessed unit becomes feared or confused. + // + // The reference resolves both with one rule, and so do we: *is this the unit I am + // driving?* If it is, we are still holding it and merely forbidden to move it — the + // mover global goes to nobody, exactly as `0x5fa600` zeroes it when the named unit is + // the current one. If it is not, `Restored` already ran and there is nothing to do. + // + // Clearing `foreign_mover` here — the obvious reading — would send us back to driving + // our own body while the server still has `m_mover` pointed at the creature, so every + // step we took would be applied to the creature instead. ControlVerdict::Released(guid) => { if player.foreign_mover == Some(guid) { - player.foreign_mover = None; + player.control_lost = true; } - player.release_mover = Some(guid); } } } @@ -287,22 +348,54 @@ pub(super) fn apply_server_moves( ); } - // Take control once the server first reports our position (the streamed `SelfPlayer` entity, - // whose transform is already in Bevy space). From here the controller drives that entity - // directly; the entity renderer attaches its body model (0041) the same way it does for any - // other player. - if !player.active { + // Take control once the server first reports our position (the streamed mover entity, whose + // transform is already in Bevy space). From here the controller drives that entity directly; + // the entity renderer attaches its body model (0041) the same way it does for any other player. + // + // The same edge serves a *mover change* — a possessed creature arriving in our hands, or our + // own body coming back (decision 1277). It is one path deliberately: seizing a body means the + // same thing either way, and the resource's pose describes whatever we were driving a moment + // ago. What differs is only the login-once half below (`first`), which owns the settle, the + // stale-world flag and the initial camera seat. + let seizing = !player.active || player.reseat; + if seizing { if let Some((pos, yaw)) = self_pose { + let first = !player.active; + player.reseat = false; + // Momentum, movement bits and the platform under our feet all belonged to the body we + // just let go of. The reference's `SetActiveMover 0x6006e0` tears exactly this down on + // the outgoing mover — resetting movement flags and cancelling click-to-move — and + // carrying any of it across would have the new body sprinting, falling or standing on a + // boat it is nowhere near. + if !first { + player.vel_y = 0.0; + player.horiz_vel = Vec3::ZERO; + player.move_flags = 0; + player.autorun = false; + player.airborne_since = None; + player.fall_far = false; + player.fall_start_y = pos.y; + player.ride = None; + // The granted modes (root, water-walk, feather-fall, hover) were granted to the + // *previous* mover; the new one's arrive on its own `SMSG_FORCE_*`/mode packets. + player.modes = Default::default(); + } player.pos = pos; player.active = true; - player.settling = true; // settle onto the initial ground once it loads (don't fall through) - player.settle_deadline = time.elapsed_secs() + SETTLE_TIMEOUT; - player.world_stale = true; // nothing is streamed yet at all — see [`Player::world_stale`] - // The streamed spawn pose carries the server's facing (the character's logout - // orientation on a fresh login) — adopt it whole, camera seated behind, like the - // reference. Zeroing here is what made every login face due north regardless. - cam.yaw = yaw; - cam.pitch = -0.45; + if first { + player.settling = true; // settle onto the initial ground once it loads (don't fall through) + player.settle_deadline = time.elapsed_secs() + SETTLE_TIMEOUT; + player.world_stale = true; // nothing is streamed yet — see [`Player::world_stale`] + // The streamed spawn pose carries the server's facing (the character's logout + // orientation on a fresh login) — adopt it whole, camera seated behind, like + // the reference. Zeroing here made every login face due north regardless. + cam.yaw = yaw; + cam.pitch = -0.45; + } + // The camera is deliberately NOT re-seated on a mover change: it is already on the new + // body (Mind Control sets `PLAYER_FARSIGHT` to the victim alongside the handoff, so + // far sight moved it there before the reins arrived), and snapping its yaw would spin + // the view for a change the player made happen on purpose. player.face_yaw = yaw; player.model_yaw = yaw; // Seed the facing-change detector with the adopted facing — the server gave it to us, @@ -314,7 +407,11 @@ pub(super) fn apply_server_moves( // cross-map worldport despawns and re-streams this entity while `player.active` stays // true — per-entity state attached only on this one-shot edge would be lost on transfer. info!( - "took control of player @ {:?} facing {:.3}{}", + "took control of {} @ {:?} facing {:.3}{}", + match player.foreign_mover { + Some(guid) => format!("possessed unit {guid:#x}"), + None => "player".to_string(), + }, player.pos, yaw.rem_euclid(std::f32::consts::TAU), crate::run_mode::free_fly_hint() @@ -392,7 +489,7 @@ fn apply_self_move( time: &Time, transports: &Query< (&Transform, &Guid), - (With, Without, Without), + (With, Without, Without), >, ) { let was_falling = player.move_flags & move_flags::FALLING != 0; diff --git a/crates/benilla-app/src/player/world_focus.rs b/crates/benilla-app/src/player/world_focus.rs index 2b27ed58..79485490 100644 --- a/crates/benilla-app/src/player/world_focus.rs +++ b/crates/benilla-app/src/player/world_focus.rs @@ -30,10 +30,12 @@ pub(super) fn publish_viewer( player: Option>, rig: Option>, screen: Option>, - store: Query<&crate::net::ObjectStore, With>, + store: Query<&crate::net::ObjectStore, With>, ) { - // The viewer's *condition*, off its own descriptor block: both are whole-screen effects keyed - // on the eye's owner, which is why they ride here rather than on any body in the scene. + // The viewer's *condition*, off **our own character's** descriptor block: both are whole-screen + // effects, which is why they ride here rather than on any body in the scene — and why they read + // `SelfPlayer` rather than the body we drive. Being drunk is a fact about you; possessing a boar + // does not sober you up, and a boar has no drunk byte to read (decision 1277). let (drunk, ghost) = match store.single() { Ok(s) => ( s.0.player_drunk_byte() diff --git a/crates/benilla-app/src/sound/liquid_loop.rs b/crates/benilla-app/src/sound/liquid_loop.rs index 919c68a5..96c44774 100644 --- a/crates/benilla-app/src/sound/liquid_loop.rs +++ b/crates/benilla-app/src/sound/liquid_loop.rs @@ -28,7 +28,7 @@ use bevy::prelude::*; use benilla_assets::coords::{bevy_to_wow, wow_to_bevy}; use benilla_formats::WaterSoundCatalog; -use crate::net::SelfPlayer; +use crate::net::ActiveMover; use benilla_assets::{AssetSet, LockRecover, WorldAssets}; use benilla_world::schedule::WorldStage; @@ -94,8 +94,8 @@ fn drive_liquid_loops( mut state: ResMut, water_sounds: Option>, world: benilla_world::world_point::WorldPoint, - player: Query<&Transform, With>, - mut emitters: Query<&mut Transform, Without>, + player: Query<&Transform, With>, + mut emitters: Query<&mut Transform, Without>, time: Res