From 5db8b5b9f45bccd0cd3cbc62056a7f3ea655d7a1 Mon Sep 17 00:00:00 2001 From: Sam Date: Fri, 14 Aug 2026 19:25:55 +0700 Subject: [PATCH] zone channels wait for the world, and end with the character MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - the zone-channel walk waits for real ground under the player, so login stops joining and leaving channels for zones you were never in - the channel session ends with the character: a character switch no longer replays the old channels into the new session - emoting at yourself goes out untargeted — "You wave.", exactly as the client sends it - a body that is not in the scene casts no shadow, names nothing and cannot be clicked; and being attached to a body is not the same as being allowed to move it Co-Authored-By: Claude Fable 5 --- crates/benilla-app/src/blob_shadow.rs | 31 ++- crates/benilla-app/src/capture/probes/act.rs | 27 ++- crates/benilla-app/src/chat_bubble.rs | 4 +- crates/benilla-app/src/creature_anim/lod.rs | 8 +- 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/nameplates.rs | 14 +- crates/benilla-app/src/net.rs | 49 ++++- crates/benilla-app/src/net/apply.rs | 7 +- crates/benilla-app/src/net/apply/objects.rs | 12 +- crates/benilla-app/src/net/motion.rs | 2 +- crates/benilla-app/src/net/motion/facing.rs | 2 +- crates/benilla-app/src/net/motion/remote.rs | 2 +- crates/benilla-app/src/net/motion/spline.rs | 15 ++ crates/benilla-app/src/player.rs | 121 ++++++----- crates/benilla-app/src/player/camera.rs | 8 +- crates/benilla-app/src/player/embody.rs | 199 ++++++++++-------- crates/benilla-app/src/player/gait.rs | 5 + crates/benilla-app/src/player/move_trace.rs | 15 ++ crates/benilla-app/src/player/movement_net.rs | 4 + crates/benilla-app/src/player/server_ride.rs | 36 +++- crates/benilla-app/src/player/state.rs | 6 +- crates/benilla-app/src/player/wire_in.rs | 56 +++-- crates/benilla-app/src/sound/liquid_loop.rs | 6 +- crates/benilla-app/src/sound/mod.rs | 6 +- crates/benilla-app/src/target/hover.rs | 31 ++- crates/benilla-app/src/ui_chat.rs | 17 +- crates/benilla-app/src/ui_chat/channels.rs | 191 ++++++++++++++++- crates/benilla-app/src/ui_chat/input/mod.rs | 35 ++- crates/benilla-app/src/ui_chat/tests.rs | 41 +++- crates/benilla-formats/src/emote_text.rs | 35 ++- 32 files changed, 754 insertions(+), 251 deletions(-) diff --git a/crates/benilla-app/src/blob_shadow.rs b/crates/benilla-app/src/blob_shadow.rs index 4dbdad19..9b817edc 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::{ActiveMover, NetEntity}; +use crate::net::{Embodied, NetEntity}; use crate::player::CameraControl; use benilla_world::decal::{DecalFrame, WorldDecal}; use benilla_world::model_fade::{fade_alpha, RenderFade}; @@ -184,8 +184,12 @@ fn update_shadows( ( &Transform, &ModelAnimations, - Has, + Has, Option<&crate::entities::mount::MountChild>, + // …and whether the owner is drawn at all. A body the exterior-scene election sent to + // pass 2 is not in the reference's scene, so it casts nothing (decision 1277). The + // election writes the ROOT's `Visibility`; this is that verdict after propagation. + Option<&InheritedVisibility>, ), Without, >, @@ -209,6 +213,10 @@ fn update_shadows( } let (mut n_total, mut n_shown, mut n_no_owner, mut n_no_clip, mut n_degen, mut n_no_ground) = (0u32, 0u32, 0u32, 0u32, 0u32, 0u32); + // Counted apart from `n_no_owner`: a body the exterior election sent to pass 2 HAS an owner, + // and folding the two would make the census answer "why did it hide" with a lie. This is the + // instrument's whole job (decision 1283). + let mut n_undrawn = 0u32; let mut root_fade: EntityHashMap = EntityHashMap::default(); for (part, fade) in &fades { let alpha = fade_alpha(fade.from, fade.to, (now - fade.started) / fade.duration); @@ -222,12 +230,23 @@ fn update_shadows( let surface_count = decals.receiver_count(); for (shadow, mut key, mut verts) in &mut shadows { n_total += 1; - let Ok((unit, anims, is_self, mount_child)) = owners.get(shadow.owner) else { + let Ok((unit, anims, is_self, mount_child, drawn)) = owners.get(shadow.owner) else { // sync_shadows despawns next frame; keep it cleared meanwhile. hide(&mut key, &mut verts); n_no_owner += 1; continue; }; + // An owner that is not drawn casts nothing. The director's report from inside Caverns of + // Time (decision 1277): the exterior election had correctly stopped drawing the Tanaris + // mobs overhead, and their shadows carried on being projected onto the cavern floor, + // because this lane keys off the unit's `Transform` and never asked whether the unit was + // in the scene. The census counts it separately, so "why is there a shadow with no + // creature" is answerable from the log rather than from a debugger. + if !drawn.is_none_or(|v| v.get()) { + hide(&mut key, &mut verts); + n_undrawn += 1; + continue; + } // Mounted: the box and the extra scale column come from the mount child (the // `mount_anims` doc above); until its model lands, the rider's own box carries the frame. let (anims, extra_scale) = match mount_child.and_then(|mc| mount_anims.get(mc.0).ok()) { @@ -436,9 +455,9 @@ fn update_shadows( }) }); debug!( - "blob shadows: {n_total} ({n_shown} shown, {n_no_owner} ownerless, {n_no_clip} \ - no-clip, {n_degen} degenerate, {n_no_ground} no-ground; {surface_count} surfaces; \ - texture {tex})" + "blob shadows: {n_total} ({n_shown} shown, {n_no_owner} ownerless, {n_undrawn} \ + undrawn, {n_no_clip} no-clip, {n_degen} degenerate, {n_no_ground} no-ground; \ + {surface_count} surfaces; texture {tex})" ); } } diff --git a/crates/benilla-app/src/capture/probes/act.rs b/crates/benilla-app/src/capture/probes/act.rs index 070e8cef..8a3d2043 100644 --- a/crates/benilla-app/src/capture/probes/act.rs +++ b/crates/benilla-app/src/capture/probes/act.rs @@ -142,10 +142,11 @@ impl Plugin for ProbeKeyPlugin { } }) .collect(); - app.insert_resource(ProbeKeys { taps }).add_systems( - bevy::app::PreUpdate, - fire_probe_key.after(bevy::input::InputSystems), - ); + app.insert_resource(ProbeKeys { taps, armed: false }) + .add_systems( + bevy::app::PreUpdate, + fire_probe_key.after(bevy::input::InputSystems), + ); } } @@ -188,14 +189,27 @@ fn probe_key_by_name(name: &str) -> Option { "Right" => KeyCode::ArrowRight, "Home" => KeyCode::Home, "End" => KeyCode::End, + // Enter at the character-select screen IS "enter world" (`char_select::input`), which makes + // the **second** login of a run reachable headlessly: `/logout` then `Enter` reproduces a + // character switch in one process. Session-scoped state that survives that boundary is a + // whole bug class, and until this key existed no probe could reach it — 1284's stale channel + // membership had to be found by the director, twice. `WOW_CHAR` is a deliberate one-shot + // (`run_mode`), so synthesizing the keypress is the only way back into the world in-process. + "Enter" => KeyCode::Enter, _ => return None, }) } -/// [`ProbeKeyPlugin`] state: one entry per scheduled tap. +/// [`ProbeKeyPlugin`] state: one entry per scheduled tap, plus the once-armed latch. #[derive(Resource)] struct ProbeKeys { taps: Vec, + /// **A self player has existed at some point this run.** The gate used to be "a self player + /// exists *right now*", which is what keeps a tap from firing into the loading screen — but it + /// also made every glue screen unreachable, and the second half of a `/logout` + `Enter` + /// character-switch probe happens at exactly one of those (1284). Latching it keeps the "not + /// before the world" intent and drops the "only while in it" accident. + armed: bool, } struct ProbeKeyTap { @@ -221,7 +235,8 @@ fn fire_probe_key( mut keys: ResMut>, mut events: MessageWriter, ) { - if probe.taps.is_empty() || self_player.is_empty() { + probe.armed |= !self_player.is_empty(); + if probe.taps.is_empty() || !probe.armed { return; } let now = time.elapsed_secs(); diff --git a/crates/benilla-app/src/chat_bubble.rs b/crates/benilla-app/src/chat_bubble.rs index ec698cff..b9e50553 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::{ActiveMover, Guid, NetEntity, SelfGuid}; +use crate::net::{Embodied, 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 cc5e1a21..8c4572ca 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::ActiveMover; +use crate::net::Embodied; use crate::target::SelectionRadius; use benilla_world::view::WorldCamera; use benilla_world::wmo_portal::{UnitWmoRoom, WmoPortalInstance, WmoRoom}; @@ -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,7 +616,7 @@ 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(ActiveMover); + app.world_mut().entity_mut(behind).insert(Embodied); advance(&mut app, PARK_AFTER_SECS + 0.2, 4); assert!( !app.world().entity(behind).contains::(), diff --git a/crates/benilla-app/src/entities.rs b/crates/benilla-app/src/entities.rs index aae1af15..1c392409 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::ActiveMover, + crate::net::Embodied, 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 `ActiveMover` away and + /// The viewer marker is reconciled, not stamped once: a `/logout` takes `Embodied` 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::ActiveMover)) + .spawn((net(EntityKind::Player, 1.0), crate::net::Embodied)) .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 58f8ddab..5feb758d 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::{ActiveMover, NetEntity, ObjectStore}; +use crate::net::{Embodied, 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 955bbc4f..46259140 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::{ActiveMover, Guid, GuidIndex, NetCommands, NetEntity, ObjectStore, SelfPlayer}; +use crate::net::{Embodied, 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/nameplates.rs b/crates/benilla-app/src/nameplates.rs index 1150566d..47ec9a6a 100644 --- a/crates/benilla-app/src/nameplates.rs +++ b/crates/benilla-app/src/nameplates.rs @@ -299,6 +299,10 @@ pub(crate) fn drive_nameplates( &Transform, Option<&ObjectStore>, Has, + // Whether the body is drawn at all — the ShouldShowName gate's unwritten first term + // (decision 1277). A body the exterior-scene election sent to pass 2 never enters the + // reference's scene, so there is nothing over which to float a name. + Option<&InheritedVisibility>, )>, self_store: Query<&ObjectStore, With>, // The show-gate inputs (one tuple param — Bevy's 16-param ceiling). @@ -383,7 +387,15 @@ pub(crate) fn drive_nameplates( // The billboard facing: screen-aligned (the camera's own rotation), this frame's camera. let facing = cam_tf.rotation; let mut seen: Vec = Vec::new(); - for (entity, net, guid, tf, store, is_self) in &units { + for (entity, net, guid, tf, store, is_self, drawn) in &units { + // **A name needs a body to float over.** The director, inside Caverns of Time: the Tanaris + // mobs 222 yd overhead had correctly stopped drawing, and their names went on hanging in + // the rock — this lane walks every net entity with a `Guid` and never asked whether the + // unit was in the scene. Ahead of the whole ShouldShowName ladder below, because it is not + // one of that gate's terms: it is the precondition for asking at all. + if !drawn.is_none_or(|v| v.get()) { + continue; + } // The ShouldShowName gate, in the client's own order (own-unit answers its cvar BEFORE // the rescue; everyone else: the current-TARGET bypass — `[0xb4e2d8]` is the selection, // not the mouseover — then the kind cvar). A unit carrying a V-key nameplate never also diff --git a/crates/benilla-app/src/net.rs b/crates/benilla-app/src/net.rs index e448300b..07a60a1e 100644 --- a/crates/benilla-app/src/net.rs +++ b/crates/benilla-app/src/net.rs @@ -40,7 +40,9 @@ use motion::{ drain_pending_moves, extrapolate_remote_units, face_target, ground_clamp_creatures, mark_swimming_creatures, sample_splines, }; -pub(crate) use motion::{jump_seed, CreatureSwimming, FacingStep, RemoteMotion, Spline}; +pub(crate) use motion::{ + jump_seed, CreatureSwimming, FacingStep, RemoteMotion, Spline, SplineStopped, +}; /// The net subsystem: spawns the background IO threads and drives the per-frame event drain. pub(crate) struct NetPlugin { @@ -184,27 +186,52 @@ pub(crate) struct ObjectStore(pub(crate) ObjectFields); /// 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 +/// *not* "the thing I steer": that is [`Embodied`], 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. +/// **The body this client is attached to** — where the camera is, whose feet make the footsteps, +/// whose height the water lines are measured against, whose server-authored spline is ours to ride. +/// The reference's **camera anchor** (`camera+0x88`), which has exactly three writers — the +/// constructor and `SetTarget 0x50d0f0`'s two legs — and is *not* touched by a control update +/// (wow-re `control-loss-and-restore.md` §3). /// /// 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*. +/// keeps none of it, exactly as `GetCamera().SetView(target)` moves the anchor at possession. That +/// is decision 0092's separation carried one step further: possession moves what you *inhabit* +/// without moving who you *are*. +/// +/// **Attached is not the same as allowed to move**, and the second is [`ActiveMover`]'s job, not +/// this marker's (decision 1281). A feared player is still attached to their own body — the +/// reference keeps the anchor on it and keeps following it, merely smoothed — while the server, not +/// their input, says where it goes. /// /// 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`. +/// our pose onto the creature. [`crate::player`] owns the placement. +#[derive(Component)] +pub(crate) struct Embodied; + +/// **The one unit this client authors motion for** — the reference's active-mover global +/// (`ds:0xc4da98`/`0xc4da9c`, written only by `SetActiveMover 0x6006e0`), as a marker on the entity +/// that global names. +/// +/// [`Embodied`] narrowed to the frames the body is ours to move. The narrowing is the reference's +/// own: `0x5fa600` **zeroes the mover globals** when a control update forbids the unit they name, +/// and with them zero the input applier (`0x514640` skips the whole tick when the mover does not +/// resolve) and every plain movement report — `0x600860`'s mover check kills the lot, heartbeat +/// included (wow-re `control-loss-and-restore.md` §2/§6). There is no separate "may not move" flag +/// in the movement path; the zeroed global *is* the immobility. +/// +/// Here it also answers the ECS's version of that question — *may the ordinary server-replay path +/// move this unit?* A body we are attached to but may not move must answer **yes**, which is how a +/// mind-controlled player sees their own character walk where their captor drives it. A body we are +/// driving must answer **no**, or the server's echo of our own movement fights the controller. So +/// the replay lanes filter `Without`, not `Without`: the two differ only +/// while control is lost, which is precisely the window where the difference is the whole point. #[derive(Component)] pub(crate) struct ActiveMover; diff --git a/crates/benilla-app/src/net/apply.rs b/crates/benilla-app/src/net/apply.rs index c84d3b41..02bb7eab 100644 --- a/crates/benilla-app/src/net/apply.rs +++ b/crates/benilla-app/src/net/apply.rs @@ -1419,9 +1419,10 @@ pub(super) fn tag_self_player( }; for (entity, guid) in &untagged { if guid.0 == me { - commands - .entity(entity) - .insert((SelfPlayer, crate::creature_anim::MovementState::default())); + // Identity only. The controller-fed [`crate::creature_anim::MovementState`] used to + // ride along here, but it belongs to whichever body we are *steering*, which is not + // always this one — `player::embody` owns it now (decision 1281). + commands.entity(entity).insert(SelfPlayer); } } } diff --git a/crates/benilla-app/src/net/apply/objects.rs b/crates/benilla-app/src/net/apply/objects.rs index 6d2587b4..3691f883 100644 --- a/crates/benilla-app/src/net/apply/objects.rs +++ b/crates/benilla-app/src/net/apply/objects.rs @@ -16,7 +16,7 @@ use benilla_world::model_fade::DespawnFade; use super::super::motion::{ create_spline, monster_move_spline, pose_transform, resolve_facing, trace_create_spline, - trace_move_snap, write_pose, + trace_move_snap, write_pose, SplineStopped, }; use super::super::{ Guid, GuidIndex, NetCommands, NetEntity, ObjectStore, RemoteMotion, SelfGuid, @@ -473,11 +473,15 @@ pub(super) fn monster_move( match monster_move_spline(path, spline_id, stop, duration_ms, flying) { // A moving path: sample_splines drives the transform along every waypoint. Some(spline) => { - commands.entity(e).insert(spline); + commands.entity(e).insert(spline).remove::(); } - // Stop/clear: freeze where the last sample left it (≈ the endpoint). + // Stop/clear: freeze where the last sample left it (≈ the endpoint) — and keep the id, + // which the server is waiting to hear back for a player-driven unit (decision 1281). None => { - commands.entity(e).remove::(); + commands + .entity(e) + .remove::() + .insert(SplineStopped(spline_id)); } } } diff --git a/crates/benilla-app/src/net/motion.rs b/crates/benilla-app/src/net/motion.rs index a90ad447..6a9b2ae3 100644 --- a/crates/benilla-app/src/net/motion.rs +++ b/crates/benilla-app/src/net/motion.rs @@ -40,7 +40,7 @@ pub(super) use spline::{ create_spline, ground_clamp_creatures, mark_swimming_creatures, monster_move_spline, sample_splines, trace_create_spline, trace_move_snap, }; -pub(crate) use spline::{CreatureSwimming, Spline}; +pub(crate) use spline::{CreatureSwimming, Spline, SplineStopped}; /// A full [`Transform`] for a freshly-spawned entity from a raw-WoW pose. WoW orientation (yaw about /// +Z) maps straight to a Bevy +Y yaw under `wow_to_bevy` — no 180° offset (unlike doodad/WMO diff --git a/crates/benilla-app/src/net/motion/facing.rs b/crates/benilla-app/src/net/motion/facing.rs index 7c6d0514..885bf2d1 100644 --- a/crates/benilla-app/src/net/motion/facing.rs +++ b/crates/benilla-app/src/net/motion/facing.rs @@ -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 the body we drive ([`ActiveMover`]) own their facing — all excluded. The +/// player ([`RemoteMotion`]) and the body we steer ([`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( diff --git a/crates/benilla-app/src/net/motion/remote.rs b/crates/benilla-app/src/net/motion/remote.rs index 65f25469..285b74e3 100644 --- a/crates/benilla-app/src/net/motion/remote.rs +++ b/crates/benilla-app/src/net/motion/remote.rs @@ -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 the body we drive ([`ActiveMover`]) are excluded — they have their own +/// (server-authored path) and the body we are steering ([`ActiveMover`]) are excluded — they have their own /// motion source. /// /// **The step is then resolved against the world** ([`crate::player::mover::grounded_step`], decision diff --git a/crates/benilla-app/src/net/motion/spline.rs b/crates/benilla-app/src/net/motion/spline.rs index 989f1617..61e8db9a 100644 --- a/crates/benilla-app/src/net/motion/spline.rs +++ b/crates/benilla-app/src/net/motion/spline.rs @@ -33,6 +33,21 @@ pub(crate) struct Spline { pub(crate) grounded: bool, } +/// **A spline the server ended by decree**, carrying the id the acknowledgement owes (decision +/// 1281). `SMSG_MONSTER_MOVE`'s stop form has no path to walk, so it leaves no [`Spline`] behind — +/// but it is a spline launch like any other on the server side, with its own fresh id, and vmangos +/// arms `HasPendingSplineDone` for it whenever the unit is a player or a player's possessed +/// creature (`MoveSplineInit::Launch`, and `Unit::StopMoving` says so in as many words: *"Will +/// trigger CMSG_MOVE_SPLINE_DONE from client"*). +/// +/// Until that acknowledgement lands — carrying **this** id, not the interrupted path's — +/// `HandleMovementOpcodes` drops every movement packet the client sends for that unit. So an +/// interrupted flee, charge or knockback needs the id kept, not discarded with the path. +/// [`crate::player::server_ride`] consumes it for the body in our hands; on anything else it is +/// inert bookkeeping, replaced by the next stop and cleared by the next real path. +#[derive(Component, Debug, Clone, Copy)] +pub(crate) struct SplineStopped(pub(crate) u32); + impl Spline { /// Average ground speed of the path in yards/second (total length ÷ duration) — what the creature /// animation selector ([`crate::creature_anim`]) reads to choose Walk vs Run. diff --git a/crates/benilla-app/src/player.rs b/crates/benilla-app/src/player.rs index dbc7b570..8459b475 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::{ActiveMover, ClientCommand, NetCommands, TeleportMessage, WorldportMessage}; +use crate::net::{ClientCommand, Embodied, NetCommands, TeleportMessage, WorldportMessage}; use crate::ui_script::InspectMode; use crate::ui_script::PointerOverUi; use benilla_assets::AssetSet; @@ -36,7 +36,7 @@ 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. +// Which single unit the client embodies (decision 1277) — the `Embodied` marker's owner. mod embody; mod follow; @@ -254,7 +254,7 @@ impl Plugin for PlayerPlugin { // reads the marker — the controller, and the collision-height mirror below. .add_systems( Update, - embody::maintain_active_mover + embody::maintain_embodiment .in_set(WorldStage::Input) .before(control) .before(mirror_mover_collision_height), @@ -309,7 +309,7 @@ impl Plugin for PlayerPlugin { /// body in our hands. fn mirror_mover_collision_height( mut player: ResMut, - mover: Query<&crate::entities::CollisionHeight, With>, + mover: Query<&crate::entities::CollisionHeight, With>, ) { if let Ok(&h) = mover.single() { if player.collision_height != h { @@ -420,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 @@ -448,7 +448,7 @@ fn control( (&Transform, &crate::net::Guid), ( With, - Without, + Without, Without, ), >, @@ -679,32 +679,51 @@ fn control( // the `sample_splines` transform and set the run animation; here we only carry the // follow-camera onto the moving avatar. Input, physics, and the outbound movement stream all // yield until the ride ends (where `drive_self_ride` acks `CMSG_MOVE_SPLINE_DONE` and resumes). - // Control revoked (B211): somebody is mind-controlling us. Same shape as the ride guard — - // camera keeps seating, input and physics and the wire all yield — and for the same reason: - // the body is not ours to drive this frame. It is NOT the free-fly branch below, which would - // fly the camera off the body; and it is not root, which leaves turning live. Nothing else - // will stop us: the server neither roots the victim nor validates their movement, so this - // gate IS the immobility (see `Player::control_lost`). + // **The three ways of not driving**, and they share every line of their answer: the camera + // keeps seating on the body, input and physics and the outbound stream all yield. // - // 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. + // - `server_riding` — a server-authored spline (Charge/knockback/taxi/a flee path) owns the + // avatar. `drive_self_ride`, ordered just before us, has already mirrored the sampled + // transform into `Player`, so there is nothing to sync and nothing to park: it is + // reporting FORWARD on the wire on purpose. + // - `control_lost` (B211) — somebody else is driving our body, or the body in our hands has + // been feared out of our control. It is NOT the free-fly branch below, which would fly + // the camera off the body; and it is not root, which leaves turning live. Nothing else + // will stop us: the server neither roots the victim nor validates their movement, so this + // gate IS the immobility (see `Player::control_lost`). + // - [`Player::reseat`] — the window between mover guids where what we intend to drive and + // what carries `Embodied` 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 it the moment a pose is there to adopt, so this is + // normally a single frame. // - // 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 { + // Note the middle one 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.server_riding || player.control_lost || player.reseat { + // Whoever is moving the body, its transform is the truth and `Player` follows it + // (decision 1281). Skipping this is what stranded the camera during a fear: the body + // ran off on its spline while the orbit stayed at the pose the controller last wrote, + // which reads as the view detaching into free flight (director, 2026-08-13). A + // `reseat` window is excluded — there the resource still describes the body we are + // letting go of, and `apply_server_moves` owns the adoption. + if player.control_lost && !player.reseat { + if let Ok((_, t, ..)) = body.single() { + let yaw = server_ride::yaw_of(t.rotation); + player.pos = t.translation; + player.face_yaw = yaw; + player.model_yaw = yaw; + } + } let pivot_h = body .single() .map(|(_, t, _, pivot, ..)| head_height(pivot, t.scale.x)) .unwrap_or(CAM_PIVOT_FALLBACK); let head = player.pos + Vec3::Y * (CAPSULE_HEIGHT - CAPSULE_RADIUS); + // Far sight outlives all three, so it has to be honoured here too — Sentry Totem + // carries no interrupt flags at all, which means you can board a taxi with your view + // still on the totem. Same substitution as the main path below; skipping it would read + // as far sight mysteriously dropping the moment a spline takes the body. let (orbit_pos, sweep_from, orbit_pivot) = match view_subject.remote { Some(v) => (v.feet, v.sweep_origin(), v.pivot_height), None => (player.pos, head, pivot_h), @@ -721,35 +740,11 @@ fn control( &collide, cam_probe, ); - movement_net::park_mover(&net.0 .0, &mut player); - return; - } - if player.server_riding { - let pivot_h = body - .single() - .map(|(_, t, _, pivot, ..)| head_height(pivot, t.scale.x)) - .unwrap_or(CAM_PIVOT_FALLBACK); - let head = player.pos + Vec3::Y * (CAPSULE_HEIGHT - CAPSULE_RADIUS); - // Far sight outlives a ride, so it has to be honoured here too — Sentry Totem carries - // no interrupt flags at all, which means you can board a taxi with your view still on - // the totem. Same substitution as the main path below; skipping it would read as far - // sight mysteriously dropping the moment a spline takes the body. - let (orbit_pos, sweep_from, orbit_pivot) = match view_subject.remote { - Some(v) => (v.feet, v.sweep_origin(), v.pivot_height), - None => (player.pos, head, pivot_h), - }; - seat_camera( - dt, - 0.0, - orbit_pos, - sweep_from, - orbit_pivot, - &mut rig, - &mut cam, - &mut cam_t, - &collide, - cam_probe, - ); + // Flush a stale run once, so observers stop extrapolating it — but never under a ride, + // whose FORWARD report is deliberate and would be cancelled every frame. + if !player.server_riding { + movement_net::park_mover(&net.0 .0, &mut player); + } return; } // ── Autorun ── TOGGLEAUTORUN through the binding table (0997; 1.12 defaults NUMLOCK + @@ -1692,9 +1687,21 @@ fn control( // toggling autorun on with W already held raises no new direction bit (VERIFIED wire-silence), // yet the reference still cancels. (0445's row says "YES" unqualified; wow-re RF-0079 §5 // corrects it to the ON edge.) - if move_flags_now & move_flags::ANY_MOVE & !player.move_flags != 0 - || jumped - || autorun_armed + // + // **And it is a fact about our own character, not about whatever we are steering** + // (decision 1281). The whole point of Mind Control is walking the victim around while the + // channel holds, and the interrupt this feeds is the *caster's*: vmangos breaks a channel on + // `m_caster`'s own position moving (`Spell::update`), and a possessed creature's steps never + // touch it. Without this gate the first movement key after a Mind Control shipped + // `CMSG_CANCEL_CHANNELLING` for our own channel 23 ms later, ending the possession — and + // since the reins then came home mid-keypress, the still-held key ran our own character, + // which reads exactly like "moving my character cancelled the spell" (director, 2026-08-13; + // reproduced live, `WOW_CAST_TRACE`). + let steering_ourselves = player.foreign_mover.is_none(); + if steering_ourselves + && (move_flags_now & move_flags::ANY_MOVE & !player.move_flags != 0 + || jumped + || autorun_armed) { net.7 .0 = true; } diff --git a/crates/benilla-app/src/player/camera.rs b/crates/benilla-app/src/player/camera.rs index 75173519..7aa093ba 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::ActiveMover; +use crate::net::Embodied; 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 [`ActiveMover`], multiplies that body's live scale, +/// ([`crate::entities`]); `control` reads it off the [`Embodied`], 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(ActiveMover).id(); + let avatar = app.world_mut().spawn(Embodied).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 index a3865cdb..6364029b 100644 --- a/crates/benilla-app/src/player/embody.rs +++ b/crates/benilla-app/src/player/embody.rs @@ -9,21 +9,26 @@ //! 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 +//! So benilla splits the *markers*, not the identity. [`SelfPlayer`] keeps meaning **my character** +//! — bags, auras, quest log, paper doll, the name over the head — and never moves. [`Embodied`] +//! means **the body I am attached to** (the reference's camera anchor, `camera+0x88`) and +//! [`ActiveMover`] means **the body I may move** (those mover globals). Of ~150 `SelfPlayer` query +//! sites the large majority are identity 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. +//! - **Attached is not allowed to move** (decision 1281). A control update that forbids a body does +//! not detach us from it: [`Embodied`] stays and only [`ActiveMover`] comes off. That is the +//! reference's own shape — `0x5fa600` zeroes the mover globals and never touches the camera +//! anchor, so the camera goes on following your feared body, merely smoothed (wow-re +//! `object-layer/scratch/control-loss-and-restore.md` §2/§3). Collapsing the two cost more than +//! the camera: the self-spline ride hangs off attachment, and with it the +//! `CMSG_MOVE_SPLINE_DONE` vmangos arms a wait for at every spline launch for a +//! player-or-player-possessed unit (`MoveSplineInit::Launch`), dropping **every** movement packet +//! until it arrives (`HandleMovementOpcodes`). A client that detaches while feared is frozen +//! server-side long after the fear ends. //! - **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 @@ -38,47 +43,45 @@ use bevy::prelude::*; use super::follow::FollowState; use super::state::Player; use crate::creature_anim::MovementState; -use crate::net::{ActiveMover, GuidIndex, RemoteMotion, SelfGuid, SelfPlayer}; +use crate::net::{ActiveMover, Embodied, 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. +/// Keep [`Embodied`] on the one body we are attached to — the possessed unit while we hold its +/// reins, our own otherwise, and nobody at all while neither is streamed — and [`ActiveMover`] on +/// it for as long as that body is also ours to move. /// -/// Runs before the controller, which reads the marker to decide what — if anything — it is driving. -pub(super) fn maintain_active_mover( +/// Runs before the controller, which reads both to decide what — if anything — it is driving. +// A Bevy system's parameter list, not an argument list. +#[allow(clippy::too_many_arguments)] +pub(super) fn maintain_embodiment( mut commands: Commands, mut player: ResMut, mut follow: ResMut, guids: (Res, Res), self_body: Query>, - tagged: Query>, - mut driving: Local>, + attached: Query>, + steering: Query>, + mut held: Local>, ) { let (self_guid, index) = (&guids.0, &guids.1); - // Who we mean to drive, and which entity that is. Three answers, not two: + // Which body we are attached to, and which entity that is. Two answers: // - // - **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. + // body we inhabit, 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(), + // + // Being *forbidden to move it* is deliberately not a third answer (decision 1281). It is the + // narrow [`ActiveMover`] marker below that comes off, and `control`'s own gate that stops + // driving; detaching outright was decision 1279's mistake, and it took the camera, the + // collision height and — the expensive one — the self-spline ride and its mandatory + // `CMSG_MOVE_SPLINE_DONE` with it. + let want_guid = player.foreign_mover.or(self_guid.0); + let want = match player.foreign_mover { + Some(guid) => index.0.get(&guid).copied(), + None => self_body.iter().next(), }; - if *driving != want_guid { - *driving = want_guid; + if *held != want_guid { + *held = 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 @@ -92,35 +95,40 @@ pub(super) fn maintain_active_mover( } } - let held = tagged.iter().next(); - if held == want { + // The narrow half: whether that body is ours to move *this frame*. It changes on its own edges — + // a fear lands and lifts without the reins ever changing hands — so it is maintained beside the + // hand-over below, not inside it. + let steer = want.filter(|_| !player.control_lost); + for e in &steering { + if Some(e) != steer { + // [`MovementState`] rides with it, because it is `unify`'s **top-precedence** leg: a + // body that keeps one while nothing is writing it animates from the last view we drove + // it with, whatever the server is actually doing to it. Dropping it hands the animation + // to the same source that now owns the motion — the relayed stream, or the spline the + // server is flee-pathing it along. + commands.entity(e).remove::<(ActiveMover, MovementState)>(); + } + } + if let Some(e) = steer { + if !steering.contains(e) { + commands + .entity(e) + .insert((ActiveMover, MovementState::default())); + } + } + + if attached.iter().next() == 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::(); - } + for e in &attached { + 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()); - } + commands.entity(e).insert(Embodied).remove::(); } } @@ -135,7 +143,7 @@ mod tests { .init_resource::() .init_resource::() .init_resource::() - .add_systems(Update, maintain_active_mover); + .add_systems(Update, maintain_embodiment); let me = app.world_mut().spawn(SelfPlayer).id(); app.world_mut().resource_mut::().0 = Some(0xAA); app.world_mut() @@ -146,6 +154,11 @@ mod tests { } fn mover(app: &mut App) -> Option { + let mut q = app.world_mut().query_filtered::>(); + q.iter(app.world()).next() + } + + fn steered(app: &mut App) -> Option { let mut q = app .world_mut() .query_filtered::>(); @@ -222,17 +235,16 @@ mod tests { ); } - /// 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. + /// The controller-fed [`MovementState`] follows the reins — including all the way home. It is + /// `unify`'s top-precedence leg, so whoever holds it decides what the body looks like it is + /// doing; leaving one on a body we have let go of freezes its animation on the last view we + /// drove it with, and failing to give it back leaves our own avatar animation-dead for the rest + /// of the session, and only ever after a possession. #[test] - fn our_own_body_never_loses_its_movement_view_to_a_possession() { + fn our_own_body_gets_its_movement_view_back_when_the_reins_come_home() { let (mut app, me) = harness(); - app.world_mut() - .entity_mut(me) - .insert(MovementState::default()); app.update(); + assert!(app.world().entity(me).contains::()); app.world_mut().resource_mut::().foreign_mover = Some(0xBB); let boar = app.world_mut().spawn(replaying()).id(); @@ -241,13 +253,21 @@ mod tests { .0 .insert(0xBB, boar); app.update(); + assert!( + !app.world().entity(me).contains::(), + "while we are driving the boar, nothing is writing our own body's view — a stale one \ + would shadow whatever the server is doing to it" + ); + assert!(app.world().entity(boar).contains::()); + 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" + "and it comes back with the reins — without this our own avatar is animation-dead for \ + the rest of the session, and only ever after a possession" ); + assert!(!app.world().entity(boar).contains::()); } /// A cross-map worldport despawns and re-streams our own body under a fresh entity while we @@ -278,27 +298,36 @@ mod tests { 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. + /// **Forbidden to move it is not letting go of it** (decision 1281) — the split the two markers + /// exist for. Being feared hands the body's *motion* to the server, which is visible and must + /// be: a mind-controlled player is seen walking where their captor drives them. But it does not + /// hand back the body, and letting go was expensive in a way nothing on screen showed — the + /// self-spline ride goes with it, and with the ride the `CMSG_MOVE_SPLINE_DONE` the server + /// blocks every later movement packet on. #[test] - fn a_body_we_may_not_move_is_driven_by_nobody_so_the_server_can_drive_it() { + fn a_body_we_may_not_move_stays_in_our_hands_and_only_stops_being_steered() { let (mut app, me) = harness(); app.update(); assert_eq!(mover(&mut app), Some(me)); + assert_eq!(steered(&mut app), Some(me)); - // Mind-controlled: the server named us with allowMove = 0. + // Feared, or mind-controlled: the server named us with allowMove = 0. app.world_mut().resource_mut::().control_lost = true; app.update(); assert_eq!( mover(&mut app), + Some(me), + "still our body — the camera, the collision height and the spline ride all hang off \ + this marker, and a client that drops it goes blind while feared" + ); + assert_eq!( + steered(&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" + "but not ours to move: the replay lanes read THIS marker, so the body walks where the \ + server drives it instead of standing frozen" ); - // And a possessed creature we have just been forbidden to move behaves the same way. + // A possessed creature that has just been feared out of our control behaves the same way. app.world_mut().resource_mut::().foreign_mover = Some(0xBB); let boar = app.world_mut().spawn(replaying()).id(); app.world_mut() @@ -306,18 +335,12 @@ mod tests { .0 .insert(0xBB, boar); app.update(); - assert_eq!(mover(&mut app), None, "held, but not ours to move"); + assert_eq!(mover(&mut app), Some(boar), "held, but not ours to move"); + assert_eq!(steered(&mut app), None); - // 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; + // Control back. 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" - ); + assert_eq!(steered(&mut app), Some(boar)); } } diff --git a/crates/benilla-app/src/player/gait.rs b/crates/benilla-app/src/player/gait.rs index 6a22274f..c4e67f72 100644 --- a/crates/benilla-app/src/player/gait.rs +++ b/crates/benilla-app/src/player/gait.rs @@ -33,6 +33,11 @@ use super::{Player, STATIONARY_CHASE_RATE}; /// MOUSE turn shuffles once the body steps (no key flag involved). A deck turning under the rider /// carries `model_yaw` rigidly at the ride block, so it never registers as a body step here — /// the shuffle (and its keyframed step sounds) only ever sees real turns (decision 0458/0466). +// The eighth parameter arrived with the possessed mover's own turn rate (1278) and clippy's +// default bound is seven. These are one frame of mover state, each read from a different one of +// the caller's queries — bundling them into a struct would name nothing the call site does not +// already say, and the gate is `-D warnings`, so the honest move is to say why, here. +#[allow(clippy::too_many_arguments)] pub(super) fn drive_body_heading( player: &mut Player, move_flags_now: u32, diff --git a/crates/benilla-app/src/player/move_trace.rs b/crates/benilla-app/src/player/move_trace.rs index fef242b8..aaa379ab 100644 --- a/crates/benilla-app/src/player/move_trace.rs +++ b/crates/benilla-app/src/player/move_trace.rs @@ -9,6 +9,8 @@ //! jitters at the surface") is a question about the depth signal frame by frame, and no other //! tag carries it: `move` is emitted by the walk arm only, so the swim frames of a flapping //! latch are exactly the ones it drops (decision 0644). +//! - **`mvr`** — one line per mover-claim packet ([`mover_claim`]): who we told the server we are +//! driving, which is what decides whether anything under `snd` is accepted. //! - **`snd`** — one line per outbound `MSG_MOVE_*` ([`sent`]), the send-side twin of `net::motion`'s //! `rly`: it makes **our own wire cadence measurable** (decision 0617), which is the only way to //! compare it against the reference's — the 1.12.1 sniff's client stream is a list of exactly these @@ -64,6 +66,19 @@ pub(super) fn sent(kind: crate::net::MoveKind, flags: u32, facing: f32, pos: [f3 ); } +/// One `mvr` line per **mover-claim** packet — `CMSG_SET_ACTIVE_MOVER` and +/// `CMSG_MOVE_NOT_ACTIVE_MOVER`. Neither is a `MSG_MOVE_*`, so neither appears under `snd`, and yet +/// the pair decides whether every packet that *does* is accepted at all: vmangos matches each +/// against the session's confirmed mover and answers a mismatch with a server-side error log the +/// client never sees. A possession that goes wrong is unreadable from `snd` alone — the stream looks +/// perfect and lands nowhere (decision 1281; the gap cost a live probe round to notice). +pub(super) fn mover_claim(what: &str, guid: u64) { + if !trace::enabled() { + return; + } + trace::line("mvr", &format!("{what} guid={guid:#x}")); +} + /// One `swim` line per frame the avatar is over liquid: the waterline, the feet, the submersion /// depth and the two latch thresholds it is being compared against, plus the regime that ran. The /// `<` / `>` markers flag the frames where the depth crossed a threshold, so a latch that is diff --git a/crates/benilla-app/src/player/movement_net.rs b/crates/benilla-app/src/player/movement_net.rs index f1998e40..28358db1 100644 --- a/crates/benilla-app/src/player/movement_net.rs +++ b/crates/benilla-app/src/player/movement_net.rs @@ -403,6 +403,10 @@ pub(super) fn park_mover(sender: &Sender, player: &mut Player) { let facing = player.face_yaw.rem_euclid(std::f32::consts::TAU); let pos = bevy_to_wow(player.pos); player.last_pos = pos; // the park is a position report too (decision 0907's reconcile) + // Traced like every other outbound move: the park is a real `MSG_MOVE_STOP` on the wire, and + // leaving it off `snd` made the trace lie by omission at exactly the edges it was wanted for — + // the handover, the free-fly detach, the moment control is taken (decision 1281). + super::move_trace::sent(MoveKind::Stop, 0, facing, pos); let _ = sender.send(ClientCommand::Move { kind: MoveKind::Stop, flags: 0, diff --git a/crates/benilla-app/src/player/server_ride.rs b/crates/benilla-app/src/player/server_ride.rs index 300c5768..a901b95c 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::{ActiveMover, ClientCommand, NetCommands, Spline}; +use crate::net::{ClientCommand, Embodied, NetCommands, Spline, SplineStopped}; use super::Player; @@ -51,10 +51,11 @@ pub(super) fn drive_self_ride( Entity, &Transform, Option<&Spline>, + Option<&SplineStopped>, 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 @@ -63,7 +64,7 @@ pub(super) fn drive_self_ride( player.server_riding = false; return; } - let Ok((entity, transform, spline, motion, twist)) = q.single_mut() else { + let Ok((entity, transform, spline, stopped, motion, twist)) = q.single_mut() else { return; }; // A teleport landed since last frame: the server relocated us, voiding any in-progress ride @@ -77,6 +78,9 @@ pub(super) fn drive_self_ride( if spline.is_some() { commands.entity(entity).remove::(); } + if stopped.is_some() { + commands.entity(entity).remove::(); + } if player.server_riding { player.server_riding = false; player.move_flags = 0; @@ -140,11 +144,33 @@ pub(super) fn drive_self_ride( // strafe-engaged charge sliding sideways out of its landing. player.vel_y = 0.0; player.horiz_vel = Vec3::ZERO; + // **Whose id?** The one the server is actually waiting on. A ride that ran to its own + // end leaves the path's id as the newest, but a ride the server *cut short* was cut by + // launching a fresh stop spline, and vmangos checks the ack against that newest id + // (`HandleMoveSplineDone`) — so an interrupted flee or charge acked with the path's id + // is silently rejected, and every movement packet after it is dropped (decision 1281). + let spline_id = stopped.map_or(player.ride_spline_id, |s| s.0); + if stopped.is_some() { + commands.entity(entity).remove::(); + } let _ = net.0.send(ClientCommand::MoveSplineDone { flags: 0, pos: bevy_to_wow(player.pos), orientation: player.face_yaw, - spline_id: player.ride_spline_id, + spline_id, + }); + } + // A stop with no ride behind it — the server halting a body it was already holding still + // (the fear that ends between flee paths, the possession's opening `StopMoving`). It arms + // the same wait and owes the same answer. + None if stopped.is_some() => { + let id = stopped.expect("checked above").0; + commands.entity(entity).remove::(); + let _ = net.0.send(ClientCommand::MoveSplineDone { + flags: 0, + pos: bevy_to_wow(transform.translation), + orientation: yaw_of(transform.rotation), + spline_id: id, }); } None => {} @@ -186,7 +212,7 @@ mod tests { grounded: true, }, twist, - ActiveMover, + Embodied, )) .id(); (app, entity, rx) diff --git a/crates/benilla-app/src/player/state.rs b/crates/benilla-app/src/player/state.rs index e430e26f..7511dfbb 100644 --- a/crates/benilla-app/src/player/state.rs +++ b/crates/benilla-app/src/player/state.rs @@ -512,7 +512,7 @@ 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. /// - /// While this holds, [`crate::net::ActiveMover`] sits on that unit's entity — or on nothing at + /// While this holds, [`crate::net::Embodied`] 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). @@ -521,7 +521,7 @@ pub(crate) struct Player { /// 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 + /// [`super::embody::maintain_embodiment`] 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: /// @@ -670,7 +670,7 @@ pub(crate) struct Player { /// The **swim pitch** (radians, +up) — the client's persistent per-unit pitch (`CMovement+0x20`, /// the swim §5's TU-B): **held** when unsteered (an idle floater keeps its pitch — never /// auto-leveled; the only zeroing writer `0x7c6e80` fires from stop-swim/teleport, not mouse - /// release). Steered by mouselook as a **DIRECT set** of the camera aim pitch, clamped + /// release). ActiveMover by mouselook as a **DIRECT set** of the camera aim pitch, clamped /// [`MOUSELOOK_PITCH_CLAMP`] (±89°) — **VERIFIED** (the camera-pitch §5, wow-re /// `swim-camera-pitch.md`, decision 0492, refuting the earlier no-camera-coupling census): /// the ref's mouse-move event chain lands in `SetPitch 0x7c6f70`, an unconditional store diff --git a/crates/benilla-app/src/player/wire_in.rs b/crates/benilla-app/src/player/wire_in.rs index c41775c9..4f064ede 100644 --- a/crates/benilla-app/src/player/wire_in.rs +++ b/crates/benilla-app/src/player/wire_in.rs @@ -21,7 +21,7 @@ use benilla_assets::coords::{bevy_to_wow, wow_to_bevy}; use crate::creature_anim::{move_flags, wrap_pi}; use crate::net::{ - ActiveMover, ClientCommand, ClientControlMessage, Guid, MoveKind, MoveModeMessage, NetCommands, + ClientCommand, ClientControlMessage, Embodied, Guid, MoveKind, MoveModeMessage, NetCommands, SelfMoveMessage, SpeedChangeMessage, TeleportMessage, WorldportMessage, }; use crate::transport::Transport; @@ -49,7 +49,7 @@ enum ControlVerdict { Released(u64), } -/// Give our own body back to the server: the stop, then the parting pose. +/// Give our own body back to the server on a **hand-over**: 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 @@ -58,6 +58,18 @@ enum ControlVerdict { /// 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. /// +/// **And none for a body merely frozen, either** (decision 1281). Being feared, confused or +/// mind-controlled is not a mover change: our own body is still the mover, and the server still +/// expects us to answer for it. What this packet does server-side is clear `m_clientMoverGuid`, and +/// that single field is the key to two doors — `HandleMoveSplineDone` refuses any acknowledgement +/// whose unit is not the confirmed mover, while `MoveSplineInit::Launch` arms +/// `HasPendingSplineDone` on every spline it starts for a player, and `HandleMovementOpcodes` drops +/// **every** movement packet until that acknowledgement arrives. Sending it on a fear therefore +/// locks the client out of its own body for good: the flee splines can no longer be acked, so the +/// server keeps our position and orientation frozen at the moment of the fear long after it lifts, +/// and every spell we cast answers "you are facing the wrong way" (director, 2026-08-13). Only a +/// map change clears it (`Map::AddPlayerToMap`), which is why relogging appeared to fix it. +/// /// 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) { @@ -66,6 +78,7 @@ fn yield_own_body(net_cmds: &NetCommands, player: &mut Player, self_guid: Option if player.server_riding { return; } + super::move_trace::mover_claim("NOT_ACTIVE_MOVER", me); let _ = net_cmds.0.send(ClientCommand::NotActiveMover { guid: me, flags: 0, @@ -107,10 +120,7 @@ pub(super) fn apply_server_moves( self_moves: &mut MessageReader, control_msgs: &mut MessageReader, self_guid: Option, - transports: &Query< - (&Transform, &Guid), - (With, Without, Without), - >, + transports: &Query<(&Transform, &Guid), (With, Without, Without)>, self_pose: Option<(Vec3, f32)>, ) -> Vec { // The control handoff (B211). Two questions, and they are NOT the same one: "is this about my @@ -123,19 +133,24 @@ 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) { - // 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. + // Somebody is driving our body. We stop driving it and say **nothing** — the mover has + // not changed hands, only the permission to move it, and the one packet that looks + // right here (`CMSG_MOVE_NOT_ACTIVE_MOVER`) is the one that strands us: see + // [`yield_own_body`]. The flush below is the whole of our answer, and it matters — we + // may have been mid-run when the fear landed, and the server would otherwise keep + // extrapolating that run for every observer. ControlVerdict::Revoked => { player.control_lost = true; - yield_own_body(net_cmds, player, self_guid); + movement_net::park_mover(&net_cmds.0, player); } ControlVerdict::Restored => { player.control_lost = false; player.foreign_mover = None; player.reseat = true; - // Re-claim ourselves as the mover — the same claim login makes. + // Re-claim ourselves as the mover — the same claim login makes. After a possession + // it is mandatory: the server's `m_clientMoverGuid` still names the creature we + // were driving. After a plain fear it is a no-op re-assert, because nothing ever + // took our own claim away (see [`yield_own_body`]). // // 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 @@ -144,6 +159,7 @@ pub(super) fn apply_server_moves( // 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. + super::move_trace::mover_claim("SET_ACTIVE_MOVER", c.mover); let _ = net_cmds .0 .send(ClientCommand::SetActiveMover { guid: c.mover }); @@ -158,10 +174,19 @@ pub(super) fn apply_server_moves( // `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); + // …but only if it still IS the mover the server attributes to us. A grant is not + // always a first grant: vmangos re-sends one every time a possessed creature stops + // fleeing (`Unit::UpdateControl` off the fear generator's `Finalize`), and by then + // our own body was handed over long ago. Yielding it twice sends a + // `CMSG_MOVE_NOT_ACTIVE_MOVER` naming a unit that is not the confirmed mover, which + // vmangos rejects with an error log every time the creature is feared. + if player.foreign_mover.is_none() { + yield_own_body(net_cmds, player, self_guid); + } player.control_lost = false; player.foreign_mover = Some(guid); player.reseat = true; + super::move_trace::mover_claim("SET_ACTIVE_MOVER", guid); let _ = net_cmds.0.send(ClientCommand::SetActiveMover { guid }); } // "That unit may not move" — about a unit that is not us. Genuinely ambiguous on the @@ -487,10 +512,7 @@ fn apply_self_move( player: &mut Player, cam: &mut FlyCam, time: &Time, - transports: &Query< - (&Transform, &Guid), - (With, Without, Without), - >, + transports: &Query<(&Transform, &Guid), (With, Without, Without)>, ) { let was_falling = player.move_flags & move_flags::FALLING != 0; player.move_flags = merge_server_flags(player.move_flags, m.flags); diff --git a/crates/benilla-app/src/sound/liquid_loop.rs b/crates/benilla-app/src/sound/liquid_loop.rs index 96c44774..e0eaf1e9 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::ActiveMover; +use crate::net::Embodied; 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