From dabb331bc6333c79d862aeb293fd87059f32be47 Mon Sep 17 00:00:00 2001 From: Sam Sung <51686767+samwhosung@users.noreply.github.com> Date: Sun, 1 Nov 2026 18:24:33 +0700 Subject: [PATCH] Camera verbs blend and hold; a /reload builds a new camera (#2962) `SetView` snapped pitch and yaw, `FlipCameraYaw` was undone by the auto-follow, and a spyglass zoom left the camera at distance 0. `SetView` blends all three by `cameraViewBlendStyle` (`0x512eb4`), and a new camera counts as moved (`0x50c400`). The flip sits outside the follow's yaw offset. The spyglass's start and end select their views, a held scope refuses, and arrival ends it (`0x5deb44`). Freelook's entry and leave stop a view's yaw and bias (`0x50f97e`, `0x50f984`). A `/reload` builds a new camera (`0x481b4e`, `0x50c0e0`): offset, flip, moves and shakes start over, and the views reread their settings (`0x50c3db`). Each has a test. Fixes #1697 Fixes #1699 Fixes #1706 Fixes #2948 Co-Authored-By: Claude Opus 5.5 --- crates/benilla-app/src/camera_shake.rs | 15 + crates/benilla-app/src/cvars/callbacks.rs | 6 + crates/benilla-app/src/cvars/inert.rs | 4 - crates/benilla-app/src/cvars/mod.rs | 9 +- crates/benilla-app/src/cvars/table.rs | 14 +- crates/benilla-app/src/player.rs | 5 +- crates/benilla-app/src/player/camera.rs | 245 ++++- .../src/player/camera/mouselook_tests.rs | 60 ++ crates/benilla-app/src/player/camera_axes.rs | 29 +- .../benilla-app/src/player/camera_channel.rs | 19 + .../benilla-app/src/player/camera_dynamics.rs | 59 +- crates/benilla-app/src/player/camera_saved.rs | 110 ++- crates/benilla-app/src/player/camera_view.rs | 911 ++++++++++++++++-- crates/benilla-app/src/player/camera_zoom.rs | 9 +- crates/benilla-app/src/player/controller.rs | 20 +- .../src/player/controller/tests.rs | 39 + crates/benilla-app/src/player/scoped_view.rs | 142 ++- crates/benilla-app/src/ui_script/lifecycle.rs | 4 + .../src/ui_script/world_entry_tests.rs | 54 ++ docs/DEVIATIONS.md | 14 +- 20 files changed, 1652 insertions(+), 116 deletions(-) diff --git a/crates/benilla-app/src/camera_shake.rs b/crates/benilla-app/src/camera_shake.rs index b6daf0bf5..6bd50ef77 100644 --- a/crates/benilla-app/src/camera_shake.rs +++ b/crates/benilla-app/src/camera_shake.rs @@ -65,6 +65,12 @@ pub(crate) struct CameraShakes { } impl CameraShakes { + /// A new camera's list is empty (`0x50c37a`), the old camera's freed with it + /// (`0x50c495`-`0x50c4aa`). + pub(crate) fn clear(&mut self) { + self.live.clear(); + } + /// Enqueue a shake at a world point. fn add(&mut self, row: CameraShake, pos: Vec3, now: f32) { self.live.push(LiveShake { @@ -395,6 +401,15 @@ mod tests { s } + /// A new camera's list is empty, so a shake in flight ends with the old camera. + #[test] + fn a_new_camera_drops_the_live_shakes() { + let mut world = World::new(); + world.insert_resource(at(FOOTSTEP_1, Vec3::ZERO)); + crate::player::camera_at_interface_load(&mut world); + assert!(world.resource::().live.is_empty()); + } + /// The signed vertical offset a shake produces, with the eye at `d` yards from it. fn vertical(row: CameraShake, d: f32, t: f32) -> f32 { at(row, Vec3::ZERO).evaluate(Vec3::X * d, 0.0, t, false).y diff --git a/crates/benilla-app/src/cvars/callbacks.rs b/crates/benilla-app/src/cvars/callbacks.rs index d213cd0ff..9b3f67a60 100644 --- a/crates/benilla-app/src/cvars/callbacks.rs +++ b/crates/benilla-app/src/cvars/callbacks.rs @@ -156,6 +156,8 @@ pub(crate) fn on_cvar_changed( pub(crate) struct CvarSteps<'w> { cvars: Option>, callbacks: CvarCallbacks<'w>, + /// The camera's views, whose `cameraView` write a scope's start or end leaves to the sync. + views: Option>, } impl CvarSteps<'_> { @@ -183,6 +185,10 @@ impl CvarSteps<'_> { let Some(cvars) = self.cvars.as_deref_mut() else { return; }; + if let Some(views) = self.views.as_mut().filter(|v| v.owes_active()) { + let view = views.take_owed_active(); + cvars.set(crate::player::camera_view::CVAR_ACTIVE_VIEW, &view); + } let script = match script { Some(script) => { if script.has_cvar_traffic() || script.cvar_lua_created() != cvars.lua_created { diff --git a/crates/benilla-app/src/cvars/inert.rs b/crates/benilla-app/src/cvars/inert.rs index b82543113..37a85ffca 100644 --- a/crates/benilla-app/src/cvars/inert.rs +++ b/crates/benilla-app/src/cvars/inert.rs @@ -20,7 +20,6 @@ use super::table::{same, Registered}; pub(super) const INERT: &[Registered] = &[ // ── The camera block ───────────────────────────────────────────────────────────────────── - same("cameraDistanceSmoothSpeed", "8.33").checked(benilla_ui::script::check_camera_decay), // 0x50b8a2 same("cameraDive", "1"), // 0x50be03 same("cameraFoVSmoothSpeed", "0.5").checked(benilla_ui::script::check_camera_rate), // 0x50b87f same("cameraHeightIgnoreStandState", "0"), // 0x50bd83 @@ -70,8 +69,6 @@ pub(super) const INERT: &[Registered] = &[ same("cameraSmoothSmartTrackFactor", "10.0"), // 0x50bb5e same("cameraSmoothSmartTurnDelay", "0.0"), // 0x50bb5e same("cameraSmoothSmartTurnFactor", "1.0"), // 0x50bb5e - same("cameraSmoothTimeMax", "2.0").checked(benilla_ui::script::check_camera_time), // 0x50bfa4 - same("cameraSmoothTimeMin", "0.1").checked(benilla_ui::script::check_camera_time), // 0x50bf81 same("cameraSmoothViewDataAlwaysDistanceDelay", "0.0"), // 0x50bbfe same("cameraSmoothViewDataAlwaysDistanceFactor", "0.0"), // 0x50bbfe same("cameraSmoothViewDataAlwaysPitchDelay", "0.0"), // 0x50bbfe @@ -185,7 +182,6 @@ pub(super) const INERT: &[Registered] = &[ same("cameraTerrainTiltSmartTurnAbsorb", "1.0"), // 0x50bc9e same("cameraTerrainTiltSmartTurnDelay", "0.0"), // 0x50bc9e same("cameraTerrainTiltSmartTurnFactor", "1.0"), // 0x50bc9e - same("cameraViewBlendStyle", "1"), // 0x50b972 same("cameraYawSmoothMax", "0.0").checked(benilla_ui::script::check_camera_yaw), // 0x50bf5e same("cameraYawSmoothMin", "0.0").checked(benilla_ui::script::check_camera_yaw), // 0x50bf3b same("camerasmooth", "1"), // 0x50ba39 diff --git a/crates/benilla-app/src/cvars/mod.rs b/crates/benilla-app/src/cvars/mod.rs index 0a6042b7c..d25e11d82 100644 --- a/crates/benilla-app/src/cvars/mod.rs +++ b/crates/benilla-app/src/cvars/mod.rs @@ -1513,6 +1513,11 @@ mod tests { follow.tracking_style.cvar().parse::().unwrap() ); assert_eq!(d["cameraYawSmoothSpeed"], follow.yaw_speed); + assert_eq!(d["cameraPitchSmoothSpeed"], follow.pitch_speed); + assert_eq!(d["cameraDistanceSmoothSpeed"], follow.distance_speed); + assert_eq!(d["cameraSmoothTimeMin"], follow.time_min); + assert_eq!(d["cameraSmoothTimeMax"], follow.time_max); + assert_eq!(d["cameraViewBlendStyle"], follow.view_blend_style as f32); assert_eq!(FollowStyle::default(), FollowStyle::Smart); assert_eq!(d["autoLootDefault"] != 0.0, LootConfig::default().auto_loot); assert_eq!( @@ -3627,10 +3632,6 @@ mod tests { "TextStatusBar.lua reads it on each CVAR_UPDATE", ), ("gxApi", "pfUI's system tooltip names the backend"), - ( - "cameraPitchSmoothSpeed", - "UIOptionsFrame.lua's Save writes it at a quarter of the Auto Follow Speed slider", - ), ( "useUiScale", "UIOptionsFrame.lua and OptionsFrame.lua branch on it to gate the uiScale slider", diff --git a/crates/benilla-app/src/cvars/table.rs b/crates/benilla-app/src/cvars/table.rs index 152f5d980..5eca2bc94 100644 --- a/crates/benilla-app/src/cvars/table.rs +++ b/crates/benilla-app/src/cvars/table.rs @@ -515,10 +515,18 @@ pub(crate) const REGISTERED: &[Registered] = &[ // `cameraCustomViewSmoothing` (`0x50bad0`, read at `0x50fc33`): with it set, a customised view // still takes the auto-return. same("cameraCustomViewSmoothing", "0"), - // `UIOptionsFrame.lua:353` names `cameraPitchSmoothSpeed`, registered "45.0" (`0x50b90b`) with - // the same validator, and `SetCVar` raises on a name no CVar has (`0x488c53`). `FollowRig` has - // one rate, so nothing reads it. + // A timed view's pitch rate (`0x512935`), which `UIOptionsFrame.lua:353` also writes, + // registered "45.0" (`0x50b90b`) with the same validator. same("cameraPitchSmoothSpeed", "45.0").checked(benilla_ui::script::check_camera_rate), + // A timed view's distance rate (`0x5121be`), registered "8.33" (`0x50b8a2`), `0x50c040`. + same("cameraDistanceSmoothSpeed", "8.33").checked(benilla_ui::script::check_camera_decay), + // The style a view verb passes `0x512e90` (`0x50b5db`), registered "1" (`0x50b972`): 1 timed, + // 2 instant, and any other seats nothing. + same("cameraViewBlendStyle", "1"), + // One arm's duration bound (`0x510f4d`, `0x513255`), registered "0.1" and "2.0" (`0x50bf81`, + // `0x50bfa4`), `0x50bfe0`, which orders nothing between the two. + same("cameraSmoothTimeMin", "0.1").checked(benilla_ui::script::check_camera_time), + same("cameraSmoothTimeMax", "2.0").checked(benilla_ui::script::check_camera_time), // `cameraPivot` `[0xbe10a4]` "1" (`0x50bda3`), smart pivot: gate `0x510690`, routing // `0x50fee0`, release `0x5107f0`; ours is `player::camera_dynamics::SmartPivot`. same("cameraPivot", "1"), diff --git a/crates/benilla-app/src/player.rs b/crates/benilla-app/src/player.rs index b9c09f711..7ede07351 100644 --- a/crates/benilla-app/src/player.rs +++ b/crates/benilla-app/src/player.rs @@ -92,6 +92,7 @@ pub(crate) struct ControlRestore; // The last writer of a self body part's render alpha: `entities::apply_unit_mat_alpha` orders // itself before it. pub(crate) use camera::apply_self_model_fade; +pub(crate) use camera_saved::{camera_at_interface_load, rebuild_camera}; use controller::control; pub(crate) use controller::LookSample; // The private camera and `state` items are re-imported for the modules beside this one, which @@ -385,8 +386,8 @@ impl Plugin for PlayerPlugin { Update, ( view_subject::publish_view_subject, - // The spyglass zoom (aura 76), before `control`, which reads it to hold the - // rig in first person. + // The spyglass zoom (aura 76), before `control`, which takes its start and end + // onto the camera. scoped_view::apply_scoped_view, ) .in_set(WorldStage::Input) diff --git a/crates/benilla-app/src/player/camera.rs b/crates/benilla-app/src/player/camera.rs index 9118e66c4..9d0f01db6 100644 --- a/crates/benilla-app/src/player/camera.rs +++ b/crates/benilla-app/src/player/camera.rs @@ -111,6 +111,17 @@ pub(crate) fn on_cvar( // follow reader takes the stored value as is (`0x512d75`). "camerayawsmoothspeed" => follow.yaw_speed = v, "cameracustomviewsmoothing" => follow.custom_view_smoothing = ev.flag(), + // A timed view's rates, past their validators (`0x50c000`, `0x50c040`), read at `0x512935` + // and `0x5121be`. + "camerapitchsmoothspeed" => follow.pitch_speed = v, + "cameradistancesmoothspeed" => follow.distance_speed = v, + // One arm's duration bound, read at `0x510f4d` and `0x513255`. + "camerasmoothtimemin" => follow.time_min = v, + "camerasmoothtimemax" => follow.time_max = v, + // The record's integer, as `0x50b5db` passes it. + "cameraviewblendstyle" => { + follow.view_blend_style = benilla_ui::script::s_str_to_int(&ev.new); + } _ => {} } } @@ -173,12 +184,30 @@ impl LookConfig { /// `cameraYawSmoothSpeed`, default 180°/s (`[0xbe1070]`, read at `0x512d75`): a duration divisor, /// as a return lasts `|Δyaw| / rate × factor`. The AUTO_FOLLOW_SPEED slider, greyed under Never. pub(crate) const FOLLOW_SPEED_DEFAULT: f32 = 180.0; -/// `cameraSmoothTimeMin`/`Max`: 0.1 s and 2.0 s (`[0xbe105c]`/`[0xbe1038]`, clamped at `0x510f4d`). +/// `cameraSmoothTimeMin`/`Max`'s defaults: 0.1 s and 2.0 s (`[0xbe105c]`/`[0xbe1038]`). const FOLLOW_TIME_MIN: f32 = 0.1; const FOLLOW_TIME_MAX: f32 = 2.0; +/// `cameraDistanceSmoothSpeed`'s default, yd/s (`[0xbe106c]`). +const VIEW_DISTANCE_SPEED: f32 = 8.33; +/// `cameraPitchSmoothSpeed`'s default, °/s (`[0xbe0ce4]`). +const VIEW_PITCH_SPEED: f32 = 45.0; +/// `cameraViewBlendStyle`'s default (`[0xbe10b0]`): a timed view. +const VIEW_BLEND_STYLE: i32 = 1; /// The reference's 0.001 already-there and same-arm epsilon (`[0x801360]`, `0x512ce4`/`0x512d41`). const FOLLOW_EPS: f32 = 1.0e-3; +/// One arm's duration under `cameraSmoothTimeMin` and `cameraSmoothTimeMax`, two ordered compares +/// (`0x510f62`-`0x510f85`, `0x513265`-`0x513286`): under the min takes it, then over the max takes +/// that, so an inverted pair, which the validators pass, lands on the max. +pub(super) fn bound_smooth_time(duration: f32, min: f32, max: f32) -> f32 { + let duration = if duration < min { min } else { duration }; + if duration > max { + max + } else { + duration + } +} + /// Camera Following Style, 1.12's `cameraSmoothStyle`: whether the camera returns behind the /// character on its own. The engine's enum is 0 Never, 1 Smart, 2 Always (`0x50b6f0` registers /// them in that order and the consumers index by `style × stride`), the values the stock dropdown @@ -286,7 +315,7 @@ pub(super) mod follow_cmd { MOVE_BITS | STRAFE_BITS | TURN_BITS | RIGHT_MOUSE | LEFT_MOUSE | TRACK | FEAR; } -/// The auto-follow's knobs: three 1.12 CVars at the reference's defaults. +/// The auto-follow's and a timed view's knobs: 1.12 CVars at the reference's defaults. #[derive(Resource, Clone, Copy, PartialEq, Debug)] pub(crate) struct FollowConfig { /// `cameraSmoothStyle`. @@ -301,6 +330,16 @@ pub(crate) struct FollowConfig { /// Not a CVar: the live view is customised ([`super::camera_view::CameraViews`]), set per /// frame by the controller. pub(crate) view_customised: bool, + /// `cameraSmoothTimeMin`, seconds ([`bound_smooth_time`]). + pub(crate) time_min: f32, + /// `cameraSmoothTimeMax`, seconds ([`bound_smooth_time`]). + pub(crate) time_max: f32, + /// `cameraViewBlendStyle`, the style a view verb passes `0x512e90` (`0x50b5db`). + pub(crate) view_blend_style: i32, + /// `cameraDistanceSmoothSpeed`, yd/s. + pub(crate) distance_speed: f32, + /// `cameraPitchSmoothSpeed`, °/s. + pub(crate) pitch_speed: f32, } impl Default for FollowConfig { @@ -311,6 +350,11 @@ impl Default for FollowConfig { yaw_speed: FOLLOW_SPEED_DEFAULT, custom_view_smoothing: false, view_customised: false, + time_min: FOLLOW_TIME_MIN, + time_max: FOLLOW_TIME_MAX, + view_blend_style: VIEW_BLEND_STYLE, + distance_speed: VIEW_DISTANCE_SPEED, + pitch_speed: VIEW_PITCH_SPEED, } } } @@ -377,7 +421,7 @@ struct FollowArm { /// The target offset: 0, directly behind, at the defaults, since `cameraYawSmoothMin`/`Max` /// are both 0 and the reference substitutes the crossed bound when the offset is outside them. to: f32, - /// Seconds the move takes, already clamped to `[FOLLOW_TIME_MIN, FOLLOW_TIME_MAX]`. + /// Seconds the move takes, already bounded ([`bound_smooth_time`]). dur: f32, /// Seconds of dead time before it starts (`Track`/`Fear` under Smart: 0.4). delay: f32, @@ -478,7 +522,7 @@ impl FollowRig { return; } let rate = input.cfg.yaw_speed.to_radians().max(FOLLOW_EPS); - let dur = (gap / rate * factor).clamp(FOLLOW_TIME_MIN, FOLLOW_TIME_MAX); + let dur = bound_smooth_time(gap / rate * factor, input.cfg.time_min, input.cfg.time_max); self.arm = Some(FollowArm { from, to, @@ -488,6 +532,54 @@ impl FollowRig { armed_with: (delay, factor), }); } + + /// A timed view's arm of the same yaw channel (`0x5131b8` → `0x512c70`): delay 0, factor 1 and + /// the offset rewrapped to within π of the target (`0x512c97`-`0x512cc0`), `rate` in degrees a + /// second. The duration armed, `None` already there (`0x512d53`). + pub(super) fn arm_view(&mut self, offset: f32, to: f32, rate: f32) -> Option { + let from = to + wrap_pi(offset - to); + let gap = (to - from).abs(); + if gap < FOLLOW_EPS { + return None; + } + let dur = gap / rate.to_radians().max(FOLLOW_EPS); + self.arm = Some(FollowArm { + from, + to, + dur, + delay: 0.0, + elapsed: 0.0, + armed_with: (0.0, 1.0), + }); + Some(dur) + } + + /// A timed view's shared duration (`0x5132a1`). + pub(super) fn set_duration(&mut self, dur: f32) { + if let Some(arm) = self.arm.as_mut() { + arm.dur = dur; + } + } + + /// A view's stop of the channel: the target takes the live offset (`0x512f48`), no flight. + pub(super) fn cancel(&mut self) { + self.arm = None; + } + + /// The offset the channel in flight is going to, `[cam+0x210]`. + pub(super) fn target_offset(&self) -> Option { + self.arm.map(|a| a.to) + } + + /// A keyboard axis's step under the channel in flight, which moves the target alone + /// (`0x511519`-`0x51152f`); `false` with no flight. + pub(super) fn move_target(&mut self, step: f32) -> bool { + let Some(arm) = self.arm.as_mut() else { + return false; + }; + arm.to += step; + true + } } /// Camera pitch clamp, ±89° (`0x8089d8`/`0x8089dc`, 1.5533430576 rad, applied in `0x510120`), the @@ -584,6 +676,18 @@ pub(crate) struct CameraControl { pub(super) toggled: bool, /// The keyboard axes the `MoveView*` pairs drive ([`super::camera_axes`]). pub(super) view_axes: super::camera_axes::ViewAxes, + /// `FlipCameraYaw`'s term `[cam+0x100]`, radians: the view's yaw adds it outside the offset + /// the follow arms (`0x50f7de`), so only another flip moves it. + pub(super) flip_yaw: f32, + /// Mode bit 6, `[cam+0x90] & 0x40`: the mouse or an axis moved the camera since the last view + /// (`0x50fee9`, `0x51131e`, cleared at `0x512f60`), or it is new (`0x50c400`), so a request for + /// the current view stays timed (`0x512eb4`). + pub(super) view_moved: bool, + /// A timed view's distance and pitch channels ([`super::camera_view::ViewBlend`]). + pub(super) view_blend: super::camera_view::ViewBlend, + /// Mode bit 3, `[cam+0x90] & 0x8`, set and cleared by the scope's edges (`0x50d33f`, + /// `0x50d368`): a view request returns before it writes (`0x512ea3`). + pub(super) scoped: bool, /// Logical cursor position captured when look began, to restore on release. pub(super) cursor_stash: Option, /// A look session is running with the OS cursor handed back: the window is unfocused. @@ -619,6 +723,31 @@ pub(crate) struct CameraControl { } impl CameraControl { + /// What a new camera's constructor leaves of the rig (`0x50c0e0`): the mode word zero but for + /// bit 6 (`0x50c156`, `0x50c400`), the flip, the bias and the ground lean 0 + /// (`0x50c1cc`-`0x50c1d8`) and every channel stopped. Its pose is the caller's, the file's in + /// the reference (`0x50c3ea`). Bit 0's session ends with the old camera: the leave shows the + /// cursor (`0x490bb6`) and the frame's teardown drops the relative mouse (`0x481d4d`). + pub(super) fn new_camera(&mut self) { + self.leave_in_place(false); + self.view_axes = Default::default(); + self.flip_yaw = 0.0; + self.view_moved = true; + self.view_blend = Default::default(); + self.scoped = false; + self.follow = Default::default(); + self.pivot = Default::default(); + self.smart_pivot = Default::default(); + self.terrain_tilt = Default::default(); + self.head_bob = Default::default(); + } + + /// Whether the camera is new or has moved since, for a test of what builds one. + #[cfg(test)] + pub(crate) fn is_new_for_test(&self) -> bool { + self.view_moved + } + /// Park the orbit distance at `d`, both the live value and the wheel target, or the glide /// drifts back; for the scripted camera park (`capture::probe_cam`). pub(crate) fn park_distance(&mut self, d: f32) { @@ -1008,24 +1137,36 @@ impl CameraControl { self.enter_in_place(button) } - /// `0x50fe30` inside a call: the mode bit, the session on `button`, the cursor hidden and the - /// device relative; the OS grab is owed to the look session, which takes it once focused. - /// Whether it entered: in freelook already, it does nothing (`0x50fe3b`). + /// Freelook's entry and leave end a view's pitch, yaw and bias flights: each target takes the + /// live value (`0x50fe90`-`0x50fecd`, `0x50f978`-`0x50f984`), which stops its channel at the + /// next step (`0x50f6b7`). + fn stop_view_flights(&mut self) { + self.view_blend.cancel_pitch(); + self.follow.cancel(); + self.smart_pivot.stop(); + } + + /// `0x50fe30` inside a call: the mode bit, the session on `button`, a view's flights stopped, + /// the cursor hidden and the device relative; the OS grab is owed to the look session, which + /// takes it once focused. Whether it entered: in freelook already, it does nothing + /// (`0x50fe3b`). fn enter_in_place(&mut self, button: LookButton) -> bool { if self.look.is_some() { return false; } self.look = Some(button); + self.stop_view_flights(); self.cursor_released = true; true } /// `0x50f940(sticky)` → `0x50fdd0` inside a call: freelook left, a toggle's with it, and the /// sticky bit kept (`0x50f958`, `0x50f95d`); a held OS cursor goes back at the look session. - /// Whether it left: out of freelook, the mode bit's test skips the leave (`0x50fddb`) and the - /// sticky bit is still written. + /// Whether it left: out of freelook, the mode bit's test skips the leave (`0x50fddb`), and the + /// sticky bit is still written and a view's flights still stopped. fn leave_in_place(&mut self, sticky: bool) -> bool { self.follow.sticky = sticky; + self.stop_view_flights(); if self.look.is_none() { return false; } @@ -1300,6 +1441,8 @@ pub(super) fn run_look_session( let mut handed = false; if rig.look.is_some() { let delta = motion; + // A mouse turn sets mode bit 6 (`0x50fee9`). + rig.view_moved |= delta != Vec2::ZERO; let d_yaw = -delta.x * yaw_rate; cam.yaw += d_yaw; // `mouseInvertPitch` flips only the pitch axis. @@ -1341,6 +1484,7 @@ fn engage_look( cursor_opts: &mut CursorOptions, ) { rig.look = Some(button); + rig.stop_view_flights(); if focused { take_cursor(rig, window, cursor_opts); } else { @@ -1352,6 +1496,7 @@ fn engage_look( /// it was already free, when it is wherever the player left it. fn disengage_look(rig: &mut CameraControl, window: &mut Window, cursor_opts: &mut CursorOptions) { rig.look = None; + rig.stop_view_flights(); let stash = rig.cursor_stash.take(); if !std::mem::take(&mut rig.cursor_released) { release_cursor(cursor_opts); @@ -1572,18 +1717,26 @@ pub(super) fn seat_camera( // The ground tilt is part of the arm's pitch, unlike the bias: `0x50f710` clamps // `[cam+0xf4] + [cam+0x108]` to ±89° before the basis is built. 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 view's yaw is the offset and facing in `cam.yaw` plus the flip (`0x50f7de`-`0x50f7f2`). + let view_yaw = cam.yaw + rig.flip_yaw; + let arm_rotation = Quat::from_euler(EulerRot::YXZ, view_yaw, arm_pitch, 0.0); // Not re-clamped: the bias is composed after that clamp (`0x50ee58`), bounded only by its own // one-sided accumulate (`SmartPivot::route_pitch`) and ±89° at the body hand-off (`0x5103e0`). let rotation = if bias == 0.0 { arm_rotation } else { - Quat::from_euler(EulerRot::YXZ, cam.yaw, arm_pitch + bias, 0.0) + Quat::from_euler(EulerRot::YXZ, view_yaw, arm_pitch + bias, 0.0) }; // `Transform::forward()` is exactly `rotation * -Z`; computed here so the write 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; + // Under a scope the eye takes distance 0 whatever the zoom holds (`0x50edb7`-`0x50edc0`). + let orbit = if dynamics.subject.scoped { + 0.0 + } else { + rig.distance + }; + let seat = pivot - cam_fwd * orbit; let boom = seat - head; let boom_len = boom.length().max(1.0e-3); // The camera collides with the WMO camera/LOS faces (DETAIL kept, NOCAMCOLLIDE dropped), @@ -1931,6 +2084,74 @@ mod tests { use benilla_world::billboard::BillboardCard; use benilla_world::mesh_tag::alpha_bits; + /// Two ordered compares (`0x510f62`-`0x510f85`, `0x513265`-`0x513286`): `f32::clamp` would + /// panic on an inverted pair, and a NaN meets neither. + #[test] + fn the_time_bound_is_two_ordered_compares() { + for (duration, min, max, expected) in [ + (1.0, 0.1, 2.0, 1.0), + (0.01, 0.1, 2.0, 0.1), + (5.0, 0.1, 2.0, 2.0), + (3.0, 5.0, 1.0, 1.0), + (0.5, 5.0, 1.0, 1.0), + ] { + assert_eq!(bound_smooth_time(duration, min, max), expected); + } + assert!(bound_smooth_time(f32::NAN, 0.1, 2.0).is_nan()); + } + + /// A return's duration reads the two CVars (`0x510f4d`): 1 rad at 180°/s is about 0.32 s, which + /// an inverted pair puts on its max. + #[test] + fn a_return_reads_the_time_cvars() { + let cfg = FollowConfig { + style: FollowStyle::Always, + tracking_style: FollowStyle::Always, + time_min: 5.0, + time_max: 1.0, + ..FollowConfig::default() + }; + let input = |command| FollowInput { + cfg, + face_yaw: 0.0, + command, + }; + let mut rig = FollowRig::default(); + rig.advance(&input(0), 1.0, 0.0, false); + rig.advance(&input(follow_cmd::FORWARD), 1.0, 0.0, false); + let (_, _, dur) = rig.probe().expect("armed"); + assert_eq!(dur, 1.0); + } + + /// A timed view runs the yaw channel the return does: by the short way across ±π + /// (`0x512c97`-`0x512cc0`), for the duration it is given, with the facing re-added each frame + /// (`0x50f7f2`), landing on the target. Already there arms nothing (`0x512d53`). + #[test] + fn a_view_arms_the_yaw_channel_by_the_short_way() { + let input = FollowInput { + cfg: FollowConfig::default(), + face_yaw: 0.5, + command: 0, + }; + let mut rig = FollowRig::default(); + assert_eq!(rig.advance(&input, 0.0, 0.0, false), None, "nothing armed"); + let gap = std::f32::consts::TAU - 6.0; + let want = gap / std::f32::consts::PI; + let armed = rig.arm_view(3.0, -3.0, 180.0).expect("armed"); + assert!((armed - want).abs() < 1e-4, "{armed}"); + assert_eq!(rig.target_offset(), Some(-3.0)); + rig.set_duration(1.0); + let halfway = rig.advance(&input, 0.0, 0.5, false).expect("running"); + assert!( + wrap_pi(halfway - (0.5 - 3.0 - gap * 0.5)).abs() < 1e-4, + "{halfway}" + ); + let landed = rig.advance(&input, 0.0, 0.6, false).expect("landing"); + assert!(wrap_pi(landed - (0.5 - 3.0)).abs() < 1e-5, "{landed}"); + assert_eq!(rig.target_offset(), None); + assert_eq!(rig.arm_view(0.25, 0.25, 180.0), None); + } + /// Step a [`PivotGlide`] at 60 Hz for `secs`, holding the target; returns each frame's height. fn glide_run(g: &mut PivotGlide, target: Option, secs: f32) -> Vec { let dt = 1.0 / 60.0; diff --git a/crates/benilla-app/src/player/camera/mouselook_tests.rs b/crates/benilla-app/src/player/camera/mouselook_tests.rs index f5c38cea1..01be354ac 100644 --- a/crates/benilla-app/src/player/camera/mouselook_tests.rs +++ b/crates/benilla-app/src/player/camera/mouselook_tests.rs @@ -255,6 +255,47 @@ fn release(world: &mut World, b: MouseButton) { } } +/// A mouse turn sets mode bit 6 (`0x50fee9`), which a session with no motion leaves clear. +#[test] +fn a_mouse_turn_marks_the_camera_moved() { + let mut w = world(false); + lua(&mut w, "MouselookStart()"); + frame(&mut w, 0.0); + assert!(w.resource::().is_looking()); + assert!(!w.resource::().view_moved); + frame(&mut w, 40.0); + assert!(w.resource::().view_moved); +} + +/// Freelook's entry and its leave stop a view's pitch, yaw and bias (`0x50fe90`-`0x50fecd`, +/// `0x50f978`-`0x50f984`), and a release out of freelook still does (`0x514c48`, `0x50fddb`). +#[test] +fn freelook_stops_a_views_flights() { + let arm = |rig: &mut CameraControl| { + rig.view_blend.arm_pitch_for_test(); + rig.follow.arm_view(0.0, 1.0, 180.0); + rig.smart_pivot.ease_for_test(); + assert!(rig.view_blend.pitch_in_flight(), "the pitch armed"); + assert!(rig.follow.target_offset().is_some(), "the yaw armed"); + assert!(rig.smart_pivot.easing_for_test(), "the bias armed"); + }; + let stopped = |rig: &CameraControl| { + !rig.view_blend.pitch_in_flight() + && rig.follow.target_offset().is_none() + && !rig.smart_pivot.easing_for_test() + }; + let mut rig = CameraControl::default(); + arm(&mut rig); + assert!(rig.enter_in_place(LookButton::Right)); + assert!(stopped(&rig), "the entry"); + arm(&mut rig); + assert!(rig.leave_in_place(false)); + assert!(stopped(&rig), "the leave"); + arm(&mut rig); + assert!(!rig.leave_in_place(false)); + assert!(stopped(&rig), "out of freelook"); +} + /// The right button's session, entered with no button: the mouse turns the camera and the body /// together, the cursor is locked and hidden, and `IsMouselooking` answers 1 (`0x514210`). #[test] @@ -730,6 +771,25 @@ fn mouselook_start_under_a_left_drag_is_the_both_button_run() { assert_eq!(w.resource::().look, Some(LookButton::Right)); } +/// A new camera's mode word holds no freelook bit (`0x50c156`), so a `ToggleMouseMove` session, +/// which no button holds, ends at a `/reload`: the leave shows the cursor (`0x490bb6`) and the +/// frame's teardown drops the relative mouse (`0x481d4d`). +#[test] +fn a_reload_ends_a_toggled_session_and_gives_the_cursor_back() { + let mut w = world(false); + lua(&mut w, "ToggleMouseMove()"); + frame(&mut w, 0.0); + assert!(looking(&w)); + assert_eq!(cursor(&mut w).grab_mode, CursorGrabMode::Locked); + crate::player::rebuild_camera(&mut w); + assert!(!looking(&w)); + assert!(!w.resource::().toggled); + frame(&mut w, 0.0); + assert!(!looking(&w), "no button brings it back"); + assert_eq!(cursor(&mut w).grab_mode, CursorGrabMode::None); + assert!(cursor(&mut w).visible); +} + /// `ToggleMouseMove` with no button enters mouselook (`0x50f910` → `0x50f930`): the cursor locks, /// and `IsMouselooking` still answers nil. With nothing held the command word is empty, and the /// motion handler turns nothing (`0x51440c`); a key held, W here, gives the word a bit, and the diff --git a/crates/benilla-app/src/player/camera_axes.rs b/crates/benilla-app/src/player/camera_axes.rs index 7509e0260..6f2705a71 100644 --- a/crates/benilla-app/src/player/camera_axes.rs +++ b/crates/benilla-app/src/player/camera_axes.rs @@ -125,7 +125,8 @@ impl ViewAxes { /// The axes' tick (`0x5112d0`), from the camera update while any is active. Distance moves /// `cameraDistanceMoveSpeed` yards a second, held to `[0, max]` (`0x511402`, `0x51145e`); the yaw /// `cameraYawMoveSpeed` degrees a second; the pitch a quarter of `cameraPitchMoveSpeed`, held to -/// ±89° (`0x8089d8`). The live value follows the target (`0x511422`, `0x51152f`, `0x5115d6`). +/// ±89° (`0x8089d8`). The live value follows the target unless its channel runs (`0x511422`, +/// `0x51152f`, `0x5115d6`). Any running axis sets mode bit 6 (`0x51131e`). /// Here Right raises the yaw as the reference's `+0x210` rises (`0x5114a7`), both turning left as /// they grow, so the camera swings to the character's right; Up raises the reference's pitch, /// which looks down as it grows, so ours, up-positive, falls. @@ -140,6 +141,7 @@ pub(super) fn tick( if !rig.view_axes.any() { return; } + rig.view_moved = true; let secs = rig.view_axes.advance(dt); let at = |axis: ViewAxis| secs[axis as usize]; let zoom = distance_speed * (at(ViewAxis::Out) - at(ViewAxis::In)); @@ -148,16 +150,20 @@ pub(super) fn tick( let target = (rig.target_distance + zoom) .max(CAM_DIST_MIN) .min(max_distance); - rig.distance = (rig.distance + (target - rig.target_distance)) - .max(CAM_DIST_MIN) - .min(max_distance); + if !rig.view_blend.distance_in_flight() { + rig.distance = (rig.distance + (target - rig.target_distance)) + .max(CAM_DIST_MIN) + .min(max_distance); + } rig.target_distance = target; } let yaw = look.yaw_speed.to_radians() * (at(ViewAxis::Right) - at(ViewAxis::Left)); - cam.yaw += yaw; + if !rig.follow.move_target(yaw) { + cam.yaw += yaw; + } let pitch = (look.pitch_speed * PITCH_RATE_SCALE).to_radians() * (at(ViewAxis::Down) - at(ViewAxis::Up)); - if pitch != 0.0 { + if pitch != 0.0 && !rig.view_blend.move_pitch_target(pitch) { cam.pitch = (cam.pitch + pitch).clamp(-CAM_PITCH_LIMIT, CAM_PITCH_LIMIT); } } @@ -217,6 +223,17 @@ mod tests { ); } + /// Any running axis sets mode bit 6 (`0x51131e`); with none running the tick never comes + /// (`0x50ea48`). + #[test] + fn a_running_axis_marks_the_camera_moved() { + let (mut r, mut c) = (rig(10.0), cam()); + run(&mut r, &mut c, &[], 1, 0.1); + assert!(!r.view_moved); + run(&mut r, &mut c, &[(ViewAxis::Right, true)], 1, 0.1); + assert!(r.view_moved); + } + /// Right raises the yaw and Left lowers it (`0x5114a7`, `0x5114f0`); Up lowers our pitch, the /// reference's rising (`0x51155c`), and Down raises it. #[test] diff --git a/crates/benilla-app/src/player/camera_channel.rs b/crates/benilla-app/src/player/camera_channel.rs index bbc620ee0..2fd689270 100644 --- a/crates/benilla-app/src/player/camera_channel.rs +++ b/crates/benilla-app/src/player/camera_channel.rs @@ -117,6 +117,25 @@ impl SmoothChannel { self.flight.is_some() } + /// The duration in flight, seconds. + pub(super) fn duration(&self) -> Option { + self.flight.map(|f| f.duration) + } + + /// Another duration for the tween in flight, as `SetView` gives every channel it armed one + /// (`0x513289`-`0x5132ab`). + pub(super) fn set_duration(&mut self, duration: f32) { + if let Some(f) = self.flight.as_mut() { + f.duration = duration; + } + } + + /// A new target under the tween in flight, as the axes move a running channel's target + /// (`0x51140c`, `0x5115d6`). + pub(super) fn retarget(&mut self, target: f32) { + self.to = target; + } + /// Sets the channel to `v` with nothing in flight, the reference's hard snap: the water band /// crossing writes the pitch's live `[cam+0xf4]` and target `[cam+0x1e0]` directly while /// `[cam+0x90] & 1` (`0x50ed13`), and a camera's first height arm snaps (`0x5127d4`). diff --git a/crates/benilla-app/src/player/camera_dynamics.rs b/crates/benilla-app/src/player/camera_dynamics.rs index 3f13e0845..6cd911cf4 100644 --- a/crates/benilla-app/src/player/camera_dynamics.rs +++ b/crates/benilla-app/src/player/camera_dynamics.rs @@ -5,7 +5,7 @@ use bevy::prelude::*; -use super::camera_channel::{Arm, SmoothChannel, CHANNEL_EPS}; +use super::camera_channel::{Arm, Armed, SmoothChannel, CHANNEL_EPS}; /// `cameraPivotDXMax` default (`[0xbe0f30]`): the yaw per motion event a vertical drag stays under. pub(crate) const PIVOT_DX_MAX_DEFAULT: f32 = 0.05; @@ -252,6 +252,44 @@ impl SmartPivot { self.bias .arm(&Arm::at(0.0, cfg.target_smooth_speed.to_radians())); } + + /// A view's stop of the channel: the target takes the live value (`0x512f54`), no flight. + pub(super) fn stop(&mut self) { + let live = self.bias(); + self.bias.snap(live); + } + + /// A timed view's arm of the bias home, delay 0 and factor 1 (`0x5131cc` → `0x512a50`), `rate` + /// in degrees a second: the duration armed, `None` already home. + pub(super) fn arm_view(&mut self, rate: f32) -> Option { + self.stop(); + match self.bias.arm(&Arm::at(0.0, rate.to_radians())) { + Armed::Started => self.bias.duration(), + Armed::Already | Armed::AtRest => None, + } + } + + /// A timed view's shared duration (`0x5132ab`). + pub(super) fn set_duration(&mut self, duration: f32) { + self.bias.set_duration(duration); + } + + /// A bias on its way home, for a test of what stops it. + #[cfg(test)] + pub(super) fn ease_for_test(&mut self) { + self.bias.snap(0.5); + self.arm_view(90.0); + } + + #[cfg(test)] + pub(super) fn easing_for_test(&self) -> bool { + self.bias.in_flight() + } + + /// An instant view's bias: 0, live and target (`0x51310b`, `0x513111`). + pub(super) fn home(&mut self) { + self.bias.snap(0.0); + } } /// `cameraTerrainTilt`, follow terrain: the camera pitches toward the slope of the ground ahead. @@ -575,6 +613,25 @@ mod tests { } } + /// A timed view arms the bias home like any channel (`0x5131cc` → `0x512a50`), at rest + /// answering none (`0x512a50` returns 0); an instant one writes 0 (`0x51310b`). + #[test] + fn a_view_takes_the_bias_home() { + let mut pivot = SmartPivot::default(); + assert_eq!(pivot.arm_view(90.0), None, "already home"); + pivot.bias.snap(0.5); + let armed = pivot.arm_view(90.0).expect("armed"); + assert!((armed - 0.5 / 90f32.to_radians()).abs() < 1e-5, "{armed}"); + pivot.set_duration(1.0); + pivot.bias.advance(0.5); + assert!((pivot.bias() - 0.25).abs() < 1e-5, "the shared time rules"); + pivot.bias.advance(0.6); + assert_eq!(pivot.bias(), 0.0); + pivot.bias.snap(0.5); + pivot.home(); + assert_eq!(pivot.bias(), 0.0); + } + /// The gate `0x510690`: each conjunct removed alone from a case that pivots. #[test] fn only_a_clipped_camera_looking_level_or_up_and_not_translating_pivots() { diff --git a/crates/benilla-app/src/player/camera_saved.rs b/crates/benilla-app/src/player/camera_saved.rs index 591ad8cb6..525b8dbda 100644 --- a/crates/benilla-app/src/player/camera_saved.rs +++ b/crates/benilla-app/src/player/camera_saved.rs @@ -9,8 +9,9 @@ //! Two keys, LF, six decimals, a trailing newline (writer `0x50c4d0`, reader `0x50c5a0`), no yaw. //! Ours is `benilla-config/camera/-.txt`, written at logout, disconnect and //! quit; the reference's UI teardown (`0x490bd0`) also writes it on `ReloadUI` and a UI-scale -//! change and reads it straight back, which ours does not. Read once per session, when the roster -//! names the character; the pitch reaches the camera through the login seize. +//! change and reads it straight back, where ours takes the same values in memory +//! ([`rebuild_camera`]). Read once per session, when the roster names the character; the pitch +//! reaches the camera through the login seize. use std::path::PathBuf; @@ -19,6 +20,7 @@ use bevy::prelude::*; use crate::client_state::{ClientState, InWorldGated}; use super::camera::{CameraControl, FlyCam, CAM_PITCH_LIMIT}; +use super::camera_view::CameraViews; use super::camera_zoom::{CAM_DIST_DEFAULT, CAM_DIST_MAX, CAM_DIST_MIN}; use super::Player; use benilla_world::view::WorldCamera; @@ -111,6 +113,7 @@ fn load_camera_pose( rig.distance = CAM_DIST_DEFAULT; rig.target_distance = CAM_DIST_DEFAULT; rig.collision_distance = CAM_DIST_DEFAULT; + rig.new_camera(); player.login_pitch = None; let Some(path) = file.path.clone() else { @@ -137,6 +140,40 @@ fn load_camera_pose( info!("camera pose: restored from {}", path.display()); } +/// What a new camera reads at the interface load that builds it, a login's as a `/reload`'s +/// (`0x481b4e` → `0x50c0e0`): the views, off the registry the dying VM has folded into, and no +/// shake. +pub(crate) fn camera_at_interface_load(world: &mut World) { + world.try_resource_scope(|world, mut views: Mut| { + if let Some(cvars) = world.get_resource::() { + views.reseed(cvars); + } + }); + if let Some(mut shakes) = world.get_resource_mut::() { + shakes.clear(); + } +} + +/// A `/reload`'s new camera: the teardown saves the pose's targets (`0x50c48a`, `0x50c4ff`, +/// `0x50c549`) and destroys the camera, and the load's constructor reads them back as live and +/// target (`0x481b4e` → `0x50c0e0`, `0x50c3ea`), with the yaw offset 0 (`0x50c1a7`). +pub(crate) fn rebuild_camera(world: &mut World) { + let facing = world.get_resource::().map_or(0.0, Player::facing); + if let Some(mut scoped) = world.get_resource_mut::() { + scoped.new_camera(); + } + world.try_resource_scope(|world, mut rig: Mut| { + let mut cams = world.query_filtered::<&mut FlyCam, With>(); + if let Ok(mut cam) = cams.single_mut(world) { + cam.pitch = rig.view_blend.pitch_target(cam.pitch); + cam.yaw = facing; + } + rig.new_camera(); + rig.distance = rig.target_distance; + rig.collision_distance = rig.target_distance; + }); +} + /// Writes the pose to the path the load resolved: by `OnExit(InWorld)` the roster is unwinding. fn save(file: &CameraPoseFile, rig: &CameraControl, pitch: f32) { let Some(path) = &file.path else { @@ -202,6 +239,47 @@ pub(super) fn plugin(app: &mut App) { mod tests { use super::*; + /// A `/reload`'s new camera: the saved targets live, the offset and the flip 0, nothing in + /// flight, and the mode word as a new camera's. + #[test] + fn a_reload_builds_a_new_camera() { + let mut world = World::new(); + world.init_resource::(); + world.resource_mut::().face_yaw = 0.5; + world.init_resource::(); + { + let mut rig = world.resource_mut::(); + rig.distance = 3.0; + rig.target_distance = 8.0; + rig.collision_distance = 2.0; + rig.flip_yaw = 1.0; + rig.scoped = true; + rig.view_blend.arm_pitch_for_test(); + rig.follow.arm_view(0.0, 1.0, 180.0); + } + world.spawn(( + FlyCam { + yaw: 1.5, + pitch: 0.25, + speed: 1.0, + }, + WorldCamera, + )); + rebuild_camera(&mut world); + let rig = world.resource::(); + assert_eq!(rig.distance, 8.0); + assert_eq!(rig.collision_distance, 8.0); + assert_eq!(rig.flip_yaw, 0.0); + assert!(rig.view_moved); + assert!(!rig.scoped); + assert!(!rig.view_blend.pitch_in_flight()); + assert_eq!(rig.follow.target_offset(), None); + let yaw = world.query::<&FlyCam>().single(&world).unwrap().yaw; + let pitch = world.query::<&FlyCam>().single(&world).unwrap().pitch; + assert_eq!(yaw, 0.5); + assert!((pitch - 1.25).abs() < 1e-5, "the pitch's target: {pitch}"); + } + /// The values come from a `camera-settings.txt` the reference wrote. #[test] fn the_rendered_file_matches_the_references_shape() { @@ -287,6 +365,34 @@ mod login_tests { ) } + /// A new camera's constructor ends by setting mode bit 6 (`0x50c400`), so the first request + /// for its own view stays timed (`0x512eb4`). + #[test] + fn a_new_camera_counts_as_moved() { + let _l = ENV_LOCK + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let tmp = std::env::temp_dir().join(format!("benilla-cam-new-{}", std::process::id())); + let _c = EnvGuard::unset("WOW_CAPTURE"); + let _h = EnvGuard::set("BENILLA_HOME", tmp.to_str().unwrap()); + let mut app = App::new(); + app.init_resource::() + .init_resource::() + .init_resource::() + .insert_resource(roster("Tank", 7)); + app.world_mut().spawn(( + FlyCam { + yaw: 0.0, + pitch: 0.0, + speed: 1.0, + }, + WorldCamera, + )); + app.world_mut().run_system_once(load_camera_pose).unwrap(); + assert!(app.world().resource::().view_moved); + std::fs::remove_dir_all(&tmp).ok(); + } + #[test] fn the_saved_pitch_is_the_login_pitch_and_a_fresh_alt_gets_the_defaults() { let _l = ENV_LOCK diff --git a/crates/benilla-app/src/player/camera_view.rs b/crates/benilla-app/src/player/camera_view.rs index f99c6eaec..c23898276 100644 --- a/crates/benilla-app/src/player/camera_view.rs +++ b/crates/benilla-app/src/player/camera_view.rs @@ -7,16 +7,17 @@ //! `0.0` is real first person ([`CAM_DIST_MIN`] is `0.0`). //! //! The views persist as the reference's archived CVars (registered at `0x50b9b0`). [`CameraViews`] -//! is seeded from them once and writes every change back, so a live `SetCVar` does not reach the -//! slots; the reference also loads them only at camera construction (`0x50fb80`). The live view is -//! remembered, not applied at startup: [`super::camera_saved`]'s pose lands over it, as the -//! reference's saved pose lands over `cameraView`'s row at UI load. +//! is seeded from them at each new camera and writes every change back, so a live `SetCVar` reaches +//! the slots only at the next one, as the reference loads them only at camera construction +//! (`0x50fb80`). The live view is remembered, not applied at startup: [`super::camera_saved`]'s +//! pose lands over it, as the reference's saved pose lands over `cameraView`'s row at UI load. //! //! Deviation: `ResetView` writes the CVars back to their defaults, because the CVar store is our //! persistence; the reference (`0x50fae0`) touches no CVar, so its reset reverts at next launch. //! -//! `SetView` glides the distance and snaps pitch and yaw: the reference arms three glide channels -//! (`0x513189`, `0x5131a3`, `0x5131b8`), the rig has only the distance one. +//! A view is seated by `cameraViewBlendStyle` ([`set_view`]): style 1 runs the distance, pitch, yaw +//! and pivot bias channels over one shared time, style 2 writes the pose at once. While a scope is +//! held it refuses every request; only the scope's own start and end select one ([`scope_edge`]). use bevy::prelude::*; @@ -25,7 +26,9 @@ use benilla_world::view::WorldCamera; use crate::creature_anim::wrap_pi; -use super::camera::{CameraControl, FlyCam, CAM_PITCH_LIMIT}; +use super::camera::{bound_smooth_time, CameraControl, FlyCam, FollowConfig, CAM_PITCH_LIMIT}; +use super::camera_channel::{Arm, Armed, SmoothChannel}; +use super::camera_dynamics::CameraOptions; use super::camera_saved::{pitch_from_file, pitch_to_file}; use super::camera_zoom::{CAM_DIST_MAX, CAM_DIST_MIN}; use super::Player; @@ -107,6 +110,9 @@ pub(crate) struct CameraViews { views: [ViewPose; VIEW_COUNT], /// The live view, persisted as [`CVAR_ACTIVE_VIEW`]. current: usize, + /// A scope's start or end moved the live view, and the frame's sync owes it `CVar::Set` + /// (`0x512f17`). + owed_active: bool, } impl Default for CameraViews { @@ -116,6 +122,7 @@ impl Default for CameraViews { current: ACTIVE_VIEW_DEFAULT .parse::() .expect("the registrar default is an index"), + owed_active: false, } } } @@ -166,38 +173,207 @@ impl CameraViews { self.current.checked_sub(1) } } + + /// What a new camera reads of the CVars: the fifteen slots (`0x50c3db` → `0x50fb80`) and the + /// live index (`0x50c17a`), an absent key being the shipped default. An index whose write a + /// scope's edge still owes is newer than its row, and stands. + pub(super) fn reseed(&mut self, cvars: &crate::cvars::Cvars) { + for (view, slot) in self.views.iter_mut().enumerate() { + // Each field falls back on its own: a bad value costs that number, not the view. + let field = |i: usize| { + cvars + .get(VIEW_CVARS[view][i]) + .and_then(|s| s.trim().parse::().ok()) + .filter(|v| v.is_finite()) + }; + let shipped_row = VIEW_DEFAULTS[view]; + let raw = |i: usize| { + field(i).unwrap_or_else(|| { + shipped_row[i] + .parse() + .expect("the reference's default strings are floats") + }) + }; + *slot = ViewPose::from_reference(raw(0), raw(1), raw(2)); + } + if self.owed_active { + return; + } + if let Some(v) = cvars + .get(CVAR_ACTIVE_VIEW) + .and_then(|s| s.trim().parse::().ok()) + .filter(|v| *v < VIEW_COUNT) + { + self.current = v; + } + } + + /// Whether a scope's start or end owes [`CVAR_ACTIVE_VIEW`] its write. + pub(crate) fn owes_active(&self) -> bool { + self.owed_active + } + + /// The owed write's value, and it is no longer owed. + pub(crate) fn take_owed_active(&mut self) -> String { + self.owed_active = false; + self.current.to_string() + } } /// Startup: seed the views and the live index from the CVar registry, which exists from `CvarLoad` /// on while the VM's mirror does not yet; an absent key is the shipped default. fn load_saved_views(cvars: Res, mut views: ResMut) { - for (view, slot) in views.views.iter_mut().enumerate() { - // Each field falls back on its own: a bad value costs that number, not the view. - let field = |i: usize| { - cvars - .get(VIEW_CVARS[view][i]) - .and_then(|s| s.trim().parse::().ok()) - .filter(|v| v.is_finite()) - }; - let shipped_row = VIEW_DEFAULTS[view]; - let raw = |i: usize| { - field(i).unwrap_or_else(|| { - shipped_row[i] - .parse() - .expect("the reference's default strings are floats") - }) - }; - *slot = ViewPose::from_reference(raw(0), raw(1), raw(2)); + views.reseed(&cvars); +} + +/// `0x512e90`'s style for a timed view. +const STYLE_TIMED: i32 = 1; +/// `0x512e90`'s style for an instant view. +const STYLE_INSTANT: i32 = 2; + +/// What [`set_view`] reads of the CVars: the style its caller passes and a timed view's rates and +/// duration bound. +#[derive(Clone, Copy, Debug)] +struct ViewCvars { + style: i32, + /// `cameraDistanceSmoothSpeed`, yd/s (`0x5121be`). + distance_speed: f32, + /// `cameraPitchSmoothSpeed`, °/s (`0x512935`). + pitch_speed: f32, + /// `cameraYawSmoothSpeed`, °/s (`0x512d75`). + yaw_speed: f32, + /// `cameraTargetSmoothSpeed`, °/s (`0x512b55`). + bias_speed: f32, + /// `cameraSmoothTimeMin` and `cameraSmoothTimeMax`, seconds (`0x513255`). + time_min: f32, + time_max: f32, +} + +impl ViewCvars { + /// Off the knobs the rows' callback writes, each at its default in a harness without it. + fn read(follow: Option<&FollowConfig>, options: Option<&CameraOptions>) -> Self { + let follow = follow.copied().unwrap_or_default(); + Self { + style: follow.view_blend_style, + distance_speed: follow.distance_speed, + pitch_speed: follow.pitch_speed, + yaw_speed: follow.yaw_speed, + bias_speed: options.copied().unwrap_or_default().target_smooth_speed, + time_min: follow.time_min, + time_max: follow.time_max, + } } - if let Some(v) = cvars - .get(CVAR_ACTIVE_VIEW) - .and_then(|s| s.trim().parse::().ok()) - .filter(|v| *v < VIEW_COUNT) - { - views.current = v; +} + +/// A timed view's distance and pitch channels (`0x512120`, `0x512830`), in flight from its arm to +/// their landing. The yaw's is the follow's ([`super::camera::FollowRig`]) and the bias's is +/// [`super::camera_dynamics::SmartPivot`]'s. +pub(super) struct ViewBlend { + distance: SmoothChannel, + pitch: SmoothChannel, +} + +impl Default for ViewBlend { + fn default() -> Self { + Self { + distance: SmoothChannel::default(), + pitch: SmoothChannel::angular(), + } } } +impl ViewBlend { + /// One channel's arm from `live`, delay 0 and factor 1: the duration armed, `None` already + /// there (`0x512184`). + fn arm(channel: &mut SmoothChannel, live: f32, target: f32, rate: f32) -> Option { + channel.snap(live); + match channel.arm(&Arm::at(target, rate)) { + Armed::Started => channel.duration(), + Armed::Already | Armed::AtRest => None, + } + } + + /// The shared duration, on whichever of the two is armed (`0x51328d`, `0x513297`). + fn set_duration(&mut self, duration: f32) { + self.distance.set_duration(duration); + self.pitch.set_duration(duration); + } + + /// Both flights ended where they stand (`0x512f2e`-`0x512f99`). + pub(super) fn stop(&mut self) { + let live = self.distance.live(); + self.distance.snap(live); + self.cancel_pitch(); + } + + /// The pitch's flight ended where it stands: freelook's entry and leave (`0x50fe90`, + /// `0x50f978`). + pub(super) fn cancel_pitch(&mut self) { + let live = self.pitch.live(); + self.pitch.snap(live); + } + + pub(super) fn distance_in_flight(&self) -> bool { + self.distance.in_flight() + } + + /// The distance channel's step toward `target`, the rig's (`0x50f2b8`): the live distance, or + /// `None` with no flight. + pub(super) fn step_distance(&mut self, target: f32, dt: f32) -> Option { + if !self.distance.in_flight() { + return None; + } + self.distance.retarget(target); + Some(self.distance.advance(dt)) + } + + /// A keyboard axis's step under the pitch's flight, which moves the target alone (`0x51153a`, + /// `0x5115d6`), held to ±89°; `false` with no flight. + pub(super) fn move_pitch_target(&mut self, step: f32) -> bool { + if !self.pitch.in_flight() { + return false; + } + let (_, target) = self.pitch.probe(); + self.pitch + .retarget((target + step).clamp(-CAM_PITCH_LIMIT, CAM_PITCH_LIMIT)); + true + } + + /// The pitch `SaveView` stores, `[cam+0x1e0]`, given the live pitch: the live one with no + /// flight, else the flight's target moved by what else moved the live one ([`step_pitch`]). + pub(super) fn pitch_target(&self, live: f32) -> f32 { + if !self.pitch.in_flight() { + return live; + } + let (channel, target) = self.pitch.probe(); + (target + live - channel).clamp(-CAM_PITCH_LIMIT, CAM_PITCH_LIMIT) + } + + /// A pitch flight, for a test of what ends it. + #[cfg(test)] + pub(super) fn arm_pitch_for_test(&mut self) { + Self::arm(&mut self.pitch, 0.0, 1.0, 1.0); + } + + #[cfg(test)] + pub(super) fn pitch_in_flight(&self) -> bool { + self.pitch.in_flight() + } +} + +/// The pitch channel's step in the camera's update (`0x50f576`): its motion, added to what the +/// mouse and the ground lean moved the pitch by, which shift the whole tween in the reference +/// (`0x510120`), held to ±89° (`0x50d720`). +pub(super) fn step_pitch(rig: &mut CameraControl, cam: &mut FlyCam, dt: f32) { + let channel = &mut rig.view_blend.pitch; + if !channel.in_flight() { + return; + } + let before = channel.live(); + let after = channel.advance(dt); + cam.pitch = (cam.pitch + after - before).clamp(-CAM_PITCH_LIMIT, CAM_PITCH_LIMIT); +} + /// The camera-view verbs inside their calls ([`benilla_ui::script::ScriptCall::CameraView`]): /// `SetView` (`0x50b5b0` → `0x512e90`) seats the view and writes `cameraView` with `CVar::Set` /// (`0x512f17`), `SaveView` (`0x50b600` → `0x50fa30` → `0x50f990`) writes the three fields @@ -206,6 +382,9 @@ fn load_saved_views(cvars: Res, mut views: ResMut { views: Option>, rig: Option>, + /// A timed view's rates and time bound, and its style ([`ViewCvars`]). + follow: Option>, + options: Option>, /// `cameraDistanceMoveSpeed`, the zoom's rate (`[0xbe0fbc]+0x24`, `0x50fc63`). zoom: Option>, cam: Query<'w, 's, &'static mut FlyCam, With>, @@ -242,6 +421,7 @@ impl ViewSteps<'_, '_> { if self.capture.is_some() { return writes; } + let cvars = ViewCvars::read(self.follow.as_deref(), self.options.as_deref()); let (Some(views), Some(rig), Some(player)) = ( self.views.as_deref_mut(), self.rig.as_deref_mut(), @@ -257,17 +437,18 @@ impl ViewSteps<'_, '_> { let mut write_active = false; match request { CameraViewRequest::Set(n) => { - write_active = apply(views, rig, &mut cam, face_yaw, usize::from(n)); + write_active = set_view(views, rig, &mut cam, face_yaw, usize::from(n), &cvars); } CameraViewRequest::Save(n) => { let view = usize::from(n); views.save( view, ViewPose { - // The target zoom, not the collision-pulled arm (`cam+0x198`). + // The targets (`0x50fa33`-`0x50fa40`): the zoom, not the collision-pulled + // arm, and where a channel in flight is going. distance: rig.target_distance, - pitch: cam.pitch, - yaw: wrap_pi(cam.yaw - face_yaw), + pitch: rig.view_blend.pitch_target(cam.pitch), + yaw: wrap_pi(rig.follow.target_offset().unwrap_or(cam.yaw - face_yaw)), }, ); write_slot = Some(view); @@ -275,22 +456,19 @@ impl ViewSteps<'_, '_> { CameraViewRequest::Reset(n) => { let view = usize::from(n); if views.reset(view) { - write_active = apply(views, rig, &mut cam, face_yaw, view); + write_active = set_view(views, rig, &mut cam, face_yaw, view, &cvars); } write_slot = Some(view); } CameraViewRequest::Next | CameraViewRequest::Prev => { let forward = request == CameraViewRequest::Next; if let Some(view) = views.step(forward) { - write_active = apply(views, rig, &mut cam, face_yaw, view); + write_active = set_view(views, rig, &mut cam, face_yaw, view, &cvars); } } CameraViewRequest::FlipYaw(degrees) => { - // `cam[+0x100] += arg * pi/180` (`0x50b6a0`). The reference keeps the flip in its - // own yaw field, added outside the follow channel (`0x50f7de`), so it survives - // re-centring; the rig has one yaw, so auto-follow reels the flip back in on Smart - // and Always. - cam.yaw = wrap_pi(cam.yaw + degrees.to_radians()); + // `[cam+0x100] += arg * pi/180` (`0x50b6c9`-`0x50b6d8`), wrapped as at `0x50d779`. + rig.flip_yaw = wrap_pi(rig.flip_yaw + degrees.to_radians()); } } if write_active { @@ -313,21 +491,103 @@ impl ViewSteps<'_, '_> { } } -/// Seat one view; the distance glides through `target_distance`. `true` when the live index moved. -fn apply( +/// `0x512e90`: make `view` current and seat it by `cvars.style`. A request for the current view, +/// with nothing having moved the camera since the last, goes at once unless its style is 0 +/// (`0x512eac`-`0x512ebf`). Every channel first stops where it stands, mode bit 6 clearing with +/// them (`0x512f22`-`0x512f99`). `true` when the live index moved, which writes `cameraView`. +fn set_view( views: &mut CameraViews, rig: &mut CameraControl, cam: &mut FlyCam, face_yaw: f32, view: usize, + cvars: &ViewCvars, ) -> bool { + // Mode bit 3 returns ahead of every write below (`0x512ea3`). + if rig.scoped { + return false; + } + let at_once = views.current == view && !rig.view_moved && cvars.style != 0; + let style = if at_once { STYLE_INSTANT } else { cvars.style }; let (pose, moved) = views.set(view); - rig.target_distance = pose.distance; - cam.pitch = pose.pitch.clamp(-CAM_PITCH_LIMIT, CAM_PITCH_LIMIT); - cam.yaw = wrap_pi(face_yaw + pose.yaw); + rig.view_moved = false; + rig.target_distance = rig.distance; + rig.view_blend.stop(); + rig.follow.cancel(); + rig.smart_pivot.stop(); + let pitch = pose.pitch.clamp(-CAM_PITCH_LIMIT, CAM_PITCH_LIMIT); + match style { + STYLE_TIMED => { + // One arm per channel, delay 0 and factor 1 (`0x513189`-`0x5131cc`), then every armed + // one takes the longest duration, bounded by the time CVars (`0x5131d1`-`0x5132ab`). + let distance = ViewBlend::arm( + &mut rig.view_blend.distance, + rig.distance, + pose.distance, + cvars.distance_speed, + ); + if distance.is_some() { + rig.target_distance = pose.distance; + } + let pitch = ViewBlend::arm( + &mut rig.view_blend.pitch, + cam.pitch, + pitch, + cvars.pitch_speed.to_radians(), + ); + let offset = wrap_pi(cam.yaw - face_yaw); + let yaw = rig.follow.arm_view(offset, pose.yaw, cvars.yaw_speed); + let bias = rig.smart_pivot.arm_view(cvars.bias_speed); + let longest = [distance, pitch, yaw, bias] + .into_iter() + .flatten() + .fold(0.0, f32::max); + let shared = bound_smooth_time(longest, cvars.time_min, cvars.time_max); + rig.view_blend.set_duration(shared); + rig.follow.set_duration(shared); + rig.smart_pivot.set_duration(shared); + } + STYLE_INSTANT => { + // The pose at once, live and target (`0x5130d8`-`0x513111`), with the arm on it. + rig.distance = pose.distance; + rig.target_distance = pose.distance; + rig.collision_distance = pose.distance; + cam.pitch = pitch; + cam.yaw = wrap_pi(face_yaw + pose.yaw); + rig.smart_pivot.home(); + } + // Any other style seats nothing (`0x51301e`). + _ => {} + } moved } +/// `0x50d320`, a scope's start or end on the camera: the start selects view 0 at once and then sets +/// mode bit 3 (`0x50d32d`-`0x50d33f`), the end clears the bit and then selects view 1 at once +/// (`0x50d368`-`0x50d378`). The controller that runs this holds no registry, so a moved index's +/// `cameraView` write waits for the frame's sync ([`CameraViews::take_owed_active`]). +pub(super) fn scope_edge( + views: &mut CameraViews, + rig: &mut CameraControl, + cam: &mut FlyCam, + face_yaw: f32, + held: bool, +) { + let instant = ViewCvars { + style: STYLE_INSTANT, + ..ViewCvars::read(None, None) + }; + let moved = if held { + let moved = set_view(views, rig, cam, face_yaw, 0, &instant); + rig.scoped = true; + moved + } else { + rig.scoped = false; + set_view(views, rig, cam, face_yaw, 1, &instant) + }; + views.owed_active |= moved; +} + pub(super) fn plugin(app: &mut App) { app.init_resource::() // After the config load, so the persisted views are in before anything selects one. @@ -336,7 +596,12 @@ pub(super) fn plugin(app: &mut App) { #[cfg(test)] mod tests { + use super::super::camera::{follow_cmd, seat_camera, FollowInput, FollowStyle, LookConfig}; + use super::super::camera_axes::tick; + use super::super::camera_dynamics::{DynamicsInput, SubjectState}; + use super::super::camera_zoom::{glide_zoom, ZoomLimit}; use super::*; + use bevy::ecs::system::RunSystemOnce; /// `SetView` writes `cameraView` inside its call (`0x512f17`) and `SaveView` the view's three /// fields (`0x50fa09`), so the chunk's next line reads both: view 4's distance is the rig's @@ -570,22 +835,550 @@ mod tests { assert_eq!(views.step(false), None); } - /// 180 is the stock binding's argument (`Bindings.xml:796`). + /// The rig and camera seated on the shipped view 1, the starting view. + fn on_view_one() -> (CameraViews, CameraControl, FlyCam) { + let views = CameraViews::default(); + let pose = views.pose(1); + let rig = CameraControl { + distance: pose.distance, + target_distance: pose.distance, + collision_distance: pose.distance, + ..Default::default() + }; + let cam = FlyCam { + yaw: 0.0, + pitch: pose.pitch, + speed: 0.0, + }; + (views, rig, cam) + } + + /// The defaults, with `style`. + fn style(style: i32) -> ViewCvars { + ViewCvars { + style, + ..ViewCvars::read(None, None) + } + } + + /// `secs` of the camera update's distance and pitch channels, at 60 Hz. + fn run_channels(rig: &mut CameraControl, cam: &mut FlyCam, secs: f32) { + const DT: f32 = 1.0 / 60.0; + for _ in 0..(secs / DT).round() as usize { + glide_zoom(DT, rig, CAM_DIST_MAX, 8.33); + step_pitch(rig, cam, DT); + } + } + + /// Style 1 arms each channel and gives them the longest duration (`0x513189`-`0x5132ab`): from + /// view 1 to view 3, 8.33 yd at 8.33 yd/s is 1 s against 20° at 45°/s, so the pitch shares the + /// second, and both pass the midpoint together on the cosine (`0x5b7bb0`). #[test] - fn flipping_the_yaw_twice_returns_the_original() { - let flip = |yaw: f32, degrees: f32| wrap_pi(yaw + degrees.to_radians()); - for start in [0.0_f32, 1.3, -2.9, std::f32::consts::PI] { - let once = flip(start, 180.0); - assert!( - (wrap_pi(once - start).abs() - std::f32::consts::PI).abs() < 1e-5, - "one flip is half a turn" - ); - let twice = flip(once, 180.0); - assert!( - wrap_pi(twice - start).abs() < 1e-5, - "two flips return: {twice} vs {start}" + fn a_timed_view_moves_its_channels_over_one_shared_time() { + let (mut views, mut rig, mut cam) = on_view_one(); + let (from, to) = (views.pose(1), views.pose(3)); + let timed = style(STYLE_TIMED); + assert!(set_view(&mut views, &mut rig, &mut cam, 0.0, 3, &timed)); + assert_eq!(rig.distance, from.distance, "nothing moves at the arm"); + assert_eq!(cam.pitch, from.pitch); + assert_eq!(rig.target_distance, to.distance, "the target does"); + assert_eq!( + rig.view_blend.distance.duration(), + rig.view_blend.pitch.duration(), + "one duration" + ); + run_channels(&mut rig, &mut cam, 0.5); + let midpoint = |a: f32, b: f32| (a + b) * 0.5; + assert!( + (rig.distance - midpoint(from.distance, to.distance)).abs() < 0.02, + "{}", + rig.distance + ); + assert!( + (cam.pitch - midpoint(from.pitch, to.pitch)).abs() < 1e-3, + "{}", + cam.pitch + ); + run_channels(&mut rig, &mut cam, 0.6); + assert_eq!(rig.distance, to.distance, "it lands"); + assert!((cam.pitch - to.pitch).abs() < 1e-4, "{}", cam.pitch); + assert!(!rig.view_blend.distance_in_flight()); + assert!(!rig.view_blend.pitch_in_flight()); + } + + /// The yaw runs on the follow's channel, to the view's saved offset (`0x5131b8`). + #[test] + fn a_timed_view_arms_the_yaw_to_its_saved_offset() { + let (mut views, mut rig, mut cam) = on_view_one(); + let to = views.pose(3); + views.save(3, ViewPose { yaw: 1.0, ..to }); + set_view(&mut views, &mut rig, &mut cam, 0.0, 3, &style(STYLE_TIMED)); + assert_eq!(rig.follow.target_offset(), Some(1.0)); + assert_eq!(cam.yaw, 0.0, "nothing moves at the arm"); + } + + /// The shared time takes the time CVars' bound (`0x513255`-`0x513286`): 5.55 to 50 yd at + /// 8.33 yd/s is 5.3 s. + #[test] + fn the_shared_time_is_bound_by_the_time_cvars() { + let duration = |time_min, time_max| { + let (mut views, mut rig, mut cam) = on_view_one(); + let far = ViewPose { + distance: CAM_DIST_MAX, + ..views.pose(3) + }; + views.save(3, far); + let cvars = ViewCvars { + time_min, + time_max, + ..style(STYLE_TIMED) + }; + set_view(&mut views, &mut rig, &mut cam, 0.0, 3, &cvars); + rig.view_blend.distance.duration() + }; + assert_eq!(duration(0.1, 2.0), Some(2.0)); + assert_eq!(duration(9.0, 30.0), Some(9.0)); + assert_eq!(duration(5.0, 1.0), Some(1.0), "an inverted pair"); + } + + /// Style 2 writes the pose, live and target, at once (`0x5130d8`-`0x513111`). + #[test] + fn style_two_writes_the_pose_at_once() { + let (mut views, mut rig, mut cam) = on_view_one(); + let to = views.pose(3); + let instant = style(STYLE_INSTANT); + assert!(set_view(&mut views, &mut rig, &mut cam, 0.0, 3, &instant)); + assert_eq!( + (rig.distance, rig.target_distance, rig.collision_distance), + (to.distance, to.distance, to.distance) + ); + assert_eq!(cam.pitch, to.pitch); + assert!(!rig.view_blend.distance_in_flight()); + assert!(!rig.view_blend.pitch_in_flight()); + } + + /// A request for the current view goes at once while mode bit 6 is clear + /// (`0x512eac`-`0x512ebf`), and is timed once the mouse or an axis has set it; the request + /// clears the bit (`0x512f60`). The index did not move, so `cameraView` is not written + /// (`0x512eef`). + #[test] + fn the_current_view_goes_at_once_until_the_camera_has_moved() { + let (mut views, mut rig, mut cam) = on_view_one(); + let home = views.pose(1).distance; + let timed = style(STYLE_TIMED); + rig.distance = 9.0; + rig.target_distance = 9.0; + assert!(!set_view(&mut views, &mut rig, &mut cam, 0.0, 1, &timed)); + assert_eq!(rig.distance, home, "at once"); + rig.distance = 9.0; + rig.target_distance = 9.0; + rig.view_moved = true; + assert!(!set_view(&mut views, &mut rig, &mut cam, 0.0, 1, &timed)); + assert_eq!(rig.distance, 9.0, "timed"); + assert!(rig.view_blend.distance_in_flight()); + assert!(!rig.view_moved); + } + + /// Any other style makes the view current and seats nothing, each target on its live value + /// (`0x512f2e`, `0x51301e`), and style 0 stays itself for the current view (`0x512ebd`). + #[test] + fn a_style_of_neither_seats_nothing() { + let (mut views, mut rig, mut cam) = on_view_one(); + let (distance, pitch) = (rig.distance, cam.pitch); + let (zero, other) = (style(0), style(3)); + rig.target_distance = 20.0; + assert!(set_view(&mut views, &mut rig, &mut cam, 0.0, 3, &other)); + assert_eq!(views.current, 3); + assert_eq!((rig.distance, rig.target_distance), (distance, distance)); + assert_eq!(cam.pitch, pitch); + assert!(!set_view(&mut views, &mut rig, &mut cam, 0.0, 3, &zero)); + assert_eq!(rig.distance, distance); + // Style 3 for the current view is not 0, so it goes at once. + assert!(!set_view(&mut views, &mut rig, &mut cam, 0.0, 3, &other)); + assert_eq!(rig.distance, views.pose(3).distance); + } + + /// While a channel runs an axis moves its target alone (`0x51140c`, `0x51152f`, `0x5115d6`), + /// and the channel lands on where it was moved. A start tick runs nothing, so of three ticks + /// two move: 1.666 yd and 4.5°. + #[test] + fn an_axis_under_a_timed_view_moves_the_target_alone() { + use benilla_ui::script::ViewAxis; + let (mut views, mut rig, mut cam) = on_view_one(); + let to = views.pose(3); + views.save(3, ViewPose { yaw: 1.0, ..to }); + set_view(&mut views, &mut rig, &mut cam, 0.0, 3, &style(STYLE_TIMED)); + let live = (rig.distance, cam.pitch, cam.yaw); + rig.view_axes.apply(&[ + (ViewAxis::Out, true), + (ViewAxis::Down, true), + (ViewAxis::Right, true), + ]); + for _ in 0..3 { + tick( + &mut rig, + &mut cam, + 0.1, + LookConfig::default(), + 8.33, + CAM_DIST_MAX, ); } + assert_eq!((rig.distance, cam.pitch, cam.yaw), live); + assert!((rig.target_distance - (to.distance + 1.666)).abs() < 1e-3); + assert!(rig.follow.target_offset().is_some_and(|t| t > 1.0)); + run_channels(&mut rig, &mut cam, 1.1); + assert_eq!(rig.distance, rig.target_distance); + assert!( + (cam.pitch - (to.pitch + 4.5f32.to_radians())).abs() < 1e-3, + "{}", + cam.pitch + ); + } + + /// The mouse's and the ground lean's steps shift the whole tween (`0x510120`): one made under + /// the flight stands when it lands. + #[test] + fn a_pitch_step_under_a_timed_view_stands_when_it_lands() { + let (mut views, mut rig, mut cam) = on_view_one(); + let to = views.pose(3); + set_view(&mut views, &mut rig, &mut cam, 0.0, 3, &style(STYLE_TIMED)); + run_channels(&mut rig, &mut cam, 0.25); + cam.pitch += 0.1; + assert!((rig.view_blend.pitch_target(cam.pitch) - (to.pitch + 0.1)).abs() < 1e-4); + run_channels(&mut rig, &mut cam, 0.9); + assert!((cam.pitch - (to.pitch + 0.1)).abs() < 1e-4, "{}", cam.pitch); + } + + /// `SaveView` stores the targets, not the live values (`0x50fa33`-`0x50fa40`). + #[test] + fn a_view_saved_in_flight_stores_where_it_is_going() { + let mut world = verb_world(); + let pose = ViewPose { + distance: 13.88, + pitch: -0.5, + yaw: 1.0, + }; + world.resource_mut::().save(3, pose); + verb(&mut world, CameraViewRequest::Set(3)); + verb(&mut world, CameraViewRequest::Save(2)); + let saved = world.resource::().pose(2); + assert_eq!(saved.distance, pose.distance); + assert!((saved.pitch - pose.pitch).abs() < 1e-5, "{}", saved.pitch); + assert!((saved.yaw - pose.yaw).abs() < 1e-5, "{}", saved.yaw); + } + + /// The verbs read the style off the row: 2 seats the view inside the call. + #[test] + fn a_view_verb_reads_the_blend_style() { + let mut world = verb_world(); + world.insert_resource(FollowConfig { + view_blend_style: STYLE_INSTANT, + ..FollowConfig::default() + }); + verb(&mut world, CameraViewRequest::Set(3)); + let want = world.resource::().pose(3).distance; + assert_eq!(world.resource::().distance, want); + } + + /// A write to each row reaches the knob a view reads, through the rows' callback. + #[test] + fn the_view_cvars_reach_their_knobs() { + let mut world = World::new(); + world.init_resource::(); + world.init_resource::(); + world.init_resource::(); + world.init_resource::(); + world + .run_system_once(|mut steps: crate::cvars::CvarSteps| { + let cvars = steps.cvars().expect("the registry"); + cvars.set("cameraViewBlendStyle", "2"); + cvars.set("cameraSmoothTimeMin", "0.25"); + cvars.set("cameraSmoothTimeMax", "1.5"); + cvars.set("cameraDistanceSmoothSpeed", "4.0"); + cvars.set("cameraPitchSmoothSpeed", "30.0"); + steps.sync(None); + }) + .expect("the sync runs"); + let knobs = *world.resource::(); + assert_eq!(knobs.view_blend_style, 2); + assert_eq!((knobs.time_min, knobs.time_max), (0.25, 1.5)); + assert_eq!((knobs.distance_speed, knobs.pitch_speed), (4.0, 30.0)); + } + + /// A world with what the view verbs read: the camera, the rig and a body facing 0. + fn verb_world() -> World { + let mut world = World::new(); + world.init_resource::(); + world.init_resource::(); + world.insert_resource(Player::default()); + world.spawn(( + FlyCam { + yaw: 0.0, + pitch: 0.0, + speed: 0.0, + }, + WorldCamera, + )); + world + } + + /// One view verb as the call host applies it, and the CVar writes it made. + fn verb(world: &mut World, request: CameraViewRequest) -> Vec<(&'static str, String)> { + world + .run_system_once(move |mut steps: ViewSteps| steps.apply(request)) + .expect("the verb runs") + } + + /// `FlipCameraYaw` adds its radians to `[cam+0x100]` (`0x50b6c9`-`0x50b6d8`) and to no other + /// word: the offset stands, and a view leaves the term, as `0x512e90` never writes it. 180 is + /// the stock binding's argument (`Bindings.xml:796`). + #[test] + fn a_flip_is_a_term_of_its_own() { + let mut world = verb_world(); + let flip = |world: &World| world.resource::().flip_yaw; + assert!(verb(&mut world, CameraViewRequest::FlipYaw(180.0)).is_empty()); + assert!( + (flip(&world).abs() - std::f32::consts::PI).abs() < 1e-5, + "one flip is half a turn" + ); + let yaw = world.query::<&FlyCam>().single(&world).unwrap().yaw; + assert_eq!(yaw, 0.0, "the offset stands"); + verb(&mut world, CameraViewRequest::FlipYaw(180.0)); + assert!(flip(&world).abs() < 1e-5, "two flips return"); + verb(&mut world, CameraViewRequest::FlipYaw(90.0)); + verb(&mut world, CameraViewRequest::Set(3)); + assert!( + (flip(&world) - std::f32::consts::FRAC_PI_2).abs() < 1e-5, + "a view leaves it" + ); + } + + /// The whole client's world, for [`seat`], with the view verbs' state and no other camera. + fn seat_app() -> App { + let mut app = crate::game_plugins::schedule_tests::headless_client_unrun(); + let world = app.world_mut(); + world.init_resource::(); + world.insert_resource(Player::default()); + world.spawn(( + Transform::default(), + FlyCam { + yaw: 0.0, + pitch: 0.0, + speed: 0.0, + }, + WorldCamera, + )); + app + } + + /// One [`seat_camera`] of the camera on a body at the origin facing 0, in an empty world, with + /// the Always follow style and `word` as the input word: the transform it wrote. + fn seat(world: &mut World, word: u32, dt: f32, scoped: bool) -> Transform { + world + .run_system_once( + move |collide: benilla_world::collision::WorldCollision, + mut rig: ResMut, + mut q: Query<(&mut Transform, &mut FlyCam)>| { + let (mut t, mut cam) = q.single_mut().expect("the camera"); + let follow = FollowInput { + cfg: FollowConfig { + style: FollowStyle::Always, + tracking_style: FollowStyle::Always, + ..FollowConfig::default() + }, + face_yaw: 0.0, + command: word, + }; + let dynamics = DynamicsInput { + options: CameraOptions::default(), + smooth_style: FollowStyle::Always, + tracking_style: FollowStyle::Always, + subject: SubjectState { + scoped, + ..Default::default() + }, + nearclip: 0.2, + surface_y: None, + }; + seat_camera( + dt, + 0.0, + Vec3::ZERO, + Vec3::Y, + 1.8, + &mut rig, + &mut cam, + &mut t, + &collide, + &follow, + &dynamics, + ); + *t + }, + ) + .expect("the seat runs") + } + + /// The view's yaw is the offset, the flip and the facing (`0x50f7de`-`0x50f7f2`), and a return + /// arms the offset alone (`0x512c70`): the walk brings the offset home and the view stays + /// flipped, looking back at the character. + #[test] + fn a_follow_return_keeps_the_flip_in_the_view() { + const DT: f32 = 1.0 / 60.0; + let mut app = seat_app(); + let world = app.world_mut(); + // An offset a drag left, then the stock binding's flip. + world.query::<&mut FlyCam>().single_mut(world).unwrap().yaw = 1.0; + verb(world, CameraViewRequest::FlipYaw(180.0)); + let mut seated = seat(world, 0, DT, false); + for _ in 0..60 { + seated = seat(world, follow_cmd::FORWARD, DT, false); + } + let yaw = world.query::<&FlyCam>().single(world).unwrap().yaw; + assert!(yaw.abs() < 1e-3, "the offset came home: {yaw}"); + let forward = seated.rotation * Vec3::NEG_Z; + assert!( + (forward - Vec3::Z).length() < 1e-3, + "the view still looks back: {forward}" + ); + } + + /// A new camera reads the slots and the live index off their CVars (`0x50fb80`, `0x50c17a`), + /// except an index whose write a scope's edge still owes. + #[test] + fn a_new_camera_reads_the_views_off_the_cvars() { + let mut cvars = crate::cvars::Cvars::default(); + cvars.set("cameraDistanceA", "20"); + cvars.set(CVAR_ACTIVE_VIEW, "3"); + let mut views = CameraViews::default(); + views.reseed(&cvars); + assert_eq!(views.pose(1).distance, 20.0); + assert_eq!(views.current, 3); + + cvars.set(CVAR_ACTIVE_VIEW, "2"); + views.current = 0; + views.owed_active = true; + views.reseed(&cvars); + assert_eq!(views.current, 0); + assert_eq!(views.take_owed_active(), "0"); + } + + /// Mode bit 3 returns ahead of every write of `0x512e90` (`0x512ea3`), whatever the style. + #[test] + fn a_held_scope_refuses_a_view_before_it_writes() { + for word in [STYLE_TIMED, STYLE_INSTANT] { + let (mut views, mut rig, mut cam) = on_view_one(); + rig.scoped = true; + rig.view_moved = true; + let pitch = cam.pitch; + let moved = set_view(&mut views, &mut rig, &mut cam, 0.5, 3, &style(word)); + assert!(!moved, "no index moved"); + assert_eq!(views.current, 1); + assert_eq!(rig.distance, views.pose(1).distance); + assert!(!rig.view_blend.distance_in_flight()); + assert!(rig.view_moved, "bit 6 clears below the return"); + assert_eq!((cam.yaw, cam.pitch), (0.0, pitch)); + } + } + + /// The verbs that select a view reach that return, so `cameraView` stands. `SaveView` writes + /// its slot with no view (`0x50fa09`), a reset of the live view its slot alone, and + /// `FlipCameraYaw` never reaches `0x512e90`. + #[test] + fn a_held_scope_refuses_only_the_verbs_that_select_a_view() { + let mut world = verb_world(); + world.resource_mut::().scoped = true; + for request in [ + CameraViewRequest::Set(3), + CameraViewRequest::Next, + CameraViewRequest::Prev, + ] { + assert!(verb(&mut world, request).is_empty()); + assert_eq!(world.resource::().current, 1); + } + assert_eq!(verb(&mut world, CameraViewRequest::Save(2)).len(), 3); + assert_eq!(verb(&mut world, CameraViewRequest::Reset(1)).len(), 3); + assert_eq!(world.resource::().distance, 0.0); + verb(&mut world, CameraViewRequest::FlipYaw(90.0)); + assert_ne!(world.resource::().flip_yaw, 0.0); + } + + /// A scope's start selects view 0 at once and then sets the bit (`0x50d32d`-`0x50d33f`); its + /// `cameraView` write is gated on the index moving (`0x512ee6`). + #[test] + fn a_scope_start_selects_view_zero_at_once() { + let (mut views, mut rig, mut cam) = on_view_one(); + scope_edge(&mut views, &mut rig, &mut cam, 0.5, true); + let pose = views.pose(0); + assert_eq!(views.current, 0); + assert!(rig.scoped); + assert_eq!((rig.distance, rig.target_distance), (0.0, 0.0)); + assert_eq!(cam.pitch, pose.pitch); + assert_eq!(cam.yaw, wrap_pi(0.5 + pose.yaw)); + assert!(views.owes_active()); + + let (mut views, mut rig, mut cam) = on_view_one(); + views.set(0); + scope_edge(&mut views, &mut rig, &mut cam, 0.5, true); + assert!(!views.owes_active(), "already on view 0"); + } + + /// A scope's end clears the bit and then selects view 1 at once (`0x50d368`-`0x50d378`), over + /// what the zoom did in between, which nothing gates (`0x50edb7` only holds the eye). + #[test] + fn a_scope_end_selects_view_one_at_once() { + let (mut views, mut rig, mut cam) = on_view_one(); + scope_edge(&mut views, &mut rig, &mut cam, 0.5, true); + assert_eq!(views.take_owed_active(), "0"); + rig.distance = 20.0; + rig.target_distance = 20.0; + scope_edge(&mut views, &mut rig, &mut cam, 0.5, false); + let pose = views.pose(1); + assert_eq!(views.current, 1); + assert!(!rig.scoped); + assert_eq!(rig.distance, pose.distance); + assert_eq!(rig.target_distance, pose.distance); + assert_eq!(rig.collision_distance, pose.distance); + assert!(!rig.view_blend.distance_in_flight()); + assert_eq!(cam.pitch, pose.pitch); + assert_eq!(cam.yaw, wrap_pi(0.5 + pose.yaw)); + assert_eq!(views.take_owed_active(), "1"); + } + + /// While the bit is set the eye takes distance 0 and the zoom stands (`0x50edb7`-`0x50edc0`). + #[test] + fn a_held_scope_seats_the_eye_on_the_pivot() { + let mut app = seat_app(); + let world = app.world_mut(); + { + let mut rig = world.resource_mut::(); + rig.distance = 5.55; + rig.target_distance = 5.55; + rig.collision_distance = 5.55; + } + let eye = seat(world, 0, 1.0 / 60.0, true).translation; + assert!((eye - Vec3::new(0.0, 1.8, 0.0)).length() < 1e-4, "{eye}"); + assert_eq!(world.resource::().distance, 5.55); + } + + /// The frame's sync makes the `CVar::Set` a scope's edge owes (`0x512f17`), once. + #[test] + fn the_sync_writes_the_view_a_scope_selected() { + let mut world = World::new(); + world.init_resource::(); + world.init_resource::(); + world.init_resource::(); + world.init_resource::(); + let (mut views, mut rig, mut cam) = on_view_one(); + scope_edge(&mut views, &mut rig, &mut cam, 0.0, true); + world.insert_resource(views); + world + .run_system_once(|mut steps: crate::cvars::CvarSteps| steps.sync(None)) + .expect("the sync runs"); + let cvars = world.resource::(); + assert_eq!(cvars.get(CVAR_ACTIVE_VIEW), Some("0")); + assert!(!world.resource::().owes_active()); } #[test] diff --git a/crates/benilla-app/src/player/camera_zoom.rs b/crates/benilla-app/src/player/camera_zoom.rs index 69ae0791a..4324e10ea 100644 --- a/crates/benilla-app/src/player/camera_zoom.rs +++ b/crates/benilla-app/src/player/camera_zoom.rs @@ -101,10 +101,15 @@ fn orbit_limit(distance_max: f32, factor: f32) -> f32 { /// The distance's glide, run every frame after the axes (`0x5112d0`, which move the target, the /// zoom's among them): the target held to the limit, so lowering the max-distance slider pulls /// the camera in, and the distance easing to it at a constant `cameraDistanceMoveSpeed` -/// (`speed`), as after a view's seat. `max` is [`ZoomLimit::max`], CVar-fed, so it bounds by -/// `max`/`min`, never `f32::clamp`, which panics on a NaN or inverted bound. +/// (`speed`), or by a timed view's channel while one runs (`0x50f2b8`). `max` is +/// [`ZoomLimit::max`], CVar-fed, so it bounds by `max`/`min`, never `f32::clamp`, which panics on +/// a NaN or inverted bound. pub(super) fn glide_zoom(dt: f32, rig: &mut CameraControl, max: f32, speed: f32) { rig.target_distance = rig.target_distance.min(max); + if let Some(live) = rig.view_blend.step_distance(rig.target_distance, dt) { + rig.distance = live; + return; + } let max_step = speed * dt; rig.distance += (rig.target_distance - rig.distance).clamp(-max_step, max_step); } diff --git a/crates/benilla-app/src/player/controller.rs b/crates/benilla-app/src/player/controller.rs index 6ad8b5a44..37e0f4125 100644 --- a/crates/benilla-app/src/player/controller.rs +++ b/crates/benilla-app/src/player/controller.rs @@ -250,7 +250,7 @@ pub(super) fn control( keys: Res>, buttons: Res>, // Tupled parameters keep this system under Bevy's limit of 16. - pointer: ( + mut pointer: ( Res, Res, Res, @@ -258,8 +258,9 @@ pub(super) fn control( Res, // Only `nearclip` is read here, for the self-avatar fade. Res, - // Whether the live view is customised, the auto-return's view gate (`0x50fc30`). - Option>, + // Whether the live view is customised, the auto-return's view gate (`0x50fc30`), and the + // views a scope's start and end select. + Option>, ), mut net: ( Res, @@ -361,6 +362,13 @@ pub(super) fn control( let view_subject = &speed_capsule.5; let self_guid = speed_capsule.6 .0; let scoped = &speed_capsule.7; + // The aura's start and end, each a selected view (`0x5ff462`, `0x61236e` → `0x50d320`), and + // the arrival's end, whose own create's `PostInit` may have made the start (`0x5deb44`). + if scoped.active() != rig.scoped || scoped.arrival_end { + if let Some(views) = pointer.6.as_deref_mut() { + camera_view::scope_edge(views, &mut rig, &mut cam, player.facing(), scoped.active()); + } + } // Far sight turns the auto-follow off: our facing is not "behind" for a body we only watch. let follow_cfg = camera::FollowConfig { style: if view_subject.remote.is_some() { @@ -469,6 +477,7 @@ pub(super) fn control( zoom_max, ); glide_zoom(dt, &mut rig, zoom_max, pointer.2.move_speed); + camera_view::step_pitch(&mut rig, &mut cam, dt); // Free fly is a dev tool on the dev chord: a bare `F` is the player's to bind. if crate::run_mode::dev_chord(&keys, KeyCode::KeyF) { @@ -960,11 +969,6 @@ pub(super) fn control( // The camera sweep's root: the capsule's top hemisphere centre, not the framing pivot. let head = player.pos + Vec3::Y * (CAPSULE_HEIGHT - CAPSULE_RADIUS); - // The spyglass lock (aura 76): re-parking each frame stands in for the reference's camera - // flag `0x8`, which makes `SetCameraView` return early. - if scoped.active() { - rig.park_distance(0.0); - } // Far sight moves only the picture: `seat_on_subject` orbits the object, all else runs. // `WOW_CAM_DUMP`: this frame's input beside `seat_camera`'s realized `[cam]` line. diff --git a/crates/benilla-app/src/player/controller/tests.rs b/crates/benilla-app/src/player/controller/tests.rs index f6a496bb2..d8acb7e48 100644 --- a/crates/benilla-app/src/player/controller/tests.rs +++ b/crates/benilla-app/src/player/controller/tests.rs @@ -1126,3 +1126,42 @@ fn a_standing_jump_keeps_its_air_nudge_for_a_key_pressed_mid_arc() { "the forward press nudges the arc: {speed}" ); } + +/// One frame with the spyglass's aura as `zoom` leaves it. +fn scope_frame(client: &mut Client, zoom: Option) { + let world = client.app.world_mut(); + world.resource_mut::().zoom = zoom; + client.frame(vec![], vec![]); +} + +/// The aura's start and end reach the camera in the frame's controller (`0x5ff462`, `0x61236e` → +/// `0x50d320`): view 0 at once and the bit, then the bit cleared and view 1 at once, which takes +/// the camera back out with no zoom asked. +#[test] +fn the_spyglass_selects_its_views_at_its_start_and_end() { + let mut client = Client::new(Body::Own, |_| {}); + scope_frame(&mut client, Some(1.0 / 6.0)); + let world = client.app.world(); + let rig = world.resource::(); + assert!(rig.scoped); + assert_eq!(rig.distance, 0.0); + assert!(world.resource::().owes_active()); + + scope_frame(&mut client, None); + let rig = client.app.world().resource::(); + assert!(!rig.scoped); + assert_eq!((rig.distance, rig.target_distance), (5.55, 5.55)); +} + +/// Our arrival ends the scope its create's `PostInit` started under a standing aura, which selects +/// view 1 at once (`0x5fbb3c`, `0x5deb44`). +#[test] +fn an_arrival_under_the_aura_selects_view_one() { + let mut client = Client::new(Body::Own, |_| {}); + let world = client.app.world_mut(); + world.resource_mut::().arrival_end = true; + client.frame(vec![], vec![]); + let rig = client.app.world().resource::(); + assert!(!rig.scoped); + assert_eq!((rig.distance, rig.target_distance), (5.55, 5.55)); +} diff --git a/crates/benilla-app/src/player/scoped_view.rs b/crates/benilla-app/src/player/scoped_view.rs index 1c2b3937a..128b6b987 100644 --- a/crates/benilla-app/src/player/scoped_view.rs +++ b/crates/benilla-app/src/player/scoped_view.rs @@ -4,7 +4,16 @@ //! and `0x612320` (remove), which match each effect's `EffectApplyAuraName` (`SpellRec+0x16c`) //! against 76 and call `0x50d320(camera, EffectMiscValue[i])`: first person, locked (camera flag //! `0x8` makes `SetCameraView` return early), with `[camera+0x40] = n × π/180`; removal restores -//! `π/2` and unlocks. The Ornate Spyglass (item 5507, spell 12883) passes 15, a 6× zoom. +//! `π/2`, unlocks and selects view 1. The Ornate Spyglass (item 5507, spell 12883) passes 15, a 6× +//! zoom. +//! +//! [`ScopedView`] follows a change of the aura. A create's `PostInit` shows every standing aura +//! (`0x5fbb3c` → `0x5ff130`), which starts the scope, and our own arrival ends it at once and +//! selects view 1 (`0x5deb44`, `0x50d320(0)`); a fresh create runs no field watcher (`0x465d79`), +//! so a scope held across a worldport comes back off, under the same aura. A `/reload`'s new camera +//! holds no scope either, and the reload itself shows no aura. In the reference the display watcher +//! shows it again (`0x604990` → `0x60abe0` → `0x60ada6` → `0x5ff130` → `0x5ff350` → `0x5ff462`), so +//! a shapeshift or morph under a standing aura restarts the scope; that path is not built here. //! //! The ratio is applied to `[camera+0x40]`, which defaults to 90°; the world projection then turns //! it into the vertical field of view by the frame's aspect (`0x5c3cc0`, [`benilla_world::view`]). @@ -27,6 +36,13 @@ const REFERENCE_DEFAULT_DEGREES: f32 = 90.0; #[derive(Resource, Default)] pub(crate) struct ScopedView { pub(crate) zoom: Option, + /// This frame's arrival found the aura on our create: its `PostInit` started the scope and the + /// arrival ends it (`0x5fbb3c`, `0x5deb44`), which selects view 1 from any view. + pub(crate) arrival_end: bool, + /// The aura the last look found (`0x604d00` compares the slots with their old values). + aura: Option, + /// A new camera was built since the last look. + camera_rebuilt: bool, } impl ScopedView { @@ -34,18 +50,38 @@ impl ScopedView { pub(crate) fn active(&self) -> bool { self.zoom.is_some() } + + /// A new camera (`0x50c0e0`) holds no scope, and building it shows no aura. + pub(super) fn new_camera(&mut self) { + self.zoom = None; + self.camera_rebuilt = true; + } + + /// One look at the aura, `(spell, degrees)`, and whether our create arrived since the last. + fn observe(&mut self, aura: Option<(u32, f32)>, arrived: bool) { + let spell = aura.map(|(spell, _)| spell); + let rebuilt = std::mem::take(&mut self.camera_rebuilt); + self.arrival_end = arrived && aura.is_some(); + if arrived || rebuilt { + self.zoom = None; + } else if spell != self.aura { + self.zoom = aura.map(|(_, degrees)| degrees / REFERENCE_DEFAULT_DEGREES); + } + self.aura = spell; + } } /// Drives the projection from aura 76 in our own aura slots only, as the reference gates it: the /// aura field is public, so another player's spyglass must not zoom ours. pub(super) fn apply_scoped_view( self_q: Query<&ObjectStore, With>, + arrived: Query<(), Added>, spells: Option>, mut scoped: ResMut, mut fov_zoom: ResMut, ) { let catalog = spells.as_ref().map(|s| &s.catalog); - scoped.zoom = self_q + let aura = self_q .single() .ok() .zip(catalog) @@ -55,13 +91,13 @@ pub(super) fn apply_scoped_view( (0..3).find_map(|i| { // The reference reads the misc value from the aura name's own effect index. (rec.effect_apply_aura[i] == SPELL_AURA_FAR_SIGHT) - .then(|| rec.effect_misc_value[i] as f32) + .then(|| (slot.spell_id, rec.effect_misc_value[i] as f32)) }) }) }) // A zero misc value is the reference's own "restore" argument, not a zero-width view. - .filter(|°| deg > 0.0) - .map(|deg| deg / REFERENCE_DEFAULT_DEGREES); + .filter(|&(_, deg)| deg > 0.0); + scoped.observe(aura, !arrived.is_empty()); // `stamp_world_fov` turns it into the field of view at the frame's aspect. let want = scoped.zoom.unwrap_or(1.0); @@ -79,6 +115,7 @@ mod tests { use benilla_world::view::world_fovy; let scoped = ScopedView { zoom: Some(15.0 / REFERENCE_DEFAULT_DEGREES), + ..Default::default() }; let normal = world_fovy(16.0 / 9.0); let fov = normal * scoped.zoom.unwrap(); @@ -92,7 +129,7 @@ mod tests { ); assert!(scoped.active()); - let none = ScopedView { zoom: None }; + let none = ScopedView::default(); assert_eq!( none.zoom.unwrap_or(1.0), 1.0, @@ -100,4 +137,97 @@ mod tests { ); assert!(!none.active()); } + + const SPYGLASS: u32 = 12883; + const HELD: Option<(u32, f32)> = Some((SPYGLASS, 15.0)); + + /// The aura's add starts the scope and its removal ends it (`0x5ff462`, `0x61236e`). + #[test] + fn the_scope_moves_on_a_change_of_the_aura() { + let mut scoped = ScopedView::default(); + scoped.observe(HELD, false); + assert_eq!(scoped.zoom, Some(15.0 / REFERENCE_DEFAULT_DEGREES)); + scoped.observe(HELD, false); + assert!(scoped.active()); + scoped.observe(None, false); + assert!(!scoped.active()); + } + + /// The create's `PostInit` starts the scope (`0x5fbb3c`) and the arrival ends it (`0x5deb44`), + /// and here the standing aura starts nothing until it changes (`0x604d00`). + #[test] + fn an_arrival_ends_the_scope_under_a_standing_aura() { + let mut scoped = ScopedView::default(); + scoped.observe(HELD, false); + scoped.observe(HELD, true); + assert!(!scoped.active()); + assert!(scoped.arrival_end); + scoped.observe(HELD, false); + assert!(!scoped.active()); + assert!(!scoped.arrival_end); + scoped.observe(None, false); + scoped.observe(HELD, false); + assert!(scoped.active(), "a new add starts it"); + } + + /// With no aura on the create nothing started, so no view is owed at this arrival. + #[test] + fn an_arrival_with_no_aura_owes_no_view() { + let mut scoped = ScopedView::default(); + scoped.observe(None, true); + assert!(!scoped.arrival_end); + } + + /// A new camera holds no scope, building it shows no aura, and it selects no view. + #[test] + fn a_new_camera_ends_the_scope_under_a_standing_aura() { + let mut scoped = ScopedView::default(); + scoped.observe(HELD, false); + scoped.new_camera(); + assert!(!scoped.active()); + scoped.observe(HELD, false); + assert!(!scoped.active()); + assert!(!scoped.arrival_end); + } + + fn store(fields: &[(u16, u32)]) -> ObjectStore { + ObjectStore(benilla_protocol::ObjectFields::from_pairs(fields)) + } + + /// Our create under the aura holds no scope; the aura's later add does, and zooms. + #[test] + fn a_create_under_the_aura_holds_no_scope_and_a_later_add_does() { + use benilla_formats::{SpellCatalog, SpellDisplay}; + // `UNIT_FIELD_AURA` slot 0 and its `AURAFLAGS` nibble. + let aura = [(47, SPYGLASS), (95, 8)]; + let mut world = World::new(); + world.init_resource::(); + world.init_resource::(); + world.insert_resource(Spells { + catalog: SpellCatalog::from_displays(std::collections::HashMap::from([( + SPYGLASS, + SpellDisplay { + effect_apply_aura: [SPELL_AURA_FAR_SIGHT, 0, 0], + effect_misc_value: [15, 0, 0], + ..Default::default() + }, + )])), + ..Spells::empty_for_tests() + }); + let apply = world.register_system(apply_scoped_view); + let me = world.spawn((store(&aura), SelfPlayer)).id(); + world.run_system(apply).expect("the look runs"); + assert!(world.resource::().arrival_end); + assert!(!world.resource::().active()); + world.run_system(apply).expect("the look runs"); + assert!(!world.resource::().arrival_end); + assert!(!world.resource::().active()); + world.entity_mut(me).insert(store(&[])); + world.run_system(apply).expect("the look runs"); + world.entity_mut(me).insert(store(&aura)); + world.run_system(apply).expect("the look runs"); + assert!(world.resource::().active()); + let zoom = world.resource::().0; + assert_eq!(zoom, 15.0 / REFERENCE_DEFAULT_DEGREES); + } } diff --git a/crates/benilla-app/src/ui_script/lifecycle.rs b/crates/benilla-app/src/ui_script/lifecycle.rs index 2f9cdbea7..e81a3dc0b 100644 --- a/crates/benilla-app/src/ui_script/lifecycle.rs +++ b/crates/benilla-app/src/ui_script/lifecycle.rs @@ -530,6 +530,7 @@ fn load_ingame_ui_seated(world: &mut World, seat: LoadSeat) { return; } mint_entry_vm(world); + crate::player::camera_at_interface_load(world); let Some(mut script) = world.remove_non_send_resource::() else { warn!("ui_script: entering the world with no VM — the in-game UI will not load"); return; @@ -1176,6 +1177,9 @@ pub(crate) fn run_pending_reload(world: &mut World) { // A boot VM, never the glue's: the reload builds no glue (`0x495664` then `0x495669`), and the // load replaces this VM at once. close_ui_session(world, ShutdownRoot::Reload); + // The teardown destroys the camera with the world frame and the load builds a new one + // (`0x481e12`, `0x481b4e` → `0x50c0e0`), ahead of the new VM's first line. + crate::player::rebuild_camera(world); install_boot_vm(world); load_ingame_ui_seated(world, LoadSeat::Live); // `UI_Init` ends by re-entering the world-enter cascade (`0x490168`), its sends included, but diff --git a/crates/benilla-app/src/ui_script/world_entry_tests.rs b/crates/benilla-app/src/ui_script/world_entry_tests.rs index fcda0e449..7d2ae65a4 100644 --- a/crates/benilla-app/src/ui_script/world_entry_tests.rs +++ b/crates/benilla-app/src/ui_script/world_entry_tests.rs @@ -663,6 +663,60 @@ fn item_locks_cross_a_reload_as_its_sweep_leaves_them() { let _ = std::fs::remove_dir_all(&tmp); } +/// The interface load builds a new camera, a login's as a `/reload`'s, which reads the views off +/// their CVars (`0x481b4e` → `0x50c0e0`, `0x50c3db` → `0x50fb80`). +#[test] +fn the_interface_load_rereads_the_camera_views() { + use crate::cvars::Cvars; + use crate::player::camera_view::CameraViews; + benilla_formats::wow_data_or_skip!(); + let _l = ENV_LOCK + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let (tmp, _c, _h) = hermetic_probe("reload-views"); + let mut world = booted_world(); + world.init_resource::(); + world.init_resource::(); + + world.resource_mut::().set("cameraDistanceA", "20"); + log_in_as(&mut world, "Onehunter", 1); + assert!( + world.resource::().current_customised(), + "the login's new camera read the row" + ); + world.resource_mut::().set("cameraDistanceA", "5.55"); + reload(&mut world, crate::client_state::ClientState::InWorld); + assert!( + !world.resource::().current_customised(), + "and so did the reload's" + ); + + drop(world); + let _ = std::fs::remove_dir_all(&tmp); +} + +/// `ReloadUI()`'s teardown destroys the camera with the world frame and its load builds a new one +/// (`0x481e12`, `0x481b4e` → `0x50c0e0`), which counts as moved (`0x50c400`). +#[test] +fn a_reload_builds_a_new_camera() { + benilla_formats::wow_data_or_skip!(); + let _l = ENV_LOCK + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let (tmp, _c, _h) = hermetic_probe("reload-camera"); + let mut world = booted_world(); + world.init_resource::(); + + log_in_as(&mut world, "Onehunter", 1); + reload(&mut world, crate::client_state::ClientState::InWorld); + + let rig = world.resource::(); + assert!(rig.is_new_for_test()); + + drop(world); + let _ = std::fs::remove_dir_all(&tmp); +} + /// `ReloadUI()` is the reference's teardown and rebuild pair (`0x495664`/`0x495669`): the logout /// and login edges, run without leaving the world. #[test] diff --git a/docs/DEVIATIONS.md b/docs/DEVIATIONS.md index aa1a89ba2..4bd7ec63b 100644 --- a/docs/DEVIATIONS.md +++ b/docs/DEVIATIONS.md @@ -91,7 +91,7 @@ Each entry is the first sentence of a `Deviation:` comment, linking to where it #### camera_shake -- The shake spawns at the kit play's position because the reference passes `&node+0x48` to the shake (`0x620e0e`) before `0x62101d` fills it from the attachment, so a bone-attached first node (`0x61fdd0`, which never writes it) shakes at the world origin its constructor left (`0x61f4c1`) and is culled beyond 80 yards of it, silencing 26 of the 58 shipped shake kits; that is a read before the write, not a behaviour. ([`src/camera_shake.rs:290`](../crates/benilla-app/src/camera_shake.rs#L290)) +- The shake spawns at the kit play's position because the reference passes `&node+0x48` to the shake (`0x620e0e`) before `0x62101d` fills it from the attachment, so a bone-attached first node (`0x61fdd0`, which never writes it) shakes at the world origin its constructor left (`0x61f4c1`) and is culled beyond 80 yards of it, silencing 26 of the 58 shipped shake kits; that is a read before the write, not a behaviour. ([`src/camera_shake.rs:296`](../crates/benilla-app/src/camera_shake.rs#L296)) #### chat_bubble @@ -125,8 +125,8 @@ Each entry is the first sentence of a `Deviation:` comment, linking to where it - The reference's `SaveConfig` (`0x63d980`) writes only at world entry (`0x46b6f6`), at the leave-world tail (`0x490c35`-`0x490c64`) and at shutdown (`0x63daf0`), so a crash or a kill loses every change since; here a player's settings survive one because the config saves a quiet second after a change. ([`src/cvars/mod.rs:1170`](../crates/benilla-app/src/cvars/mod.rs#L1170)) - The reference clips at full scale and has no headroom mechanism (its SFX-bus duck `0x457960` is a sidechain armed only by server-pushed voice lines); benilla limits because every SFX is mastered to full scale and overlapping kits clip. ([`src/cvars/table.rs:243`](../crates/benilla-app/src/cvars/table.rs#L243)) - Here the dial multiplies the camera's own rate, because benilla does not change the OS pointer speed, a system-wide setting. ([`src/cvars/table.rs:493`](../crates/benilla-app/src/cvars/table.rs#L493)) -- "0" raises a borderless fullscreen window instead of mode-setting the display, because Wayland and macOS offer no mode-set and X11's leaves the desktop changed after a crash (`crate::video`). ([`src/cvars/table.rs:623`](../crates/benilla-app/src/cvars/table.rs#L623)) -- Benilla, which has no exclusive mode, applies the same curve in the composite pass (`crate::ui_gamma::DisplayGamma`), because the skipped upload would make a slider that moves no pixel. `SetGamma 0x4891f0` writes `"%f"` ("1.000000"), which `SaveConfig` (`0x63da4a`) keeps as a diff against the default "1.0", as the reference does. ([`src/cvars/table.rs:748`](../crates/benilla-app/src/cvars/table.rs#L748)) +- "0" raises a borderless fullscreen window instead of mode-setting the display, because Wayland and macOS offer no mode-set and X11's leaves the desktop changed after a crash (`crate::video`). ([`src/cvars/table.rs:631`](../crates/benilla-app/src/cvars/table.rs#L631)) +- Benilla, which has no exclusive mode, applies the same curve in the composite pass (`crate::ui_gamma::DisplayGamma`), because the skipped upload would make a slider that moves no pixel. `SetGamma 0x4891f0` writes `"%f"` ("1.000000"), which `SaveConfig` (`0x63da4a`) keeps as a diff against the default "1.0", as the reference does. ([`src/cvars/table.rs:756`](../crates/benilla-app/src/cvars/table.rs#L756)) #### death @@ -202,11 +202,11 @@ Each entry is the first sentence of a `Deviation:` comment, linking to where it - Here it rides the deck, because the reference stores the world point unconverted and reads it back as deck-local (`0x60fe6c`, `0x610e95`) and so, on a transport, walks astray. ([`src/player/approach.rs:420`](../crates/benilla-app/src/player/approach.rs#L420)) - Here the deck is fixed at the arm, because the reference converts back through the transport the player stands on each frame (`0x610e6b`), so stepping off mid-walk sends the point astray. ([`src/player/approach.rs:450`](../crates/benilla-app/src/player/approach.rs#L450)) - Here the melee walk does not turn, because the reference's melee arm names the player's own guid (`0x60fe5f`), so it turns to face itself, a fixed heading. ([`src/player/approach.rs:666`](../crates/benilla-app/src/player/approach.rs#L666)) -- The shape is kept, its scale anchored to `LOOK_SENSITIVITY` on both axes at the default speeds, because the unit factor is a per-machine OS setting and this keeps the shipped feel: both axes turn alike, where the reference's yaw turns 1.5× faster than its pitch. ([`src/player/camera.rs:121`](../crates/benilla-app/src/player/camera.rs#L121)) +- The shape is kept, its scale anchored to `LOOK_SENSITIVITY` on both axes at the default speeds, because the unit factor is a per-machine OS setting and this keeps the shipped feel: both axes turn alike, where the reference's yaw turns 1.5× faster than its pitch. ([`src/player/camera.rs:132`](../crates/benilla-app/src/player/camera.rs#L132)) - A delay holds the channel at `from`, because the reference's `0x512830` backs the start time up by the delay, so `s` runs negative and the even cosine jumps the value away and back, an unclamped divide rather than a behaviour. No default reaches it: every `cameraTerrainTilt*` row's `Delay` is 0, and the pitch and bias arms pass 0. ([`src/player/camera_channel.rs:13`](../crates/benilla-app/src/player/camera_channel.rs#L13)) -- A code table, not the reference's ninety `cameraTerrainTilt