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:
@@ -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})"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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>(),
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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>>,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>>,
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user