diff --git a/crates/benilla-app/src/entities.rs b/crates/benilla-app/src/entities.rs index bc33c26e..8f88c716 100644 --- a/crates/benilla-app/src/entities.rs +++ b/crates/benilla-app/src/entities.rs @@ -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. diff --git a/crates/benilla-app/src/entities/attach/mod.rs b/crates/benilla-app/src/entities/attach/mod.rs index 16b21511..7adf453d 100644 --- a/crates/benilla-app/src/entities/attach/mod.rs +++ b/crates/benilla-app/src/entities/attach/mod.rs @@ -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, +) -> Option { + 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 { diff --git a/crates/benilla-app/src/net.rs b/crates/benilla-app/src/net.rs index c2da1e38..c057cf71 100644 --- a/crates/benilla-app/src/net.rs +++ b/crates/benilla-app/src/net.rs @@ -97,6 +97,7 @@ impl Plugin for NetPlugin { .add_message::() .add_message::() .add_message::() + .add_message::() .add_message::() .add_message::() .add_message::() @@ -696,6 +697,23 @@ pub(crate) enum ClientCommand { jump: Option, transport: Option, }, + /// 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, } +/// 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 diff --git a/crates/benilla-app/src/net/apply.rs b/crates/benilla-app/src/net/apply.rs index b48b3ffc..71d3b167 100644 --- a/crates/benilla-app/src/net/apply.rs +++ b/crates/benilla-app/src/net/apply.rs @@ -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, + // 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, ), // 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, diff --git a/crates/benilla-app/src/net/io.rs b/crates/benilla-app/src/net/io.rs index 657da30a..b0092a42 100644 --- a/crates/benilla-app/src/net/io.rs +++ b/crates/benilla-app/src/net/io.rs @@ -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), diff --git a/crates/benilla-app/src/player.rs b/crates/benilla-app/src/player.rs index a99f1908..2ce01332 100644 --- a/crates/benilla-app/src/player.rs +++ b/crates/benilla-app/src/player.rs @@ -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::(); app.init_resource::(); + // 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::() + .init_resource::() + .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, + // 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, ), // 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, + // 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, + // 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, + // The spyglass scope ([`scoped_view`]): while held, the rig is pinned to first person and + // the wheel cannot leave it. + Res, ), mut commands: Commands, mut player: ResMut, @@ -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, diff --git a/crates/benilla-app/src/player/scoped_view.rs b/crates/benilla-app/src/player/scoped_view.rs new file mode 100644 index 00000000..575c8cfd --- /dev/null +++ b/crates/benilla-app/src/player/scoped_view.rs @@ -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, +} + +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>, + spells: Option>, + mut scoped: ResMut, + mut projections: Query<&mut Projection, With>, +) { + 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()); + } +} diff --git a/crates/benilla-app/src/player/state.rs b/crates/benilla-app/src/player/state.rs index eee272a3..8c1015e0 100644 --- a/crates/benilla-app/src/player/state.rs +++ b/crates/benilla-app/src/player/state.rs @@ -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, + /// **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, /// 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 diff --git a/crates/benilla-app/src/player/view_subject.rs b/crates/benilla-app/src/player/view_subject.rs new file mode 100644 index 00000000..449cc098 --- /dev/null +++ b/crates/benilla-app/src/player/view_subject.rs @@ -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, +} + +/// 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, + self_store: Query<&ObjectStore, With>, + index: Res, + poses: Query<(&Transform, Option<&CameraPivot>)>, + net: Res, + mut engaged: Local>, +) { + 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::() + .init_resource::() + .insert_resource(NetCommands(tx)) + .add_systems(Update, publish_view_subject); + // The toggle vote, drained as engage/release booleans. + let votes = |rx: &crossbeam_channel::Receiver| -> Vec { + 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::().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::(me).unwrap() = + ObjectStore(benilla_protocol::ObjectFields::from_pairs(&[ + (FARSIGHT, lo), + (FARSIGHT + 1, 0), + ])); + }; + set(&mut app, 0x1234); + app.update(); + assert!( + app.world().resource::().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::() + .0 + .insert(0x1234, subject); + app.update(); + let remote = app + .world() + .resource::() + .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::().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"); + } +} diff --git a/crates/benilla-app/src/player/wire_in.rs b/crates/benilla-app/src/player/wire_in.rs index 8971f531..9356ebee 100644 --- a/crates/benilla-app/src/player/wire_in.rs +++ b/crates/benilla-app/src/player/wire_in.rs @@ -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) -> 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, mode_msgs: &mut MessageReader, self_moves: &mut MessageReader, + control_msgs: &mut MessageReader, + self_guid: Option, transports: &Query< (&Transform, &Guid), (With, Without, Without), >, self_pose: Option<(Vec3, f32)>, ) -> Vec { + // The control handoff (B211). Two questions, and they are NOT the same one: "is this about my + // 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)); + } +} diff --git a/crates/benilla-protocol/src/events.rs b/crates/benilla-protocol/src/events.rs index e5e115d9..f91df247 100644 --- a/crates/benilla-protocol/src/events.rs +++ b/crates/benilla-protocol/src/events.rs @@ -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 diff --git a/crates/benilla-protocol/src/events/decode.rs b/crates/benilla-protocol/src/events/decode.rs index b06fbad6..6ba81d04 100644 --- a/crates/benilla-protocol/src/events/decode.rs +++ b/crates/benilla-protocol/src/events/decode.rs @@ -742,6 +742,9 @@ pub fn decode(packet: ServerPacket) -> Vec { ServerPacket::MountSpecialAnim { guid } => { vec![SessionEvent::MountSpecial { guid }] } + ServerPacket::ClientControlUpdate { mover, allow_move } => { + vec![SessionEvent::ClientControl { mover, allow_move }] + } ServerPacket::ShowTaxiNodes { window, flightmaster, diff --git a/crates/benilla-protocol/src/messages/opcode.rs b/crates/benilla-protocol/src/messages/opcode.rs index 62c3b194..4f8c678f 100644 --- a/crates/benilla-protocol/src/messages/opcode.rs +++ b/crates/benilla-protocol/src/messages/opcode.rs @@ -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 diff --git a/crates/benilla-protocol/src/messages/packet.rs b/crates/benilla-protocol/src/messages/packet.rs index c1194d98..4589e65c 100644 --- a/crates/benilla-protocol/src/messages/packet.rs +++ b/crates/benilla-protocol/src/messages/packet.rs @@ -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(), diff --git a/crates/benilla-protocol/src/messages/parse.rs b/crates/benilla-protocol/src/messages/parse.rs index 230fc303..15fdfabe 100644 --- a/crates/benilla-protocol/src/messages/parse.rs +++ b/crates/benilla-protocol/src/messages/parse.rs @@ -989,6 +989,12 @@ pub fn parse_server(opcode: u16, body: &[u8]) -> io::Result { 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). diff --git a/crates/benilla-protocol/src/messages/update_object/fields/mod.rs b/crates/benilla-protocol/src/messages/update_object/fields/mod.rs index fdeb1234..bc3d6146 100644 --- a/crates/benilla-protocol/src/messages/update_object/fields/mod.rs +++ b/crates/benilla-protocol/src/messages/update_object/fields/mod.rs @@ -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 diff --git a/crates/benilla-protocol/src/messages/update_object/fields/player.rs b/crates/benilla-protocol/src/messages/update_object/fields/player.rs index f85e1020..456df8b6 100644 --- a/crates/benilla-protocol/src/messages/update_object/fields/player.rs +++ b/crates/benilla-protocol/src/messages/update_object/fields/player.rs @@ -182,6 +182,16 @@ impl ObjectFields { pub fn player_money(&self) -> Option { 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 { + 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. diff --git a/crates/benilla-protocol/src/messages/update_object/fields/tests.rs b/crates/benilla-protocol/src/messages/update_object/fields/tests.rs index b994519f..d56b723a 100644 --- a/crates/benilla-protocol/src/messages/update_object/fields/tests.rs +++ b/crates/benilla-protocol/src/messages/update_object/fields/tests.rs @@ -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). diff --git a/crates/benilla-protocol/src/world/writer/self_movement.rs b/crates/benilla-protocol/src/world/writer/self_movement.rs index 0d6f9be4..670e0843 100644 --- a/crates/benilla-protocol/src/world/writer/self_movement.rs +++ b/crates/benilla-protocol/src/world/writer/self_movement.rs @@ -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 diff --git a/crates/benilla-protocol/tests/simple_packets.rs b/crates/benilla-protocol/tests/simple_packets.rs index f324e060..1098b380 100644 --- a/crates/benilla-protocol/tests/simple_packets.rs +++ b/crates/benilla-protocol/tests/simple_packets.rs @@ -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 + } + )); +} diff --git a/crates/benilla-world/src/exterior_cull.rs b/crates/benilla-world/src/exterior_cull.rs index 187db592..f19bab33 100644 --- a/crates/benilla-world/src/exterior_cull.rs +++ b/crates/benilla-world/src/exterior_cull.rs @@ -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::().add_systems( PostUpdate, apply_exterior_cull @@ -283,6 +286,51 @@ type UnownedSceneFilter = ( Without, ); +/// **`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>, + 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, mut bodies: Query, mut verdict: ResMut, + trace: Option>, ) { 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)], + bodies: &[(Vec3, Option, Aabb)], ) -> (Vec, ExteriorCullVerdict) { let mut app = App::new(); app.init_resource::() @@ -494,15 +565,14 @@ mod tests { )); let ids: Vec = 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 = 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!( diff --git a/crates/benilla-world/src/world_unit.rs b/crates/benilla-world/src/world_unit.rs index 885fd0d8..c980d48b 100644 --- a/crates/benilla-world/src/world_unit.rs +++ b/crates/benilla-world/src/world_unit.rs @@ -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, }