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:
Sam
2026-08-14 19:23:38 +07:00
co-authored by Claude Fable 5
parent 7b8e5f99bd
commit ebb9ce9ab8
22 changed files with 1135 additions and 44 deletions
+14 -3
View File
@@ -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.
+47 -7
View File
@@ -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 {
+32
View File
@@ -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
+10
View File
@@ -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,
+9
View File
@@ -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),
+141 -5
View File
@@ -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| 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());
}
}
+33
View File
@@ -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");
}
}
+149 -2
View File
@@ -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));
}
}
+10
View File
@@ -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
}
));
}
+143 -21
View File
@@ -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!(
+12 -6
View File
@@ -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>,
}