far sight, possession and the spyglass: three mechanisms
- the server picks the view and the mover independently — far sight moves the eye, client control moves the reins, and Mind Control sets both, which is why its camera works for free - while you hold another body's reins your own stands still, so the server never smears your pose onto the creature you drive - the Ornate Spyglass is a local 6x zoom, not a camera move — there was never a wire half to be missing - the scene election fails closed: three classes of creature that escaped it are back under its verdict Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -469,9 +469,20 @@ fn publish_world_units(
|
||||
// answers `None` on purpose**: `transport::tick_transports` writes that root's
|
||||
// `Visibility` every frame off its own timetable, and a second writer there is the
|
||||
// fight decision 0025 exists to prevent — so the world is told not to decide, rather
|
||||
// than told a box and left to race. Every other body offers its model's box, once it
|
||||
// has one.
|
||||
bound: (!anchored).then_some(bound).flatten().map(|b| b.0),
|
||||
// than told a box and left to race.
|
||||
//
|
||||
// Every other body is elected from its FIRST frame, with a degenerate box at its own
|
||||
// origin until its model resolves. Waiting for the extent looks conservative and is
|
||||
// not: the bound reaches this reconciler only after `attach_entity_visuals` has already
|
||||
// spawned the visual, so "no box yet ⇒ admit" drew every streaming mob for one whole
|
||||
// frame through a sealed ceiling — and a cavern runs slowly enough that one frame is
|
||||
// most of a second. The origin is the server's own position, exact from the start.
|
||||
bound: (!anchored).then(|| {
|
||||
bound.map_or_else(
|
||||
|| bevy::camera::primitives::Aabb::from_min_max(Vec3::ZERO, Vec3::ZERO),
|
||||
|b| b.0,
|
||||
)
|
||||
}),
|
||||
};
|
||||
// Only write on a real change: the component is change-detected downstream, and a
|
||||
// per-frame rewrite would mark every body dirty for every reader every frame.
|
||||
|
||||
@@ -644,13 +644,29 @@ pub(super) fn attach_entity_visuals(
|
||||
bevy::camera::primitives::Aabb::from_min_max(clip.bounds_min, clip.bounds_max)
|
||||
})
|
||||
});
|
||||
// …and the same box is this body's **election bound** (decision 1270): standing in a
|
||||
// sealed WMO room the reference never submits an outdoor object at all, so the cull
|
||||
// needs one whole-object AABB per body, on its root. Recorded here and restated onto
|
||||
// `WorldUnit::bound` by `publish_world_units` — the one reconciler that answers every
|
||||
// "what is this body to the world" question, exactly as it already does for the
|
||||
// collision height. A body with no authored idle box records none and keeps drawing.
|
||||
if let Some(aabb) = idle_aabb {
|
||||
// This body's **election bound** (decision 1270): standing in a sealed WMO room the
|
||||
// reference never submits an outdoor object at all, so the cull needs one whole-object
|
||||
// AABB per body, on its root. Recorded here and restated onto `WorldUnit::bound` by
|
||||
// `publish_world_units` — the one reconciler that answers every "what is this body to
|
||||
// the world" question, exactly as it already does for the collision height.
|
||||
//
|
||||
// **Not `idle_aabb` above, and that distinction is the whole of it.** `first_seq` is
|
||||
// the *rendering* gate — "is this seed worth a skin + player" (0130's content gate) —
|
||||
// and it is `None` for every model whose idle does not move its bones off the rest
|
||||
// pose. The picker can take that: it falls back per part (`idle_aabb.or(part.aabb)`).
|
||||
// The election cannot, because a body with no bound is a body it never decides — so
|
||||
// reading the render gate here silently exempted a whole class of creature from the
|
||||
// cull, and they kept drawing through the ceiling. `idle_clip` is the same selection
|
||||
// asked as an *identity* (`ModelAnimations`' own doc: "conflating the two is the whole
|
||||
// defect"), and where even that box is unauthored — a degenerate CAaBox is common in
|
||||
// 1.12 art — the model's bind-pose extent is what an unarmed body actually draws.
|
||||
let election_bound = dm
|
||||
.and_then(|m| m.animations.as_ref())
|
||||
.and_then(|a| a.idle_clip())
|
||||
.filter(|c| c.bounds_max.cmpgt(c.bounds_min).all())
|
||||
.map(|c| bevy::camera::primitives::Aabb::from_min_max(c.bounds_min, c.bounds_max))
|
||||
.or_else(|| union_aabb(model.unwrap_or_default().iter().filter_map(|p| p.aabb)));
|
||||
if let Some(aabb) = election_bound {
|
||||
commands.entity(entity).insert(super::ModelBound(aabb));
|
||||
}
|
||||
// Everything one part's spawn reads from this unit, gathered once (`attach::dress`) —
|
||||
@@ -912,6 +928,30 @@ pub(super) fn attach_entity_visuals(
|
||||
/// the Bevy-graph-driven hosts. `holder` is the rig root that carries (or will carry) the
|
||||
/// [`benilla_world::rig_palette::RigSkin`] — every joint marks it with a `RigJoint`, which is what the
|
||||
/// palette change-sweep iterates (0720).
|
||||
/// The enclosing box of a model's parts — its bind-pose extent, in model space.
|
||||
///
|
||||
/// The election bound's fallback (decision 1270) for a body whose armed idle authors no CAaBox.
|
||||
/// Every part of one model shares that space, so their union is the model, and for a body that is
|
||||
/// not armed at all it is exactly what gets drawn. A bind box can be a poor bound for a body whose
|
||||
/// idle moves it far (0637's duel flag, modelled 9 yd up and planted by its Stand) — which is why
|
||||
/// the authored box is preferred whenever there is one, and why this is a fallback rather than the
|
||||
/// rule. Over-large is the safe direction here: it can only admit, never hide.
|
||||
fn union_aabb(
|
||||
boxes: impl Iterator<Item = bevy::camera::primitives::Aabb>,
|
||||
) -> Option<bevy::camera::primitives::Aabb> {
|
||||
let mut boxes = boxes;
|
||||
let first = boxes.next()?;
|
||||
let (mut min, mut max) = (first.min(), first.max());
|
||||
for b in boxes {
|
||||
min = min.min(b.min());
|
||||
max = max.max(b.max());
|
||||
}
|
||||
Some(bevy::camera::primitives::Aabb::from_min_max(
|
||||
Vec3::from(min),
|
||||
Vec3::from(max),
|
||||
))
|
||||
}
|
||||
|
||||
/// A display model's source path as a readable inspector label (the asset path, sans `mpq://` source).
|
||||
/// Empty for the model-less variant or a path-less handle.
|
||||
fn display_label(handle: &ModelHandle) -> String {
|
||||
|
||||
@@ -97,6 +97,7 @@ impl Plugin for NetPlugin {
|
||||
.add_message::<TeleportMessage>()
|
||||
.add_message::<SelfMoveMessage>()
|
||||
.add_message::<SpeedChangeMessage>()
|
||||
.add_message::<ClientControlMessage>()
|
||||
.add_message::<MoveModeMessage>()
|
||||
.add_message::<ServerSoundMessage>()
|
||||
.add_message::<EmoteMessage>()
|
||||
@@ -696,6 +697,23 @@ pub(crate) enum ClientCommand {
|
||||
jump: Option<JumpInfo>,
|
||||
transport: Option<TransportPose>,
|
||||
},
|
||||
/// Claim `guid` as our mover (`CMSG_SET_ACTIVE_MOVER`). Login sends it for our own body; a
|
||||
/// possession handoff re-sends it for the unit we were handed, because the server drops every
|
||||
/// `MSG_MOVE_*` for a mover it has not confirmed.
|
||||
SetActiveMover { guid: u64 },
|
||||
/// Release `guid` as our mover (`CMSG_MOVE_NOT_ACTIVE_MOVER`) at the pose it is parting on —
|
||||
/// the server re-broadcasts a stop under that guid from this payload, so observers do not keep
|
||||
/// the unit sliding.
|
||||
NotActiveMover {
|
||||
guid: u64,
|
||||
flags: u32,
|
||||
pos: [f32; 3],
|
||||
orientation: f32,
|
||||
fall_time: u32,
|
||||
},
|
||||
/// Vote on the far-sight view (`CMSG_FAR_SIGHT`): `true` as it attaches, `false` as it
|
||||
/// releases. The reference sends both; neither names an object.
|
||||
FarSight { engage: bool },
|
||||
/// Echo a same-map teleport ack (`MSG_MOVE_TELEPORT_ACK_Client`) — without it the server freezes
|
||||
/// our movement until relog.
|
||||
TeleportAck { guid: u64, counter: u32 },
|
||||
@@ -1467,6 +1485,20 @@ pub(crate) struct SelfMoveMessage {
|
||||
pub(crate) jump: Option<benilla_protocol::JumpInfo>,
|
||||
}
|
||||
|
||||
/// The server granted or revoked control of a unit (`SMSG_CLIENT_CONTROL_UPDATE`). Written by
|
||||
/// [`apply_net_updates`] verbatim — the *decision* is the controller's, because only it knows the
|
||||
/// live pose it would have to park, so the drain deliberately interprets nothing.
|
||||
///
|
||||
/// **`mover` is the unit spoken about, not a new subject.** The server revokes by naming *us*
|
||||
/// (`mover == self guid`, `allow_move = false`), which is what a mind-controlled player receives
|
||||
/// about themselves, and grants by naming somebody else. Reading it as "the new mover" gets the
|
||||
/// victim's case exactly backwards — it would hand us control of ourselves at the moment we lost it.
|
||||
#[derive(Message, Clone, Copy)]
|
||||
pub(crate) struct ClientControlMessage {
|
||||
pub(crate) mover: u64,
|
||||
pub(crate) allow_move: bool,
|
||||
}
|
||||
|
||||
/// The server changed one of our own mover's speeds (`SMSG_FORCE_*_SPEED_CHANGE` — aura, mount, GM
|
||||
/// `.modify speed`). Written by [`apply_net_updates`] (which also applies the new value to the
|
||||
/// entity's [`UnitSpeeds`], and only forwards changes addressed to our guid); read by the player
|
||||
|
||||
@@ -127,6 +127,9 @@ pub(super) fn apply_net_updates(
|
||||
// A `MSG_MOVE_*` the server addressed to OUR mover (decision 0725): a pose it wrote, with
|
||||
// no handshake — the controller applies it in `player::wire_in`.
|
||||
MessageWriter<super::SelfMoveMessage>,
|
||||
// The possession handoff (`SMSG_CLIENT_CONTROL_UPDATE`): control of a unit granted or
|
||||
// revoked. Forwarded verbatim — only the controller knows the pose it would have to park.
|
||||
MessageWriter<super::ClientControlMessage>,
|
||||
),
|
||||
// One tuple param (the 16-SystemParam ceiling): the ask-once query caches + the gossip/merchant
|
||||
// state the net drain fills for the NPC-interaction windows (decision 0081).
|
||||
@@ -362,6 +365,7 @@ pub(super) fn apply_net_updates(
|
||||
mut disconnects,
|
||||
mut server_said,
|
||||
mut self_moves,
|
||||
mut client_control,
|
||||
) = session_msgs;
|
||||
// Descriptor seeds/deltas for objects created *earlier in this same drain* can't land on their
|
||||
// entities yet (the spawn `Command` hasn't run), so they accumulate here and flush once at the end.
|
||||
@@ -1314,6 +1318,12 @@ pub(super) fn apply_net_updates(
|
||||
SessionEvent::MountSpecial { guid } => {
|
||||
mount::mount_special(guid, &self_guid, &index, &mut audio.14)
|
||||
}
|
||||
// Possession's control half (B211). Forwarded whole and unjudged: the guid may name us
|
||||
// (a revoke) or somebody else (a grant), and only the controller can act on either —
|
||||
// it owns the pose to park and the mover claim to send.
|
||||
SessionEvent::ClientControl { mover, allow_move } => {
|
||||
client_control.write(super::ClientControlMessage { mover, allow_move });
|
||||
}
|
||||
SessionEvent::Pong { sequence } => session::pong(sequence, &aura.2, &mut status),
|
||||
SessionEvent::PacketDropped {
|
||||
opcode,
|
||||
|
||||
@@ -622,6 +622,15 @@ fn writer_loop(
|
||||
jump,
|
||||
transport,
|
||||
),
|
||||
ClientCommand::SetActiveMover { guid } => w.set_active_mover(guid),
|
||||
ClientCommand::NotActiveMover {
|
||||
guid,
|
||||
flags,
|
||||
pos,
|
||||
orientation,
|
||||
fall_time,
|
||||
} => w.move_not_active_mover(guid, flags, pos, orientation, fall_time),
|
||||
ClientCommand::FarSight { engage } => w.far_sight(engage),
|
||||
ClientCommand::TeleportAck { guid, counter } => w.teleport_ack(guid, counter),
|
||||
ClientCommand::WorldportAck => w.worldport_ack(),
|
||||
ClientCommand::SetSelection { guid } => w.set_selection(guid),
|
||||
|
||||
@@ -49,12 +49,17 @@ mod movement_net;
|
||||
// step through the very same code, the way the reference runs every mover through one controller
|
||||
// (decision 0059's byte trail).
|
||||
pub(crate) mod mover;
|
||||
/// The spyglass zoom — aura 76, a client-local camera override with no wire half at all (B151).
|
||||
mod scoped_view;
|
||||
mod server_ride;
|
||||
mod setup;
|
||||
mod state;
|
||||
/// The step-up diagnostic probe — the blocked-frame report behind the `stup` trace tag.
|
||||
pub(crate) mod step_probe;
|
||||
mod swim;
|
||||
/// What the camera orbits, when that is not our own body — the far-sight anchor (B151, and Mind
|
||||
/// Control's camera half in B211, which rides the same field).
|
||||
mod view_subject;
|
||||
mod wire_in;
|
||||
|
||||
// `apply_self_model_fade` is `pub(crate)`-visible: it is the LAST writer of a self body part's
|
||||
@@ -169,6 +174,25 @@ impl Plugin for PlayerPlugin {
|
||||
);
|
||||
app.init_resource::<camera::LookConfig>();
|
||||
app.init_resource::<camera::ZoomLimit>();
|
||||
// Far sight: resolve `PLAYER_FARSIGHT` into a pose before `control` reads it to seat the
|
||||
// camera. A separate system rather than another query on `control` for a hard reason —
|
||||
// `control` already holds the self entity's `Transform` mutably, so it cannot also read an
|
||||
// arbitrary unit's.
|
||||
app.init_resource::<view_subject::ViewSubject>()
|
||||
.init_resource::<scoped_view::ScopedView>()
|
||||
.add_systems(
|
||||
Update,
|
||||
(
|
||||
view_subject::publish_view_subject,
|
||||
// The spyglass zoom (aura 76) — a different mechanism entirely, sharing only
|
||||
// the family name. Also before `control`, which reads the scope to hold the rig
|
||||
// in first person: the reference's camera lock.
|
||||
scoped_view::apply_scoped_view,
|
||||
)
|
||||
.in_set(WorldStage::Input)
|
||||
.before(control)
|
||||
.run_if(in_state(crate::char_select::ClientState::InWorld)),
|
||||
);
|
||||
app.add_systems(Startup, setup::setup_player.after(AssetSet::Open))
|
||||
// The world camera renders only when the world can be seen (decision 0540): in world,
|
||||
// or under the opaque loading screen (whose covered render is what compiles the
|
||||
@@ -327,6 +351,10 @@ fn control(
|
||||
// The posture queue (decision 0881): `/sit` and its family ask here; the X key is read
|
||||
// inline below. One setter, either way.
|
||||
MessageReader<StandStateRequest>,
|
||||
// The possession handoff (B211): control of a unit granted or revoked. Lands here rather
|
||||
// than at the net drain because both answers it needs — the mover claim and the parting
|
||||
// pose — are the controller's to give.
|
||||
MessageReader<crate::net::ClientControlMessage>,
|
||||
),
|
||||
// Nested into one param to stay within Bevy's 16-element system-param tuple limit (see `mouse`).
|
||||
speed_capsule: (
|
||||
@@ -341,6 +369,16 @@ fn control(
|
||||
// state from here — raw `keys` remain only for the dev chord's free-fly toggle
|
||||
// (decision 1043) and the look-session mouse.
|
||||
Res<crate::bindings::BindingsState>,
|
||||
// What the camera orbits, when that is not our body ([`view_subject`], B151). Resolved by
|
||||
// a system ordered just before us, because `control` holds the self `Transform` mutably
|
||||
// and so cannot also read the far-sight object's.
|
||||
Res<view_subject::ViewSubject>,
|
||||
// Our own guid — the control handoff (B211) is a statement about *some* unit, and telling
|
||||
// "the server revoked my body" from "the server handed me a creature" is exactly this test.
|
||||
Res<crate::net::SelfGuid>,
|
||||
// The spyglass scope ([`scoped_view`]): while held, the rig is pinned to first person and
|
||||
// the wheel cannot leave it.
|
||||
Res<scoped_view::ScopedView>,
|
||||
),
|
||||
mut commands: Commands,
|
||||
mut player: ResMut<Player>,
|
||||
@@ -420,6 +458,9 @@ fn control(
|
||||
&speed_capsule.6,
|
||||
);
|
||||
let binds = &speed_capsule.7;
|
||||
let view_subject = &speed_capsule.8;
|
||||
let self_guid = speed_capsule.9 .0;
|
||||
let scoped = &speed_capsule.10;
|
||||
let dt = time.delta_secs();
|
||||
// While a focused UI EditBox (the chat input, a mail field) owns the keyboard, keyboard reads see
|
||||
// "no keys held" — so the avatar isn't also driven while typing (a `.tele` command). Mouse still
|
||||
@@ -505,7 +546,13 @@ fn control(
|
||||
// reference produces by never running the turn emitter at all. Restoring rather than gating
|
||||
// inside the session keeps the approved camera path (0050/0366's right-drag coupling) untouched.
|
||||
// `mouse_turned` then reads false by construction, so the seated-turn stand-up cannot fire either.
|
||||
if stunned {
|
||||
// Losing the reins has the same shape, and the binary says so explicitly: with the mover global
|
||||
// zeroed, `0x514640` skips the whole tick at `51466c` — input is still *sampled*, the body turn
|
||||
// is skipped at `514474` — while the camera rotate at `514444` happens BEFORE the mover lookup
|
||||
// and so keeps working. A mind-controlled player can still look around; they just cannot turn
|
||||
// or move their body. Same restore, for the same reason, and it covers holding somebody else's
|
||||
// reins too: the turn belongs to whatever we are driving, which is not this body.
|
||||
if stunned || player.control_lost || player.foreign_mover.is_some() {
|
||||
player.face_yaw = yaw_before_look;
|
||||
}
|
||||
let mouse_turned = player.face_yaw != yaw_before_look;
|
||||
@@ -550,6 +597,8 @@ fn control(
|
||||
&mut net.4,
|
||||
&mut net.5,
|
||||
&mut net.9,
|
||||
&mut net.11,
|
||||
self_guid,
|
||||
transports,
|
||||
self_player
|
||||
.single()
|
||||
@@ -557,6 +606,20 @@ fn control(
|
||||
.map(|(_, t, ..)| (t.translation, server_ride::yaw_of(t.rotation))),
|
||||
);
|
||||
|
||||
// A mover the server took back owes it a parting pose (`CMSG_MOVE_NOT_ACTIVE_MOVER`), and this
|
||||
// is the first point in the frame that has one. Without it the server never clears its
|
||||
// confirmed-mover state and never re-broadcasts the stop, so observers keep the unit sliding on
|
||||
// its last relayed pose.
|
||||
if let Some(guid) = player.release_mover.take() {
|
||||
let _ = net.0 .0.send(crate::net::ClientCommand::NotActiveMover {
|
||||
guid,
|
||||
flags: 0,
|
||||
pos: benilla_assets::coords::bevy_to_wow(player.pos),
|
||||
orientation: player.face_yaw.rem_euclid(std::f32::consts::TAU),
|
||||
fall_time: 0,
|
||||
});
|
||||
}
|
||||
|
||||
let flat = |v: Vec3| Vec3::new(v.x, 0.0, v.z).normalize_or_zero();
|
||||
|
||||
// The platform carry (decision 0438 phase 2): while attached to a transport, recompose the
|
||||
@@ -611,14 +674,70 @@ 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 park below flushes one `MSG_MOVE_STOP`, which matters — we may have been mid-run at
|
||||
// the moment control was taken, and the server would otherwise keep extrapolating that run
|
||||
// for every observer.
|
||||
// The same gate serves both ways of not driving our own body: the reins taken from us
|
||||
// (mind-controlled), or the reins of something else in our hands. The second is the
|
||||
// sharper one — outbound moves carry no guid, so with a foreign mover claimed, streaming
|
||||
// our body's pose would land on the possessed unit and teleport it onto us. Standing our
|
||||
// own character still is also what possession looks like from the outside.
|
||||
if player.control_lost || player.foreign_mover.is_some() {
|
||||
let pivot_h = self_player
|
||||
.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);
|
||||
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,
|
||||
);
|
||||
movement_net::park_mover(&net.0 .0, &mut player);
|
||||
return;
|
||||
}
|
||||
if player.server_riding {
|
||||
let pivot_h = self_player
|
||||
.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, player.pos, head, pivot_h, &mut rig, &mut cam, &mut cam_t, &collide,
|
||||
dt,
|
||||
0.0,
|
||||
orbit_pos,
|
||||
sweep_from,
|
||||
orbit_pivot,
|
||||
&mut rig,
|
||||
&mut cam,
|
||||
&mut cam_t,
|
||||
&collide,
|
||||
cam_probe,
|
||||
);
|
||||
return;
|
||||
@@ -1502,6 +1621,23 @@ fn control(
|
||||
// framing pivot — see `seat_camera`'s doc for why. Computed here (not in `camera`) because it
|
||||
// depends on the avatar's own capsule constants, which are a movement concern.
|
||||
let head = player.pos + Vec3::Y * (CAPSULE_HEIGHT - CAPSULE_RADIUS);
|
||||
// The spyglass lock (aura 76): pin the rig to first person for as long as the scope is
|
||||
// held. Parking BOTH the live distance and the wheel target is what makes it a lock rather
|
||||
// than a nudge — the wheel writes `target_distance`, and re-parking here every frame is our
|
||||
// equivalent of the reference's camera flag `0x8` making `SetCameraView` early-return.
|
||||
if scoped.active() {
|
||||
rig.park_distance(0.0);
|
||||
}
|
||||
// Far sight (B151): while `PLAYER_FARSIGHT` names an object, the rig orbits IT instead of
|
||||
// the body — the three arguments below are the entire feature. Everything else in this
|
||||
// system runs untouched, which is the point: Mind Vision leaves you walking, streaming,
|
||||
// hearing and sending movement as yourself while only the picture moves. The sweep origin
|
||||
// moves with the subject too; rooting it at our own head would cast the boom across the
|
||||
// world and jam it on the first wall in between ([`RemoteView::sweep_origin`]).
|
||||
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, cam_pivot_height),
|
||||
};
|
||||
// `turn_delta` is the character's own turn this frame (keyboard turn, or the drunk veer)
|
||||
// — the deck's yaw delta was already applied to `cam.yaw` at the ride block (frame motion
|
||||
// carries the camera unconditionally; only input turns respect `seat_camera`'s
|
||||
@@ -1509,9 +1645,9 @@ fn control(
|
||||
seat_camera(
|
||||
dt,
|
||||
turn_delta,
|
||||
player.pos,
|
||||
head,
|
||||
cam_pivot_height,
|
||||
orbit_pos,
|
||||
sweep_from,
|
||||
orbit_pivot,
|
||||
&mut rig,
|
||||
&mut cam,
|
||||
&mut cam_t,
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
//! **The spyglass zoom** — `SPELL_AURA_FAR_SIGHT` (aura 76), which is not far sight at all.
|
||||
//!
|
||||
//! The Ornate Spyglass (item 5507 → spell 12883 "Longsight") reads like a member of the far-sight
|
||||
//! family and is filed with it in the bug ledger, but the two share only a name. Mind Vision and
|
||||
//! Sentry Totem are `SPELL_AURA_BIND_SIGHT`, a *server* mechanism: the server moves its own
|
||||
//! viewpoint and names the object in `PLAYER_FARSIGHT` ([`super::view_subject`]). Aura 76 is a
|
||||
//! **client-local camera override** and never touches that field — which is why the server appears
|
||||
//! to do nothing for the spyglass, and why chasing it on the wire finds no packet to handle.
|
||||
//!
|
||||
//! **VERIFIED against the 5875 binary** (wow-re `object-layer/scratch/farsight-and-client-control.md`,
|
||||
//! §5 cross-checked): the aura watcher `0x604d00` routes to `0x5ff350` (add) / `0x612320` (remove);
|
||||
//! both, **for the local player only** (`0x5fa6d0`), walk the spell's three effects comparing
|
||||
//! `EffectApplyAuraName[i]` (`SpellRec+0x16c`) against `0x4c` = 76 and call
|
||||
//! `0x50d320(camera, EffectMiscValue[i])`. That function forces **first person**, sets a camera flag
|
||||
//! (`|= 0x8`) that makes `SetCameraView` early-return — so the view is *locked* — and writes
|
||||
//! `[camera+0x40] = n × π/180`. On removal it restores `π/2` and unlocks.
|
||||
//!
|
||||
//! So the spyglass is a locked first-person view at a **6× zoom**: `EffectMiscValue = 15` against a
|
||||
//! stored default of 90.
|
||||
//!
|
||||
//! **We copy the ratio, not the number, and that is deliberate.** The reference's `[camera+0x40]`
|
||||
//! and our [`CAM_FOVY`] are different quantities: theirs defaults to 90°, while ours is the
|
||||
//! *effective vertical* field of view and is 45° (≈ the reference's measured 44.1°). Writing 15°
|
||||
//! straight into our projection would be a third again too narrow. `15/90` is exact, dimensionless,
|
||||
//! and survives whatever our FOV constant is — the same reasoning `camera.rs` uses for the
|
||||
//! click-travel constants, where copying the reference's raw device units would have bound us to
|
||||
//! its mouse scaling.
|
||||
|
||||
use bevy::camera::{PerspectiveProjection, Projection};
|
||||
use bevy::prelude::*;
|
||||
|
||||
use crate::net::{ObjectStore, SelfPlayer};
|
||||
use crate::ui_action::Spells;
|
||||
use benilla_world::view::{WorldCamera, CAM_FOVY};
|
||||
|
||||
/// `SPELL_AURA_FAR_SIGHT` — `0x4c`, the value the reference's effect walk compares against.
|
||||
const SPELL_AURA_FAR_SIGHT: u32 = 76;
|
||||
|
||||
/// The reference's unzoomed `[camera+0x40]`, in degrees (`π/2`, its ctor default at `0x50a706`).
|
||||
/// Only ever a **denominator** here — see the module note on why we take the ratio.
|
||||
const REFERENCE_DEFAULT_DEGREES: f32 = 90.0;
|
||||
|
||||
/// The scope currently held, as a fraction of the normal field of view (`1.0` = unzoomed).
|
||||
///
|
||||
/// `None` is the overwhelmingly common case and means "no override at all" — distinct from
|
||||
/// `Some(1.0)`, which would still lock the camera into first person.
|
||||
#[derive(Resource, Default)]
|
||||
pub(crate) struct ScopedView {
|
||||
pub(crate) zoom: Option<f32>,
|
||||
}
|
||||
|
||||
impl ScopedView {
|
||||
/// True while a scope is held — the camera is locked into first person for as long as it is.
|
||||
pub(crate) fn active(&self) -> bool {
|
||||
self.zoom.is_some()
|
||||
}
|
||||
}
|
||||
|
||||
/// Read our own aura slots for aura 76 and drive the projection from it.
|
||||
///
|
||||
/// Local player only, exactly as the reference gates it: aura 76 on somebody *else* is none of our
|
||||
/// camera's business, and the field is public, so we would otherwise zoom whenever a nearby player
|
||||
/// raised a spyglass.
|
||||
pub(super) fn apply_scoped_view(
|
||||
self_q: Query<&ObjectStore, With<SelfPlayer>>,
|
||||
spells: Option<Res<Spells>>,
|
||||
mut scoped: ResMut<ScopedView>,
|
||||
mut projections: Query<&mut Projection, With<WorldCamera>>,
|
||||
) {
|
||||
let catalog = spells.as_ref().map(|s| &s.catalog);
|
||||
scoped.zoom = self_q
|
||||
.single()
|
||||
.ok()
|
||||
.zip(catalog)
|
||||
.and_then(|(store, catalog)| {
|
||||
store.0.unit_auras().find_map(|slot| {
|
||||
let rec = catalog.get(slot.spell_id)?;
|
||||
(0..3).find_map(|i| {
|
||||
// The misc value is read from the SAME effect index that carries the aura
|
||||
// name — the reference indexes both by `i`. A spell whose aura 76 sits in
|
||||
// effect 2 would otherwise be read against effect 0's unrelated misc value.
|
||||
(rec.effect_apply_aura[i] == SPELL_AURA_FAR_SIGHT)
|
||||
.then(|| rec.effect_misc_value[i] as f32)
|
||||
})
|
||||
})
|
||||
})
|
||||
// A zero misc value is the reference's own "restore" argument, not a zero-width view.
|
||||
.filter(|°| deg > 0.0)
|
||||
.map(|deg| deg / REFERENCE_DEFAULT_DEGREES);
|
||||
|
||||
let Ok(mut projection) = projections.single_mut() else {
|
||||
return;
|
||||
};
|
||||
let want = CAM_FOVY * scoped.zoom.unwrap_or(1.0);
|
||||
if let Projection::Perspective(PerspectiveProjection { fov, .. }) = &mut *projection {
|
||||
if (*fov - want).abs() > f32::EPSILON {
|
||||
*fov = want;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The ratio, and the two ways a naive port gets it wrong: writing the reference's 15 straight
|
||||
/// into a projection whose default is 45°, or restoring to the reference's 90° instead of ours.
|
||||
#[test]
|
||||
fn the_spyglass_narrows_our_own_field_of_view_six_fold() {
|
||||
let scoped = ScopedView {
|
||||
zoom: Some(15.0 / REFERENCE_DEFAULT_DEGREES),
|
||||
};
|
||||
let fov = CAM_FOVY * scoped.zoom.unwrap();
|
||||
assert!(
|
||||
(fov - CAM_FOVY / 6.0).abs() < 1e-6,
|
||||
"the spyglass is a 6x zoom off whatever our normal FOV is, not a literal 15 degrees"
|
||||
);
|
||||
assert!(
|
||||
(fov.to_degrees() - 7.5).abs() < 1e-4,
|
||||
"…which lands at 7.5 degrees vertical for our 45 degree default, not 15"
|
||||
);
|
||||
assert!(scoped.active());
|
||||
|
||||
let none = ScopedView { zoom: None };
|
||||
assert_eq!(
|
||||
CAM_FOVY * none.zoom.unwrap_or(1.0),
|
||||
CAM_FOVY,
|
||||
"unscoped restores OUR default, never the reference's stored 90"
|
||||
);
|
||||
assert!(!none.active());
|
||||
}
|
||||
}
|
||||
@@ -468,6 +468,39 @@ pub(crate) struct Player {
|
||||
pub(super) follow_forward: bool,
|
||||
/// Free-fly (`F`): the camera moves on its own and the avatar/server position is frozen.
|
||||
pub(crate) detached: bool,
|
||||
/// **The server has taken the reins off us** — `SMSG_CLIENT_CONTROL_UPDATE` naming our own guid
|
||||
/// with `allowMove = 0`. Set while somebody is mind-controlling us; cleared when control comes
|
||||
/// back (B211).
|
||||
///
|
||||
/// This is the *whole* of what stops a mind-controlled player walking away, and that is a
|
||||
/// verified property of the server rather than an assumption: vmangos never roots the victim,
|
||||
/// sends it no speed change, its `StopMoving()` is a documented no-op for a possessed player,
|
||||
/// and `HandleMovementOpcodes` carries no charm check — so it will accept and apply any
|
||||
/// movement we choose to send. Nothing else is coming; if the client does not stop itself,
|
||||
/// nothing stops.
|
||||
///
|
||||
/// Deliberately **not** a [`MoveModes`] entry. That family is the four ack'd server modes
|
||||
/// (decision 0866), each with its own SMSG/ack pair and its own movement-flag bit; this owes no
|
||||
/// ack, carries no flag, and suppresses turning too — which root explicitly does not.
|
||||
pub(crate) control_lost: bool,
|
||||
/// A mover the server just took back from us, still owed its `CMSG_MOVE_NOT_ACTIVE_MOVER`.
|
||||
///
|
||||
/// Parked here rather than sent at the wire drain because the release carries a *pose*, and the
|
||||
/// controller is the only thing that owns one. Cleared the frame it goes out.
|
||||
pub(crate) release_mover: Option<u64>,
|
||||
/// **We hold somebody else's reins** — a unit the server handed us and we claimed as our mover
|
||||
/// (mind-controlling a creature, Eye of Kilrogg). `None` whenever the mover is our own body.
|
||||
///
|
||||
/// While this holds, the controller must not drive our own body onto the wire, and the reason
|
||||
/// is sharper than tidiness: outbound `MSG_MOVE_*` carry **no guid** — the server attributes
|
||||
/// them to whatever we last claimed — so streaming our body's pose under a claimed creature's
|
||||
/// mover would teleport that creature onto us. Suppressing our body is also simply what
|
||||
/// possession looks like: your own character stands still while you drive the other thing.
|
||||
///
|
||||
/// Driving the held unit is the named residual (decision 1269): its capsule, speeds and
|
||||
/// take-control edge are all still the avatar's alone, so for now the possessed unit stays put
|
||||
/// rather than moving wrongly.
|
||||
pub(crate) foreign_mover: Option<u64>,
|
||||
/// Feet position in **Bevy** coords (converted to raw WoW only when sending to the server).
|
||||
pub(crate) pos: Vec3,
|
||||
/// Vertical velocity (yd/s, Bevy +Y up) for gravity/jump/fall. Integrated each frame; zeroed while
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
//! **What the camera orbits** — the body, or a far-sight object standing somewhere else entirely.
|
||||
//!
|
||||
//! Decision 0092 settled that this client has two answers to "where am I?": the **camera eye**
|
||||
//! (what it draws, culls and clicks) and the **active-player character** (what it is in and hears).
|
||||
//! Far sight adds no third answer — it moves the *eye*, and leaves the character exactly where it
|
||||
//! was. That is the whole of the feature, and it is why this is a published fact rather than a
|
||||
//! branch in the controller: Mind Vision must keep your body walking, your zone text, your
|
||||
//! minimap, your ambience and your movement stream all yours, while the picture comes from
|
||||
//! somewhere else. Free-fly ([`Player::detached`]) looks superficially similar and is the opposite
|
||||
//! shape — it swaps the *whole* control branch and parks the mover — so far sight must never be
|
||||
//! built as its peer.
|
||||
//!
|
||||
//! **The server names the object and nothing else announces it** (VERIFIED vmangos): the only
|
||||
//! signal is `PLAYER_FARSIGHT` (player field 712, a guid pair) arriving in a values block for our
|
||||
//! own object. Non-zero means look through it, zero means come home. Three details of how it
|
||||
//! arrives are load-bearing here:
|
||||
//!
|
||||
//! - **The set runs ~400 ms late** (`Player::ScheduleCameraUpdate` defers it by `BATCHING_INTERVAL`)
|
||||
//! while the clear is immediate. So the object is always created, and its surroundings always
|
||||
//! streamed, well before we are told to look through it — we never have to wait for the subject.
|
||||
//! - **The clear is broadcast to every client in range**, not just ours: it goes out through
|
||||
//! `DirectSendPublicValueUpdate`, which bypasses the field's PRIVATE flag entirely. Reading only
|
||||
//! our own [`SelfPlayer`] store is what keeps a neighbour cancelling Mind Vision from moving our
|
||||
//! camera.
|
||||
//! - **`SMSG_CLEAR_FAR_SIGHT_IMMEDIATE` never arrives.** vmangos has zero send sites for it, so
|
||||
//! there is nothing to handle and nothing to wait for; every teardown is the field going to zero.
|
||||
//!
|
||||
//! Mind Control rides the same field (the server sets it to the victim alongside the control
|
||||
//! handoff), so possession's camera half falls out of this for free and needs no arm of its own.
|
||||
|
||||
use bevy::prelude::*;
|
||||
|
||||
use super::{head_height, CameraPivot};
|
||||
use crate::net::{ClientCommand, GuidIndex, NetCommands, ObjectStore, SelfPlayer};
|
||||
|
||||
/// Where the camera should sit its rig this frame, when that is *not* our own body.
|
||||
///
|
||||
/// `None` — the overwhelmingly common case — means "the body", and the controller takes its
|
||||
/// normal path. This deliberately carries a resolved **pose** rather than an `Entity`: the
|
||||
/// controller cannot query an arbitrary unit's `Transform` (it already holds the self entity's
|
||||
/// mutably, and the two queries would conflict), and resolving here also lets one place answer
|
||||
/// "far sight is set but the object is not streamed" without the camera ever seeing a half-state.
|
||||
#[derive(Resource, Default)]
|
||||
pub(crate) struct ViewSubject {
|
||||
pub(crate) remote: Option<RemoteView>,
|
||||
}
|
||||
|
||||
/// A far-sight subject's pose, in the two forms the camera rig asks for.
|
||||
#[derive(Clone, Copy)]
|
||||
pub(crate) struct RemoteView {
|
||||
/// The subject's feet, world space — what the rig orbits.
|
||||
pub(crate) feet: Vec3,
|
||||
/// Its framing pivot height above those feet (attachment-17 derived, scaled), the same
|
||||
/// quantity [`head_height`] answers for our own avatar.
|
||||
pub(crate) pivot_height: f32,
|
||||
}
|
||||
|
||||
impl RemoteView {
|
||||
/// The origin the camera-collision boom sweeps *from*.
|
||||
///
|
||||
/// For our own body the controller roots this at the capsule's top hemisphere centre, which it
|
||||
/// can do because it owns the avatar's capsule constants. A far-sight subject has no capsule of
|
||||
/// ours to read, so the framing pivot stands in — it is head-height by construction, which is
|
||||
/// what the sweep wants.
|
||||
///
|
||||
/// **Rooting the sweep at the subject rather than at our body is not a detail.** The boom is
|
||||
/// cast from this point out to the seat; left at our own head it would be cast across the whole
|
||||
/// world to a subject that may be hundreds of yards away, and jam against the first wall in
|
||||
/// between — parking the camera at our feet with the view pointing at nothing.
|
||||
pub(crate) fn sweep_origin(&self) -> Vec3 {
|
||||
self.feet + Vec3::Y * self.pivot_height
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve `PLAYER_FARSIGHT` on our own descriptor into a pose the rig can use.
|
||||
///
|
||||
/// Runs before the controller so the substitution is a plain read there. Deliberately silent about
|
||||
/// a far sight whose object we have not streamed: the camera stays on the body, which is the right
|
||||
/// picture while we wait, and the server's ordering makes the gap vanishingly rare.
|
||||
pub(super) fn publish_view_subject(
|
||||
mut subject: ResMut<ViewSubject>,
|
||||
self_store: Query<&ObjectStore, With<SelfPlayer>>,
|
||||
index: Res<GuidIndex>,
|
||||
poses: Query<(&Transform, Option<&CameraPivot>)>,
|
||||
net: Res<NetCommands>,
|
||||
mut engaged: Local<Option<u64>>,
|
||||
) {
|
||||
let anchor = self_store
|
||||
.iter()
|
||||
.next()
|
||||
.and_then(|store| store.0.player_farsight());
|
||||
subject.remote = anchor
|
||||
.and_then(|guid| index.0.get(&guid).copied())
|
||||
.and_then(|entity| poses.get(entity).ok())
|
||||
.map(|(t, pivot)| RemoteView {
|
||||
feet: t.translation,
|
||||
pivot_height: head_height(pivot, t.scale.x),
|
||||
});
|
||||
|
||||
// The toggle vote, on the edge — the reference sends it from exactly two sites, `1` as the view
|
||||
// attaches and `0` as it releases (`0x5ee290`). Keyed off the FIELD, not off whether we managed
|
||||
// to resolve the object: the server is asking about the view it already chose, and answering
|
||||
// "released" merely because the subject has not streamed yet would tear down its object stream
|
||||
// around the very object we are waiting for.
|
||||
if *engaged != anchor {
|
||||
if anchor.is_some() {
|
||||
let _ = net.0.send(ClientCommand::FarSight { engage: true });
|
||||
} else {
|
||||
let _ = net.0.send(ClientCommand::FarSight { engage: false });
|
||||
}
|
||||
*engaged = anchor;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The three states the resolver has to tell apart, and the two that must both read "body".
|
||||
///
|
||||
/// The middle one is the case the ordering makes rare but not impossible — the field names an
|
||||
/// object we have not streamed. Answering `None` there is what keeps the camera on the body
|
||||
/// instead of at the world origin, which is what a naive `unwrap_or_default` would give.
|
||||
#[test]
|
||||
fn an_unset_or_unstreamed_far_sight_both_resolve_to_the_body() {
|
||||
let (tx, rx) = crossbeam_channel::unbounded();
|
||||
let mut app = App::new();
|
||||
app.init_resource::<ViewSubject>()
|
||||
.init_resource::<GuidIndex>()
|
||||
.insert_resource(NetCommands(tx))
|
||||
.add_systems(Update, publish_view_subject);
|
||||
// The toggle vote, drained as engage/release booleans.
|
||||
let votes = |rx: &crossbeam_channel::Receiver<ClientCommand>| -> Vec<bool> {
|
||||
rx.try_iter()
|
||||
.filter_map(|c| match c {
|
||||
ClientCommand::FarSight { engage } => Some(engage),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
|
||||
// A subject standing 100 yards away, at scale 1.
|
||||
let subject = app
|
||||
.world_mut()
|
||||
.spawn((
|
||||
Transform::from_xyz(100.0, 0.0, 0.0),
|
||||
CameraPivot { height_local: 2.0 },
|
||||
))
|
||||
.id();
|
||||
|
||||
// No far sight: the body.
|
||||
let me = app
|
||||
.world_mut()
|
||||
.spawn((
|
||||
SelfPlayer,
|
||||
Transform::default(),
|
||||
ObjectStore(benilla_protocol::ObjectFields::from_pairs(&[])),
|
||||
))
|
||||
.id();
|
||||
app.update();
|
||||
assert!(
|
||||
app.world().resource::<ViewSubject>().remote.is_none(),
|
||||
"no far sight set → the camera stays on the body"
|
||||
);
|
||||
assert!(votes(&rx).is_empty(), "no field, no vote");
|
||||
|
||||
// Far sight set, but the object never streamed — still the body, not the origin.
|
||||
const FARSIGHT: u16 = 712;
|
||||
let set = |app: &mut App, lo: u32| {
|
||||
*app.world_mut().get_mut::<ObjectStore>(me).unwrap() =
|
||||
ObjectStore(benilla_protocol::ObjectFields::from_pairs(&[
|
||||
(FARSIGHT, lo),
|
||||
(FARSIGHT + 1, 0),
|
||||
]));
|
||||
};
|
||||
set(&mut app, 0x1234);
|
||||
app.update();
|
||||
assert!(
|
||||
app.world().resource::<ViewSubject>().remote.is_none(),
|
||||
"far sight naming an unstreamed object → the body, never the world origin"
|
||||
);
|
||||
assert_eq!(
|
||||
votes(&rx),
|
||||
[true],
|
||||
"the vote is keyed off the FIELD, not off resolving the object — voting 'released' \
|
||||
here would tear down the server's stream around the object we are waiting for"
|
||||
);
|
||||
app.update();
|
||||
assert!(
|
||||
votes(&rx).is_empty(),
|
||||
"and it fires on the edge, not per frame"
|
||||
);
|
||||
|
||||
// Streamed: the subject's feet, and its pivot as the sweep origin's height.
|
||||
app.world_mut()
|
||||
.resource_mut::<GuidIndex>()
|
||||
.0
|
||||
.insert(0x1234, subject);
|
||||
app.update();
|
||||
let remote = app
|
||||
.world()
|
||||
.resource::<ViewSubject>()
|
||||
.remote
|
||||
.expect("a streamed far-sight object resolves");
|
||||
assert_eq!(remote.feet, Vec3::new(100.0, 0.0, 0.0));
|
||||
assert_eq!(
|
||||
remote.sweep_origin(),
|
||||
Vec3::new(100.0, 2.0, 0.0),
|
||||
"the boom sweeps from the SUBJECT's head, not ours — otherwise it is cast across the \
|
||||
world and stops at the first wall in between"
|
||||
);
|
||||
|
||||
// Cleared back to zero: home, by the same path a real teardown takes.
|
||||
set(&mut app, 0);
|
||||
app.update();
|
||||
assert!(
|
||||
app.world().resource::<ViewSubject>().remote.is_none(),
|
||||
"a zeroed field is the ONLY teardown signal there is — no CLEAR_FAR_SIGHT_IMMEDIATE"
|
||||
);
|
||||
assert_eq!(votes(&rx), [false], "and the release votes 0, once");
|
||||
}
|
||||
}
|
||||
@@ -21,8 +21,8 @@ use benilla_assets::coords::{bevy_to_wow, wow_to_bevy};
|
||||
|
||||
use crate::creature_anim::{move_flags, wrap_pi};
|
||||
use crate::net::{
|
||||
ClientCommand, Guid, MoveKind, MoveModeMessage, NetCommands, SelfMoveMessage, SelfPlayer,
|
||||
SpeedChangeMessage, TeleportMessage, WorldportMessage,
|
||||
ClientCommand, ClientControlMessage, Guid, MoveKind, MoveModeMessage, NetCommands,
|
||||
SelfMoveMessage, SelfPlayer, SpeedChangeMessage, TeleportMessage, WorldportMessage,
|
||||
};
|
||||
use crate::transport::Transport;
|
||||
use benilla_world::world_map::CurrentMap;
|
||||
@@ -30,6 +30,36 @@ use benilla_world::world_map::CurrentMap;
|
||||
use super::camera::FlyCam;
|
||||
use super::{movement_net, Player, SETTLE_TIMEOUT};
|
||||
|
||||
/// What one `SMSG_CLIENT_CONTROL_UPDATE` means for us — the four cases its two fields make, named.
|
||||
///
|
||||
/// Worth a type because the packet is genuinely ambiguous read casually: it is a statement *about a
|
||||
/// unit*, not an assignment, and the naive reading ("the mover is now `mover`") silently inverts
|
||||
/// the mind-controlled victim — the case where the server names **us** to say we may no longer
|
||||
/// move. That victim is half of B211, so the inversion would have shipped as the bug it was meant
|
||||
/// to fix.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
enum ControlVerdict {
|
||||
/// Somebody is driving our body. We stop moving it — nothing else will stop us.
|
||||
Revoked,
|
||||
/// Our own body is ours again.
|
||||
Restored,
|
||||
/// We were handed the reins of another unit, and owe it a mover claim.
|
||||
Granted(u64),
|
||||
/// A unit we were driving was taken back, and owes a parting pose.
|
||||
Released(u64),
|
||||
}
|
||||
|
||||
/// Classify a control update. `self_guid` is `None` only before login has named us, where nothing
|
||||
/// can be "about me" yet.
|
||||
fn control_verdict(mover: u64, allow_move: bool, self_guid: Option<u64>) -> ControlVerdict {
|
||||
match (self_guid == Some(mover), allow_move) {
|
||||
(true, false) => ControlVerdict::Revoked,
|
||||
(true, true) => ControlVerdict::Restored,
|
||||
(false, true) => ControlVerdict::Granted(mover),
|
||||
(false, false) => ControlVerdict::Released(mover),
|
||||
}
|
||||
}
|
||||
|
||||
/// Drain this frame's server-authored movement messages, apply them to the mover, and send each
|
||||
/// ack. Returns the frame's forced-speed changes: pre-control/detached they were acked here with
|
||||
/// the parked pose; controlled, the caller's movement stream acks them with its live payload.
|
||||
@@ -49,12 +79,50 @@ pub(super) fn apply_server_moves(
|
||||
speed_msgs: &mut MessageReader<SpeedChangeMessage>,
|
||||
mode_msgs: &mut MessageReader<MoveModeMessage>,
|
||||
self_moves: &mut MessageReader<SelfMoveMessage>,
|
||||
control_msgs: &mut MessageReader<ClientControlMessage>,
|
||||
self_guid: Option<u64>,
|
||||
transports: &Query<
|
||||
(&Transform, &Guid),
|
||||
(With<Transport>, Without<SelfPlayer>, 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
|
||||
// own body?" and "may that unit move?". The server revokes by naming *us* with allowMove=0 —
|
||||
// which is what a mind-controlled player receives about themselves — and grants by naming
|
||||
// somebody else, so a single "the mover is now `mover`" reading gets the victim backwards.
|
||||
//
|
||||
// The claim reply is not optional. vmangos discards every `MSG_MOVE_*` for a mover it has not
|
||||
// confirmed (`Player::GetConfirmedMover`), so a grant we never answer leaves the possessed unit
|
||||
// frozen server-side while our client cheerfully walks it around locally.
|
||||
for c in control_msgs.read() {
|
||||
match control_verdict(c.mover, c.allow_move, self_guid) {
|
||||
ControlVerdict::Revoked => player.control_lost = true,
|
||||
ControlVerdict::Restored => {
|
||||
player.control_lost = false;
|
||||
player.foreign_mover = None;
|
||||
// Re-claim ourselves as the mover — the same claim login makes.
|
||||
let _ = net_cmds
|
||||
.0
|
||||
.send(ClientCommand::SetActiveMover { guid: c.mover });
|
||||
}
|
||||
// Claim them, or the server drops everything we send for that unit. Recording the
|
||||
// claim is what stops the controller streaming OUR body's pose under their guid —
|
||||
// outbound moves carry none of their own, so that would teleport them onto us.
|
||||
ControlVerdict::Granted(guid) => {
|
||||
player.foreign_mover = Some(guid);
|
||||
let _ = net_cmds.0.send(ClientCommand::SetActiveMover { guid });
|
||||
}
|
||||
// The matching `CMSG_MOVE_NOT_ACTIVE_MOVER` rides the controller's own park, which
|
||||
// owns the pose that has to go in it.
|
||||
ControlVerdict::Released(guid) => {
|
||||
if player.foreign_mover == Some(guid) {
|
||||
player.foreign_mover = None;
|
||||
}
|
||||
player.release_mover = Some(guid);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Cross-map worldport (`.tele Orgrimmar`, initial-login map, a boat crossing the sea): the net
|
||||
// bridge surfaced it as a message earlier this frame (WorldStage::Net). Snap the avatar, bump
|
||||
// `CurrentMap` so the terrain streamer swaps ADTs on the next frame, and ack if required (the
|
||||
@@ -495,3 +563,82 @@ mod self_move_tests {
|
||||
assert_eq!(f::ON_TRANSPORT & f::SERVER_AUTHORED, 0);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod control_tests {
|
||||
use super::*;
|
||||
|
||||
const ME: u64 = 0x0000_0000_0000_0045;
|
||||
const VICTIM: u64 = 0x0000_0000_0000_0099;
|
||||
const CREATURE: u64 = 0xF130_000C_1A00_A2B4;
|
||||
|
||||
/// The victim's inversion trap, stated as a test: the packet that revokes control names **us**,
|
||||
/// so "the mover is now `mover`" would read it as *gaining* control of ourselves at the exact
|
||||
/// moment we lost it — and a mind-controlled player would keep walking, which is the reported
|
||||
/// bug (B211's second half).
|
||||
#[test]
|
||||
fn naming_us_is_never_a_grant_and_naming_another_is_never_about_our_body() {
|
||||
assert_eq!(
|
||||
control_verdict(ME, false, Some(ME)),
|
||||
ControlVerdict::Revoked,
|
||||
"the server revokes by naming US — this is the mind-controlled victim's packet"
|
||||
);
|
||||
assert_eq!(
|
||||
control_verdict(ME, true, Some(ME)),
|
||||
ControlVerdict::Restored
|
||||
);
|
||||
assert_eq!(
|
||||
control_verdict(CREATURE, true, Some(ME)),
|
||||
ControlVerdict::Granted(CREATURE)
|
||||
);
|
||||
assert_eq!(
|
||||
control_verdict(CREATURE, false, Some(ME)),
|
||||
ControlVerdict::Released(CREATURE)
|
||||
);
|
||||
}
|
||||
|
||||
/// vmangos's real Mind Control sequences, in order, from both sides of the spell. The caster's
|
||||
/// *end* sequence is the one worth pinning: it restores us BEFORE releasing the victim, so a
|
||||
/// handler that keyed off "the last packet wins" would leave the caster unable to move.
|
||||
#[test]
|
||||
fn the_mind_control_sequences_classify_in_order() {
|
||||
// Caster, possession start: one grant naming the victim. (`Unit::UpdateControl`.)
|
||||
assert_eq!(
|
||||
control_verdict(VICTIM, true, Some(ME)),
|
||||
ControlVerdict::Granted(VICTIM)
|
||||
);
|
||||
// Caster, possession end: `(self, 1)` then `(victim, 0)` — restore first, release second.
|
||||
let caster_end = [
|
||||
control_verdict(ME, true, Some(ME)),
|
||||
control_verdict(VICTIM, false, Some(ME)),
|
||||
];
|
||||
assert_eq!(
|
||||
caster_end,
|
||||
[ControlVerdict::Restored, ControlVerdict::Released(VICTIM)],
|
||||
"restore lands BEFORE the release; last-packet-wins would strand the caster"
|
||||
);
|
||||
|
||||
// Victim's own client, from ITS point of view (self_guid == VICTIM): revoked at the start,
|
||||
// restored at the end. Both name the victim; only the byte differs.
|
||||
assert_eq!(
|
||||
control_verdict(VICTIM, false, Some(VICTIM)),
|
||||
ControlVerdict::Revoked
|
||||
);
|
||||
assert_eq!(
|
||||
control_verdict(VICTIM, true, Some(VICTIM)),
|
||||
ControlVerdict::Restored
|
||||
);
|
||||
}
|
||||
|
||||
/// Before login names us, nothing can be about our body — and in particular a packet must not
|
||||
/// classify as `Revoked` and silently freeze the character the moment control starts.
|
||||
#[test]
|
||||
fn an_unknown_self_guid_never_revokes_our_own_body() {
|
||||
assert_eq!(
|
||||
control_verdict(ME, false, None),
|
||||
ControlVerdict::Released(ME),
|
||||
"with no self guid this is somebody else's unit, not our body being frozen"
|
||||
);
|
||||
assert_eq!(control_verdict(ME, true, None), ControlVerdict::Granted(ME));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -874,6 +874,16 @@ pub enum SessionEvent {
|
||||
/// per-player broadcaster does not) — so the app self-suppresses it on receive and plays
|
||||
/// its own flourish locally at send time.
|
||||
MountSpecial { guid: u64 },
|
||||
/// The server handed us — or took from us — the reins of a unit (`SMSG_CLIENT_CONTROL_UPDATE`).
|
||||
///
|
||||
/// `mover` is the unit being spoken about, **not** necessarily a new subject: when the server
|
||||
/// takes control away it names *us*, with `allow_move = false`. So the two questions the app
|
||||
/// asks of this are different — "is this about me?" (`mover == self guid`) and "may that unit
|
||||
/// move?" — and conflating them gets the mind-controlled *victim*'s case backwards.
|
||||
///
|
||||
/// Answering with `CMSG_SET_ACTIVE_MOVER` is not optional when control is granted: vmangos
|
||||
/// discards every `MSG_MOVE_*` for a mover it has not confirmed.
|
||||
ClientControl { mover: u64, allow_move: bool },
|
||||
/// A packet the codec dropped on the floor: `unparseable = false` means **no parse arm exists**
|
||||
/// for the opcode at all (it fell through to `ServerPacket::Other` — a wire-coverage gap);
|
||||
/// `true` means a parser exists but errored on this body (the reader skipped it to keep the
|
||||
|
||||
@@ -742,6 +742,9 @@ pub fn decode(packet: ServerPacket) -> Vec<SessionEvent> {
|
||||
ServerPacket::MountSpecialAnim { guid } => {
|
||||
vec![SessionEvent::MountSpecial { guid }]
|
||||
}
|
||||
ServerPacket::ClientControlUpdate { mover, allow_move } => {
|
||||
vec![SessionEvent::ClientControl { mover, allow_move }]
|
||||
}
|
||||
ServerPacket::ShowTaxiNodes {
|
||||
window,
|
||||
flightmaster,
|
||||
|
||||
@@ -145,6 +145,21 @@ pub const SMSG_CAST_RESULT: u16 = 0x0130; // 304
|
||||
|
||||
// The pet action bar's inbound wire (decision 0982; VERIFIED vmangos `Opcodes_1_12_1.h`:
|
||||
// 377/378/710/312). Bodies in [`super::pet`].
|
||||
/// The control handoff (VERIFIED vmangos `Opcodes_1_12_1.h`: 345; built at
|
||||
/// `Server/Packets/Misc.cpp:677-682`). Body: **packed** mover guid, then a `u8` `allowMove`.
|
||||
///
|
||||
/// It is the whole of possession's control half, and it is a *statement about one unit*, not a
|
||||
/// swap: the server always sends it to our own session, naming some unit and whether we may drive
|
||||
/// it. Mind Control's start sends the caster `(victim, 1)` and the victim `(victim, 0)`; the end
|
||||
/// sends the caster `(self, 1)` **then** `(victim, 0)`, and the victim `(victim, 1)`.
|
||||
///
|
||||
/// **Two consequences the server leans on us for.** It expects a
|
||||
/// [`CMSG_SET_ACTIVE_MOVER`] reply and drops every `MSG_MOVE_*` for the new mover until it
|
||||
/// arrives (`Player::GetConfirmedMover`). And it never immobilises a possessed *player* — no root,
|
||||
/// no speed change, and its `StopMoving()` is a documented no-op for one — nor does it validate
|
||||
/// their movement, so `allowMove = 0` for our own guid is the **only** thing standing between a
|
||||
/// mind-controlled player and walking away. Enforcing that is the client's job.
|
||||
pub const SMSG_CLIENT_CONTROL_UPDATE: u16 = 0x0159; // 345
|
||||
/// The whole pet bar in one body — the ten slots, the react/command state, the pet's spell list
|
||||
/// and its cooldowns. Its **8-byte guid-only form is the teardown** (`Player::RemovePetActionBar`),
|
||||
/// and the only signal that the bar has gone away.
|
||||
@@ -441,6 +456,27 @@ pub const CMSG_ATTACKSTOP: u16 = 0x0142; // 322
|
||||
pub const CMSG_SETSHEATHED: u16 = 0x01E0; // 480
|
||||
pub const CMSG_AUTH_SESSION: u16 = 0x01ED;
|
||||
pub const CMSG_SET_ACTIVE_MOVER: u16 = 0x026A;
|
||||
/// The other half of the mover handshake (VERIFIED vmangos `Opcodes_1_12_1.h`: 721,
|
||||
/// `MovementHandler.cpp:886-965`). Body: the full u64 guid of the mover we are *giving up*, then a
|
||||
/// whole `MovementInfo`. It clears the server's `m_clientMoverGuid` and re-broadcasts a stop under
|
||||
/// the old guid, so skipping it strands observers on that mover's last relayed pose.
|
||||
pub const CMSG_MOVE_NOT_ACTIVE_MOVER: u16 = 0x02D1; // 721
|
||||
/// The far-sight **toggle vote** (VERIFIED vmangos `Opcodes_1_12_1.h`: 634,
|
||||
/// `MiscHandler.cpp:1138-1155`). Body is a single `u8`: `1` = look through the object, `0` = look
|
||||
/// through my own body again.
|
||||
///
|
||||
/// **The client never names the object.** The server resolves it from `PLAYER_FARSIGHT`, which
|
||||
/// only the server writes — so this is a vote on a view it already chose, not a request. Both
|
||||
/// branches pass `update_far_sight_field = false`, so **neither touches the field**: it keeps
|
||||
/// naming the object while the view toggles under it.
|
||||
///
|
||||
/// **Sending `0` is destructive and must be deliberate.** It moves the server's *visibility*
|
||||
/// source back to our body while `PLAYER_FARSIGHT` still names the object — the world around that
|
||||
/// object stops streaming while the camera is still anchored to it. Sending nothing is the safe
|
||||
/// default: the server tracks no reply, and it has already attached the viewpoint before we could
|
||||
/// answer. `1` is a no-op in every normal flow (`Camera::SetView` early-returns on an unchanged
|
||||
/// source); it exists for the buff-click toggle, whose only shipped user is Sentry Totem.
|
||||
pub const CMSG_FAR_SIGHT: u16 = 0x027A; // 634
|
||||
/// Empty body. The real client sends it from exactly ONE site — inside the local auto-repeat
|
||||
/// cancel `0x6ea080` (`0x6ea0c6`) — so it rides along with *every* cancel trigger (wow-re
|
||||
/// `nocked-ammo-cancel.md`). vmangos `HandleCancelAutoRepeatSpellOpcode` interrupts the held
|
||||
|
||||
@@ -1017,6 +1017,13 @@ pub enum ServerPacket {
|
||||
MountSpecialAnim {
|
||||
guid: u64,
|
||||
},
|
||||
/// `SMSG_CLIENT_CONTROL_UPDATE` — packed mover guid + `u8` allowMove (VERIFIED vmangos
|
||||
/// `Server/Packets/Misc.cpp:677-682`). Always addressed to us; `mover` is the unit it speaks
|
||||
/// about, which is our own guid when the server is taking control *away*.
|
||||
ClientControlUpdate {
|
||||
mover: u64,
|
||||
allow_move: bool,
|
||||
},
|
||||
/// `SMSG_SHOWTAXINODES` — the taxi map (vmangos `SendTaxiMenu`, `TaxiHandler.cpp:82-96`;
|
||||
/// layout in [`super::taxi::read_show_taxi_nodes`], decision 0484). `window` is the
|
||||
/// window-framing constant vmangos always writes `1`; `flightmaster` names the NPC the menu
|
||||
@@ -1295,6 +1302,7 @@ impl ServerPacket {
|
||||
ServerPacket::MountResult { mount: true, .. } => "SMSG_MOUNTRESULT".into(),
|
||||
ServerPacket::MountResult { mount: false, .. } => "SMSG_DISMOUNTRESULT".into(),
|
||||
ServerPacket::MountSpecialAnim { .. } => "SMSG_MOUNTSPECIAL_ANIM".into(),
|
||||
ServerPacket::ClientControlUpdate { .. } => "SMSG_CLIENT_CONTROL_UPDATE".into(),
|
||||
ServerPacket::ShowTaxiNodes { .. } => "SMSG_SHOWTAXINODES".into(),
|
||||
ServerPacket::TaxiNodeStatus { .. } => "SMSG_TAXINODE_STATUS".into(),
|
||||
ServerPacket::ActivateTaxiReply { .. } => "SMSG_ACTIVATETAXIREPLY".into(),
|
||||
|
||||
@@ -989,6 +989,12 @@ pub fn parse_server(opcode: u16, body: &[u8]) -> io::Result<ServerPacket> {
|
||||
opcode::SMSG_MOUNTSPECIAL_ANIM => ServerPacket::MountSpecialAnim {
|
||||
guid: read_u64_le(&mut r)?,
|
||||
},
|
||||
// The control handoff: PACKED guid (unlike the flourish above) + one byte (VERIFIED
|
||||
// vmangos `Server/Packets/Misc.cpp:677-682` — `WriteAsPacked()`, then `uint8 allowMove`).
|
||||
opcode::SMSG_CLIENT_CONTROL_UPDATE => ServerPacket::ClientControlUpdate {
|
||||
mover: read_packed_guid(&mut r)?,
|
||||
allow_move: read_u8(&mut r)? != 0,
|
||||
},
|
||||
// The taxi/flight-master family (decision 0484): the map (SHOWTAXINODES), a status
|
||||
// answer/first-visit learn (TAXINODE_STATUS), the activate verdict, and the multi-hop
|
||||
// path ack (NEW_TAXI_PATH, empty body).
|
||||
|
||||
@@ -324,6 +324,14 @@ const FIELD_PLAYER_VENDORBUYBACK_SLOT_1: u16 = 624; // 12 slots × 2 (item guids
|
||||
// PLAYER_FIELD_KEYRING_SLOT_1 — the next link of the same chain the buyback comment walks
|
||||
// (624 + 12×2 = 648; +32×2 = 712 = FARSIGHT ✓, which the chain's far end already pins).
|
||||
const FIELD_PLAYER_KEYRING_SLOT_1: u16 = 648; // 32 slots × 2 (item guids), wire slots 81–112
|
||||
// PLAYER_FARSIGHT — the object our view is anchored to (Mind Vision, Sentry Totem, the Ornate
|
||||
// Spyglass), or 0 for "my own body". A 2-field guid, PRIVATE, so it only ever arrives on our own
|
||||
// descriptor — exactly the consumer. Its index needs no fresh derivation: it is the next link of
|
||||
// the same chain the two comments above walk, and both ends of that chain are tested anchors
|
||||
// (648 + 32×2 = 712; +2 combo 714, +2 = XP 716 ✓). vmangos's hex comment reads 0x2C2 = 706 — the
|
||||
// same 6-low drift as COINAGE/XP, and not what the server compiles: its enum arithmetic says
|
||||
// UNIT_END(0xBC = 188) + 0x20C(524) = 712.
|
||||
const FIELD_PLAYER_FARSIGHT: u16 = 712;
|
||||
const FIELD_PLAYER_BUYBACK_PRICE_1: u16 = 1226; // 12 × u32 copper, indexed slot−69
|
||||
const FIELD_PLAYER_BUYBACK_TIMESTAMP_1: u16 = 1238; // 12 × u32 — the client's sort key only
|
||||
// PLAYER_FIELD_COINAGE, our purse in copper (INT, PRIVATE — sent only for our own player, the exact
|
||||
|
||||
@@ -182,6 +182,16 @@ impl ObjectFields {
|
||||
pub fn player_money(&self) -> Option<u32> {
|
||||
self.get_u32(FIELD_PLAYER_FIELD_COINAGE)
|
||||
}
|
||||
/// `PLAYER_FARSIGHT` — the object our view is anchored to while a far-sight effect runs (Mind
|
||||
/// Vision, Sentry Totem, the Ornate Spyglass), or `None` when the view is our own body.
|
||||
///
|
||||
/// PRIVATE, so like [`Self::player_xp`] it only ever streams for our own avatar — which is the
|
||||
/// whole consumer: this answers "what does the camera orbit", never anything about another
|
||||
/// player. `0` is the cleared state and reads as `None`, following the house convention for
|
||||
/// guid fields ([`Self::unit_charmed_by`] and friends): a zero guid is absence, not an object.
|
||||
pub fn player_farsight(&self) -> Option<u64> {
|
||||
self.get_guid(FIELD_PLAYER_FARSIGHT).filter(|&g| g != 0)
|
||||
}
|
||||
/// `PLAYER_XP` — our current experience within the level (`UnitXP("player")`). `None` for a
|
||||
/// non-player unit (no PLAYER block) or before the field has streamed; a PRIVATE field, so it
|
||||
/// only ever arrives for our own avatar's descriptor.
|
||||
|
||||
@@ -63,6 +63,18 @@ fn combat_stat_indices_chain_to_the_tested_anchors() {
|
||||
FIELD_PLAYER_WATCHED_FACTION_INDEX,
|
||||
FIELD_PLAYER_FIELD_COINAGE + 85
|
||||
);
|
||||
// Far sight is the guid pair immediately before the combo-target pair, which is itself the two
|
||||
// dwords before the live-tested XP pair — so it is chain-locked from BOTH ends against tested
|
||||
// anchors and needs none of its own: keyring 648 +64 = 712, +2 combo 714, +2 XP 716. Worth
|
||||
// asserting because vmangos's hex comment for this field reads 0x2C2 = 706, six low (the same
|
||||
// drift as COINAGE/XP); its enum arithmetic, which is what the server compiles, agrees with us.
|
||||
assert_eq!(FIELD_PLAYER_FARSIGHT, UNIT_END + 0x20C);
|
||||
assert_eq!(
|
||||
FIELD_PLAYER_FARSIGHT,
|
||||
FIELD_PLAYER_KEYRING_SLOT_1 + 2 * 32,
|
||||
"far sight closes the 32-slot keyring array"
|
||||
);
|
||||
assert_eq!(FIELD_PLAYER_FARSIGHT + 2, FIELD_PLAYER_FIELD_COMBO_TARGET);
|
||||
// The combo-target GUID is the two dwords immediately BEFORE the live-tested XP pair, so it
|
||||
// needs no anchor of its own. The binary agrees from the other side: `GetComboPoints 0x51a190`
|
||||
// reads it at `[player+0xe68]+0x838`, and 0x838/4 = 0x20E (decision 0875).
|
||||
|
||||
@@ -48,6 +48,50 @@ impl WorldWriter {
|
||||
self.send(opcode, &messages::movement(&info))
|
||||
}
|
||||
|
||||
/// Claim `guid` as the unit whose movement we are sending (`CMSG_SET_ACTIVE_MOVER`, a full u64).
|
||||
///
|
||||
/// **The mover handshake is client-driven — no server packet sets it.** The server states who
|
||||
/// we *may* drive (`SMSG_CLIENT_CONTROL_UPDATE`) and then waits; until this reply lands,
|
||||
/// `Player::GetConfirmedMover` resolves nothing for the new mover and **every** `MSG_MOVE_*` we
|
||||
/// send for it is discarded (vmangos `MovementHandler.cpp:844-884`, which also rejects a claim
|
||||
/// on a unit it never handed us). Login makes this claim for our own body; possession simply
|
||||
/// re-makes it for somebody else's.
|
||||
pub fn set_active_mover(&mut self, guid: u64) -> Result<()> {
|
||||
self.send(opcode::CMSG_SET_ACTIVE_MOVER, &messages::full_guid(guid))
|
||||
}
|
||||
|
||||
/// Vote on the far-sight view the server has already chosen (`CMSG_FAR_SIGHT`, one `u8`).
|
||||
///
|
||||
/// The real client sends this from exactly two sites, both inside its far-sight engage/release
|
||||
/// function `0x5ee290`: `1` when the view attaches (`5ee3cb`/`5ee3dd`), `0` when it releases
|
||||
/// (`5ee4f3`/`5ee504`). It names no object — the server resolves that from `PLAYER_FARSIGHT`,
|
||||
/// which only the server writes.
|
||||
pub fn far_sight(&mut self, engage: bool) -> Result<()> {
|
||||
self.send(opcode::CMSG_FAR_SIGHT, &[u8::from(engage)])
|
||||
}
|
||||
|
||||
/// Give up `guid` as our mover (`CMSG_MOVE_NOT_ACTIVE_MOVER`): the full u64 guid being
|
||||
/// released, then a `MovementInfo` at its parting pose.
|
||||
///
|
||||
/// The pose is the point. vmangos clears its confirmed-mover state and **re-broadcasts a stop
|
||||
/// under the old guid** built from what we send here (`MovementHandler.cpp:886-965`), so
|
||||
/// skipping it strands every observer on that unit's last relayed pose — a possessed creature
|
||||
/// left sliding where the possession ended.
|
||||
pub fn move_not_active_mover(
|
||||
&mut self,
|
||||
guid: u64,
|
||||
flags: u32,
|
||||
pos: [f32; 3],
|
||||
orientation: f32,
|
||||
fall_time: u32,
|
||||
) -> Result<()> {
|
||||
let mut info = movement_info(pos, orientation, flags);
|
||||
info.fall_time = fall_time;
|
||||
let mut body = messages::full_guid(guid);
|
||||
body.extend_from_slice(&messages::movement(&info));
|
||||
self.send(opcode::CMSG_MOVE_NOT_ACTIVE_MOVER, &body)
|
||||
}
|
||||
|
||||
/// Acknowledge that a server-authored spline (Charge/knockback/taxi — an `SMSG_MONSTER_MOVE`
|
||||
/// addressed to our own guid) finished: `CMSG_MOVE_SPLINE_DONE` with a `MovementInfo` at the
|
||||
/// ride's endpoint and the `spline_id` we were driven by. The server sets `SplineDonePending` for
|
||||
|
||||
@@ -474,3 +474,57 @@ fn transfer_pending_both_shapes_and_abort_parse_and_decode() {
|
||||
[SessionEvent::TransferAborted { reason: 2 }]
|
||||
));
|
||||
}
|
||||
|
||||
/// `SMSG_CLIENT_CONTROL_UPDATE` — the possession handoff. Golden hex is hand-derived from
|
||||
/// vmangos's builder (`Server/Packets/Misc.cpp:677-682`): `moverGuid.WriteAsPacked()` then
|
||||
/// `uint8 allowMove`. The packed form is a mask byte whose bit *i* marks a non-zero byte *i* of
|
||||
/// the little-endian u64, followed by exactly those bytes — so the same guid costs a different
|
||||
/// number of bytes depending on its value, and getting the mask wrong desynchronises everything
|
||||
/// after it in the stream.
|
||||
///
|
||||
/// **The two cases below are the two the app must tell apart**, and they differ only in a guid:
|
||||
/// the server grants control by naming somebody *else* and revokes it by naming *us*.
|
||||
#[test]
|
||||
fn client_control_update_reads_a_packed_mover_and_the_allow_byte() {
|
||||
// Grant: a creature guid 0xF130000C1A00A2B4 with allowMove = 1. Non-zero LE bytes are
|
||||
// b4 a2 00 1a 0c 00 30 f1 → indices 0,1,3,4,6,7 → mask 0b1101_1011 = 0xdb.
|
||||
let grant = hx("dbb4a21a0c30f101");
|
||||
match messages::parse_server(messages::opcode::SMSG_CLIENT_CONTROL_UPDATE, &grant).unwrap() {
|
||||
ServerPacket::ClientControlUpdate { mover, allow_move } => {
|
||||
assert_eq!(mover, 0xF130_000C_1A00_A2B4);
|
||||
assert!(allow_move);
|
||||
}
|
||||
other => panic!("client control update, got {}", other.name()),
|
||||
}
|
||||
// Revoke: our own player guid 0x45 (one non-zero byte, index 0 → mask 0x01) with
|
||||
// allowMove = 0. This is the shape a mind-controlled player receives about themselves, and
|
||||
// the whole of what stops them walking away — vmangos never roots them.
|
||||
let revoke = hx("014500");
|
||||
let revoke = messages::parse_server(messages::opcode::SMSG_CLIENT_CONTROL_UPDATE, &revoke)
|
||||
.expect("revoke parses");
|
||||
assert!(matches!(
|
||||
revoke,
|
||||
ServerPacket::ClientControlUpdate {
|
||||
mover: 0x45,
|
||||
allow_move: false
|
||||
}
|
||||
));
|
||||
assert!(matches!(
|
||||
decode(revoke).as_slice(),
|
||||
[SessionEvent::ClientControl {
|
||||
mover: 0x45,
|
||||
allow_move: false
|
||||
}]
|
||||
));
|
||||
// A zero guid packs to a lone zero mask byte and no guid bytes at all — the degenerate case
|
||||
// that would read the allow byte AS the guid if the mask were mishandled.
|
||||
let empty = messages::parse_server(messages::opcode::SMSG_CLIENT_CONTROL_UPDATE, &hx("0001"))
|
||||
.expect("empty guid parses");
|
||||
assert!(matches!(
|
||||
empty,
|
||||
ServerPacket::ClientControlUpdate {
|
||||
mover: 0,
|
||||
allow_move: true
|
||||
}
|
||||
));
|
||||
}
|
||||
|
||||
@@ -152,6 +152,9 @@ pub(crate) struct ExteriorCullPlugin;
|
||||
|
||||
impl Plugin for ExteriorCullPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
if std::env::var_os("WOW_CULL_TRACE").is_some() {
|
||||
app.insert_resource(CullTrace);
|
||||
}
|
||||
app.init_resource::<ExteriorCullVerdict>().add_systems(
|
||||
PostUpdate,
|
||||
apply_exterior_cull
|
||||
@@ -283,6 +286,51 @@ type UnownedSceneFilter = (
|
||||
Without<crate::world_unit::WorldUnit>,
|
||||
);
|
||||
|
||||
/// **`WOW_CULL_TRACE=1` — one line per body per frame, saying why it drew.**
|
||||
///
|
||||
/// `cull_bodies`/`cull_bodies_hidden` say *how many*; they cannot say *which*, and the difference
|
||||
/// cost three live runs of inference during decision 1270. A body escapes this cull in exactly three
|
||||
/// ways — it was never elected (no `WorldUnit::bound`), it claims a WMO room, or a window admitted
|
||||
/// its box — and from a screenshot, and from the aggregate counters, all three look identical.
|
||||
///
|
||||
/// Off (the var unset) this is one `Option` read per body. Deliberately not throttled like
|
||||
/// `WOW_VIS_TRACE`: the audience is the server's visibility stream (dozens), and the question it
|
||||
/// answers — "which body, on which frame" — is destroyed by sampling.
|
||||
fn trace_body(
|
||||
trace: &Option<Res<CullTrace>>,
|
||||
e: Entity,
|
||||
class: &str,
|
||||
gt: &GlobalTransform,
|
||||
room: Option<&crate::wmo_portal::UnitWmoRoom>,
|
||||
bound: Option<&Aabb>,
|
||||
admitted: bool,
|
||||
) {
|
||||
if trace.is_none() {
|
||||
return;
|
||||
}
|
||||
let p = gt.translation();
|
||||
println!(
|
||||
"CULL_BODY {e} {class} admitted={admitted} claim={claim} bound={bound} \
|
||||
pos=[{:.1},{:.1},{:.1}]",
|
||||
p.x,
|
||||
p.y,
|
||||
p.z,
|
||||
claim = match room.map(|r| r.room()) {
|
||||
None => "absent".to_string(),
|
||||
Some(None) => "outdoors".to_string(),
|
||||
Some(Some(r)) => format!("g{}", r.group),
|
||||
},
|
||||
bound = match bound {
|
||||
None => "none".to_string(),
|
||||
Some(b) => format!("r{:.1}", Vec3::from(b.half_extents).length()),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// `WOW_CULL_TRACE` — see [`trace_body`].
|
||||
#[derive(Resource)]
|
||||
pub(crate) struct CullTrace;
|
||||
|
||||
/// What the body leg reads per net body — one whole-object test on its root, which is the
|
||||
/// reference's own granularity (`0x682f40`), and which takes the whole visual with it: body
|
||||
/// geosets, worn gear under their joints, the mount and its rider (`mount::spawn_mount_child`
|
||||
@@ -296,6 +344,7 @@ type UnownedSceneFilter = (
|
||||
/// yet, and an unknown room is a **timing gap, not a verdict** — the same law the missing-`Aabb`
|
||||
/// arm above takes, and for the same reason: deciding on absent information flickers the world.
|
||||
type ElectedBody = (
|
||||
Entity,
|
||||
&'static GlobalTransform,
|
||||
&'static crate::world_unit::WorldUnit,
|
||||
Option<&'static crate::wmo_portal::UnitWmoRoom>,
|
||||
@@ -316,6 +365,7 @@ fn apply_exterior_cull(
|
||||
mut scene: Query<UnownedScene, UnownedSceneFilter>,
|
||||
mut bodies: Query<ElectedBody>,
|
||||
mut verdict: ResMut<ExteriorCullVerdict>,
|
||||
trace: Option<Res<CullTrace>>,
|
||||
) {
|
||||
let set = |vis: &mut Visibility, target: Visibility| {
|
||||
if *vis != target {
|
||||
@@ -341,15 +391,36 @@ fn apply_exterior_cull(
|
||||
// The body leg: one whole-object test per net body (see [`ElectedBody`]), serial because the
|
||||
// audience is the server's visibility stream — dozens, not the tens of thousands above.
|
||||
let (mut body_n, mut body_hidden) = (0usize, 0usize);
|
||||
for (gt, body, room, mut vis) in &mut bodies {
|
||||
for (e, gt, body, room, mut vis) in &mut bodies {
|
||||
let Some(bound) = body.bound.as_ref() else {
|
||||
continue; // the game says this body is not ours to decide — `WorldUnit::bound`
|
||||
// The game says this body is not ours to decide (`WorldUnit::bound`). Traced too: an
|
||||
// un-elected body is invisible to every counter below, which is exactly how a whole
|
||||
// class of creature sat outside this cull unnoticed (decision 1270 §5).
|
||||
trace_body(&trace, e, "unelected", gt, room, None, true);
|
||||
continue;
|
||||
};
|
||||
body_n += 1;
|
||||
// In a WMO room ⇒ its building's own walk submits it, not the exterior one. No claim yet ⇒
|
||||
// not decided. Either way the gate stands down and the body keeps drawing.
|
||||
let exempt = room.is_none_or(|r| r.room().is_some());
|
||||
// In a WMO room ⇒ its building's own walk submits it, not the exterior one.
|
||||
//
|
||||
// **No claim yet reads as outdoors, not as exempt**, and that asymmetry is the whole of the
|
||||
// director's second report. A claim is `try_insert`ed by `track_unit_interiors` a frame or
|
||||
// two after a body spawns, so "undecided ⇒ admit" let every streaming mob draw through a
|
||||
// sealed ceiling for exactly as long as the gap — which at a cavern's frame rate is most of
|
||||
// a second, per mob, continuously. The two error directions are not symmetric: guessing
|
||||
// *outdoors* makes a mob in your own room appear a frame late, guessing *in a room* makes a
|
||||
// mob outside appear through solid rock. And `UnitWmoRoom::default()` is itself a
|
||||
// no-room claim, so this simply decides the missing component the way the present one would.
|
||||
let exempt = room.is_some_and(|r| r.room().is_some());
|
||||
let admitted = exempt || gate.admits(gt, Some(bound));
|
||||
trace_body(
|
||||
&trace,
|
||||
e,
|
||||
if exempt { "in-room" } else { "outdoor" },
|
||||
gt,
|
||||
room,
|
||||
Some(bound),
|
||||
admitted,
|
||||
);
|
||||
body_hidden += usize::from(!admitted);
|
||||
set(
|
||||
&mut vis,
|
||||
@@ -474,7 +545,7 @@ mod tests {
|
||||
/// verdict in spawn order plus the counters.
|
||||
fn run_bodies(
|
||||
windows: ExteriorWindows,
|
||||
bodies: &[(Vec3, Option<crate::wmo_portal::UnitWmoRoom>)],
|
||||
bodies: &[(Vec3, Option<crate::wmo_portal::UnitWmoRoom>, Aabb)],
|
||||
) -> (Vec<Visibility>, ExteriorCullVerdict) {
|
||||
let mut app = App::new();
|
||||
app.init_resource::<ExteriorCullVerdict>()
|
||||
@@ -494,15 +565,14 @@ mod tests {
|
||||
));
|
||||
let ids: Vec<Entity> = bodies
|
||||
.iter()
|
||||
.map(|(at, room)| {
|
||||
// A yard-ish box on the body's own origin — a creature's authored idle CAaBox.
|
||||
.map(|(at, room, bound)| {
|
||||
let mut e = app.world_mut().spawn((
|
||||
GlobalTransform::from_translation(*at),
|
||||
crate::world_unit::WorldUnit {
|
||||
wades: true,
|
||||
scale: 1.0,
|
||||
height: 2.0,
|
||||
bound: Some(Aabb::from_min_max(Vec3::splat(-0.5), Vec3::splat(0.5))),
|
||||
bound: Some(*bound),
|
||||
},
|
||||
Visibility::Inherited,
|
||||
));
|
||||
@@ -536,6 +606,15 @@ mod tests {
|
||||
}
|
||||
const NOT_RAYED_YET: Option<crate::wmo_portal::UnitWmoRoom> = None;
|
||||
|
||||
/// A yard-ish box on the body's own origin — a creature's authored idle CAaBox.
|
||||
fn creature_box() -> Aabb {
|
||||
Aabb::from_min_max(Vec3::splat(-0.5), Vec3::splat(0.5))
|
||||
}
|
||||
/// …and what the game writes before a body's model has resolved: a point at its origin.
|
||||
fn unresolved() -> Aabb {
|
||||
Aabb::from_min_max(Vec3::ZERO, Vec3::ZERO)
|
||||
}
|
||||
|
||||
/// **The Caverns of Time report, as a test** (decision 1270). Sealed room ⇒ no windows ⇒ the
|
||||
/// exterior walk never runs, so an outdoor body is not submitted at all — while a body standing
|
||||
/// in a WMO room rides the sibling producer `0x6834e0` and still draws. Both bodies sit at the
|
||||
@@ -546,7 +625,10 @@ mod tests {
|
||||
let ahead = Vec3::new(0.0, 0.0, -50.0);
|
||||
let (vis, verdict) = run_bodies(
|
||||
ExteriorWindows::Windows(Vec::new()),
|
||||
&[(ahead, outdoors()), (ahead, in_a_room())],
|
||||
&[
|
||||
(ahead, outdoors(), creature_box()),
|
||||
(ahead, in_a_room(), creature_box()),
|
||||
],
|
||||
);
|
||||
assert_eq!(
|
||||
vis,
|
||||
@@ -556,16 +638,56 @@ mod tests {
|
||||
assert_eq!((verdict.bodies, verdict.bodies_hidden), (2, 1));
|
||||
}
|
||||
|
||||
/// A body whose room has not been rayed yet is **not decided** — an unknown room is a timing
|
||||
/// gap, not a verdict, the same law the missing-`Aabb` arm takes. Asserted in the sealed room,
|
||||
/// which is the only state where the two answers differ.
|
||||
/// **A body whose model has not resolved yet is elected all the same**, on the point at its own
|
||||
/// origin — the director's second report, after the first cut had landed: creatures still
|
||||
/// appearing overhead inside Caverns of Time.
|
||||
///
|
||||
/// Every streamed mob drew for one whole frame, because the model's extent reaches
|
||||
/// `publish_world_units` only *after* `attach_entity_visuals` has already spawned the visual,
|
||||
/// and a bound-less body was admitted. That looked like a conservative default and was a defect:
|
||||
/// at a cavern's frame rate one frame is most of a second, and mobs stream continuously, so a
|
||||
/// per-body flash reads as "they are still there". The origin never needed waiting for — it is
|
||||
/// the server's own position, exact from the body's first frame.
|
||||
#[test]
|
||||
fn an_unrayed_body_is_not_decided() {
|
||||
let (vis, _) = run_bodies(
|
||||
fn a_body_whose_model_has_not_resolved_is_elected_on_its_origin() {
|
||||
let (vis, verdict) = run_bodies(
|
||||
ExteriorWindows::Windows(Vec::new()),
|
||||
&[(Vec3::new(0.0, 0.0, -50.0), NOT_RAYED_YET)],
|
||||
&[(Vec3::new(0.0, 0.0, -50.0), outdoors(), unresolved())],
|
||||
);
|
||||
assert_eq!(vis, vec![Visibility::Inherited]);
|
||||
assert_eq!(
|
||||
vis,
|
||||
vec![Visibility::Hidden],
|
||||
"a point at the origin is a testable bound — the sealed room must reject it"
|
||||
);
|
||||
assert_eq!((verdict.bodies, verdict.bodies_hidden), (1, 1));
|
||||
}
|
||||
|
||||
/// A body whose room has not been rayed yet reads as **outdoors**, and is gated.
|
||||
///
|
||||
/// This test asserted the opposite when the first cut landed — "an unknown room is a timing gap,
|
||||
/// not a verdict", borrowed from the missing-`Aabb` arm beside it. The director refuted it from
|
||||
/// the screen: creatures still overhead inside Caverns of Time. `track_unit_interiors`
|
||||
/// `try_insert`s a claim a frame or two after a body spawns, so on a continuous stream of mobs
|
||||
/// there is always a handful mid-gap, and admitting them drew each one through the ceiling for
|
||||
/// most of a second.
|
||||
///
|
||||
/// The lesson is that the two arms are not the same question. A missing bound leaves nothing to
|
||||
/// test, so the cull genuinely cannot decide. A missing *claim* still has a default the
|
||||
/// component itself states — `UnitWmoRoom::default()` is a no-room claim — and the error
|
||||
/// directions are lopsided: a body wrongly called outdoors appears a frame late, a body wrongly
|
||||
/// called indoors appears through solid rock.
|
||||
#[test]
|
||||
fn an_unrayed_body_reads_as_outdoors() {
|
||||
let (vis, verdict) = run_bodies(
|
||||
ExteriorWindows::Windows(Vec::new()),
|
||||
&[(Vec3::new(0.0, 0.0, -50.0), NOT_RAYED_YET, creature_box())],
|
||||
);
|
||||
assert_eq!(
|
||||
vis,
|
||||
vec![Visibility::Hidden],
|
||||
"no claim is not a licence to draw through a sealed room"
|
||||
);
|
||||
assert_eq!((verdict.bodies, verdict.bodies_hidden), (1, 1));
|
||||
}
|
||||
|
||||
/// Outdoors the driver takes its `{0,0,1,1}` leg and every body is submitted as before — the
|
||||
@@ -575,10 +697,10 @@ mod tests {
|
||||
let (vis, verdict) = run_bodies(
|
||||
ExteriorWindows::Unrestricted,
|
||||
&[
|
||||
(Vec3::new(0.0, 0.0, -50.0), outdoors()),
|
||||
(Vec3::new(0.0, 0.0, -50.0), outdoors(), creature_box()),
|
||||
// Behind the camera, and still submitted: `Unrestricted` is a stand-down, not a
|
||||
// frustum test. Bevy's own cull is what drops this one, one system later.
|
||||
(Vec3::new(0.0, 0.0, 50.0), outdoors()),
|
||||
(Vec3::new(0.0, 0.0, 50.0), outdoors(), creature_box()),
|
||||
],
|
||||
);
|
||||
assert_eq!(vis, vec![Visibility::Inherited; 2]);
|
||||
@@ -592,8 +714,8 @@ mod tests {
|
||||
let (vis, verdict) = run_bodies(
|
||||
ExteriorWindows::Windows(vec![[0.1, -1.0, 1.0, 1.0]]),
|
||||
&[
|
||||
(Vec3::new(40.0, 0.0, -50.0), outdoors()),
|
||||
(Vec3::new(-40.0, 0.0, -50.0), outdoors()),
|
||||
(Vec3::new(40.0, 0.0, -50.0), outdoors(), creature_box()),
|
||||
(Vec3::new(-40.0, 0.0, -50.0), outdoors(), creature_box()),
|
||||
],
|
||||
);
|
||||
assert_eq!(
|
||||
|
||||
@@ -72,13 +72,19 @@ pub struct WorldUnit {
|
||||
/// It exists because standing in a sealed WMO room the reference never *submits* an outdoor
|
||||
/// object at all (`crate::exterior_cull`, decision 1270) — so the cull needs one whole-object
|
||||
/// AABB per body, on its root, which is the reference's own granularity. The game supplies the
|
||||
/// armed idle's authored CAaBox (decision 0637 — a skinned body's bind-pose box is not where it
|
||||
/// draws), unscaled: the root transform already carries the display scale.
|
||||
/// loader-armed idle's authored CAaBox (decision 0637 — a skinned body's bind-pose box is not
|
||||
/// where it draws), unscaled: the root transform already carries the display scale.
|
||||
///
|
||||
/// `None` says **don't decide**, and it is a real answer, not an absence — a body whose display
|
||||
/// has not resolved, and a transport, whose root `Visibility` its own tick owns every frame
|
||||
/// (a second writer there is precisely the fight decision 0025 forbids). Either way the body
|
||||
/// keeps drawing, which is what it did before this field existed.
|
||||
/// **`None` means one thing only: not the world's to decide.** In 1.12 content that is the
|
||||
/// transport, whose root `Visibility` its own tick writes every frame — a second writer there
|
||||
/// is the fight decision 0025 forbids.
|
||||
///
|
||||
/// A body whose model has **not resolved yet** is elected all the same, with a degenerate box
|
||||
/// at its own origin. That is not a fallback, it is the honest bound: the origin is the
|
||||
/// server's position and is known exactly from the body's first frame, while the model's extent
|
||||
/// crosses two reconcilers before it arrives. Admitting the body meanwhile was a real defect,
|
||||
/// not a conservative default — every streamed mob drew for one full frame through a sealed
|
||||
/// room's ceiling, and at a cavern's frame rate one frame is most of a second (1270 §5).
|
||||
pub bound: Option<bevy::camera::primitives::Aabb>,
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user