zone channels wait for the world, and end with the character

- 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 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-08-14 19:25:55 +07:00
co-authored by Claude Fable 5
parent 6950e62135
commit 5db8b5b9f4
32 changed files with 754 additions and 251 deletions
+25 -6
View File
@@ -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<ActiveMover>,
Has<Embodied>,
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<BlobShadow>,
>,
@@ -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<f32> = 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})"
);
}
}
+21 -6
View File
@@ -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<KeyCode> {
"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<ProbeKeyTap>,
/// **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<ButtonInput<KeyCode>>,
mut events: MessageWriter<bevy::input::keyboard::KeyboardInput>,
) {
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();
+2 -2
View File
@@ -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<VPlates>,
self_guid: Res<SelfGuid>,
units: Query<(Entity, &Guid, &Transform), With<NetEntity>>,
self_q: Query<&Transform, With<ActiveMover>>,
self_q: Query<&Transform, With<Embodied>>,
camera: Query<(&Camera, &Transform), With<WorldCamera>>,
mut atlas: Option<ResMut<UiFontAtlas>>,
mut quads: ResMut<UiQuads>,
+4 -4
View File
@@ -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<AnimParked>,
Has<ActiveMover>,
Has<Embodied>,
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<RigPose>,
>,
self_hosts: Query<Has<ActiveMover>>,
self_hosts: Query<Has<Embodied>>,
instances: Query<&WmoPortalInstance>,
wmos: Res<Assets<WmoModel>>,
mut out_since: Local<EntityHashMap<f32>>,
@@ -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::<AnimParked>(),
+5 -5
View File
@@ -448,7 +448,7 @@ fn publish_world_units(
bodies: Query<WireBody>,
viewers: Query<(
Entity,
Has<crate::net::ActiveMover>,
Has<crate::net::Embodied>,
Has<benilla_world::world_unit::ViewerUnit>,
)>,
) {
@@ -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::<crate::net::ActiveMover>();
.remove::<crate::net::Embodied>();
app.world_mut()
.run_system_once(publish_world_units)
.expect("reconciler runs");
+3 -3
View File
@@ -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<ActiveMover>,
Has<Embodied>,
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<RootState>,
self_pos: Query<&Transform, With<ActiveMover>>,
self_pos: Query<&Transform, With<Embodied>>,
joints: Query<&GlobalTransform>,
footsteps: Option<Res<Footsteps>>,
creatures: Option<Res<Creatures>>,
+2 -2
View File
@@ -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<ActiveMover>,
Without<Embodied>,
>;
/// The shared frame geometry the blip emitters draw in: the widget centre/side, the active
+13 -1
View File
@@ -299,6 +299,10 @@ pub(crate) fn drive_nameplates(
&Transform,
Option<&ObjectStore>,
Has<SelfPlayer>,
// 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<SelfPlayer>>,
// 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<Entity> = 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
+38 -11
View File
@@ -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<ActiveMover>`, and everything that
/// replays *server* motion reads `Without<ActiveMover>`.
/// 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<ActiveMover>`, not `Without<Embodied>`: 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;
+4 -3
View File
@@ -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);
}
}
}
+8 -4
View File
@@ -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::<SplineStopped>();
}
// 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::<Spline>();
commands
.entity(e)
.remove::<Spline>()
.insert(SplineStopped(spline_id));
}
}
}
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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(
+1 -1
View File
@@ -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
@@ -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.
+64 -57
View File
@@ -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<Player>,
mover: Query<&crate::entities::CollisionHeight, With<ActiveMover>>,
mover: Query<&crate::entities::CollisionHeight, With<Embodied>>,
) {
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<ActiveMover>, Without<FlyCam>),
(With<Embodied>, Without<FlyCam>),
>,
window: Single<(&mut Window, &mut CursorOptions), With<PrimaryWindow>>,
// 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<crate::transport::Transport>,
Without<ActiveMover>,
Without<Embodied>,
Without<FlyCam>,
),
>,
@@ -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;
}
+4 -4
View File
@@ -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<CameraControl>,
self_player: Query<(Entity, Option<&crate::aura_visual::AuraNodes>), With<ActiveMover>>,
self_player: Query<(Entity, Option<&crate::aura_visual::AuraNodes>), With<Embodied>>,
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
+111 -88
View File
@@ -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<Player>,
mut follow: ResMut<FollowState>,
guids: (Res<SelfGuid>, Res<GuidIndex>),
self_body: Query<Entity, With<SelfPlayer>>,
tagged: Query<Entity, With<ActiveMover>>,
mut driving: Local<Option<u64>>,
attached: Query<Entity, With<Embodied>>,
steering: Query<Entity, With<ActiveMover>>,
mut held: Local<Option<u64>>,
) {
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::<ActiveMover>();
// 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::<MovementState>();
}
for e in &attached {
commands.entity(e).remove::<Embodied>();
}
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::<RemoteMotion>();
// 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::<RemoteMotion>();
}
}
@@ -135,7 +143,7 @@ mod tests {
.init_resource::<FollowState>()
.init_resource::<SelfGuid>()
.init_resource::<GuidIndex>()
.add_systems(Update, maintain_active_mover);
.add_systems(Update, maintain_embodiment);
let me = app.world_mut().spawn(SelfPlayer).id();
app.world_mut().resource_mut::<SelfGuid>().0 = Some(0xAA);
app.world_mut()
@@ -146,6 +154,11 @@ mod tests {
}
fn mover(app: &mut App) -> Option<Entity> {
let mut q = app.world_mut().query_filtered::<Entity, With<Embodied>>();
q.iter(app.world()).next()
}
fn steered(app: &mut App) -> Option<Entity> {
let mut q = app
.world_mut()
.query_filtered::<Entity, With<ActiveMover>>();
@@ -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::<MovementState>());
app.world_mut().resource_mut::<Player>().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::<MovementState>(),
"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::<MovementState>());
app.world_mut().resource_mut::<Player>().foreign_mover = None;
app.update();
assert!(
app.world().entity(me).contains::<MovementState>(),
"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::<MovementState>());
}
/// 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::<Player>().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::<Player>().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::<Player>().reseat = false;
// Control back.
app.world_mut().resource_mut::<Player>().control_lost = false;
app.update();
assert_eq!(mover(&mut app), Some(boar));
assert!(
app.world().resource::<Player>().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));
}
}
+5
View File
@@ -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,
@@ -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
@@ -403,6 +403,10 @@ pub(super) fn park_mover(sender: &Sender<ClientCommand>, 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,
+31 -5
View File
@@ -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<ActiveMover>,
With<Embodied>,
>,
) {
// 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::<Spline>();
}
if stopped.is_some() {
commands.entity(entity).remove::<SplineStopped>();
}
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::<SplineStopped>();
}
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::<SplineStopped>();
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)
+3 -3
View File
@@ -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
+39 -17
View File
@@ -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<u64>) {
@@ -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<SelfMoveMessage>,
control_msgs: &mut MessageReader<ClientControlMessage>,
self_guid: Option<u64>,
transports: &Query<
(&Transform, &Guid),
(With<Transport>, Without<ActiveMover>, Without<FlyCam>),
>,
transports: &Query<(&Transform, &Guid), (With<Transport>, Without<Embodied>, Without<FlyCam>)>,
self_pose: Option<(Vec3, f32)>,
) -> Vec<SpeedChangeMessage> {
// 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<Transport>, Without<ActiveMover>, Without<FlyCam>),
>,
transports: &Query<(&Transform, &Guid), (With<Transport>, Without<Embodied>, Without<FlyCam>)>,
) {
let was_falling = player.move_flags & move_flags::FALLING != 0;
player.move_flags = merge_server_flags(player.move_flags, m.flags);
+3 -3
View File
@@ -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<LiquidLoopState>,
water_sounds: Option<Res<WaterSounds>>,
world: benilla_world::world_point::WorldPoint,
player: Query<&Transform, With<ActiveMover>>,
mut emitters: Query<&mut Transform, Without<ActiveMover>>,
player: Query<&Transform, With<Embodied>>,
mut emitters: Query<&mut Transform, Without<Embodied>>,
time: Res<Time>,
kits: Option<ResMut<SoundKits>>,
assets: Option<Res<WorldAssets>>,
+3 -3
View File
@@ -9,7 +9,7 @@
use bevy::prelude::*;
use crate::net::ActiveMover;
use crate::net::Embodied;
use crate::player::{head_height, CameraPivot, Player};
use benilla_world::schedule::WorldStage;
use benilla_world::view::WorldCamera;
@@ -269,8 +269,8 @@ fn update_audio_listener(
mut listener: ResMut<AudioListener>,
mut out: NonSendMut<SoundOutput>,
player: Res<Player>,
self_av: Query<(&Transform, Option<&CameraPivot>), With<ActiveMover>>,
cam: Query<&Transform, (With<WorldCamera>, Without<ActiveMover>)>,
self_av: Query<(&Transform, Option<&CameraPivot>), With<Embodied>>,
cam: Query<&Transform, (With<WorldCamera>, Without<Embodied>)>,
) {
// At-character (the default). `player.pos` is the feet; the head offset is the shared
// model-derived pivot height, and the facing is the aim yaw about world-up (world +Y).
+27 -4
View File
@@ -98,7 +98,12 @@ pub(super) fn update_hover(
// pick outranks everything in the halo retry, so the hover doesn't strobe between two units).
mut last_pick: Local<Option<Entity>>,
// Unit roots: pose + scale, the playing animation (for its bounds sphere), the descriptor store
// (alive-vs-dead sets the pass-2 priority), and the part children.
// (alive-vs-dead sets the pass-2 priority), the part children — and whether the body is drawn
// at all. **You cannot click what is not in the scene** (decision 1277): a body the
// exterior-scene election sent to pass 2 never reaches the reference's draw list, so it takes
// no mouseover, no sword cursor and no click. This lane used to ray-test every unit root with a
// `Guid` and never ask, which is how the director could still target Tanaris mobs through a
// sealed cavern ceiling after their models had correctly stopped drawing.
roots: Query<
(
Entity,
@@ -109,6 +114,7 @@ pub(super) fn update_hover(
Option<&ObjectStore>,
Option<&Children>,
Option<&crate::entities::mount::MountChild>,
Option<&InheritedVisibility>,
),
(With<Guid>, Without<SelfPlayer>),
>,
@@ -119,7 +125,13 @@ pub(super) fn update_hover(
parts: Query<(&Mesh3d, &benilla_world::rig_palette::RigPart)>,
// Fallback path: a unit's pickable mesh children — `WorldObject` kind, model-local `Aabb`, world
// transform, and the link to the streamed parent (whose `Guid` we resolve the hit to).
meshes: Query<(&ChildOf, &Aabb, &GlobalTransform, &WorldObject)>,
meshes: Query<(
&ChildOf,
&Aabb,
&GlobalTransform,
&WorldObject,
Option<&InheritedVisibility>,
)>,
units: Query<&Guid, Without<SelfPlayer>>,
) {
hovered.target = None;
@@ -160,10 +172,17 @@ pub(super) fn update_hover(
// Units the faithful path *owns* (they have skinned parts): excluded from the AABB fallback
// even when the broad phase rejects them — the reference wouldn't click them there either.
let mut faithful: HashSet<Entity> = HashSet::new();
for (entity, gt, net, anims, drv, store, children, mount_child) in &roots {
for (entity, gt, net, anims, drv, store, children, mount_child, drawn) in &roots {
if !matches!(net.kind, EntityKind::Unit | EntityKind::Player) {
continue;
}
// Not drawn ⇒ not clickable (see the query's note). Deliberately BEFORE `faithful.insert`
// below, so an undrawn unit is not claimed by the faithful path either — otherwise it
// would merely be excluded from the AABB fallback and stay unpickable by accident rather
// than by rule.
if !drawn.is_none_or(|v| v.get()) {
continue;
}
let Some(children) = children else { continue };
let skinned: Vec<_> = children.iter().filter_map(|c| parts.get(c).ok()).collect();
if skinned.is_empty() {
@@ -246,10 +265,14 @@ pub(super) fn update_hover(
// ── Fallback for skinless units only: the model-bounds box test (pre-RE interim) ────────────
// A unit's parts are separate meshes; testing each and keeping the closest parent is equivalent
// to testing the union. `dir` is normalized, so `t` is a world distance like the hits above.
for (child_of, aabb, gt, obj) in &meshes {
for (child_of, aabb, gt, obj, drawn) in &meshes {
if obj.kind != ModelKind::Creature {
continue;
}
// …and the same rule on the skinless fallback: a part inherits its root's pass-2 verdict.
if !drawn.is_none_or(|v| v.get()) {
continue;
}
let parent = child_of.parent();
if faithful.contains(&parent) || units.get(parent).is_err() {
continue; // posed-mesh-tested above, or not a targetable unit (e.g. a GameObject part)
+15 -2
View File
@@ -96,10 +96,23 @@ impl Plugin for UiChatPlugin {
)
// The zone-channel auto-join (0288 P6): the client half of a handshake vmangos
// deliberately leaves to us. In-world only, and it early-outs on an unchanged zone.
//
// Both session-end edges clear its state (1284): leaving the world at all, and a socket
// drop that stays in-world for the reconnect. The disconnect twin is chained BEFORE the
// walk so a drop and a walk landing on the same frame cannot re-diff against membership
// the drop just invalidated.
.add_systems(
Update,
channels::auto_join_zone_channels
.run_if(in_state(crate::char_select::ClientState::InWorld)),
(
channels::end_session_channels_on_disconnect,
channels::auto_join_zone_channels
.run_if(in_state(crate::char_select::ClientState::InWorld)),
)
.chain(),
)
.add_systems(
OnExit(crate::char_select::ClientState::InWorld),
channels::end_session_channels,
);
}
}
+186 -5
View File
@@ -80,17 +80,101 @@ pub(crate) struct ZoneChannelWalk {
/// The `(zone name, in a capital)` the held set was composed for. `None` = never walked, or
/// the session ended — either way the next in-world frame re-walks from scratch.
at: Option<(String, bool)>,
/// **Is there a character session to join channels for?** Armed by `EnteredWorldMessage`,
/// disarmed by [`end_channel_session`] — a positive edge in both directions, and the reason
/// this is a field rather than an ordering.
///
/// Without it the walk is live on the *logout frame*: `ClientState` has not left `InWorld`
/// yet, the avatar is still standing there settled, and the session-end clear has just emptied
/// `at` — so the walk faithfully re-diffs from nothing and sends a JOIN for the zone the
/// character is *leaving*. Caught live by the `/logout` + `Enter` probe (1284): two joins
/// stamped at the same millisecond as "tearing down the streamed world".
live: bool,
}
impl ZoneChannelWalk {
/// Forget everything: a dead socket took our channel membership with it, so the next login
/// must re-join rather than assume.
/// Forget everything: leaving the world took our channel membership with it, so the next entry
/// must re-join rather than assume. See [`end_channel_session`].
fn clear_session(&mut self) {
self.held.clear();
self.at = None;
self.live = false;
}
}
/// **Channel membership dies with the character session, so this state must die with it too**
/// (1284).
///
/// Server-side that is not a policy but a destructor: `Player::CleanupChannels`
/// (vmangos `src/game/Objects/Player.cpp:5107`) walks every channel the player is in and leaves
/// it, and it is called from `~Player` and from the logout cleanup. A new character — or the same
/// character after a reconnect — enters the world in **zero** channels, always.
///
/// Ours used to survive that boundary, and the director caught it on a character switch: the walk
/// still `held` the previous character's `General - Tanaris`, so its first diff on the new
/// character sent LEAVE(Tanaris) — which the new session is genuinely not in, so the server
/// answered "Not on channel 1. General - Tanaris." — and [`ChannelState::joined`] still listed
/// those two dead rows, which is why the real joins came back numbered 3 and 4 instead of 1 and 2.
///
/// Three things end together because they are one fact: what we asked for ([`ZoneChannelWalk`]),
/// what the server confirmed ([`ChannelState::joined`]), and the VM's mirror of the latter (what
/// `GetChannelName` answers an addon). The edit box's channel target goes too — it holds the wire
/// name of a channel that no longer exists for us, and a `/2` typed on the new character would
/// otherwise send into it.
fn end_channel_session(
script: Option<&mut benilla_ui::script::UiScript>,
channels: &mut ChannelState,
walk: &mut ZoneChannelWalk,
edit: &mut super::edit::ChatEditState,
) {
walk.clear_session();
channels.joined.clear();
edit.channel_target.clear();
edit.channel_number = 0;
if let Some(script) = script {
script.set_joined_channels(Vec::new());
}
}
/// The session-end edge: a confirmed `/logout` back to the glue layer (`OnExit(InWorld)`), which is
/// the character switch the director's screenshot caught.
pub(super) fn end_session_channels(
script: Option<NonSendMut<benilla_ui::script::UiScript>>,
mut channels: ResMut<ChannelState>,
mut walk: ResMut<ZoneChannelWalk>,
mut edit: ResMut<super::edit::ChatEditState>,
) {
end_channel_session(
script.map(NonSendMut::into_inner),
&mut channels,
&mut walk,
&mut edit,
);
}
/// The other end: a socket that died. A **recoverable** drop never leaves `InWorld` (0065 keeps the
/// avatar as the local puppet for the reconnect), so the edge above does not fire for it — but the
/// reconnect still builds a fresh `Player` server-side, with the same empty channel list a fresh
/// login has. Both edges therefore clear, and they clear the same four things through the same
/// function so neither can drift into being the more thorough one.
pub(super) fn end_session_channels_on_disconnect(
script: Option<NonSendMut<benilla_ui::script::UiScript>>,
mut channels: ResMut<ChannelState>,
mut walk: ResMut<ZoneChannelWalk>,
mut edit: ResMut<super::edit::ChatEditState>,
mut disconnects: MessageReader<crate::net::DisconnectedMessage>,
) {
if disconnects.read().next().is_none() {
return;
}
end_channel_session(
script.map(NonSendMut::into_inner),
&mut channels,
&mut walk,
&mut edit,
);
}
/// Startup: read `ChatChannels.dbc` into [`ChannelState`], which owns it because the two things
/// that need it — composing the auto-join names and answering a chat event's arg7 — are both its
/// business. Absent install ⇒ an empty catalog: no auto-join, arg7 stays 0, nothing errors.
@@ -137,6 +221,17 @@ pub(crate) fn wanted_channels(
.collect()
}
/// Is the zone under the player the one they are actually standing in?
///
/// The walk's gate (1280). Three states answer no, and each one cost real wire traffic before this
/// existed: no avatar at all, an avatar not yet active, and — the expensive one — an avatar still
/// **settling**, which is benilla's stand-in for the reference's loading screen: the body has been
/// snapped but the destination's terrain and WMOs are still arriving, so the leaf area under it is
/// still moving. The reference walks *after* that window, always; we polled inside it.
fn zone_is_settled(player: Option<&crate::player::Player>) -> bool {
player.is_some_and(|p| p.active && !p.settling)
}
/// The walk: re-join the zone channels whenever the zone changes, and drop the ones that no
/// longer apply.
///
@@ -153,6 +248,18 @@ pub(crate) fn wanted_channels(
/// reaches the same states; it is a poll rather than a hook because the zone is already derived
/// here, not published as an event payload.
///
/// **…but NOT while the world is still arriving** ([`Player::settling`], 1280). The reference's
/// "world entry" is behind a loading screen: by the time it walks, the destination ADT and its WMOs
/// are resident and the zone under the player is final. Ours streams asynchronously, so the leaf
/// area moves *twice* on the way in — first while the body still holds a pre-snap position (the
/// map centre: tile 32,32 is Eastern Plaguelands on map 0, The Barrens on map 1 — the two bogus
/// zones in the director's login screenshot), then again when the WMO interior claim lands a beat
/// after the outdoor MCNK area (Tanaris → Caverns of Time, reproduced live). Each transient cost a
/// real JOIN and a real LEAVE on the wire, for zones the player was never in — chat lines and all.
/// `settling` is exactly the loading screen's own gate (released by the terrain streamer once the
/// destination is resident, decision 0737), which makes this the reference's timing rather than an
/// arbitrary debounce.
///
/// **Selection:** the client's live predicate is the *saved* `ZONECHANNELS` mask, not `flags & 1`
/// read fresh — but that mask is **seeded from `flags & 1` when there is no usable chat-cache
/// file** (`0x4997fc`, gated `0x4997e8`). We persist no chat cache, so every session is that
@@ -163,15 +270,26 @@ pub(super) fn auto_join_zone_channels(
channels: Res<ChannelState>,
areas: Option<Res<AreaTableRes>>,
world: benilla_world::world_point::WorldPoint,
player: Option<Res<crate::player::Player>>,
mut walk: ResMut<ZoneChannelWalk>,
mut disconnects: MessageReader<crate::net::DisconnectedMessage>,
mut entered: MessageReader<crate::net::EnteredWorldMessage>,
) {
if disconnects.read().next().is_some() {
walk.clear_session();
// World entry arms the walk; the session-end clears disarm it ([`end_session_channels`] and its
// disconnect twin, which own the whole fact — walk, confirmed list, VM mirror, edit target).
if entered.read().next().is_some() {
walk.live = true;
}
if !walk.live {
return; // no character session — see `ZoneChannelWalk::live`
}
let (Some(areas), false) = (areas, channels.channels.is_empty()) else {
return;
};
// Not until the world under the player is the one they are actually standing in — see the
// "Timing" note above.
if !zone_is_settled(player.as_deref()) {
return;
}
// The zone under the player, or nothing to do yet (tiles not streamed, no body).
let Some(zone_row) = world
.area()
@@ -299,6 +417,69 @@ mod tests {
assert!(wanted_channels(&catalog(), "", true, CITY).is_empty());
}
/// **The walk does not believe the zone until the world has settled** (1280).
///
/// The director's login screenshot is what this guards: three zones' channels joined on the
/// way in — two of them the map centre (tile 32,32 = Eastern Plaguelands on map 0, The Barrens
/// on map 1), read while the body still sat at its pre-snap position — and then leave requests
/// for zones they had never been in, which the server answered "Not on channel …". Every one
/// of those lines existed because the walk ran a frame too early.
#[test]
fn the_walk_waits_for_the_world_under_the_player() {
use crate::player::Player;
assert!(!zone_is_settled(None), "no avatar: no zone to believe");
let mut p = Player::default();
assert!(!zone_is_settled(Some(&p)), "inactive avatar");
p.active = true;
p.settling = true;
assert!(
!zone_is_settled(Some(&p)),
"settling — the destination's terrain and WMOs are still arriving, so the leaf area \
under the body is still moving"
);
p.settling = false;
assert!(zone_is_settled(Some(&p)), "settled: the zone is now final");
}
/// **Leaving the world ends the channel session — all four halves of it** (1284).
///
/// The director's character switch: the walk still held the previous character's
/// `General - Tanaris`, so its first diff on the new character sent a LEAVE the new session
/// answered "Not on channel 1. General - Tanaris.", and the stale confirmed list pushed the
/// real joins to slots 3 and 4. Server-side there is nothing to keep — `Player::CleanupChannels`
/// runs in `~Player` — so every one of these must be empty at the next world entry.
#[test]
fn leaving_the_world_ends_the_channel_session() {
let mut channels = ChannelState::default();
let mut walk = ZoneChannelWalk::default();
let mut edit = super::super::edit::ChatEditState::default();
// A live session: two zone channels asked for, both confirmed, `/2` targeted.
walk.held = vec!["General - Tanaris".into(), "LocalDefense - Tanaris".into()];
walk.at = Some(("Tanaris".into(), false));
channels.joined = walk.held.clone();
edit.channel_target = "LocalDefense - Tanaris".into();
edit.channel_number = 2;
// No VM in a unit test; the mirror leg is the one line this cannot reach, and
// `end_session_channels` is a two-line wrapper over exactly this call.
end_channel_session(None, &mut channels, &mut walk, &mut edit);
assert!(walk.held.is_empty(), "nothing is held");
assert_eq!(walk.at, None, "and the next entry re-walks from scratch");
assert!(
channels.joined.is_empty(),
"the confirmed list is the server's, and the server just destroyed it — a survivor \
here is what renumbers the next character's channels"
);
assert_eq!(edit.channel_target, "");
assert_eq!(edit.channel_number, 0, "a `/2` now targets nothing");
}
/// No city word (a locale whose sentinel row ships blank) ⇒ the city-named rows are skipped,
/// not composed as `"Trade - "`. The client has no such guard; declining is ours.
#[test]
+34 -1
View File
@@ -89,6 +89,36 @@ fn target_player_name(
names.resolve(guid, commands).map(str::to_string)
}
/// The target guid a `CMSG_TEXT_EMOTE` carries — the current selection, **except that emoting at
/// yourself goes out untargeted** (decision 1282).
///
/// That exception is `DoEmote`'s last act before it builds the packet, VERIFIED at `0x5ef611`:
///
/// ```text
/// 5ef611: mov eax,[edi+8] ; &ownGuid
/// 5ef614: mov ecx,[ebp+0xc] ; the target guid low ... cmp / jne past
/// 5ef61e: cmp edx,[eax+4] ; ... and high ... cmp / jne past
/// 5ef623: mov [ebp+0xc],ebx ; ebx = 0 (xor at 0x5ef56b, never reloaded)
/// 5ef626: mov [ebp+0x10],ebx ; -> the packet's target guid is ZERO
/// ```
///
/// **This is why vanilla has no self-emote sentence.** 1274 read the receive-side composer
/// correctly and then asserted an outcome — "You wave at ⟨YourName⟩." — from an input this gate
/// makes unreachable: the server never echoes your own name back as the target, so the untargeted
/// column is what you *and* everyone in range read ("You wave." / "Sam waves."). Without the gate
/// we would emit "Sam waves at Sam." to the whole zone.
///
/// Compared by **entity** rather than guid, which is the same predicate and costs no 17th system
/// param: `SelfPlayer` marks the entity whose guid *is* `SelfGuid` (`net.rs`), and [`Selection`]'s
/// only writer (`target::scan`) sets `target` and `guid` together from one streamed entity — so
/// "the selection is my entity" and "the selection is my guid" cannot disagree.
pub(super) fn emote_target(selection: &Selection, me: Option<Entity>) -> u64 {
match selection.guid {
Some(guid) if !(me.is_some() && selection.target == me) => guid,
_ => 0,
}
}
#[allow(clippy::too_many_arguments)]
/// The client-local diagnostics' inputs, as one [`SystemParam`] — [`drain_chat_input`] is at the
/// 16-parameter ceiling, and a named struct beats a nested tuple nobody can read.
@@ -640,7 +670,10 @@ pub(super) fn drain_chat_input(
.stand
.write(crate::player::StandStateRequest { state: state as u8 });
}
let target = selection.guid.unwrap_or(0);
let target = emote_target(
&selection,
self_player.single().ok().map(|(entity, _, _)| entity),
);
match commands
.0
.send(ClientCommand::TextEmote { text_id, target })
+40 -1
View File
@@ -2,7 +2,7 @@ use crate::net::ChatKind;
use super::event::{default_color, ChatEvent, ChatEventKind as K};
use super::frames::compose;
use super::input::{emote_send_eligible, ParsedChat};
use super::input::{emote_send_eligible, emote_target, ParsedChat};
/// A player-line event (the wire bridge's output shape) — sender resolved, optional flag.
fn ev(kind: K, text: &str, sender: &str) -> ChatEvent {
@@ -120,6 +120,45 @@ fn text_emote_lines_are_verbatim_and_never_wear_the_senders_name() {
.contains("[Bob]"));
}
/// **A self-target goes out as guid 0** — `DoEmote`'s last act before it builds the packet
/// (`0x5ef611`), and the reason vanilla has no self-emote sentence (decision 1282, correcting
/// 1274's claim that you would read "You wave at ⟨YourName⟩.").
///
/// Without this the server echoes your own name back as the emote's target and the *whole zone*
/// reads "Sam waves at Sam." — so the control below (a selection that is someone else survives
/// intact) is what makes this a gate and not a mute button.
#[test]
fn emoting_at_your_own_selection_sends_an_untargeted_emote() {
use crate::target::Selection;
use bevy::prelude::Entity;
let me = Entity::from_raw_u32(7).unwrap();
let them = Entity::from_raw_u32(9).unwrap();
// Myself selected: the guid is dropped on the floor, exactly as `mov [ebp+0xc],ebx` does.
let sel = Selection {
target: Some(me),
guid: Some(0xdead_beef),
};
assert_eq!(emote_target(&sel, Some(me)), 0);
// The control — someone else selected: the guid goes out untouched.
let sel = Selection {
target: Some(them),
guid: Some(0xdead_beef),
};
assert_eq!(emote_target(&sel, Some(me)), 0xdead_beef);
// No selection at all is already untargeted, and a not-yet-streamed self entity must not make
// an empty selection look like a self-target (the `me.is_some()` guard).
assert_eq!(emote_target(&Selection::default(), Some(me)), 0);
let sel = Selection {
target: Some(them),
guid: Some(0xdead_beef),
};
assert_eq!(emote_target(&sel, None), 0xdead_beef);
}
/// The receive half of B156 on the real tables (decision 1274): the five reachable sentence forms,
/// the performer in arg2, and the three silent rows. The composition law itself is pinned in
/// `benilla_formats::emote_text`; what this covers is the seam — that the app hands the composer
+27 -8
View File
@@ -67,11 +67,15 @@ pub struct EmoteTextCatalog {
/// The facts `0x49b200` branches on, in the composer's own terms.
///
/// **The self-target edge is real and it is the reference's**: `target_is_you` is only ever tested
/// when you are *not* the performer (the guid compare at `0x49b2c8` jumps past it), so emoting at
/// yourself takes the "you are the performer, the target is someone else" leg and renders
/// **"You wave at ⟨YourName⟩."** — vanilla has no reflexive form. [`EmoteTextCatalog::compose`]
/// reproduces that by deriving both facts here rather than trusting a caller to pre-clear one.
/// **`target_is_you` is only tested when you are not the performer** — the guid compare at
/// `0x49b2c8` jumps past it — so [`EmoteTextCatalog::compose`] derives both facts here rather than
/// trusting a caller to pre-clear one.
///
/// That branch order once looked like it meant "a self-emote renders *You wave at ⟨YourName⟩.*",
/// and decision 1274 said so. **It does not** (corrected by 1282): the sending client zeroes a
/// self-target before the packet is built (`DoEmote 0x5ef611`), so `target` arrives EMPTY for that
/// action and the untargeted column wins — "You wave.". Vanilla has no reflexive form *and* no
/// self-named one; the case simply never reaches this struct.
#[derive(Debug, Clone, Copy)]
pub struct EmoteLine<'a> {
/// The performer's resolved name — the `%s` the reference fills from the `NameCache` record
@@ -310,10 +314,18 @@ mod tests {
assert_eq!(cat.compose(WAVE, &mine, 0).as_deref(), Some("You wave."));
}
/// The self-target edge (`0x49b2c8` jumps past the target compare): emoting at yourself is
/// **not** reflexive — you get your own name in the target slot.
/// **The composer has no self-target case, because it can never be handed one** — and this
/// test exists to record that, since the shape of the code invites the opposite conclusion
/// (decision 1282, correcting 1274).
///
/// The branch order is real: `0x49b2c8` matching the performer jumps past the target compare,
/// so if a self-emote *did* arrive with your own name in the target slot, this is what it
/// would render. It never arrives. The **send** side zeroes a self-target before the packet
/// exists (`DoEmote 0x5ef611`), so the server echoes an empty name and the untargeted column
/// wins — "You wave.", asserted just above. Reading this arm as "what a self-emote does" is
/// exactly the mistake 1274 made.
#[test]
fn emoting_at_yourself_takes_the_performer_leg_and_names_you() {
fn the_self_named_target_form_is_unreachable_from_the_wire() {
let data = crate::wow_data_or_skip!();
let mut chain = Chain::open(&data).expect("open patch chain");
let cat = load_emote_text_catalog(&mut chain).expect("load");
@@ -325,6 +337,13 @@ mod tests {
cat.compose(WAVE, &me, 0).as_deref(),
Some("You wave at Me.")
);
// What the wire actually delivers for that same action — an EMPTY target — and therefore
// what a player really reads.
let me = EmoteLine {
performer_is_you: true,
..line("Me", "", "Me")
};
assert_eq!(cat.compose(WAVE, &me, 0).as_deref(), Some("You wave."));
}
/// The gender rung: WAVE has no female column, so `8|ctx` misses and rung 2 (`ctx` alone)