Files
benilla-octo/crates/benilla-app/src/player/camera.rs
T
Sam SungandClaude Opus 5.5 5b94c3eba8 Comments name no chat reporter, channel or message id, and quote no report (#90)
Comments that named a chat reporter, cited a chat channel or message id, or quoted a report word for word now state the symptom in their own words. The test fixtures that used real character names use invented ones.

Verified: 52 of the 56 files strip to identical code with their comments removed; the other 4 differ only in the fixture names, and with those mapped back they strip identical too. `cargo fmt --check` is clean, and the `ui_party`, `unit_frame_tests` and `capture` tests pass with the install present and no skips. Record numbers still in these comments go with the comment pass (#41).

Fixes #34

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 14:29:56 +07:00

2674 lines
138 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! The third-person camera rig: the two mouse-look modes (right-drag turns the character, left-drag
//! orbits the camera), the wheel-zoom glide, the collision-swept boom that seats the camera behind the
//! avatar's framing [`CameraPivot`], and the self-avatar zoom-in fade as the boom pulls into first
//! person. Split out of the controller — this owns the camera's pose and input session, not the
//! avatar/movement/networking [`super::control`] drives with it.
use bevy::ecs::entity::EntityHashSet;
use bevy::input::mouse::AccumulatedMouseMotion;
use bevy::mesh::MeshTag;
use bevy::prelude::*;
use bevy::window::{CursorGrabMode, CursorOptions, PrimaryWindow};
use super::camera_channel::{Arm, SmoothChannel};
use super::camera_dynamics::{DynamicsInput, HeadBob, SmartPivot, TerrainTilt};
use crate::creature_anim::wrap_pi;
use crate::net::Embodied;
use benilla_assets::materials::WowModelMaterial;
use benilla_world::interact::{WorldClick, WorldRightClick, WorldRightPress};
use benilla_world::model_fade::{
self_model_fade_alpha, FadeMaterials, PendingAppearFade, RenderFade, SELF_FADE_WINDOW,
};
/// The reference's up-edge click predicate, in **camera degrees and milliseconds** — the whole
/// orbit-vs-select law (decision 1122). Verbatim, from `0x514ae0`, which returns
/// 1 = suppress / 0 = dispatch:
///
/// ```text
/// isClick = elapsed < 200ms
/// || (elapsed < 800ms && yaw_travel < 2.25° && pitch_travel < 2.0°)
/// ```
///
/// **The `ms` on those two numbers is now settled, where 1122 could only infer it**:
/// the clock both constants are compared against is `0x42c010` → `0x42b790`, whose counter is the
/// `KERNEL32!GetTickCount` import at `[0x7ff310]` and whose scale is stored as `1.0/freq × 1000.0`
/// — milliseconds in either counter mode. Nothing here changes; the units simply stopped being a
/// guess.
///
/// **A press under 200 world selects however far the mouse swept.** That arm is the entire bug report
/// (ledger B226): flick the cursor across a mob and click on arrival with the hand still moving, and
/// the reference targets it — the camera has been orbiting since the first motion sample and selects
/// anyway. The two mechanisms are independent and share only the button state.
///
/// **The orbit has no threshold at all** — it engages on the *down* edge (`0x51491f`, guarded only by
/// the 0→1 held transition) and the first motion sample already turns the camera. benilla deferred it
/// behind 4 px of travel and, worse, *destroyed* the pending click on crossing; there is no such
/// thing in the reference. The travel numbers below gate the **click**, never the orbit.
///
/// The literals in the binary are 8.0 event-units per axis against `Σ|0.8·Δx|` / `Σ|0.6·Δy|` of raw
/// device counts. Copying those would bind us to the reference's device scaling, which is itself
/// only INFERRED ("1 unit = 1 mouse count" is not established). So we copy the **angle** they mean,
/// which is exact and device-independent: 8.0 event-units is `Σ|Δx| = 10.0` raw ⇒
/// `cameraYawMoveSpeed·10/800` = **2.25°** of yaw at the shipped default 180; and `Σ|Δy| = 13.333`
/// raw ⇒ `cameraPitchMoveSpeed·13.333/600` = **2.0°** of pitch at the default 90.
///
/// Per axis, independently, and **accumulated absolute travel** — not net displacement from the
/// press point (`0x514400`: `fabs; fadd; fstp`, no subtraction and no stored origin). A shake that
/// returns to where it started still spends the budget.
const CLICK_HOLD_CEILING: f32 = 0.800;
const CLICK_FREE_WINDOW: f32 = 0.200;
const CLICK_YAW_TRAVEL: f32 = 2.25 * std::f32::consts::PI / 180.0;
const CLICK_PITCH_TRAVEL: f32 = 2.0 * std::f32::consts::PI / 180.0;
/// A primary button's press, while it is still undecided — the reference's world-input state
/// (`[0xbe1148]`): press instant `+0x14`, and the two independent motion accumulators zeroed at
/// `0x514910`/`0x514913`. Our accumulators are in **radians of camera rotation** rather than device
/// counts, for the reason in [`CLICK_HOLD_CEILING`]'s note.
#[derive(Clone, Copy)]
pub(super) struct PressGesture {
/// Seconds on the app clock when the button went down.
at: f32,
/// Accumulated |Δyaw| and |Δpitch| this press has asked the camera for, radians.
yaw_travel: f32,
pitch_travel: f32,
}
impl PressGesture {
fn new(now: f32) -> Self {
Self {
at: now,
yaw_travel: 0.0,
pitch_travel: 0.0,
}
}
/// `0x514ae0`, minimised: the free window first, then the bounded-travel window.
fn is_click(&self, now: f32) -> bool {
let elapsed = now - self.at;
elapsed < CLICK_FREE_WINDOW
|| (elapsed < CLICK_HOLD_CEILING
&& self.yaw_travel < CLICK_YAW_TRAVEL
&& self.pitch_travel < CLICK_PITCH_TRAVEL)
}
}
/// Third-person orbit-distance limits (yards), from `FUN_005112d0` + the camera CVars:
/// max orbit = `cameraDistanceMax × cameraDistanceMaxFactor`,
/// **hard-capped at 50**; the low clamp is **0** — zoom-to-first-person (at distance 0 the eye sits at
/// the framing pivot, inside the head, and the avatar fades to invisible — see
/// [`benilla_world::model_fade::self_model_fade_alpha`]). The out-of-box max is **15** (`15 × 1`) —
/// the reference's, and since 1804 ours. This file shipped the factor fully raised (30 yd) from
/// the day it was written — a taste call ("a wider view") that nobody had weighed against the
/// client it imitates. The slider is still there and still reaches 30; it just is not where a
/// fresh install starts. Our starting zoom
/// is 15 — the reference's own shipped `cameraDistance` is 5.55 (`0x84f488`), a divergence
/// this file has always carried in its own words
/// ("pulled back a bit further than vanilla's own default for a wider view") and one 1804 leaves
/// alone: it is the camera's *initial state*, not a settings row, and it is the director's look.
pub(super) const CAM_DIST_MIN: f32 = 0.0;
/// The reference's `cameraDistanceMax` — the BASE the factor multiplies (registrar default 15).
/// Not exposed: 1.12's panel offers only the factor, so this stays the constant it is there.
pub(super) const CAM_DIST_BASE_MAX: f32 = 15.0;
/// `cameraDistanceMaxFactor`'s slider range — 1.12's own (MAX_FOLLOW_DIST: 1 … 2, step 0.1).
pub(crate) const CAM_DIST_FACTOR_RANGE: std::ops::RangeInclusive<f32> = 1.0..=2.0;
/// The orbit ceiling at the factor slider's **top** — the furthest any saved view or restored
/// camera pose may sit. Not the shipped default any more (1804): [`ZoomLimit::default`] is the
/// slider at rest, `1 × 15`. This is what a distance read back off disk is clamped to, which has
/// to admit the whole slider range rather than only its resting point.
pub(super) const CAM_DIST_MAX: f32 = CAM_DIST_BASE_MAX * 2.0;
pub(super) const CAM_DIST_DEFAULT: f32 = 15.0;
/// The max-orbit knob (decision 1140) — 1.12's `cameraDistanceMaxFactor` over the base above.
/// A fourth frozen constant made reachable: [`CAM_DIST_MAX`] was the only zoom ceiling there was.
///
/// **The default is the reference's 1.0** — `15 yd` (`cameraDistanceMax` defaults to `"15.0"`,
/// `0x84fbd0`, and `cameraDistanceMaxFactor` to `"1.0"`, `0x82e92c`). It was
/// 2.0 from 1140 until 1804, which is the whole reason that record exists: the raised factor was a
/// reasonable taste call on its day and it was never weighed as a *default*, so benilla shipped a
/// camera that started 15 yd further out than the client it imitates. Raising the slider re-clamps
/// nothing; lowering it re-clamps the live target on the next frame, so the view comes in rather
/// than waiting for the next wheel notch.
#[derive(Resource)]
pub(crate) struct ZoomLimit {
/// Max orbit distance in yards — `CAM_DIST_BASE_MAX × factor`, hard-capped like the client's 50.
pub(crate) max: f32,
}
impl Default for ZoomLimit {
fn default() -> Self {
// The slider at rest — `cameraDistanceMaxFactor` 1.0 × the 15 yd base, i.e. the reference's
// own out-of-box ceiling. `CAM_DIST_MAX` is the slider's TOP, and belongs to the clamps.
Self {
max: CAM_DIST_BASE_MAX,
}
}
}
impl ZoomLimit {
/// Set from the CVar's factor, clamped to the reference's slider range first.
pub(crate) fn set_factor(&mut self, factor: f32) {
let f = factor.clamp(*CAM_DIST_FACTOR_RANGE.start(), *CAM_DIST_FACTOR_RANGE.end());
self.max = CAM_DIST_BASE_MAX * f;
}
/// The live factor — the inverse of [`Self::set_factor`], kept for the weld test (the
/// registry holds the string itself since 2303).
#[cfg(test)]
pub(crate) fn factor(&self) -> f32 {
self.max / CAM_DIST_BASE_MAX
}
}
/// Yards the wheel moves the target per notch — `CameraZoomIn`/`CameraZoomOut`'s default `amount`
/// (VERIFIED 1.0 in `WoW.exe`).
const CAM_ZOOM_STEP: f32 = 1.0;
/// Camera zoom speed in **yards/second** — `cameraDistanceMoveSpeed` (VERIFIED default 8.33). Vanilla
/// glides the distance toward the wheel target at this *constant velocity* (linear, frame-delta-scaled
/// — `FUN_005112d0` in `WoW.exe`), **not** an exponential ease.
const CAM_MOVE_SPEED: f32 = 8.33;
/// Mouse-look sensitivity at the slider's neutral notch — radians of camera rotation per pixel of
/// mouse motion. [`LookConfig::sensitivity`] scales it; this is the ×1.0 case, and it is what the
/// client felt like before there was a slider at all (decision 1140).
const LOOK_SENSITIVITY: f32 = 0.003;
/// The `mousespeed` slider's range — 1.12's own (UIOptionsFrameSliders' MOUSE_SENSITIVITY row:
/// 0.5 … 1.5, step 0.05). A multiplier over [`LOOK_SENSITIVITY`], so the registered default 1.0
/// reproduces the shipped feel exactly.
pub(crate) const MOUSE_SPEED_RANGE: std::ops::RangeInclusive<f32> = 0.5..=1.5;
/// The camera rows' change callback (decision 2303) — the look, zoom and follow knobs.
pub(crate) fn on_cvar(
ev: On<crate::cvars::CvarChanged>,
mut look: ResMut<LookConfig>,
mut zoom: ResMut<ZoomLimit>,
mut follow: ResMut<FollowConfig>,
) {
let v = ev.num();
match ev.key().as_str() {
"mouseinvertpitch" => look.invert_pitch = v != 0.0,
// The 1.12 slider's own range; an off-grid hand-edit rides between stops, like the others.
"mousespeed" => {
look.sensitivity = v.clamp(*MOUSE_SPEED_RANGE.start(), *MOUSE_SPEED_RANGE.end());
}
// The reference's `0x50b330` validator REJECTS an out-of-range value rather than clamping
// it: it prints `Value out of range (%f - %f)` and `CVar::Set` never stores, so the old
// value stands. That is a different posture from every clamping row, and it is the
// faithful one — a script writing 1e9 gets a refusal, not a silently pinned camera.
"camerayawmovespeed" | "camerapitchmovespeed" => {
if !CAMERA_SPEED_RANGE.contains(&v) {
warn!(
"cvar {}: value out of range ({} - {}) — ignored",
ev.name,
CAMERA_SPEED_RANGE.start(),
CAMERA_SPEED_RANGE.end()
);
return;
}
if ev.is("cameraYawMoveSpeed") {
look.yaw_speed = v;
} else {
look.pitch_speed = v;
}
}
"cameradistancemaxfactor" => zoom.set_factor(v),
// The three stops are 1 Smart / 2 Always / 3 Never; anything else reads as the registrar
// default rather than as a dead camera (`FollowStyle::from_cvar`).
"camerasmoothstyle" => follow.style = FollowStyle::from_cvar(v),
// Its sibling selector — the one the reference swaps in for the externally-driven states.
"camerasmoothtrackingstyle" => follow.tracking_style = FollowStyle::from_cvar(v),
// The auto-follow rate, clamped to 1.12's own AUTO_FOLLOW_SPEED slider range.
"camerayawsmoothspeed" => {
follow.yaw_speed = v.clamp(*FOLLOW_SPEED_RANGE.start(), *FOLLOW_SPEED_RANGE.end());
}
_ => {}
}
}
/// **The mouse-look rate law, and the one place benilla's units are not the reference's.**
///
/// The reference's own law (`0x50fee0`):
///
/// ```text
/// Δyaw_deg = cameraYawMoveSpeed × Δx / 800
/// Δpitch_deg = cameraPitchMoveSpeed × Δy / 600
/// ```
///
/// **800 × 600 is a screen, not a magic number** — the era's reference resolution. At the shipped
/// `180`/`90` the law reads: a drag across the full screen width is a half-turn, and a drag up the
/// full screen height is horizon-to-zenith. That is what makes the two divisors and the two
/// defaults one design rather than four constants.
///
/// **What does NOT transfer is the unit.** The reference has *no DirectInput import at all*: it
/// integrates `WM_MOUSEMOVE` (`0x42d31c`), so its `Δ` is a
/// **screen pixel after Windows pointer acceleration** — which is exactly why its `mousespeed`
/// slider works by calling `SPI_SETMOUSESPEED` on the OS rather than scaling anything in-engine.
/// benilla's `Δ` is `AccumulatedMouseMotion`, i.e. winit's `DeviceEvent::MouseMotion`: **raw,
/// unaccelerated device units**. `deg per accelerated pixel` and `deg per raw unit` are different
/// quantities, and the factor between them is a per-machine OS setting, not a fact about the
/// client — so transplanting `180`/`90` as absolute numbers would be a confident guess on a
/// load-bearing constant.
///
/// So we take the law's **shape** — per-axis, linear in the CVar — and anchor its **scale** to
/// [`LOOK_SENSITIVITY`], the rate this client has shipped and the director has been looking at
/// since 1140. The CVars therefore keep the reference's own defaults (1804: a setting's default is
/// the reference's) and the divergence lands here, in a named constant, where it can be read.
///
/// **The one live divergence this leaves**, stated rather than buried: the reference is
/// *anisotropic* at its defaults — `180/800 = 0.225` deg/px of yaw against `90/600 = 0.15` of
/// pitch, so its yaw turns 1.5× faster than its pitch. Ours is isotropic, because
/// [`LOOK_SENSITIVITY`] is one number. The **ratio** is unit-independent (both axes take the same
/// acceleration curve), so unlike the absolute scale it *is* transferable — it is simply a feel
/// change, and feel is the director's call, not a fidelity bug to fix quietly.
const LOOK_YAW_PER_SPEED: f32 = LOOK_SENSITIVITY / 180.0;
const LOOK_PITCH_PER_SPEED: f32 = LOOK_SENSITIVITY / 90.0;
/// The reference's validator range for all four `camera*MoveSpeed`/`SmoothSpeed` CVars
/// (`0x50c000` → `0x50b330`). It **rejects rather than clamps**: out of range prints
/// `"Value out of range (%f - %f)"` and `CVar::Set` skips the store, so the old value stands.
pub(crate) const CAMERA_SPEED_RANGE: std::ops::RangeInclusive<f32> = 0.1..=360.0;
/// The mouse-look player knobs (decision 0961): `mouseInvertPitch` is 1.12's own Interface
/// Options checkbox (UIOptionsFrame.lua index 1, CVar-backed), settable from the Options
/// window's Controls page through the CVar store (0954). Inverted, moving the mouse up pitches
/// the camera down — the delta.y term flips sign at the one apply site, both drag styles alike.
///
/// `sensitivity` is 1.12's `mousespeed` slider (1140), the same story one layer down: the rate was
/// a frozen constant with no way to reach it. It multiplies [`LOOK_SENSITIVITY`] at BOTH apply
/// sites — the rotation itself and the click-vs-drag travel budget — because the reference scales
/// the device delta once, upstream of everything that reads it, and a budget measured in unscaled
/// pixels would make a click's drag threshold drift as the slider moved.
#[derive(Resource, Clone, Copy)]
pub(crate) struct LookConfig {
pub(crate) invert_pitch: bool,
pub(crate) sensitivity: f32,
/// `cameraYawMoveSpeed` — the MOUSE_LOOK_SPEED slider (90…270 by 10). See
/// [`LOOK_YAW_PER_SPEED`] for why the number is the reference's and the scale is ours.
pub(crate) yaw_speed: f32,
/// `cameraPitchMoveSpeed` — no slider of its own; `UIOptionsFrame_Save` writes it as
/// `cameraYawMoveSpeed / 2` beside the yaw one, which is exactly the reference's 180/90 pair.
pub(crate) pitch_speed: f32,
}
impl Default for LookConfig {
fn default() -> Self {
Self {
invert_pitch: false,
sensitivity: 1.0,
// The reference's registered defaults, and with them the shipped feel: at these two
// values both axes land on `LOOK_SENSITIVITY` exactly.
yaw_speed: 180.0,
pitch_speed: 90.0,
}
}
}
impl LookConfig {
/// Radians of camera rotation per pixel of mouse motion, this session. ONE function because
/// both readers must agree: the look rotation itself and the click-vs-drag travel budget that
/// decides whether a press was a click. Splitting them would let the slider move the drag
/// threshold out from under the gesture (decision 1140).
pub(super) fn yaw_rate(self) -> f32 {
self.yaw_speed * LOOK_YAW_PER_SPEED * self.sensitivity
}
/// The pitch axis's rate — its own CVar, because the reference's law has its own divisor for
/// it (600, the reference screen's height) and its own default (90).
pub(super) fn pitch_rate(self) -> f32 {
self.pitch_speed * LOOK_PITCH_PER_SPEED * self.sensitivity
}
}
/// The auto-follow's angular rate — 1.12's `cameraYawSmoothSpeed`, registrar default **180 °/s**
/// (`WoW.exe` `[0xbe1070]`, read at `0x512d75`). It is not a slew rate but a *duration* divisor:
/// the transition below lasts `|Δyaw| / rate × factor`, so 180 °/s is the **average** rate at
/// factor 1, and the cosine profile peaks at π/2 × that. 1.12 exposes it as the AUTO_FOLLOW_SPEED
/// slider (`UIOptionsFrameSliders`, 90 … 270 by 10), disabled while the style is Never.
pub(crate) const FOLLOW_SPEED_DEFAULT: f32 = 180.0;
/// The 1.12 slider's own range for [`FollowConfig::yaw_speed`].
pub(crate) const FOLLOW_SPEED_RANGE: std::ops::RangeInclusive<f32> = 90.0..=270.0;
/// `cameraSmoothTimeMin`/`Max` — the transition's duration floor and ceiling, **VERIFIED
/// 0.1 s / 2.0 s** (`[0xbe105c]`/`[0xbe1038]`, clamped at `0x510f4d`). These are why the felt
/// rate is *not* 180 °/s at the ends: a 5° correction still takes 0.1 s, and a lazy `Track`
/// return (factor 10) is capped at 2 s however far it has to come.
const FOLLOW_TIME_MIN: f32 = 0.1;
const FOLLOW_TIME_MAX: f32 = 2.0;
/// The "already there / already arming this" epsilon — the reference's own `0.001`
/// (`[0x801360]`, `0x512ce4`/`0x512d41`). A float-equality guard, not a perceptible deadzone.
const FOLLOW_EPS: f32 = 1.0e-3;
/// **Camera Following Style** — 1.12's `cameraSmoothStyle` (decisions 1493/1502): does the camera
/// return to behind the character on its own?
///
/// benilla shipped the reference behaviour *removed* from the day the camera was written (the
/// orbit offset simply persisted, a director's call) — this is the setting that gives it back, at
/// the reference's own default: **Smart**.
///
/// **The enum is the ENGINE's, `0 = Never · 1 = Smart · 2 = Always`** — twice over (the
/// registration loop in `0x50b6f0` walks
/// `{"Never","Smart","Always"}` filling three blocks in order, and both consumers index by
/// `style × stride`). 1.12's own *dropdown* writes `1/2/3` instead (`UIOptionsFrameCameraDropDown`
/// — Smart 1, Always 2, Never **3**), and 3 is not a style at all: the validator accepts it
/// (`0x50b330(v, 0, 3)`) but the terrain-tilt consumer does not bound-check and indexes 360 bytes
/// past its table. So the reference's shipped UI writes an out-of-range value for Never, and a
/// client must clamp to 0..2. We do: our own dropdown writes the engine's numbers, and a stray
/// `3` is *read* as Never — what whoever wrote it meant.
#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
pub(crate) enum FollowStyle {
/// Never auto-adjust: the orbit offset stays exactly where the hand left it. This is what
/// benilla did unconditionally before 1493. It does **not** stop the rigid keyboard-turn
/// carry — in the reference that is a different mechanism entirely (the camera yaw is stored
/// *relative* to the followed unit's facing), and Never leaves it running.
Never,
/// Stay where placed, *except* while the character is being driven — the reference's own
/// "(Recommended Mode)" and its registrar default.
#[default]
Smart,
/// Always prefer being behind the character: every input edge arms a return, standing still
/// included.
Always,
}
impl FollowStyle {
/// From the CVar's number. `3` is the shipped-UI's Never (see the type doc); anything else off
/// the ladder reads as the registrar default rather than as a dead camera.
pub(crate) fn from_cvar(v: f32) -> Self {
match v as i32 {
0 => Self::Never,
2 => Self::Always,
// The 1.12 dropdown's own Never. Out of range for the engine's tables — accepted here
// because a config or addon carrying it means Never, not "surprise me".
3 => Self::Never,
_ => Self::Smart,
}
}
/// The CVar string this style is — the inverse of [`Self::from_cvar`], kept for the
/// round-trip test (the registry holds the string itself since 2303).
#[cfg(test)]
pub(crate) fn cvar(self) -> &'static str {
match self {
Self::Never => "0",
Self::Smart => "1",
Self::Always => "2",
}
}
/// The `cameraSmooth<Style><State>{Delay,Factor}` row — family A at `[0xbe0e70]`, at its
/// defaults. Factor `0` means *cancel*: the armed
/// transition is dropped and the camera keeps the offset it has.
///
/// Family B (`cameraSmoothViewData<Style>Yaw{Delay,Factor}`) multiplies in: its Yaw factor is
/// `1.0` under Smart and Always and `0.0` under Never, and its delay is `0.0` at every style —
/// so for the yaw channel the composition is the identity and the table below is the answer.
fn row(self, state: FollowState) -> (f32, f32) {
match self {
// Every row is 0/0 — and family B's Never Yaw factor is 0.0 as well, twice over.
Self::Never => (0.0, 0.0),
Self::Smart => match state {
// The two states that make Smart *smart*: nothing returns while you stand or stop.
FollowState::Idle | FollowState::Stop => (0.0, 0.0),
// Driven from outside (a taxi, a spline, a fear): a lazy return — 0.4 s of delay
// and factor 10, which is 18 °/s of average rate before the 2 s cap bites.
FollowState::Track | FollowState::Fear => (0.4, 10.0),
FollowState::Move | FollowState::Strafe | FollowState::Turn => (0.0, 1.0),
},
// Always is factor 1.0 in every state, Idle and Stop included.
Self::Always => (0.0, 1.0),
}
}
}
/// The seven arming states of 1.12's auto-return classifier (`0x510960`),
/// **in the reference's own priority order — highest first**. The
/// winner is the highest-priority bit set, and the states are read off the *camera's* input
/// command word, not off the character's velocity: right-mouse alone is a `Turn`, a turn key
/// under right-mouse is a `Strafe`, and both mouse buttons are a `Move`.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub(super) enum FollowState {
/// Driven by something that is not us and is not a spline — the reference's external-control
/// flag `[cam+0x90] & 0x1000`.
Fear,
Turn,
Strafe,
Move,
/// Externally-driven movement (taxi / server spline) — `[cam+0x90] & 0x100`.
Track,
/// This edge released a movement input (the reference's `stopping` argument). Carries the same
/// `(0, 0)` / `(0, 1)` rows as [`FollowState::Idle`] at every shipped style, so the two are
/// currently indistinguishable in behaviour — kept apart because the *matrix* keeps them apart.
Stop,
Idle,
}
/// The camera's input command word — 1.12's `[InputControl+0x4]` bit for bit,
/// because the state classifier and the *edge* that arms a
/// transition are both defined on it. benilla has no PitchUp/PitchDown bindings, so those two bits
/// are simply never set.
pub(super) mod follow_cmd {
/// `TurnOrAction` — right mouse / mouselook.
pub(in crate::player) const RIGHT_MOUSE: u32 = 0x1;
/// `CameraOrSelectOrMove` — left mouse.
pub(in crate::player) const LEFT_MOUSE: u32 = 0x2;
pub(in crate::player) const FORWARD: u32 = 0x10;
pub(in crate::player) const BACKWARD: u32 = 0x20;
pub(in crate::player) const STRAFE_LEFT: u32 = 0x40;
pub(in crate::player) const STRAFE_RIGHT: u32 = 0x80;
pub(in crate::player) const TURN_LEFT: u32 = 0x100;
pub(in crate::player) const TURN_RIGHT: u32 = 0x200;
pub(in crate::player) const AUTORUN: u32 = 0x1000;
/// The union of bits 20/21/23, which the reference folds into the camera's `Track` flag —
/// externally-driven movement.
pub(in crate::player) const TRACK: u32 = 0x100000;
/// External control (the reference's own `[cam+0x90] & 0x1000`, not an InputControl bit —
/// carried here so one word carries every edge the arming function reacts to).
pub(in crate::player) const FEAR: u32 = 0x2000_0000;
/// The bits `Move` reads: forward, backward, autorun.
pub(in crate::player) const MOVE_BITS: u32 = FORWARD | BACKWARD | AUTORUN;
pub(in crate::player) const STRAFE_BITS: u32 = STRAFE_LEFT | STRAFE_RIGHT;
pub(in crate::player) const TURN_BITS: u32 = TURN_LEFT | TURN_RIGHT;
}
/// The knobs behind the auto-follow (decision 1502) — the style, the style the *externally-driven*
/// states use instead, and the rate. All three are 1.12 CVars with the reference's own defaults.
#[derive(Resource, Clone, Copy, PartialEq, Debug)]
pub(crate) struct FollowConfig {
/// `cameraSmoothStyle`.
pub(crate) style: FollowStyle,
/// `cameraSmoothTrackingStyle` — the selector the reference swaps in when the state mask so
/// much as *contains* `Track` or `Fear` (`0x510a51 test bl,0x44`, before the priority scan),
/// indexing the very same matrices. Registrar default `"1"` = Smart, like its sibling.
pub(crate) tracking_style: FollowStyle,
/// `cameraYawSmoothSpeed`, °/s — see [`FOLLOW_SPEED_DEFAULT`].
pub(crate) yaw_speed: f32,
}
impl Default for FollowConfig {
fn default() -> Self {
Self {
style: FollowStyle::default(),
tracking_style: FollowStyle::default(),
yaw_speed: FOLLOW_SPEED_DEFAULT,
}
}
}
/// What [`seat_camera`] needs to run the auto-follow this frame: the knobs, where "behind" is, and
/// the input word whose *edges* arm a return. Bundled because they are one question, and
/// `seat_camera` is already at its argument ceiling.
pub(super) struct FollowInput {
pub(super) cfg: FollowConfig,
/// The character's own facing (same yaw convention as [`FlyCam::yaw`] — a right-drag couples
/// the two directly), so the camera's *offset* is `cam.yaw − face_yaw`.
pub(super) face_yaw: f32,
/// This frame's [`follow_cmd`] word.
pub(super) command: u32,
}
impl FollowInput {
/// The winning state — the reference's descending priority scan, highest bit first.
fn state(&self, stopping: bool) -> FollowState {
let mf = self.command;
let held = |bits: u32| mf & bits != 0;
if held(follow_cmd::FEAR) {
FollowState::Fear
} else if held(follow_cmd::TURN_BITS) || held(follow_cmd::RIGHT_MOUSE) {
FollowState::Turn
} else if held(follow_cmd::STRAFE_BITS)
|| (held(follow_cmd::RIGHT_MOUSE) && held(follow_cmd::TURN_BITS))
{
FollowState::Strafe
} else if held(follow_cmd::MOVE_BITS)
|| (held(follow_cmd::RIGHT_MOUSE) && held(follow_cmd::LEFT_MOUSE))
{
FollowState::Move
} else if held(follow_cmd::TRACK) {
FollowState::Track
} else if stopping {
FollowState::Stop
} else {
FollowState::Idle
}
}
/// Which style picks the row: the *tracking* style whenever the mask so much as contains
/// `Track` or `Fear`, even when a higher-priority state supplies the row.
fn style(&self) -> FollowStyle {
if self.command & (follow_cmd::TRACK | follow_cmd::FEAR) != 0 {
self.cfg.tracking_style
} else {
self.cfg.style
}
}
}
/// The armed yaw transition — the reference's descriptor `[+0x208 startMs, +0x20c dur,
/// +0x210 target, +0x214 start]`, in seconds and in *offset* space.
#[derive(Clone, Copy, Debug)]
struct FollowArm {
/// The offset the transition started from, radians (camera yaw minus character facing).
from: f32,
/// Where it is going. `0.0` — directly behind — at the shipped defaults, because
/// `cameraYawSmoothMin`/`Max` are both `0.0` and the reference *substitutes* the crossed bound
/// for the saved view yaw whenever the live offset is outside the band.
to: f32,
/// Seconds the move takes, already clamped to `[FOLLOW_TIME_MIN, FOLLOW_TIME_MAX]`.
dur: f32,
/// Seconds of dead time before it starts (`Track`/`Fear` under Smart: 0.4).
delay: f32,
/// Seconds since it was armed.
elapsed: f32,
/// What it was armed with — the reference's re-arm memo (`[+0x218, +0x21c]`), so a repeated
/// edge asking for the same transition is a no-op instead of restarting the swing.
armed_with: (f32, f32),
}
/// The auto-follow's own state on the rig: the input word we last saw (edges are what arm a
/// return) and the transition in flight, if any.
#[derive(Default)]
pub(super) struct FollowRig {
last_command: Option<u32>,
arm: Option<FollowArm>,
}
impl FollowRig {
/// Run the auto-follow for a frame and return the camera yaw it wants, if it wants one.
///
/// The shape is the reference's, and the shape is the point (`0x510960`, `0x50f160`): a
/// transition is **armed on an input edge**, from a snapshot taken at that instant,
/// and then plays out unattended — it is *not* a per-frame chase of a moving target. That is
/// why "drag the camera aside, then press W" swings you back over one smooth arc, while
/// holding W changes nothing at all.
fn advance(
&mut self,
input: &FollowInput,
cam_yaw: f32,
dt: f32,
look_held: bool,
) -> Option<f32> {
let word = input.command;
let previous = self.last_command.replace(word);
// A held drag owns the camera: the yaw channel is frozen (`0x50f623`), arming is gated
// (`0x510850`'s `!([cam+0x90] & 1)`), and **entering** mouse-look cancels whatever was in
// flight outright (`0x50fe30` zeroes the descriptors). So the return does not resume when
// the button comes up — it begins at the next input edge after the release, which is
// usually the release itself (the mouse bits are part of the word).
if look_held {
self.arm = None;
return None;
}
// The EDGE: any movement/camera binding changing state re-evaluates the transition. The
// reference re-evaluates on the binding call itself, not on a change of the classified
// state, which is why releasing the drag while standing still is an Idle *arming* under
// Always and an Idle *cancel* under Smart.
if let Some(p) = previous.filter(|p| *p != word) {
// `stopping` — the reference's second argument, set by the Stop half of a movement
// binding. Our equivalent is the edge itself: a movement bit went away.
let stopping = (p & !word)
& (follow_cmd::MOVE_BITS | follow_cmd::STRAFE_BITS | follow_cmd::TURN_BITS)
!= 0;
self.arm(input, cam_yaw, stopping);
}
let arm = self.arm.as_mut()?;
arm.elapsed += dt;
let t = arm.elapsed - arm.delay;
if t < 0.0 {
return None; // still inside the delay window
}
let s = t / arm.dur;
let offset = if s >= 1.0 {
let to = arm.to;
self.arm = None;
to
} else {
// The reference's kernel `0x5b7bb0` — a **cosine** smoothstep, eased at both ends:
// `a + (b − a)·(1 − cos(πs))/2`.
let e = (1.0 - (std::f32::consts::PI * s).cos()) * 0.5;
arm.from + (arm.to - arm.from) * e
};
Some(wrap_pi(input.face_yaw + offset))
}
/// What the transition is doing, for `WOW_CAM_DUMP` — `None` when nothing is armed, else
/// `(elapsed, delay, duration)` in seconds. The auto-follow is the one camera behaviour with
/// no headless retest (nothing synthesizes a mouse drag), so the trace line is how a run says
/// what it armed and when.
fn probe(&self) -> Option<(f32, f32, f32)> {
self.arm.map(|a| (a.elapsed, a.delay, a.dur))
}
/// The arming half (`0x510960` → `0x512c70`): pick the row, then either cancel outright or
/// snapshot a transition to the target offset.
fn arm(&mut self, input: &FollowInput, cam_yaw: f32, stopping: bool) {
let (delay, factor) = input.style().row(input.state(stopping));
if factor == 0.0 {
// Cancel: the target becomes the live yaw and the channel disarms — the camera simply
// keeps the offset it has. This is Smart standing still, and it is all of Never.
self.arm = None;
return;
}
// The target: directly behind, at the shipped defaults (see [`FollowArm::to`]).
let to = 0.0;
let from = wrap_pi(cam_yaw - input.face_yaw);
let gap = (to - from).abs();
if gap < FOLLOW_EPS {
return; // already there — the reference returns without arming
}
// The re-arm memo: an edge asking for the transition already in flight is a no-op, so a
// second keypress mid-swing does not restart it from the current angle.
if self.arm.is_some_and(|a| {
a.to == to
&& (a.armed_with.0 - delay).abs() < FOLLOW_EPS
&& (a.armed_with.1 - factor).abs() < FOLLOW_EPS
}) {
return;
}
let rate = input.cfg.yaw_speed.to_radians().max(FOLLOW_EPS);
let dur = (gap / rate * factor).clamp(FOLLOW_TIME_MIN, FOLLOW_TIME_MAX);
self.arm = Some(FollowArm {
from,
to,
dur,
delay,
elapsed: 0.0,
armed_with: (delay, factor),
});
}
}
/// Camera pitch clamp (radians) — **±89.00°** (`WoW.exe` `0x8089d8`/`0x8089dc` =
/// 1.5533430576 rad; the pitch integrate `FUN_00510120`). A single uniform
/// clamp at every zoom level — the reference has **no** distinct first-person look-down limit.
pub(super) const CAM_PITCH_LIMIT: f32 = 89.0 * std::f32::consts::PI / 180.0;
/// How fast the camera glides back out to the player's chosen zoom once an obstruction clears (1/s).
/// Pull-*in* is instant (a wall must never sit between the camera and the character); only the
/// push-*out* eases — the vanilla feel of the camera snapping close past an obstacle and easing back.
const CAM_RETURN_RATE: f32 = 6.0;
/// The camera framing pivot — the point the boom looks at + seats behind, and the first-person eye at
/// zoom 0 — sits at `feet + H` where **H is model-derived** (not a fixed height):
/// `H = (attach17.z + 0.0972) × scale` from **M2 attachment id 17** (`WoW.exe` `0x50cbc0`) —
/// ~neck height on every character (1.90 human / 0.88 gnome), with a `0.9 × vertex-box`
/// fallback only for models lacking that attachment. Floored at [`CAM_PIVOT_FLOOR`]. The per-model
/// pre-scale height rides on [`CameraPivot`], stamped at attach; `control` multiplies the live scale and
/// floors. The collision sweep still starts from the *head* (not the pivot), so a jump in a low room
/// stops the camera under the ceiling — see `control`.
///
/// That height is one of the reference's **three** presets, rebuilt together by `0x50ca90` and
/// chosen between per frame by `0x50f880`. benilla builds two of the three: the standing height
/// above, and the **swim** preset `cam+0x124` — the same height less the model's
/// [`CameraPivot::swim_drop_local`] — selected on MOVEFLAG_SWIMMING. The zoomed-in/zoomed-out pair
/// (`cam+0x11c`/`cam+0x120`, split on `cam+0x198 < 1.8315`) is **not built**; see
/// [`model_pivot_height`].
///
/// Floor (yd) on the world pivot height — VERIFIED `5/6` (`0x50ca90`'s per-preset clamp, and
/// `0x50e570`'s corridor lower bound).
pub(super) const CAM_PIVOT_FLOOR: f32 = 5.0 / 6.0;
/// Ceiling (yd) on the world pivot height — VERIFIED `15.0` (`[0x8089c8]`, the upper arm of the same
/// per-preset clamp in `0x50ca90`). A giant's scale cannot walk the framing pivot off into the sky.
pub(super) const CAM_PIVOT_CEIL: f32 = 15.0;
/// Pivot height used before the avatar model has attached (so `CameraPivot` isn't on the entity yet):
/// a human's ~neck height, so the first frames of third-person don't ride high. Replaced by the exact
/// model-derived value the moment the body attaches — as a **snap**, not a glide ([`PivotGlide`]).
pub(super) const CAM_PIVOT_FALLBACK: f32 = 1.8;
/// One modeled unit's world framing-pivot height: its model-local [`CameraPivot`] × the given scale,
/// clamped to `[CAM_PIVOT_FLOOR, CAM_PIVOT_CEIL]` — the reference's per-preset clamp in `0x50ca90`.
///
/// **`swimming` selects the preset, per frame, the way `0x50f880` does**
/// (`0x50f89e test [[unit+0x118]+0x40],0x200000` — MOVEFLAG_SWIMMING on the *camera target's*
/// CMovement word): set, and the pivot drops by the model's own
/// [`benilla_formats::M2Bounds::swim_pivot_drop`] before the clamp, which is the reference's
/// `cam+0x124` preset. Clear, and it stays the standing height. The drop is a *model* constant
/// (`StandSeq.max.z − SwimSeq.max.z`, `0x50ccf6`), so a body that authors no Swim sequence carries
/// `0.0` and the two presets are the same number — correct for anything that cannot swim.
///
/// **What is deliberately not here:** the reference keeps a *third* preset. `0x50f880`'s non-swim
/// leg picks `cam+0x11c` (zoomed-in) or `cam+0x120` (zoomed-out) on `cam+0x198 < 1.8315`
/// (`[0x8089b0]`), and benilla builds neither — one standing height serves both zoom regimes. On a
/// scale-1 human the reference computes those two presets *equal* (both 1.9002692), so nothing yet
/// says what authors them apart; naming it beats stubbing a threshold we cannot justify (1203).
pub(super) fn model_pivot_height(pivot: &CameraPivot, scale: f32, swimming: bool) -> f32 {
let local = if swimming {
pivot.height_local - pivot.swim_drop_local
} else {
pivot.height_local
};
(local * scale).clamp(CAM_PIVOT_FLOOR, CAM_PIVOT_CEIL)
}
/// World head height above a modeled unit's feet — [`model_pivot_height`], or the neck-height
/// [`CAM_PIVOT_FALLBACK`] when the body has no model yet. The single definition shared by the things
/// that sit at the character's head: the framing-pivot *target*, the far-sight subject's pivot, and
/// the 3D-audio listener (the client's `SoundListenerAtCharacter=1` default, `0x457890`).
///
/// **Which `scale` to pass is a fidelity question with a verified answer**: the reference's pivot
/// preset multiplies the **raw**
/// `OBJECT_FIELD_SCALE_X` descriptor (vtable slot 7 = `0x469f10`, `fld [descriptors+0x10]`), *not*
/// the 2 s-eased render scale — the two are deliberately split in the binary (`0x4833d3` folds the
/// eased one in for a selection-ring consumer, and only there). So the camera passes
/// [`crate::net::NetEntity::scale`] and the pivot moves in **one** step per model event, which
/// [`PivotGlide`] then walks; multiplying by the eased scale instead would stack a second, slower
/// ease on top of the first and is what made a shapeshift snap *and* drift. The audio listener still
/// passes the rendered scale — it tracks the drawn body, and nothing verified says otherwise.
///
/// **Always the standing preset.** Its two consumers are a *head*, not the camera's framing pivot:
/// the 3D-audio listener sits at our own head, and the far-sight subject is a unit whose movement
/// flags we do not carry. The swim preset is the framing pivot's alone — [`model_pivot_height`]
/// with `swimming` — and the driven body's own target goes through
/// [`super::body_pose::pivot_target`], not here.
pub(crate) fn head_height(pivot: Option<&CameraPivot>, scale: f32) -> f32 {
pivot.map_or(CAM_PIVOT_FALLBACK, |p| model_pivot_height(p, scale, false))
}
/// `cameraHeightSmoothSpeed` (yd/s) — the pivot channel's rate, VERIFIED registrar default `"1.2"`.
/// The duration of a move is `|Δh| / this` (`0x51276c`/`0x512777`), so it is an *average* rate: the
/// cosine profile peaks at `π/2 ×` it in the middle and is zero at both ends. There is no duration
/// clamp on this channel (unlike the yaw channel's `[0.1 s, 2.0 s]`).
const CAM_PIVOT_SMOOTH_SPEED: f32 = 1.2;
/// **The camera's pivot-height channel** — the height the framing pivot actually rides, chasing
/// the model-derived target with a cosine smoothstep instead of taking it raw.
///
/// The reference's live `cam+0xfc` chasing target `cam+0x1c8`: armed by `0x5126b0` → `0x512790`,
/// stepped by `0x50f160`'s `[0x50f36a, 0x50f417)` block — the **fourth instantiation** of the
/// channel template [`SmoothChannel`] holds, which is why nothing of the tween lives here any more.
/// What is left is the two things this instantiation does that its three siblings do not.
/// **This is why a druid shapeshift does not snap the reference's camera**, and it glides in *both*
/// directions: the solver's `max(target, live)` (`0x50e5a9`) is only the collision-corridor seed, and
/// the far chain clamps the result back down to the live value (`0x50e767`), so an unobstructed pivot
/// simply *is* `cam+0xfc` — rising or falling.
///
/// Two structural facts, both load-bearing:
/// - **The first arm of a camera's life snaps** (`0x5127d4`; the latch bit `0x80` is never cleared —
/// image-wide census). So logging in establishes the height instantly, and everything after it
/// glides. Nothing else re-snaps: a target-GUID change, a mount, a morph, `SetView` — all glide.
/// - **A model that has not resolved yet holds the channel**, it does not re-aim it (the reference
/// skips the whole camera update while the preset is stale, `0x50e907`). Ours is the `None` target:
/// during the frames a swapped-in model is loading, the pivot stays where it is and one glide runs
/// when the new height lands.
#[derive(Default)]
pub(super) struct PivotGlide {
/// The channel itself — **linear**, not angular: its live value is yards, so the armer's `2π`
/// rewrap must not run on it.
channel: SmoothChannel,
/// Has the channel ever been armed? The reference's latch bit `0x80` — false only until the
/// first model-derived height arrives, which is therefore a snap.
seeded: bool,
}
impl PivotGlide {
/// Arm the channel with this frame's model-derived target (`None` while the subject has no
/// model — hold), step whatever is in flight, and return the height to frame at.
///
/// Called every frame, which is the reference's own cadence (`0x50f880` from the driver tail
/// `0x50f011`): the armer's own two epsilon refusals turn a steady target into a no-op, so
/// "arm per frame" and "arm on change" are the same thing except at the instant the target
/// actually moves.
pub(super) fn advance(&mut self, target: Option<f32>, dt: f32) -> f32 {
if let Some(target) = target {
if self.seeded {
self.channel.arm(&Arm::at(target, CAM_PIVOT_SMOOTH_SPEED));
} else {
// The latch: the first height a camera ever sees is established, not travelled to.
self.seeded = true;
self.channel.snap(target);
}
}
self.channel.advance(dt)
}
/// What the channel is doing, for `WOW_CAM_DUMP`: `(live, target)`. A pivot question is a
/// *timing* question — "does it snap?" is answered by these two columns on a trace, never by
/// watching a capture (method: timing is measured, never eyeballed).
pub(super) fn probe(&self) -> (f32, f32) {
self.channel.probe()
}
}
/// Which mouse button is driving mouse-look, if any — the two vanilla look modes. While looking, the
/// OS cursor is hidden + locked in place (relative motion drives the camera); `cursor_stash` is the
/// position it's restored to on release so it reappears exactly where the user pressed. `pub(crate)`
/// so the [`crate::cursor`] subsystem can hide the cursor while looking (`is_looking`).
#[derive(Resource, Default)]
pub(crate) struct CameraControl {
/// Current third-person orbit distance (yards) — eased toward `target_distance` each frame so the
/// wheel zoom glides instead of snapping (like the real client).
pub(super) distance: f32,
/// Where the wheel set the orbit distance; `distance` chases this.
pub(super) target_distance: f32,
/// Effective length of the camera arm (from the head pivot out to the camera) after world
/// collision. Pulled in instantly when geometry intrudes (so a wall never sits between the camera
/// and the character), eased back out when it clears. Kept separate from the zoom `distance` so the
/// player's chosen zoom is preserved while obstructed and restored once the view is open again.
pub(super) collision_distance: f32,
/// The button currently held for look, or `None`.
pub(super) look: Option<LookButton>,
/// **Is the player in mouse-look right now?** — the reference's `[cam+0x90] & 1`, set at
/// `0x50fe41` and cleared at `0x50fddd`, identified at the bytes by the two mode strings
/// `"Camera FREELOOK"` / `"Camera NORMAL"`. Written by [`run_look_session`] and read the same
/// frame by [`seat_on_subject`], which is `cameraTerrainTilt`'s hand-off edge.
pub(super) freelook: bool,
/// **Which mouse buttons the world owns** this frame ([`WorldMouse`]) — the player side's one
/// answer to "did the UI eat that press?", written by [`latch_world_mouse`] before anything
/// reads a button. The look session, the camera's input command word and the both-button run
/// all read it instead of `ButtonInput`.
pub(super) world_mouse: WorldMouse,
/// Logical cursor position captured when look began, to restore on release.
pub(super) cursor_stash: Option<Vec2>,
/// The self-avatar's render alpha for this frame, from the camera-to-pivot distance
/// ([`benilla_world::model_fade::self_model_fade_alpha`]): `1.0` third-person (opaque), ramping to `0.0` as
/// the camera zooms into the head (first-person). `control` computes it (it owns the pivot + camera
/// pose); [`apply_self_model_fade`] applies it to the body parts. Starts opaque.
pub(super) self_fade_alpha: f32,
/// The auto-follow's own state (decision 1502): the input word we last saw, and the armed
/// return in flight. It lives on the rig rather than beside the knob because it is *pose*, not
/// setting — a transition survives the frame, not the session.
pub(super) follow: FollowRig,
/// The framing pivot's height channel — smoothed, not taken raw ([`PivotGlide`]). On the rig
/// for the same reason `follow` is, and for one more: it belongs to the **camera**, not to the
/// body, which is why it glides *through* a change of subject (a shapeshift, a far-sight
/// switch) instead of being reset by one.
pub(super) pivot: PivotGlide,
/// `cameraPivot`'s pitch-bias channel ([`SmartPivot`], decision 2149) — pose, like the two
/// above it.
pub(super) smart_pivot: SmartPivot,
/// `cameraTerrainTilt`'s ground-pitch channel and its 100 ms probe throttle ([`TerrainTilt`],
/// decision 2149).
pub(super) terrain_tilt: TerrainTilt,
/// `cameraBobbing`'s session latch and eye offset ([`HeadBob`], decision 2149).
pub(super) head_bob: HeadBob,
/// **Did the collision sweep actually clip the camera on the frame just seated?** The
/// reference's `[cam+0x90] & 0x30000`, which nothing but the solver `0x50e570` writes and the
/// driver ORs in per frame — and which [`SmartPivot`]'s gate reads as its sixth conjunct, so
/// that an unobstructed camera never pivots. Written by [`seat_camera`]; read a frame later by
/// [`run_look_session`], exactly as the reference's input handler reads the flags the last
/// driver pass left (`0x50fee0`'s sole caller `0x514446` precedes the mover lookup).
pub(super) clipped: bool,
}
impl CameraControl {
/// Park the orbit distance at `d` — **both** the live value and the wheel target.
///
/// Both, or the wheel glide eases `distance` back toward the old target every frame and a
/// parked shot drifts through the whole zoom while the burst is running.
///
/// The scripted camera park (`capture::probe_cam`, decision 0653) is the only caller. It gets a
/// named method rather than `pub(crate)` fields because an instrument reaching into gameplay is
/// the allowed direction but not a licence to open gameplay's internals to the whole crate
/// (decision 1174) — this is the entire surface the probe needs.
pub(crate) fn park_distance(&mut self, d: f32) {
self.distance = d;
self.target_distance = d;
}
/// True while a mouse-look drag is active (right- or left-button). The cursor is hidden then.
pub(crate) fn is_looking(&self) -> bool {
self.look.is_some()
}
/// The self-avatar's render alpha this frame (`1.0` third-person → `0.0` first-person). The
/// blob shadow multiplies it in for the self unit — the reference's shadow diffuse rides the
/// same model fade slot the body does (`[model+0x180]`).
pub(crate) fn self_fade(&self) -> f32 {
self.self_fade_alpha
}
}
/// The active mouse-look mode. `Right` turns the character (movement follows the camera heading);
/// `Left` orbits the camera around a stationary character (vanilla left-drag look).
#[derive(Clone, Copy, PartialEq, Debug)]
pub(super) enum LookButton {
Right,
Left,
}
impl LookButton {
fn button(self) -> MouseButton {
match self {
LookButton::Right => MouseButton::Right,
LookButton::Left => MouseButton::Left,
}
}
}
/// **The mouse buttons the world owns**, latched at the press — where the player side asks
/// about a primary *press*, instead of `ButtonInput<MouseButton>` (ledger B364). The raw buttons
/// stay readable for the hold and release of a gesture already claimed, which is
/// [`run_look_session`]'s business and no one else's.
///
/// 1.12 never reads the mouse for any of this. It reads two *bindings* — `TurnOrAction` (right)
/// and `CameraOrSelectOrMove` (left) — and a press a UI frame captured never dispatches them: it
/// sets neither of `[InputControl+0x4]`'s mouse bits, engages no look session, and classifies as
/// no [`FollowState`]. That is the observable rule, and it is why a right-click in a bag has never
/// turned anyone. Only the look session applied it here; the command word and the both-button run
/// read the raw buttons, so a right-click on a Who-list row was a `Turn`, whose Smart row is
/// `(0.0, 1.0)` — an immediate return that ran to completion and swung the camera round to behind
/// the character (B364). The same raw read had both primaries over a bag running the avatar
/// forward, through [`super::state::forward_axis`]'s both-button term.
///
/// **Latched, not re-tested each frame**, because the reference latches: the down that reached the
/// world sets the bit, and that button's *up* clears it. A level test would hand the button back
/// to the UI mid-gesture the moment a frame appeared under the (stationary, locked) cursor — an
/// edge on the command word from nothing the player did.
#[derive(Default)]
pub(super) struct WorldMouse {
/// Held by the world right now, indexed by [`LookButton`].
held: [bool; 2],
/// Took its DOWN edge from the world this frame, same index.
down: [bool; 2],
}
impl WorldMouse {
/// Is the world holding this button?
pub(super) fn held(&self, b: LookButton) -> bool {
self.held[b as usize]
}
/// Did the world take this button's DOWN edge this frame?
pub(super) fn down(&self, b: LookButton) -> bool {
self.down[b as usize]
}
/// Both primaries in the world's hand — vanilla's both-button run, minus the presses the UI ate.
pub(super) fn both(&self) -> bool {
self.held(LookButton::Right) && self.held(LookButton::Left)
}
/// Latch this frame. `world_press` says whether a DOWN edge *now* belongs to the world; the
/// held bits then ride to their own release, whatever the cursor is over by then — including
/// the release a cover synthesises by emptying the button planes, which is what keeps a
/// loading screen from stranding a latched bit.
fn update(&mut self, buttons: &ButtonInput<MouseButton>, world_press: bool) {
for b in [LookButton::Right, LookButton::Left] {
let i = b as usize;
self.down[i] = world_press && buttons.just_pressed(b.button());
self.held[i] = (self.held[i] || self.down[i]) && buttons.pressed(b.button());
}
}
}
/// Decide, once per frame, which mouse buttons the world owns — [`CameraControl::world_mouse`].
///
/// **Its own system**, ahead of every reader, rather than a call inside [`super::control`]: the
/// readers are not all in the controller. The look session and the camera's command word are, but
/// `/follow`'s both-button cancel ([`super::follow::steer_follow`]) is a system that runs *before*
/// it — reading a latch the controller wrote would put that cancel a frame behind, and a frame
/// late on an edge-driven cancel is the wrong frame entirely.
///
/// A press belongs to the world when it lands in the viewport off the UI — or whenever a look
/// session already owns the (hidden, locked) cursor, because the second button of a chord joins a
/// gesture the world already has.
pub(super) fn latch_world_mouse(
buttons: Res<ButtonInput<MouseButton>>,
// **The raw flag — a press that lands on a V-plate is the PLATE's, not the camera's** (decision
// 2233, reversing 2159's exception).
//
// 2159 read it the other way and excepted plates here, on an inference from the other end:
// entering freelook disables plate mouse input (`0x60f830`, from `0x483e80`), "a toggle that
// would have nothing to do if a press on a plate could not reach freelook". The reference
// refutes that: `0x60f830` has plenty to do for a press that starts on
// the **world** and then drags the pointer across a plate mid-turn. The press itself never gets
// there. `0x7662c0` delivers a mouse-down to exactly ONE frame — `[root+0x80]` else
// `[root+0x7c]` — sets the capture at `0x7663e9`, calls `[vt+0x68]`, and returns 0, stopping
// the bus walk; and `CBindings::ExecuteBinding 0x4b7990` has exactly six call sites image-wide,
// every one inside a `CGWorldFrame` vtable handler. So with a plate under the cursor the
// binding that would start mouselook is never reached, and the release goes to the plate's own
// click slot (`0x7792d0` → `0x7cb910` → `0x4949f0`, mask 1 select / mask 4 interact — the same
// two terminals the world right-click's object leg reaches).
//
// What it costs is real and is the reference's own cost: **you cannot swing the camera by
// dragging off a nameplate.** Plates are small dead patches for turning, exactly as in 1.12.
// The one exception is the plate's own `+0x3c` veto (`0x7cba30`) — while a ground-targeted
// spell is armed the plates refuse the hit test, the WorldFrame wins the press, and the gesture
// *does* enter mouselook. That is built where it belongs, in the hit test itself
// ([`benilla_ui::script::UiScript::set_nameplate_hit_test_veto`]), so it arrives here for free
// as `PointerOverUi` simply being false over a vetoing plate.
//
// `PointerOverUiPanel` keeps its other reader — the wheel still zooms with the cursor on a
// plate, which is a different law (the wheel walks PAST a frame that merely takes the mouse).
pointer_over_ui: Res<crate::ui_script::PointerOverUi>,
mut rig: ResMut<CameraControl>,
cameras: Query<&Camera, With<FlyCam>>,
window: Single<&Window, With<PrimaryWindow>>,
) {
let Ok(camera) = cameras.single() else {
return;
};
let over_ui = pointer_over_ui.0;
let world_press = rig.look.is_some() || (cursor_in_viewport(&window, camera) && !over_ui);
rig.world_mouse.update(&buttons, world_press);
}
#[derive(Component)]
// `pub(crate)` on the TYPE only — the scripted camera park has to name it in a query. Its fields
// stay `pub(super)`; [`FlyCam::park`] is the whole surface an instrument gets (decision 1174).
pub(crate) struct FlyCam {
pub(super) yaw: f32,
pub(super) pitch: f32,
pub(super) speed: f32,
}
impl FlyCam {
/// Point the rig at an absolute world yaw/pitch — the scripted camera park's one lever
/// (`capture::probe_cam`, decision 0653). From here on this is the identical path a mouse-drag
/// takes.
pub(crate) fn park(&mut self, yaw: f32, pitch: f32) {
self.yaw = yaw;
self.pitch = pitch;
}
}
/// The per-model camera-pivot height in **model-local yards, pre-scale** — `attach17.z + 0.0972` (M2
/// attachment id 17) for a character, else `0.9 × vertex-box Z-extent`; the reference's camera-target
/// height (`0x50cbc0`). Stamped on every modeled unit at attach
/// ([`crate::entities`]); `control` reads it off the [`Embodied`], multiplies that body's live scale,
/// and floors at [`CAM_PIVOT_FLOOR`] to get the world pivot the third-person camera looks at (and the
/// first-person eye). `0.0` for a bounds-less display (→ floor).
#[derive(Component, Clone, Copy)]
pub(crate) struct CameraPivot {
pub height_local: f32,
/// How far that height drops while this body **swims**, model-local and pre-scale
/// ([`benilla_formats::M2Bounds::swim_pivot_drop`] — `StandSeq.max.z − SwimSeq.max.z`,
/// `0x50ccf6`). The reference builds the swim framing-pivot preset `cam+0x124` by subtracting
/// exactly this from the standing one before the shared clamp, and `0x50f880` picks it whenever
/// the camera target carries MOVEFLAG_SWIMMING. `0.0` for a model with no Swim sequence (every
/// non-character model) and for a bounds-less display — the two presets then coincide.
pub swim_drop_local: f32,
}
/// Mouse-look session state machine — start/stop/hand-off between the two look buttons, cursor
/// grab/stash/restore, and the two click tests that emit [`WorldClick`]/[`WorldRightClick`]. Also
/// applies this frame's accumulated motion as look rotation while a button is held (right-drag syncs
/// the character facing too). Called once per frame from [`super::control`]; `both_buttons` is
/// vanilla's both-button run (steers like a right-drag without its own click test).
///
/// **Orbit and select are independent** (decision 1122): every primary press engages its look
/// session immediately and *also* arms a click test, and the release decides the click on
/// [`PressGesture::is_click`] alone. There is no "promotion" and nothing cancels the click for
/// having moved — the pending click used to be destroyed the moment the cursor crossed a 4 px
/// threshold, which is why a drag could never select (ledger B226).
pub(super) fn run_look_session(
buttons: &ButtonInput<MouseButton>,
mouse_motion: &AccumulatedMouseMotion,
both_buttons: bool,
rig: &mut CameraControl,
cam: &mut FlyCam,
face_yaw: &mut f32,
window: &mut Window,
cursor_opts: &mut CursorOptions,
inspect_enabled: bool,
// A left press this frame the UI already consumed as a cursor-payload world drop (0216 §3) —
// the left click test must yield to it exactly as it yields to a UI hover, so dropping a held
// item never also selects.
click_consumed: bool,
world_click: &mut MessageWriter<WorldClick>,
world_right_click: &mut MessageWriter<WorldRightClick>,
world_right_press: &mut MessageWriter<WorldRightPress>,
left_click: &mut Option<PressGesture>,
right_click: &mut Option<PressGesture>,
look_cfg: LookConfig,
// The camera-option knobs + this frame's gate facts (decision 2149). `cameraPivot`'s routing
// lives in the look session because the reference's does: `0x50fee0` IS the mouse-motion
// handler, and the whole decision is "does THIS motion event go into the pitch or the bias".
dynamics: &DynamicsInput,
// Seconds on the app clock — the press predicate's two time gates are measured against it.
now: f32,
) {
// The right button's DOWN edge, before any click-vs-drag classification — the reference's
// WorldFrame OnMouseDown fires at the press whether it becomes a click or a turn. Whether the
// press was the world's at all is [`latch_world_mouse`]'s single answer, shared with the
// camera's command word: the viewport off the UI, or a right join into a left-orbit, whose
// session already owns the cursor. Ground-targeting's cancel reads this edge (decision 0792).
if rig.world_mouse.down(LookButton::Right) {
world_right_press.write(WorldRightPress);
}
// A chord — both primaries down — is a both-button run, never a select. The reference kills the
// pending click on the *second* press and refuses to arm a new one while another primary is held
// (`0x514ac1`, `0x51481a`), so neither release of a chord can dispatch. Cancel both tests.
// The world's pair, not the device's: what those two sites test is the *binding* state, so a
// primary a UI frame is holding has never been half of a chord (ledger B364).
if rig.world_mouse.both() {
*left_click = None;
*right_click = None;
}
// Both tests just ride their session, accumulating the camera rotation the press has asked for;
// the release decides. The travel is charged from the *input* delta, before the pitch clamp —
// the reference accumulates raw device motion, so a drag pinned at the pitch limit still spends
// its budget.
let (yaw_rate, pitch_rate) = (look_cfg.yaw_rate(), look_cfg.pitch_rate());
let dyaw = (mouse_motion.delta.x * yaw_rate).abs();
let dpitch = (mouse_motion.delta.y * pitch_rate).abs();
for test in [&mut *left_click, &mut *right_click].into_iter().flatten() {
test.yaw_travel += dyaw;
test.pitch_travel += dpitch;
}
// Mouse-look start/stop + cursor grab. **Both** buttons engage their look session on the DOWN
// edge — the reference has no deferral and no engage threshold (`0x51491f`), and the first
// motion sample already turns the camera. The click test is a separate rider decided at the
// release, which is exactly what lets one gesture orbit *and* select. Either button hides +
// locks the cursor in place (so it can't drift out of the window while we turn) and restores it
// where it was on release. A press that begins over the debug panel is egui's, not ours.
if let Some(active) = rig.look {
if !buttons.pressed(active.button()) {
// The session's button went up: settle its click test against the reference predicate.
// A handoff release (the other button still held) never fires — the chord already
// cancelled both tests above.
let test = match active {
LookButton::Left => left_click.take(),
LookButton::Right => right_click.take(),
};
if let Some(test) = test {
if test.is_click(now) {
match active {
LookButton::Left => {
world_click.write(WorldClick);
}
LookButton::Right => {
world_right_click.write(WorldRightClick);
}
}
}
}
// The latched button went up. If the *other* look button is still held **by the
// world** (both-button run → single-button), hand the look session off to it rather
// than ending it — vanilla keeps turning/orbiting seamlessly on the remaining button,
// cursor staying hidden throughout. A primary the UI is holding is not a candidate:
// its binding never fired, so the reference has nothing to hand off to (B364).
let other = match active {
LookButton::Right => LookButton::Left,
LookButton::Left => LookButton::Right,
};
if rig.world_mouse.held(other) {
rig.look = Some(other);
} else {
rig.look = None;
cursor_opts.grab_mode = CursorGrabMode::None;
// Show the cursor again (cross-platform; on macOS hiding is the cursor subsystem's job).
cursor_opts.visible = true;
if let Some(pos) = rig.cursor_stash.take() {
window.set_cursor_position(Some(pos));
}
}
}
} else {
// A press over the egui dev UI (the overlaid debug panel, the perf pill), over a
// mouse-enabled player frame, or outside the world viewport is not ours — the whole content
// of [`WorldMouse`], and what keeps a slider-drag from grabbing the cursor into mouse-look.
// Right-drag turn. Arms its context-click test too; not armed when left is already down (a
// chord is never a click).
if rig.world_mouse.down(LookButton::Right) {
rig.look = Some(LookButton::Right);
rig.cursor_stash = window.cursor_position();
cursor_opts.grab_mode = CursorGrabMode::Locked;
cursor_opts.visible = false;
*right_click =
(!rig.world_mouse.held(LookButton::Left)).then(|| PressGesture::new(now));
} else if rig.world_mouse.down(LookButton::Left) && !inspect_enabled {
// Left-drag orbit — engaged on the press, exactly like right, because the reference
// engages on the press (`0x51491f`). The select is not deferred behind it; it rides
// along and settles at the release. While the inspector is armed left belongs to it
// (its own copy-on-click handler), so neither the orbit nor the test starts.
rig.look = Some(LookButton::Left);
rig.cursor_stash = window.cursor_position();
cursor_opts.grab_mode = CursorGrabMode::Locked;
cursor_opts.visible = false;
// A press the UI already consumed as a cursor-payload world drop (0216 §3) still orbits
// — the reference's orbit is unconditional on the down edge — but must not also select.
*right_click = None;
*left_click = (!click_consumed && !rig.world_mouse.held(LookButton::Right))
.then(|| PressGesture::new(now));
}
}
// Apply this frame's accumulated motion as look rotation while a button is held. Right-drag also
// turns the character (its facing tracks the camera yaw); left-drag leaves the character facing.
if let Some(active) = rig.look {
let delta = mouse_motion.delta;
let d_yaw = -delta.x * yaw_rate;
cam.yaw += d_yaw;
// `mouseInvertPitch` flips only the pitch axis (the 1.12 checkbox's whole meaning).
let dy = if look_cfg.invert_pitch {
-delta.y
} else {
delta.y
};
// **The pivot's fork** (`0x50fee0`, decision 2149): a pinned camera looking level-or-up,
// dragged mostly vertically, spends this delta on the view's pitch BIAS and leaves the
// arm alone. `None` is the reference's pure-pivot frame — the integrator does not run.
let d_pitch = -dy * pitch_rate;
if let Some(d_pitch) = rig.smart_pivot.route_pitch(
d_pitch,
d_yaw,
cam.pitch,
&dynamics.subject,
rig.clipped,
&dynamics.options,
) {
cam.pitch = (cam.pitch + d_pitch).clamp(-CAM_PITCH_LIMIT, CAM_PITCH_LIMIT);
}
if active == LookButton::Right || both_buttons {
*face_yaw = cam.yaw;
}
}
// **The freelook latch.** Right-held is the reference's mouse-look; a left-drag is an orbit and
// is not freelook. A both-button run steers exactly as a right-drag does, which is the same
// "the world holds the right button" the `face_yaw` sync above tests — so it counts, and the
// two tests stay written the same way on purpose.
rig.freelook = rig.look == Some(LookButton::Right) || (rig.look.is_some() && both_buttons);
}
/// Wheel-zoom: the CAMERAZOOMIN/OUT bindings set a new target orbit distance, and the actual
/// distance glides toward it at a constant `cameraDistanceMoveSpeed` (vanilla's linear,
/// frame-delta-scaled glide — not an ease). Runs every frame regardless of active/detached state,
/// mirroring the reference camera. `scroll` is this frame's net zoom-in amount (wheel notches in
/// line-equivalents — the binding dispatch normalizes trackpad pixels — or the 1.12 key step of
/// 1.0 per press; positive = closer), so a rebound zoom key feels exactly like a wheel notch.
pub(super) fn apply_zoom_scroll(scroll: f32, dt: f32, rig: &mut CameraControl, max: f32) {
if scroll != 0.0 {
rig.target_distance =
(rig.target_distance - scroll * CAM_ZOOM_STEP).clamp(CAM_DIST_MIN, max);
}
// Re-clamp every frame, not just on a notch: lowering the Max Camera Distance slider has to
// pull a camera already sitting past the new ceiling back in, and the glide below then eases
// it there at the same yd/s a wheel notch would.
rig.target_distance = rig.target_distance.min(max);
// Glide the actual distance toward the wheel target at a constant `cameraDistanceMoveSpeed` yd/s,
// stopping exactly there — the verified vanilla behavior (linear, frame-delta-scaled; not an ease).
let max_step = CAM_MOVE_SPEED * dt;
rig.distance += (rig.target_distance - rig.distance).clamp(-max_step, max_step);
}
/// Seat the camera on **whatever the rig is orbiting this frame** — our own body, or a far-sight
/// subject (B151: Mind Vision, Sentry Totem, and Mind Control's camera half in B211) while
/// `PLAYER_FARSIGHT` names one. Three substitutions and then [`seat_camera`]: the orbit centre,
/// the collision sweep's origin, and the pivot height's target.
///
/// It exists because the controller seats the camera from **two** places — the ordinary driving
/// path, and the stand-down path where a spline, a possession or a reseat window owns the body —
/// and far sight outlives all of those (Sentry Totem carries no interrupt flags at all, so you can
/// board a taxi with your view still on the totem). Two copies of the substitution is how one of
/// them silently stops honouring it.
///
/// `feet`/`head` are the caller's, because the head offset is the avatar capsule's and those
/// constants are a movement concern; `body_pivot` is the target height read off the driven body
/// this frame, used only when nothing else is being watched.
pub(super) fn seat_on_subject(
dt: f32,
turn_delta: f32,
feet: Vec3,
head: Vec3,
body_pivot: Option<f32>,
view: &super::view_subject::ViewSubject,
rig: &mut CameraControl,
cam: &mut FlyCam,
cam_t: &mut Mut<Transform>,
collide: &benilla_world::collision::WorldCollision<'_, '_>,
follow: &FollowInput,
dynamics: &DynamicsInput,
) {
// 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
// ([`super::view_subject::RemoteView::sweep_origin`]).
let (orbit_pos, sweep_from) = match view.remote {
Some(v) => (v.feet, v.sweep_origin()),
None => (feet, head),
};
// The framing height is the **channel's**, not this frame's target: it eases there over
// `|Δh| / 1.2` s with a cosine profile, so a shapeshift, a mount, a growth aura or a far-sight
// switch move the camera smoothly instead of teleporting it ([`PivotGlide`], `0x50f160`). A
// far-sight subject supplies the target the same way the body
// does — one channel, whatever it is looking at.
let live_pivot = rig
.pivot
.advance(view.remote.map(|v| v.pivot_height).or(body_pivot), dt);
// **`cameraWaterCollision`'s corridor** — the half 2149 and 2170 each shipped without, and the
// reason the option is atomic. [`super::camera_water`] carries the block, the byte sites and
// the why; this is only the wiring.
//
// Classified against the channel's **target** (`[cam+0x1c8]`), never its live value — the
// polarity is easy to invert, and byte-exact. Banding against a continuously-eased quantity
// chatters on its own, independently of the corridor.
//
// `headroom` is `1.0`: the reference scales the reach by the hit fraction of a vertical
// head-room probe at `0x50e6bd` that this client has never had. Absent and named, not stubbed
// (1203) — a low ceiling over a swimmer keeps the full reach, which is a pre-existing gap in
// the pivot rather than something the corridor introduces.
let (_, pivot_target) = rig.pivot.probe();
let (band, depth) = super::camera_water::classify(
// Our own body's cached surface, and only when the camera is watching our own body:
// `0x511ad0` reads the camera TARGET's liquid object and far sight carries none.
view.remote
.is_none()
.then_some(dynamics.surface_y)
.flatten(),
orbit_pos.y,
pivot_target,
);
let corridor = if dynamics.options.water_collision {
super::camera_water::corridor(band, depth, live_pivot)
} else {
super::camera_water::corridor_off(live_pivot)
};
let orbit_pivot = super::camera_water::pivot_height(&corridor, pivot_target, live_pivot, 1.0);
// **And the corridor's floor lifts the SWEEP ORIGIN, which is the half that actually stops the
// pin.** The reference builds the boom's start from the clamped `*heightOut` itself
// (`0x50e786`), so in the surface band it sweeps from `surface + 2/9`; this client roots the
// boom at the capsule's top hemisphere centre instead, a pre-existing divergence that is
// harmless on land and decisive here. A surface-swimming human male's head sits
// `surface + 0.171`, and the camera probe has a `0.15` radius — so the probe starts **21 mm**
// clear of the water it is now allowed to hit, and a fraction of a degree of downward pitch
// collapses the arm from 15 yd to nothing.
//
// Measured, not reasoned: the end-to-end pitch sweep in `benilla_world::collision`
// (`a_surface_swimmers_arm_moves_smoothly_through_the_whole_pitch_range`) put that step at
// **6.65 yd** with the corridor applied to the framing pivot alone. Lifting the origin to the
// corridor floor takes it to a fraction of a yard. `max`, so this can only ever raise the
// origin: dry land and the submerge band are untouched, and nothing here can push the sweep
// start down into geometry.
let sweep_from = Vec3::new(
sweep_from.x,
sweep_from.y.max(orbit_pos.y + corridor.floor),
sweep_from.z,
);
// **`cameraTerrainTilt`'s probe and channel.** The probe looks at the ground AHEAD of the
// subject, not under it: a horizontal ray along its facing from `feet + 5/3`, its hit pulled
// back `5/18`, then a `64/9` drop — so the slope is the rise of the ground you are walking
// ONTO over the run to it. Rooted at the subject, which is what makes far sight tilt to the
// totem's hill rather than to ours.
let ground_probe = || {
let origin = orbit_pos + Vec3::Y * super::camera_dynamics::PROBE_LIFT;
let fwd = Quat::from_rotation_y(dynamics.subject.facing) * Vec3::NEG_Z;
let reach = Dir3::new(fwd)
.ok()
.and_then(|d| collide.ray_body(origin, d, super::camera_dynamics::PROBE_REACH))
.map_or(super::camera_dynamics::PROBE_REACH, |h| {
h.distance - super::camera_dynamics::PROBE_BACKOFF
});
let ahead = origin + fwd * reach;
let ground_y = collide
.ray_body(ahead, Dir3::NEG_Y, super::camera_dynamics::PROBE_DROP)
.map_or(ahead.y - super::camera_dynamics::PROBE_DROP, |h| {
ahead.y - h.distance
});
// `L = √(Δx² + Δy²)` is a length, so a hit closer than the backoff runs the probe
// *behind* the subject and still divides by a positive run — the reference's own
// arithmetic, not a guard added here.
(ground_y - orbit_pos.y) / reach.abs().max(1.0e-3)
};
rig.terrain_tilt.advance(
ground_probe,
dynamics.options.terrain_tilt,
&dynamics.subject,
dynamics.smooth_style,
&dynamics.options,
dt,
);
// **The mouse-look hand-off** (`0x50d500` push / `0x50d520` pop).
// Run after the channel has stepped, so an edge hands off the value this frame is about to
// compose. The reference fires it from the input handler instead; the two differ by at most one
// frame of channel motion, which at `cameraGroundSmoothSpeed` is a fortieth of a degree.
let handed = rig.terrain_tilt.hand_off(rig.freelook);
if handed != 0.0 {
cam.pitch = (cam.pitch + handed).clamp(-CAM_PITCH_LIMIT, CAM_PITCH_LIMIT);
}
// **`cameraBobbing`'s latch and kernel.** The session is armed off the input-command word, not
// off this gate, so it runs whatever the CVar says and only the OUTPUT is gated — which is what
// makes turning the CVar on mid-run start the bob at the phase the session has reached.
// `rig.distance` and not `collision_distance`: the reference's first conjunct is on the zoom
// (`[cam+0xec]`), so a camera squeezed against a wall is not thereby in first person.
rig.head_bob
.advance(rig.distance, &dynamics.subject, &dynamics.options, dt);
seat_camera(
dt,
turn_delta,
orbit_pos,
sweep_from,
orbit_pivot,
rig,
cam,
cam_t,
collide,
follow,
dynamics,
);
}
/// Seat the third-person camera: orient it, orbit it behind the avatar's torso with a collision
/// sweep from the head to the ideal seat (snap-in instantly, ease back out), write the resulting
/// transform, and compute the self-avatar zoom-in fade from the realized camera-to-pivot distance.
/// A **keyboard** turn (or the drunk veer, which rides `turn_delta` the same way — decision 1018)
/// carries the camera rigidly — the character's own turns only: a transport
/// deck turning under the rider is frame motion and is applied to `cam.yaw` at the ride block in
/// [`super::control`], bypassing this function's look-session gate (routing it here was the
/// right-drag drift bug — the gate ate the deck's share while a drag was held). A left-drag orbit
/// offset is then reeled back in by the **auto-follow**, on the player's `cameraSmoothStyle`
/// setting ([`FollowStyle`], decision 1493) — or kept forever, on Never.
/// `head`/`player_pos` are precomputed by [`super::control`] (which owns the avatar capsule
/// constants); `cam_pivot_height` is the world pivot height it derived from [`CameraPivot`] this
/// frame.
pub(super) fn seat_camera(
dt: f32,
turn_delta: f32,
player_pos: Vec3,
head: Vec3,
cam_pivot_height: f32,
rig: &mut CameraControl,
cam: &mut FlyCam,
cam_t: &mut Mut<Transform>,
collide: &benilla_world::collision::WorldCollision<'_, '_>,
follow: &FollowInput,
dynamics: &DynamicsInput,
) {
// A keyboard turn carries the camera RIGIDLY (char and camera rotate as one — the reference
// look, director's call closing 0050's open "camera follow on turn"): an eased chase of a
// continuously-turning facing lags by ω/rate, which read as the char angled on screen while
// run-turning and a release-snap landing off-camera. A drag (`rig.look` held) owns the camera
// — no INPUT-turn carry against the user's hand. (A transport deck's turn is not an input and
// never arrives here — the ride block applies it to `cam.yaw` directly, drag or no drag.)
//
// **The auto-follow** (1.12's `cameraSmoothStyle`, decisions 1493/1502) rides the same gate
// for the same reason — a held drag owns the camera, hand on it. It is NOT a per-frame chase:
// an input edge arms a cosine-smoothstep return to directly-behind and that transition then
// plays out unattended ([`FollowRig::advance`]). It writes an absolute yaw because our camera
// stores one; the reference stores the *offset* and re-adds the facing at render time
// (`0x50f7f2`), which is the same picture and a different mechanism (and the reason Never
// must not, and here does not, touch the rigid carry above).
let look_held = rig.look.is_some();
if !look_held {
cam.yaw += turn_delta;
}
if let Some(yaw) = rig.follow.advance(follow, cam.yaw, dt, look_held) {
cam.yaw = yaw;
}
// Orient the camera, then orbit it behind the avatar's torso. The framing **pivot** is
// `feet + cam_pivot_height` (model-derived, ~neck height — [`CameraPivot`]); the camera looks at
// it and, at zoom 0, sits *on* it (first-person eye inside the head). Camera collision is a single
// sweep of the probe sphere from the player's *head* (the capsule's top hemisphere centre) out to
// the ideal camera seat (`pivot - fwd·zoom`). The camera rides along that sweep, stopping at the
// first surface (held off it by the probe radius). Rooting the arm at the head is what makes it
// robust: body collision keeps the head inside the room — even mid-jump it can't pass the ceiling
// — so the swept camera can never end up on the far side of a wall or ceiling. That is why a jump
// in a low room no longer pushes it through the roof: the sweep just stops under the ceiling
// instead of overshooting (the old min-distance floor used to force the camera *past* a too-close
// hit — gone; collision wins outright). `cast_move` ignores origin penetration, so a head grazing
// a surface still casts outward.
// **The seat is built from the UNBIASED pitch and the view from the biased one** — the whole
// of `cameraPivot` is that ordering (decision 2149). The reference computes the eye at
// `0x50edcc → 0x50de00` and stores it, and only *then* rotates the camera basis `[cam+0x14]`
// by `[cam+0x104]` at `0x50ee32`; so the arm never swings and the look direction does. The
// bias is zero at every default until a pinned camera is dragged, so `arm_rotation` and
// `rotation` are the same quaternion on almost every frame.
let bias = rig.smart_pivot.bias();
// **The ground tilt IS part of the arm's pitch**, unlike the bias: `0x50f710` composes
// `[cam+0xf4] + [cam+0x108]` and clamps the SUM to ±89° before the basis is built, and the eye
// is then seated from that basis. So a followed terrain moves the camera; a smart pivot does
// not.
let arm_pitch = (cam.pitch + rig.terrain_tilt.pitch()).clamp(-CAM_PITCH_LIMIT, CAM_PITCH_LIMIT);
let arm_rotation = Quat::from_euler(EulerRot::YXZ, cam.yaw, arm_pitch, 0.0);
// **The composite is deliberately NOT re-clamped to ±89°.** The reference's clamp at
// `0x50f710` binds `[cam+0xf4] + [cam+0x108]` — the pitch channel plus the ground tilt — and
// the bias is composed **after** it, at `0x50ee58`, so the rendered pitch is not bounded by it.
// The bound that does apply to the bias is the one-sided
// one on its own accumulate ([`SmartPivot::route_pitch`]), and the ±89° on the body hand-off
// (`0x5103e0`), which is where `mover_pitch` takes it.
let rotation = if bias == 0.0 {
arm_rotation
} else {
Quat::from_euler(EulerRot::YXZ, cam.yaw, arm_pitch + bias, 0.0)
};
// `Transform::forward()` is exactly `rotation * -Z` (no renormalize), computed here from the
// local so the write below can be gated.
let cam_fwd = arm_rotation * Vec3::NEG_Z;
let pivot = player_pos + Vec3::Y * cam_pivot_height;
let seat = pivot - cam_fwd * rig.distance;
let boom = seat - head;
let boom_len = boom.length().max(1.0e-3);
// The camera collides with the WMO *camera/LOS* faces (keeps DETAIL overhangs like forge pipes,
// drops NOCAMCOLLIDE) + terrain/doodads/GameObjects — its own audience, not the walking mesh.
//
// **And the waterline, under `cameraWaterCollision`** — registered `"1"`, so this is on out of
// the box. `0x50e5ec` ORs the `0xf0000` ADT-liquid nibble into the word all three of
// `0x50e570`'s queries carry, and it reaches `0x69cc13` through four direct calls, where it
// gates a Möller–Trumbore intersection (`0x7c2c40`, hit distance written at `0x7c2e5f`) over
// the chunk's four MCLQ slots. Two-sided, and with no near floor — which is precisely why the
// pivot corridor above is not optional: nothing in the trace itself stops a boom that starts
// on the water plane, so the *origin* is what has to be lifted clear.
//
// **And that water leg is a RAY, not this probe sphere** (decision 2185): `0x7c2c40` is a
// ray/triangle test and `0x672170` carries no radius, so the `2/9` yd the corridor lifts the
// origin by is a clearance budgeted for a point. The probe is `0.3` — it does not fit, and a
// level boom behind a surface swimmer was coming back pinned at zero. The split lives in
// [`benilla_world::collision::WorldCollision::cast_camera`], which owns the probe now; the
// sphere still sweeps the solid world, which is what it was always for.
let hit = collide.cast_camera(head, boom, dynamics.options.water_collision);
// The solver's own clip verdict (`0x50e570`'s `0x30000` return, OR'd into `[cam+0x90]` by the
// driver) — [`SmartPivot`]'s sixth conjunct, and the reason an unobstructed camera never
// pivots. Written here because here is the only place that knows.
rig.clipped = hit.is_some();
let open = hit.unwrap_or(boom_len);
// Snap in instantly when geometry intrudes (a wall must never sit between camera and character);
// ease back out to the open arm length once it clears — the vanilla snap-close-then-glide-back.
rig.collision_distance = if open < rig.collision_distance {
open
} else {
let t = 1.0 - (-CAM_RETURN_RATE * dt).exp();
rig.collision_distance + (open - rig.collision_distance) * t
};
let frac = (rig.collision_distance / boom_len).clamp(0.0, 1.0);
let seated = head + boom * frac;
// **The head bob is a pure world-space translation of the eye**, added last, into the same
// slot [`crate::camera_shake`] writes — which is exactly what the reference does (`0x50eb0f`
// folds the bob into the shake's accumulator and `0x50de00` applies the pair once). Zero on
// every frame nothing is bobbing, so the no-op write gate below still holds a parked camera
// bit-stable.
let translation = seated + rig.head_bob.offset();
// The no-op write gate (decision 1362 — 1355's clamp lesson, at the camera): a parked
// camera's pose is bit-stable once the collision ease settles, but writing it anyway marked
// the camera's transform changed every frame — which re-ran its propagation and told every
// camera-watching gate in the app that the view moved when it hadn't. Bit equality, not an
// epsilon: a real sub-epsilon drift must still land.
{
let t = cam_t.bypass_change_detection();
if t.rotation != rotation || t.translation != translation {
t.rotation = rotation;
t.translation = translation;
cam_t.set_changed();
}
}
// No waterline handling here — deliberately. The reference NEVER moves the eye for liquid
// (zero liquid-height queries in the camera TU `[0x7ac640, 0x7ae010)`); the no-straddle
// experience is the *submersion probe's* — the frame flips
// submerged the moment the lowest near-plane corner reaches the surface
// (`liquid::detect_submersion`, the corner-min probe), and with the near plane at the
// `nearclip` CVar's registered 0.1 the whole crossing band is a few inches tall (2163 — it
// said "the reference's 1/9" until the per-frame re-stamp at `0x511bd4` was read). 0905's eye
// snap — the local compensation for the old 1.0-yd near plane — is removed with its cause
// (its record is superseded; see the 0905-successor decision).
// `WOW_CAM_DUMP=frame`: the REALIZED pose, per frame, bit-exact — not the pose that was asked for.
//
// Every scripted probe sets `yaw`/`pitch`/`distance` and we then reason as though the camera is
// therefore where we put it. It is not: `collision_distance` is an exponentially-eased chase of a
// per-frame collision CAST, so a grazing hit that alternates gives an arm that snaps in and eases
// back out, and the camera keeps moving for as long as that lasts — with the scripted pose
// perfectly constant the whole time. B38's "the camera is static by construction, so nothing
// camera-derived can be the cause" (0671) rests entirely on that being untrue, and it was never
// measured. `open` is printed beside the eased arm so a hit/miss alternation in the CAST is
// visible even on a frame where the ease has not yet moved the camera far enough to see.
if cam_dump_enabled() {
// `follow=` is the auto-follow's own reading (1502): the offset the return is animating,
// the state the input word classifies to, and — once armed — how far through the
// transition this frame is. `off` moving while `arm` reads `-` means something other than
// the follow moved the camera.
let (elapsed, delay, dur) = rig.follow.probe().unwrap_or((-1.0, -1.0, -1.0));
eprintln!(
"[cam] yaw {:.6} pitch {:.6} dist {:.6} open {:.6} coll {:.6} frac {:.6} \
pos [{:.6},{:.6},{:.6}] bits [{:08x},{:08x},{:08x}] \
follow off {:.6} state {:?} word {:06x} arm {:.3}/{:.3}+{:.3}",
cam.yaw,
cam.pitch,
rig.distance,
open,
rig.collision_distance,
frac,
translation.x,
translation.y,
translation.z,
translation.x.to_bits(),
translation.y.to_bits(),
translation.z.to_bits(),
wrap_pi(cam.yaw - follow.face_yaw),
follow.state(false),
follow.command,
elapsed,
dur,
delay,
);
}
// The pivot bias's own per-frame half (`0x50ed77` → `0x5107f0`): with the gate no longer true
// the bias eases back to zero at `cameraTargetSmoothSpeed`; with it true an ease in flight is
// cancelled where it stands. Runs after the sweep, so `rig.clipped` is this frame's.
rig.smart_pivot.advance(
cam.pitch,
&dynamics.subject,
rig.clipped,
dynamics.tracking_style,
&dynamics.options,
dt,
);
// Fade the avatar as the camera nears its pivot (zoom-in / a wall pulling the boom in): opaque
// in third-person, ramping to invisible in first-person. Keyed off the *realized* camera→pivot
// distance (collision-pulled), so backing into a wall also thins you — the faithful behavior.
// Off the SEATED eye, not the bobbed one: the fade is a statement about how far the boom was
// pulled in, and a 5 cm wobble is not that.
//
// The LIVE `nearclip`, not a constant (2163): the fade is defined as finishing where the near
// plane starts cutting, so a player who moves that plane has to move this with it.
rig.self_fade_alpha = self_model_fade_alpha(
(seated - pivot).length(),
dynamics.nearclip,
SELF_FADE_WINDOW,
);
}
/// Apply the self-avatar zoom-in fade ([`CameraControl::self_fade_alpha`], computed in [`control`]) to
/// the player's own body parts **and every attach-model descendant** (held items, helm, shoulders —
/// [`crate::entities::BoneAttach`] rides them several levels down through the joint hierarchy), so you
/// go translucent then invisible — weapon and armor included — as the camera zooms into the head. Drives
/// the same per-instance render-alpha channel as [`benilla_world::model_fade::apply_render_fade`] — the `MeshTag`
/// alpha field on the blend-twin material — and hard-hides via [`Visibility`] at α 0 (true
/// first-person; cheaper + cleaner than a ≈0-alpha head sitting on the camera).
///
/// Runs **after** the interior classifier + the appear/despawn fades so its override wins the frame; it
/// overrides while fading (`α < 1`) and, on the frame the fade ends, **releases** the channel back
/// (decision 0213): the classifier skips settled parts and rewrites only on a classification change, so
/// without an explicit hand-back a fade episode that ends in a jump past 1 (a hitch frame closing the
/// camera ease in one step, a pivot jump) left the avatar latched on the blend twin at its last low alpha
/// — stuck translucent until the player happened to cross a room boundary. At steady `α ≥ 1` it does
/// nothing, leaving the classifier the sole steady-state author. Parts mid appear/despawn fade
/// (`RenderFade`/`PendingAppearFade`) are left to that fade — it's brief, owns the channel, and performs
/// its own release on completion.
///
/// Walks the **full** descendant tree from the avatar root rather than just its direct children: body
/// submeshes are direct children of the root, but a held item / helm / shoulder is a child of a joint
/// entity (itself a descendant of the root, at varying depth) — an earlier direct-`Children`-only version
/// silently skipped every attach model. The self-player entity is singular, so a per-frame tree walk over
/// its handful of joints + submeshes is nil cost.
///
/// **The tree is not the whole model.** An M2's BILLBOARD batches can't be tree children — their mesh is
/// centred on the bone pivot and their transform belongs to the billboard system, so every one of them is
/// a world ROOT entity that merely *follows* an anchor inside the tree (decision 0153). The descendant
/// walk therefore cannot see them, and the night-elf eye glow — two additive `…EYEGLOW.BLP` billboard
/// quads at head height — went on burning in mid-air after the body it belongs to had gone (reported
/// first-hand; ledger B71). Cards are picked up here by testing their follow-anchor against the walked
/// set, and folded into the same α — the idiom [`crate::blob_shadow`] already uses for the other
/// world-root follower of the self avatar ("the self first-person fade rides the same model-fade slot in
/// the reference"). One multiply covers both halves: it feathers with the body, and at α 0 the additive
/// compose (`wow_model.wgsl`: `out_rgb *= faded_alpha`) takes the card to black, which for an ADD blend
/// is gone. That deliberately avoids `Visibility`, which the card's own hidden-owner mirror authors every
/// frame in a different system.
#[allow(clippy::type_complexity)] // one Bevy system's full input set
pub(crate) fn apply_self_model_fade(
rig: Res<CameraControl>,
self_player: Query<(Entity, Option<&crate::aura_visual::AuraNodes>), With<Embodied>>,
children_of: Query<&Children>,
mut parts: Query<
(
&FadeMaterials,
&mut MeshTag,
&mut MeshMaterial3d<WowModelMaterial>,
&mut Visibility,
Option<&benilla_world::interior::InteriorLit>,
Has<benilla_world::model_render::FarSideOfWater>,
),
(
Without<RenderFade>,
Without<PendingAppearFade>,
// Disjointness for the card query below (both want `&mut MeshTag`). A card carries
// `FadeMaterials` too since 0836, so this now genuinely diverts them — into the loop
// at the end, which applies the same law without touching `Visibility` (a card's own
// hidden-owner mirror authors that in a different system).
Without<benilla_world::billboard::BillboardCard>,
),
>,
mut cards: Query<(
&benilla_world::billboard::BillboardCard,
&mut MeshTag,
Option<&benilla_world::doodad_anim::MatAnim>,
Option<&FadeMaterials>,
Option<&mut MeshMaterial3d<WowModelMaterial>>,
Option<&benilla_world::interior::InteriorLit>,
Has<benilla_world::model_render::FarSideOfWater>,
)>,
// The water-plane axis, composed into every pick below (`far_resolved`) like every other
// owner of the handle channel — the feather and the classifier converge, never re-swap.
far_twins: Res<benilla_world::model_render::FarSideTwins>,
mut reauthor: ResMut<benilla_world::interior::InteriorReauthor>,
mut was_fading: Local<bool>,
) {
let fading = rig.self_fade_alpha < 1.0;
if !fading && !*was_fading {
// Steady opaque: nothing to author and nothing to release.
return;
}
let Ok((root, aura)) = self_player.single() else {
*was_fading = false;
return;
};
// Our own live aura translucency (stealth, invisibility, ghost — `crate::aura_visual`) is a
// FACTOR of this fade, not a rival author: this system runs last on the self body and writes the
// alpha field verbatim, so a zoom-in while stealthed must carry the aura's term or the feather
// would silently re-opaque the character to 1.0 × the camera ramp. Folding it in here also makes
// the release edge honest — it releases at the *product*, so a fade ending while still stealthed
// hands the material back only if the body is genuinely opaque again.
let feather = rig.self_fade_alpha * crate::aura_visual::root_alpha(aura);
// The walked set doubles as "which anchors belong to this model" for the card pass — built only
// while fading, so the steady state (the early return above) never pays for it.
let mut walked = EntityHashSet::default();
apply_self_fade_to_descendants(
root,
feather,
&children_of,
&mut parts,
&far_twins,
&mut reauthor,
&mut walked,
);
let alpha = feather.clamp(0.0, 1.0);
for (card, mut tag, anim, fm, mat, lit, far_side) in &mut cards {
if !card
.follows()
.is_some_and(|anchor| walked.contains(&anchor))
{
continue;
}
// The card's steady author is `entities::apply_unit_mat_alpha`, ordered before this system,
// which writes the batch's per-sequence factor every frame — so composing from `current`
// (not from the tag we'd read back) keeps that animation alive under the fade, and the
// release frame's `α = 1` write lands exactly on the value it would have had.
let authored = anim.map_or(1.0, |a| a.current);
let bits = benilla_world::mesh_tag::with_alpha(tag.0, authored * alpha);
if tag.0 != bits {
tag.0 = bits;
}
// …and the blend twin while feathering, exactly as a mesh part does. The alpha alone is
// enough for an ADDITIVE card (`wow_model.wgsl` folds it into the colour, so α 0 is black
// is gone), but an OPAQUE one — a pauldron's camera-facing trim, a chain link — ignores it
// entirely and stayed solid in first person until the card carried a twin to swap to
// (decision 0836). No `Visibility` here: that channel belongs to the card's hidden-owner
// mirror in another system.
if let (Some(fm), Some(mut mat)) = (fm, mat) {
let want = benilla_world::model_render::far_resolved(
fm.material_for(lit, alpha < 1.0),
far_side,
&far_twins,
);
if mat.0 != *want {
mat.0 = want.clone();
}
}
}
*was_fading = fading;
}
/// Depth-first helper for [`apply_self_model_fade`]: apply the fade (or, at `α ≥ 1`, the release) to
/// `entity` if it's a fadeable part, then recurse into its children regardless (a joint or an attach-model
/// root carries no `FadeMaterials` itself but must still be descended through to reach the mesh leaves
/// under it). Every entity visited — parts, joints, attach roots, billboard anchors alike — is recorded
/// in `walked`, which the caller uses to recognise the world-root billboard cards that follow this model.
#[allow(clippy::type_complexity)]
fn apply_self_fade_to_descendants(
entity: Entity,
alpha: f32,
children_of: &Query<&Children>,
parts: &mut Query<
(
&FadeMaterials,
&mut MeshTag,
&mut MeshMaterial3d<WowModelMaterial>,
&mut Visibility,
Option<&benilla_world::interior::InteriorLit>,
Has<benilla_world::model_render::FarSideOfWater>,
),
(
Without<RenderFade>,
Without<PendingAppearFade>,
Without<benilla_world::billboard::BillboardCard>,
),
>,
far_twins: &benilla_world::model_render::FarSideTwins,
reauthor: &mut benilla_world::interior::InteriorReauthor,
walked: &mut EntityHashSet,
) {
walked.insert(entity);
if let Ok((fm, mut tag, mut mat, mut vis, lit, far_side)) = parts.get_mut(entity) {
if alpha >= 1.0 {
// The release edge (runs once, on the frame the fade ends — decision 0213): un-hide,
// restore the alpha field this system owns, and hand the material back to the part's
// law. The alpha restore is unconditional: the classifier's payload writes carry the
// tag's alpha through since 0755 (that is what lets a part re-lane mid-fade), so
// leaning on its re-author to *also* re-opaque the avatar — as this used to — would
// leave it stuck translucent, the exact 0213 bug.
if *vis != Visibility::Inherited {
*vis = Visibility::Inherited;
}
let bits = benilla_world::mesh_tag::with_alpha(tag.0, 1.0);
if tag.0 != bits {
tag.0 = bits;
}
let want = benilla_world::model_render::far_resolved(
fm.material_for(lit, false),
far_side,
far_twins,
);
if mat.0 != *want {
mat.0 = want.clone();
}
// A classifier-lit part is still enqueued so the next run re-asserts its full payload
// (probe slot / fog bit) over whatever this feather episode wrote — 0734's queue.
if lit.is_some() {
reauthor.0.push(entity);
}
} else if alpha <= 0.0 {
// First-person: hide outright. Leave tag/material to the classifier (not drawn anyway).
if *vis != Visibility::Hidden {
*vis = Visibility::Hidden;
}
} else {
if *vis != Visibility::Inherited {
*vis = Visibility::Inherited;
}
// Feathering: ride the blend twin with the alpha packed into the tag's alpha field
// (the cutout ignores α; `with_alpha` preserves the ground-shade byte so a shadowed
// avatar doesn't flash lit while zooming).
let bits = benilla_world::mesh_tag::with_alpha(tag.0, alpha);
if tag.0 != bits {
tag.0 = bits;
}
// A bake-classified part feathers on the PROBE-lit blend twin — the room light
// rides the fade (the tag re-lane keeps the slot alongside the alpha, 0355); the
// exterior twin at shade byte 0 read as full outdoor intensity deep indoors
// (director-caught, 2026-07-13). Shared with the appear/despawn ramp since 0755, so
// the two can never disagree about which twin a law wants.
let want = benilla_world::model_render::far_resolved(
fm.material_for(lit, true),
far_side,
far_twins,
);
if mat.0 != *want {
mat.0 = want.clone();
}
}
}
if let Ok(children) = children_of.get(entity) {
for &child in children {
apply_self_fade_to_descendants(
child,
alpha,
children_of,
parts,
far_twins,
reauthor,
walked,
);
}
}
}
/// True if the OS pointer is over the world camera's render area. The world camera now fills the window
/// (the debug panel overlays rather than insetting), so this is really just "is the cursor inside the
/// window?"; the panel itself is excluded by `PointerOverUi` at the call site. Kept viewport-aware in
/// case anything insets the camera again.
fn cursor_in_viewport(window: &Window, camera: &Camera) -> bool {
let Some(cursor) = window.physical_cursor_position() else {
return false;
};
match &camera.viewport {
Some(vp) => {
let min = vp.physical_position.as_vec2();
let max = min + vp.physical_size.as_vec2();
cursor.x >= min.x && cursor.y >= min.y && cursor.x < max.x && cursor.y < max.y
}
None => true,
}
}
/// Free-fly (pre-connect or `F`-detached): aim from the look angles, move the camera directly —
/// WASD in the camera basis, Space/C up/down, Ctrl 5× boost. The avatar stays frozen where it was;
/// [`super::control`] parks the mover before calling so the wire never extrapolates a phantom walk.
pub(super) fn fly_free(
dt: f32,
keys: &ButtonInput<KeyCode>,
typing: bool,
rig: &mut CameraControl,
cam: &mut FlyCam,
cam_t: &mut Transform,
) {
let keys_pressed = |k: KeyCode| !typing && keys.pressed(k);
// Detached / pre-connect: keep the avatar fully opaque (you flew off to look at it — no fade).
rig.self_fade_alpha = 1.0;
cam_t.rotation = Quat::from_euler(EulerRot::YXZ, cam.yaw, cam.pitch, 0.0);
let forward = *cam_t.forward();
let right = *cam_t.right();
let mut dir = Vec3::ZERO;
if keys_pressed(KeyCode::KeyW) {
dir += forward;
}
if keys_pressed(KeyCode::KeyS) {
dir -= forward;
}
if keys_pressed(KeyCode::KeyD) {
dir += right;
}
if keys_pressed(KeyCode::KeyA) {
dir -= right;
}
if keys_pressed(KeyCode::Space) {
dir += Vec3::Y;
}
if keys_pressed(KeyCode::KeyC) {
dir -= Vec3::Y;
}
if dir != Vec3::ZERO {
let boost = if keys_pressed(KeyCode::ControlLeft) {
5.0
} else {
1.0
};
cam_t.translation += dir.normalize() * cam.speed * boost * dt;
}
}
/// `$WOW_CAM_DUMP` — the per-frame camera/turn dump (this file's seat and the controller's turn
/// line share it). One read for the process: both sites sit on the every-frame path.
pub(crate) fn cam_dump_enabled() -> bool {
static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
*ON.get_or_init(|| std::env::var_os("WOW_CAM_DUMP").is_some())
}
#[cfg(test)]
mod tests {
use super::super::camera_channel::CHANNEL_EPS;
use super::*;
use benilla_assets::BillboardInfo;
use benilla_formats::BillboardKind;
use benilla_world::billboard::BillboardCard;
use benilla_world::mesh_tag::alpha_bits;
/// Step a [`PivotGlide`] at 60 Hz for `secs`, holding the target, and return the heights it
/// passed through (one per frame).
fn glide_run(g: &mut PivotGlide, target: Option<f32>, secs: f32) -> Vec<f32> {
let dt = 1.0 / 60.0;
(0..(secs / dt).round() as usize)
.map(|_| g.advance(target, dt))
.collect()
}
/// **The report** (the director, on the reference vs ours): shifting form on the real client
/// *glides* the camera to the new body's height; ours snapped there and then drifted. The
/// channel's whole job is that this is one smooth move, in **both** directions — the solver's
/// `max(target, live)` is only a collision seed, and the far chain clamps back to the live
/// value (`0x50e767`), so nothing about a *rising* target arrives early.
#[test]
fn a_shapeshift_glides_the_pivot_both_ways_and_never_snaps() {
// Tauren → cat: the heights measured off a live probe run.
let (tauren, cat) = (2.4659_f32, 1.0552_f32);
let mut g = PivotGlide::default();
// The first height a camera ever sees is established, not travelled to (`0x5127d4`).
assert_eq!(
g.advance(Some(tauren), 1.0 / 60.0),
tauren,
"the first arm snaps"
);
for (from, to) in [(tauren, cat), (cat, tauren)] {
let expected = (to - from).abs() / CAM_PIVOT_SMOOTH_SPEED;
let frames = glide_run(&mut g, Some(to), expected * 2.0);
// It arrives, and only at the end.
assert!((frames.last().copied().unwrap() - to).abs() < CHANNEL_EPS);
let arrived = frames
.iter()
.position(|h| (h - to).abs() < CHANNEL_EPS)
.unwrap();
let took = arrived as f32 / 60.0;
assert!(
(took - expected).abs() < 0.05,
"|Δh| / 1.2 yd/s = {expected:.3} s, took {took:.3} s ({from} → {to})"
);
// No frame jumps: the biggest single step is the cosine's own midpoint rate, which
// for these Δ is far under half the total. A snap would put the whole Δ in one frame.
let biggest = frames
.windows(2)
.map(|w| (w[1] - w[0]).abs())
.fold(0.0, f32::max);
assert!(
biggest < (to - from).abs() * 0.5,
"the pivot must never teleport: biggest step {biggest} of Δ {}",
(to - from).abs()
);
}
}
/// A model that has not resolved yet **holds** the channel — the reference skips the camera
/// update outright while the preset is stale (`0x50e907`), which is what makes a display swap
/// read as a pause and then one glide. Aiming at a placeholder in the meantime would send the
/// camera on a round trip.
#[test]
fn a_body_with_no_model_holds_the_pivot_instead_of_re_aiming_it() {
let mut g = PivotGlide::default();
g.advance(Some(2.4659), 1.0 / 60.0);
let held = glide_run(&mut g, None, 0.5);
assert!(
held.iter().all(|h| *h == 2.4659),
"no target ⇒ no motion (the model is still loading)"
);
// …and the glide that follows starts from where it held, not from a placeholder.
let frames = glide_run(&mut g, Some(1.0552), 2.0);
assert!(frames[0] < 2.4659 && frames[0] > 2.4);
}
/// The per-frame re-arm has to be a **no-op** in steady state (the setter's 0.001 epsilon,
/// `0x5126b0`). Restarting the move from its own midpoint every frame would stretch it
/// asymptotically and it would never arrive — the classic re-arm bug this epsilon prevents.
#[test]
fn re_arming_the_same_target_every_frame_does_not_stretch_the_glide() {
let mut g = PivotGlide::default();
g.advance(Some(1.0), 1.0 / 60.0);
let frames = glide_run(&mut g, Some(2.2), 2.0);
assert!(
(frames.last().copied().unwrap() - 2.2).abs() < CHANNEL_EPS,
"a per-frame re-arm must still arrive"
);
}
/// The pivot target is the model height × the **raw** scale, clamped to the reference's own
/// `[5/6, 15]` band (`0x50ca90`) — a giant's aura cannot walk the framing pivot into the sky,
/// and a shrink cannot bury it in the floor.
#[test]
fn the_pivot_target_is_clamped_to_the_references_band() {
let p = CameraPivot {
height_local: 2.0,
swim_drop_local: 0.0,
};
assert_eq!(model_pivot_height(&p, 1.0, false), 2.0);
assert_eq!(model_pivot_height(&p, 0.01, false), CAM_PIVOT_FLOOR);
assert_eq!(model_pivot_height(&p, 100.0, false), CAM_PIVOT_CEIL);
}
/// **The swim preset** — the reference's `cam+0x124`, selected by `0x50f880` on
/// MOVEFLAG_SWIMMING and built at `0x50ccf6` as the standing height less
/// `StandSeq.max.z − SwimSeq.max.z`. The numbers are the shipped Human Male's, measured off
/// the binary: standing 1.9002692, swimming 1.5120120.
#[test]
fn swimming_takes_the_lower_pivot_preset() {
let human = CameraPivot {
height_local: 1.9002692,
swim_drop_local: 0.3882572,
};
assert!((model_pivot_height(&human, 1.0, false) - 1.9002692).abs() < 1e-5);
assert!((model_pivot_height(&human, 1.0, true) - 1.512_012).abs() < 1e-5);
// The preset multiplies the scale, then clamps — the swim leg shares the band with the
// standing one (`0x50ca90` clamps all three presets together).
assert!(
(model_pivot_height(&human, 2.0, true) - 2.0 * 1.512_012).abs() < 1e-5,
"the swim preset scales like its sibling"
);
assert_eq!(model_pivot_height(&human, 0.01, true), CAM_PIVOT_FLOOR);
assert_eq!(model_pivot_height(&human, 100.0, true), CAM_PIVOT_CEIL);
}
/// A model with **no Swim sequence** — every non-character model, and the reference's own
/// both-sequences-present guard (`0x711960` on ids 0 and 0x2a). Its drop is `0.0`, so the swim
/// preset degenerates to the standing one and a body that cannot swim never dips.
#[test]
fn a_model_that_cannot_swim_keeps_the_standing_preset() {
let chicken = CameraPivot {
height_local: 1.2,
swim_drop_local: 0.0,
};
assert_eq!(
model_pivot_height(&chicken, 1.0, true),
model_pivot_height(&chicken, 1.0, false),
);
assert_eq!(model_pivot_height(&chicken, 1.0, true), 1.2);
}
/// A press that has travelled `yaw`/`pitch` **degrees** of camera rotation.
fn press(yaw_deg: f32, pitch_deg: f32) -> PressGesture {
PressGesture {
at: 0.0,
yaw_travel: yaw_deg.to_radians(),
pitch_travel: pitch_deg.to_radians(),
}
}
/// The report this whole change exists for (ledger B226, decision 1122): **a fast click
/// selects however far the mouse swept.** Under 200 world the reference asks nothing about
/// motion at all (`0x514ae0`'s first arm, `0x514b24`) — which is the gesture people actually
/// make, flicking the cursor at a mob and clicking on arrival with the hand still moving.
/// benilla used to destroy the pending click after 4 px of travel, so this case never fired.
#[test]
fn a_fast_click_selects_however_far_the_mouse_swept() {
// A whole screen's worth of sweep — orders of magnitude past the travel gate.
let swept = press(90.0, 45.0);
assert!(
swept.is_click(0.199),
"under 200 world, travel is not consulted"
);
// And the camera is expected to have orbited through all of it: the two are independent,
// which is the half that makes the reference's gesture possible at all.
}
/// The `mousespeed` slider is a MULTIPLIER over the shipped per-pixel rate (decision 1140), and
/// the neutral notch has to reproduce the old constant exactly — the whole point of registering
/// the default at 1.0 is that nobody's feel changes until they move the slider. Both the look
/// rotation and the click-vs-drag travel budget read this one function, so the drag threshold
/// scales with the pointer instead of drifting away from it.
#[test]
fn the_sensitivity_slider_is_a_multiplier_over_the_shipped_rate() {
// **The shipped feel is unchanged by the two move-speed CVars landing**: at the
// reference's own defaults BOTH axes come out at exactly the rate this client has always
// used. That is the whole point of anchoring the scale to `LOOK_SENSITIVITY` rather than
// transplanting the reference's deg-per-accelerated-pixel onto our raw device delta.
let d = LookConfig::default();
assert_eq!(d.yaw_rate(), LOOK_SENSITIVITY);
assert_eq!(d.pitch_rate(), LOOK_SENSITIVITY);
let fast = LookConfig {
sensitivity: 1.5,
..Default::default()
};
assert_eq!(fast.yaw_rate(), LOOK_SENSITIVITY * 1.5);
assert_eq!(fast.pitch_rate(), LOOK_SENSITIVITY * 1.5);
let slow = LookConfig {
sensitivity: *MOUSE_SPEED_RANGE.start(),
..Default::default()
};
assert_eq!(slow.yaw_rate(), LOOK_SENSITIVITY * 0.5);
assert_eq!(slow.pitch_rate(), LOOK_SENSITIVITY * 0.5);
}
/// **Each move-speed CVar scales its own axis, linearly, and only its own** — the reference's
/// law shape (`Δyaw_deg = value × Δx / 800`, `Δpitch_deg = value × Δy / 600`).
///
/// The slider's own stops are the check: `UIOptionsFrameSliders`' MOUSE_LOOK_SPEED row runs
/// 90…270, so its ends are half and one-and-a-half times the shipped rate.
#[test]
fn each_move_speed_cvar_scales_its_own_axis() {
let doubled_yaw = LookConfig {
yaw_speed: 360.0,
..Default::default()
};
assert_eq!(doubled_yaw.yaw_rate(), LOOK_SENSITIVITY * 2.0);
assert_eq!(
doubled_yaw.pitch_rate(),
LOOK_SENSITIVITY,
"the yaw CVar must not move the pitch axis"
);
let doubled_pitch = LookConfig {
pitch_speed: 180.0,
..Default::default()
};
assert_eq!(doubled_pitch.pitch_rate(), LOOK_SENSITIVITY * 2.0);
assert_eq!(doubled_pitch.yaw_rate(), LOOK_SENSITIVITY);
// The MOUSE_LOOK_SPEED slider's two ends. Approximate, because the two sides multiply
// the same three factors in a different order and f32 is not associative — the property
// under test is the ratio, not the last bit.
let near = |a: f32, b: f32| (a - b).abs() < 1e-9;
for (speed, factor) in [(90.0_f32, 0.5_f32), (270.0, 1.5)] {
let at = LookConfig {
yaw_speed: speed,
..Default::default()
};
assert!(near(at.yaw_rate(), LOOK_SENSITIVITY * factor));
}
// `mousespeed` still multiplies on top of both — the 1140 property, unchanged.
let both = LookConfig {
yaw_speed: 270.0,
sensitivity: 1.5,
..Default::default()
};
assert!(near(both.yaw_rate(), LOOK_SENSITIVITY * 1.5 * 1.5));
}
/// **The auto-follow** (decisions 1493/1502) — 1.12's `cameraSmoothStyle`, the setting benilla
/// spent its whole life behaving as "Never". The properties that a re-derivation gets wrong,
/// and that the reference's mechanism (`0x510960`, `0x50f160`) turns on:
/// it is armed by an input **edge** and then plays out unattended (not a per-frame chase of a
/// moving target), the profile is a **cosine** smoothstep, the duration is `|Δ| / rate ×
/// factor` **clamped to [0.1 s, 2.0 s]**, and Smart's `Idle`/`Stop` rows are a *cancel* — which
/// is what "stays where you put it, except while you're moving" actually is.
#[test]
fn the_auto_follow_is_armed_by_an_input_edge_and_eases_home() {
const DT: f32 = 1.0 / 120.0;
// A quarter turn of orbit offset, left there by a drag.
const OFFSET: f32 = std::f32::consts::FRAC_PI_2;
fn cfg(style: FollowStyle) -> FollowConfig {
FollowConfig {
style,
tracking_style: style,
yaw_speed: FOLLOW_SPEED_DEFAULT,
}
}
/// Run `secs` of frames at a fixed input word; returns the camera yaw it ends on.
fn run(rig: &mut FollowRig, cfg: FollowConfig, word: u32, cam_yaw: f32, secs: f32) -> f32 {
let mut yaw = cam_yaw;
for _ in 0..((secs / DT).round() as i32).max(0) {
let input = FollowInput {
cfg,
face_yaw: 0.0,
command: word,
};
if let Some(y) = rig.advance(&input, yaw, DT, false) {
yaw = y;
}
}
yaw
}
// ── Smart: standing still with the camera dragged aside, nothing happens; the moment the
// W edge lands, one armed transition brings it home over |Δ|/180°/s = 0.5 s.
let mut rig = FollowRig::default();
let c = cfg(FollowStyle::Smart);
let parked = run(&mut rig, c, 0, OFFSET, 1.0);
assert_eq!(
parked, OFFSET,
"Smart standing still leaves the camera alone"
);
let half = run(&mut rig, c, follow_cmd::FORWARD, parked, 0.25);
assert!(
half < OFFSET * 0.75 && half > OFFSET * 0.25,
"mid-swing, eased: {half}"
);
let home = run(&mut rig, c, follow_cmd::FORWARD, half, 0.3);
assert!(home.abs() < 1.0e-4, "arrived behind the character: {home}");
// And holding W changes nothing further — the transition is spent, not a standing chase.
let still_home = run(&mut rig, c, follow_cmd::FORWARD, home + 0.4, 1.0);
assert_eq!(
still_home,
home + 0.4,
"a HELD key re-arms nothing: only edges arm"
);
// ── Smart: releasing W (an edge into Stop) cancels rather than arming, so a camera nudged
// while stopping stays nudged.
let mut rig = FollowRig::default();
let held = run(&mut rig, c, follow_cmd::FORWARD, 0.0, 0.1);
let released = run(&mut rig, c, 0, held + OFFSET, 0.5);
assert_eq!(released, held + OFFSET, "Stop is a cancel under Smart");
// ── Always arms on that very same Idle edge — the one row where the two styles differ.
let mut rig = FollowRig::default();
let a = cfg(FollowStyle::Always);
let held = run(&mut rig, a, follow_cmd::FORWARD, 0.0, 0.1);
let returned = run(&mut rig, a, 0, held + OFFSET, 1.0);
assert!(
returned.abs() < 1.0e-4,
"Always returns even from a standstill: {returned}"
);
// ── Never is inert, edge or no edge.
let mut rig = FollowRig::default();
let n = cfg(FollowStyle::Never);
let _ = run(&mut rig, n, 0, OFFSET, 0.1);
assert_eq!(
run(&mut rig, n, follow_cmd::FORWARD, OFFSET, 2.0),
OFFSET,
"Never never arms"
);
// ── The duration floor: a 5° correction is 0.028 s of travel at 180 °/s, and the
// reference's 0.1 s minimum overrides it — so it is NOT finished after 0.05 s.
let mut rig = FollowRig::default();
let small = 5.0_f32.to_radians();
let _ = run(&mut rig, c, 0, small, DT);
let mid = run(&mut rig, c, follow_cmd::FORWARD, small, 0.05);
assert!(
mid.abs() > 1.0e-4,
"the 0.1 s floor is doing the work: {mid}"
);
assert!(run(&mut rig, c, follow_cmd::FORWARD, mid, 0.06).abs() < 1.0e-4);
// ── Track (a taxi, a spline): Smart takes it lazily — 0.4 s of dead time first, then a
// factor-10 return the 2 s ceiling caps.
let mut rig = FollowRig::default();
let _ = run(&mut rig, c, 0, OFFSET, DT);
let delayed = run(&mut rig, c, follow_cmd::TRACK, OFFSET, 0.3);
assert_eq!(delayed, OFFSET, "nothing moves inside the 0.4 s delay");
let crawling = run(&mut rig, c, follow_cmd::TRACK, delayed, 0.6);
assert!(
crawling > OFFSET * 0.5,
"a factor-10 return is a crawl, not a swing: {crawling}"
);
assert!(
run(&mut rig, c, follow_cmd::TRACK, crawling, 2.0).abs() < 1.0e-4,
"and it does arrive, inside the 2 s cap"
);
// ── A held drag freezes the channel outright: the hand owns the camera.
let mut rig = FollowRig::default();
let input = FollowInput {
cfg: c,
face_yaw: 0.0,
command: follow_cmd::FORWARD,
};
assert!(rig.advance(&input, OFFSET, DT, true).is_none());
assert!(rig.advance(&input, OFFSET, DT, true).is_none());
// ── …and entering the drag **cancels** what was in flight (`0x50fe30` zeroes the
// descriptors), so the return does not simply resume when the button comes up. Grab the
// camera mid-swing and it stays where the hand left it until the next input edge.
let mut rig = FollowRig::default();
let _ = run(&mut rig, c, 0, OFFSET, DT);
let mid = run(&mut rig, c, follow_cmd::FORWARD, OFFSET, 0.1);
assert!(mid < OFFSET && mid > 0.0, "mid-swing: {mid}");
let dragging = FollowInput {
cfg: c,
face_yaw: 0.0,
command: follow_cmd::FORWARD | follow_cmd::LEFT_MOUSE,
};
assert!(rig.advance(&dragging, mid, DT, true).is_none());
// The word is unchanged from the drag frame's, so nothing re-arms on its own…
let parked = {
let mut yaw = mid;
for _ in 0..120 {
let input = FollowInput {
cfg: c,
face_yaw: 0.0,
command: follow_cmd::FORWARD | follow_cmd::LEFT_MOUSE,
};
if let Some(y) = rig.advance(&input, yaw, DT, false) {
yaw = y;
}
}
yaw
};
assert_eq!(parked, mid, "the cancelled transition does not resume");
// …and the release's own edge (the mouse bit leaving the word) is what starts a new one.
assert!(
run(&mut rig, c, follow_cmd::FORWARD, parked, 1.0).abs() < 1.0e-4,
"the release edge arms a fresh return"
);
}
/// Right-clicking a row of the Who list must not swing the camera round to
/// behind the character — over ~180°, on a body that never turned. The click itself was the
/// UI's (1816's no-fall-through hit test opened the dropdown); only the camera's command word
/// saw it, because the word's two mouse bits were built from `ButtonInput` with no UI term.
/// `RIGHT_MOUSE` alone is a [`FollowState::Turn`], and Smart's Turn row is `(0.0, 1.0)` — no
/// delay, full factor — so the return armed on the press edge and ran to completion.
///
/// The decode is pinned end to end, from the latch to the row the classifier picks, because
/// each half was individually right: the classifier is the reference's (the test below), and
/// the look session's own gate was already there. What was missing was the word reading the
/// same gate.
#[test]
fn a_press_the_ui_ate_never_reaches_the_camera_command_word() {
let word = |world_press: bool| {
let mut rig = CameraControl::default();
let mut buttons = ButtonInput::<MouseButton>::default();
buttons.press(MouseButton::Right);
rig.world_mouse.update(&buttons, world_press);
super::super::input::look_input(
&crate::bindings::BindingsState::default(),
&super::super::Player::default(),
&rig,
)
.follow_command
};
let classify = |command| {
FollowInput {
cfg: FollowConfig::default(),
face_yaw: 0.0,
command,
}
.state(false)
};
// The report: the press landed on a UI row.
assert_eq!(word(false), 0, "a captured press sets no mouse bit");
assert_eq!(classify(word(false)), FollowState::Idle);
assert_eq!(
FollowStyle::Smart.row(FollowState::Idle),
(0.0, 0.0),
"and Idle is the row that arms nothing — the swing has no source"
);
// The control that must not change: the same press in the world still turns.
assert_eq!(word(true), follow_cmd::RIGHT_MOUSE);
assert_eq!(classify(word(true)), FollowState::Turn);
assert_eq!(FollowStyle::Smart.row(FollowState::Turn), (0.0, 1.0));
}
/// The latch's own law: a button is claimed at the DOWN edge and held to its own release.
///
/// Level-testing "is the cursor over UI *right now*" instead would drop the bit mid-drag the
/// moment a frame appeared under the (stationary, locked) cursor — a phantom edge on the
/// command word, which is exactly what arms a return. And the second button of a chord has to
/// join: the reference's both-button run is both bindings held, and the second press lands on
/// a cursor the first one already hid.
#[test]
fn the_world_holds_a_button_from_its_press_to_its_release() {
let mut rig = CameraControl::default();
let mut buttons = ButtonInput::<MouseButton>::default();
// Pressed over a UI row: never claimed, and no amount of later frames claims it.
buttons.press(MouseButton::Right);
rig.world_mouse.update(&buttons, false);
assert!(!rig.world_mouse.held(LookButton::Right));
buttons.clear();
rig.world_mouse.update(&buttons, true);
assert!(
!rig.world_mouse.held(LookButton::Right),
"a press the UI ate is never handed back mid-hold"
);
buttons.release(MouseButton::Right);
buttons.clear();
// Pressed in the world: claimed, and it survives the UI arriving under the locked cursor.
buttons.press(MouseButton::Right);
rig.world_mouse.update(&buttons, true);
assert!(rig.world_mouse.down(LookButton::Right));
buttons.clear();
rig.world_mouse.update(&buttons, false);
assert!(rig.world_mouse.held(LookButton::Right));
assert!(
!rig.world_mouse.down(LookButton::Right),
"the edge is one frame"
);
// The chord's second button joins the gesture the world already holds…
buttons.press(MouseButton::Left);
rig.world_mouse.update(&buttons, true);
assert!(
rig.world_mouse.both(),
"both primaries = the both-button run"
);
// …and the release is what ends it — including the synthetic one a cover produces by
// emptying the button planes, which is a release of both without a `just_released`.
buttons = ButtonInput::<MouseButton>::default();
rig.world_mouse.update(&buttons, false);
assert!(!rig.world_mouse.both());
assert!(!rig.world_mouse.held(LookButton::Right));
assert!(!rig.world_mouse.held(LookButton::Left));
}
/// The state classifier's three vanilla input rules (`0x510960`) —
/// the ones a client that keys off character *velocity* cannot reproduce, because they are
/// read off the camera's own command word: right-mouse alone is a `Turn`, a turn key held
/// **under** right-mouse is a `Strafe`, and both mouse buttons together are a `Move`. Plus the
/// priority order, which is what decides the row when several are true at once.
#[test]
fn the_follow_state_reads_the_camera_input_word_not_the_character() {
let state = |command: u32, stopping: bool| {
FollowInput {
cfg: FollowConfig::default(),
face_yaw: 0.0,
command,
}
.state(stopping)
};
assert_eq!(state(0, false), FollowState::Idle);
assert_eq!(state(0, true), FollowState::Stop);
assert_eq!(state(follow_cmd::RIGHT_MOUSE, false), FollowState::Turn);
assert_eq!(
state(follow_cmd::RIGHT_MOUSE | follow_cmd::TURN_LEFT, false),
FollowState::Turn,
"Turn outranks the Strafe its own condition also satisfies"
);
assert_eq!(state(follow_cmd::STRAFE_LEFT, false), FollowState::Strafe);
assert_eq!(
state(follow_cmd::RIGHT_MOUSE | follow_cmd::LEFT_MOUSE, false),
FollowState::Turn,
"both buttons satisfy Move, but Turn outranks it"
);
assert_eq!(state(follow_cmd::LEFT_MOUSE, false), FollowState::Idle);
assert_eq!(state(follow_cmd::AUTORUN, false), FollowState::Move);
assert_eq!(state(follow_cmd::TRACK, false), FollowState::Track);
assert_eq!(
state(follow_cmd::TRACK | follow_cmd::FORWARD, false),
FollowState::Move,
"Move outranks Track"
);
assert_eq!(
state(follow_cmd::FEAR | follow_cmd::FORWARD, false),
FollowState::Fear,
"Fear outranks everything"
);
// …and the tracking style is the selector the moment Track or Fear is merely PRESENT,
// even when a higher-priority state supplies the row.
let mixed = FollowInput {
cfg: FollowConfig {
style: FollowStyle::Never,
tracking_style: FollowStyle::Always,
yaw_speed: FOLLOW_SPEED_DEFAULT,
},
face_yaw: 0.0,
command: follow_cmd::TRACK | follow_cmd::FORWARD,
};
assert_eq!(mixed.style(), FollowStyle::Always);
assert_eq!(mixed.state(false), FollowState::Move);
}
/// The enum is the ENGINE's (0/1/2), not the 1/2/3 the reference's own dropdown writes — and
/// that stray `3` still has to mean Never, because that is what whoever wrote it meant.
#[test]
fn the_follow_style_enum_is_the_engines_and_tolerates_the_dropdowns_stray() {
assert_eq!(FollowStyle::from_cvar(0.0), FollowStyle::Never);
assert_eq!(FollowStyle::from_cvar(1.0), FollowStyle::Smart);
assert_eq!(FollowStyle::from_cvar(2.0), FollowStyle::Always);
assert_eq!(FollowStyle::from_cvar(3.0), FollowStyle::Never);
// Off the ladder entirely: the registrar default, not a dead camera.
assert_eq!(FollowStyle::from_cvar(-1.0), FollowStyle::Smart);
assert_eq!(FollowStyle::from_cvar(9.0), FollowStyle::Smart);
for style in [FollowStyle::Never, FollowStyle::Smart, FollowStyle::Always] {
assert_eq!(
FollowStyle::from_cvar(style.cvar().parse::<f32>().unwrap()),
style,
"the string round-trips"
);
}
assert_eq!(FollowStyle::default(), FollowStyle::Smart);
}
/// The second arm: between 200 and 800 world the travel gate applies, per axis and independently
/// (`0x514ae0`'s `both accums < 8.0` arm). The thresholds are the reference's 2.25° of yaw and
/// 2.0° of pitch — see [`CLICK_HOLD_CEILING`] for why we hold the angle, not the raw literal.
#[test]
fn between_the_windows_a_steady_hand_still_clicks_but_a_drag_does_not() {
assert!(press(2.0, 1.5).is_click(0.5), "inside both travel gates");
assert!(
!press(2.3, 1.5).is_click(0.5),
"yaw alone spends the budget"
);
assert!(!press(2.0, 2.1).is_click(0.5), "pitch alone spends it too");
// Exactly at a threshold is a drag: the reference's compare is `< 8.0`, not `<=`.
assert!(!press(2.25, 0.0).is_click(0.5), "the yaw gate is exclusive");
assert!(
!press(0.0, 2.0).is_click(0.5),
"the pitch gate is exclusive"
);
}
/// The 800 world ceiling (`0x514aeb lea eax,[edx-0x320]`) is absolute — a long hold is never a
/// click, however still the hand was. This is the arm that keeps a deliberate camera orbit from
/// re-targeting whatever it started on, and it is the reason the fix could not simply be
/// "always select on release".
#[test]
fn a_long_hold_is_never_a_click_however_still() {
let motionless = press(0.0, 0.0);
assert!(motionless.is_click(0.799), "just inside the ceiling");
assert!(!motionless.is_click(0.8), "the ceiling is exclusive");
assert!(
!motionless.is_click(5.0),
"a long motionless hold is a drag"
);
}
/// The self-avatar's zoom-to-first-person fade reaches its BILLBOARD cards — the night-elf eye
/// glow (ledger B71: two additive quads left burning in mid-air after the body was hidden).
/// A card is a world ROOT following an anchor inside the model (decision 0153), so the fade's
/// descendant walk can only claim it through that anchor — and must claim ONLY its own: every
/// brazier and lamppost in the zone is a card too, and dimming those with the player's zoom
/// would be a far worse bug than the one being fixed.
#[test]
fn self_fade_reaches_the_avatars_billboard_cards_and_no_others() {
let info = BillboardInfo {
bone: 0,
pivot: Vec3::new(0.0, 2.14, 0.0), // the eye-glow bone, head height
kind: BillboardKind::Spherical,
scale_anim: None,
seq_translations: vec![],
};
let mut app = App::new();
app.init_resource::<CameraControl>();
app.init_resource::<benilla_world::interior::InteriorReauthor>();
// The water-plane twin map the feather composes with (empty — no water in a fixture).
app.init_resource::<benilla_world::model_render::FarSideTwins>();
app.add_systems(Update, apply_self_model_fade);
// The avatar: root -> joint (the eye-glow bone). Its card follows the joint.
let avatar = app.world_mut().spawn(Embodied).id();
let joint = app.world_mut().spawn(Transform::default()).id();
app.world_mut().entity_mut(avatar).add_child(joint);
let eye_glow = app
.world_mut()
.spawn((
BillboardCard::following_joint(&info, joint),
MeshTag(alpha_bits(1.0)),
))
.id();
// A brazier across the square: same mechanism, another model entirely.
let brazier_anchor = app.world_mut().spawn(Transform::default()).id();
let brazier = app
.world_mut()
.spawn((
BillboardCard::following(&info, brazier_anchor),
MeshTag(alpha_bits(1.0)),
))
.id();
let tag_of = |app: &App, e: Entity| app.world().entity(e).get::<MeshTag>().unwrap().0;
// Mid-feather: the glow rides the body's alpha down.
app.world_mut()
.resource_mut::<CameraControl>()
.self_fade_alpha = 0.5;
app.update();
assert_eq!(
tag_of(&app, eye_glow),
alpha_bits(0.5),
"the avatar's card feathers with the body"
);
assert_eq!(
tag_of(&app, brazier),
alpha_bits(1.0),
"another model's card is untouched by the player's zoom"
);
// First person: the additive compose (`out_rgb *= faded_alpha`) takes it to black.
app.world_mut()
.resource_mut::<CameraControl>()
.self_fade_alpha = 0.0;
app.update();
assert_eq!(tag_of(&app, eye_glow), alpha_bits(0.0));
// Back out to third person — the release frame hands the authored value back.
app.world_mut()
.resource_mut::<CameraControl>()
.self_fade_alpha = 1.0;
app.update();
assert_eq!(
tag_of(&app, eye_glow),
alpha_bits(1.0),
"the release edge restores the card, like the body parts"
);
}
}