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 <noreply@anthropic.com>
This commit is contained in:
Sam Sung
2026-11-01 18:24:33 +07:00
committed by GitHub
parent 3409dacf0b
commit dabb331bc6
20 changed files with 1652 additions and 116 deletions
+15
View File
@@ -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::<CameraShakes>().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
@@ -156,6 +156,8 @@ pub(crate) fn on_cvar_changed(
pub(crate) struct CvarSteps<'w> {
cvars: Option<ResMut<'w, Cvars>>,
callbacks: CvarCallbacks<'w>,
/// The camera's views, whose `cameraView` write a scope's start or end leaves to the sync.
views: Option<ResMut<'w, crate::player::camera_view::CameraViews>>,
}
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 {
-4
View File
@@ -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
+5 -4
View File
@@ -1513,6 +1513,11 @@ mod tests {
follow.tracking_style.cvar().parse::<f32>().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",
+11 -3
View File
@@ -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"),
+3 -2
View File
@@ -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)
+233 -12
View File
@@ -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<f32> {
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<f32> {
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<Vec2>,
/// 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<f32>, secs: f32) -> Vec<f32> {
let dt = 1.0 / 60.0;
@@ -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::<CameraControl>().is_looking());
assert!(!w.resource::<CameraControl>().view_moved);
frame(&mut w, 40.0);
assert!(w.resource::<CameraControl>().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::<CameraControl>().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::<CameraControl>().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
+23 -6
View File
@@ -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]
@@ -117,6 +117,25 @@ impl SmoothChannel {
self.flight.is_some()
}
/// The duration in flight, seconds.
pub(super) fn duration(&self) -> Option<f32> {
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`).
@@ -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<f32> {
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() {
+108 -2
View File
@@ -9,8 +9,9 @@
//! Two keys, LF, six decimals, a trailing newline (writer `0x50c4d0`, reader `0x50c5a0`), no yaw.
//! Ours is `benilla-config/camera/<realm>-<character>.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<CameraViews>| {
if let Some(cvars) = world.get_resource::<crate::cvars::Cvars>() {
views.reseed(cvars);
}
});
if let Some(mut shakes) = world.get_resource_mut::<crate::camera_shake::CameraShakes>() {
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::<Player>().map_or(0.0, Player::facing);
if let Some(mut scoped) = world.get_resource_mut::<super::scoped_view::ScopedView>() {
scoped.new_camera();
}
world.try_resource_scope(|world, mut rig: Mut<CameraControl>| {
let mut cams = world.query_filtered::<&mut FlyCam, With<WorldCamera>>();
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::<Player>();
world.resource_mut::<Player>().face_yaw = 0.5;
world.init_resource::<CameraControl>();
{
let mut rig = world.resource_mut::<CameraControl>();
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::<CameraControl>();
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::<CameraPoseFile>()
.init_resource::<CameraControl>()
.init_resource::<Player>()
.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::<CameraControl>().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
File diff suppressed because it is too large Load Diff
+7 -2
View File
@@ -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);
}
+12 -8
View File
@@ -250,7 +250,7 @@ pub(super) fn control(
keys: Res<ButtonInput<KeyCode>>,
buttons: Res<ButtonInput<MouseButton>>,
// Tupled parameters keep this system under Bevy's limit of 16.
pointer: (
mut pointer: (
Res<AccumulatedMouseMotion>,
Res<camera::LookConfig>,
Res<camera_zoom::ZoomLimit>,
@@ -258,8 +258,9 @@ pub(super) fn control(
Res<camera_dynamics::CameraOptions>,
// Only `nearclip` is read here, for the self-avatar fade.
Res<benilla_world::view::ViewDistance>,
// Whether the live view is customised, the auto-return's view gate (`0x50fc30`).
Option<Res<camera_view::CameraViews>>,
// Whether the live view is customised, the auto-return's view gate (`0x50fc30`), and the
// views a scope's start and end select.
Option<ResMut<camera_view::CameraViews>>,
),
mut net: (
Res<NetCommands>,
@@ -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.
@@ -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<f32>) {
let world = client.app.world_mut();
world.resource_mut::<scoped_view::ScopedView>().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::<CameraControl>();
assert!(rig.scoped);
assert_eq!(rig.distance, 0.0);
assert!(world.resource::<camera_view::CameraViews>().owes_active());
scope_frame(&mut client, None);
let rig = client.app.world().resource::<CameraControl>();
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::<scoped_view::ScopedView>().arrival_end = true;
client.frame(vec![], vec![]);
let rig = client.app.world().resource::<CameraControl>();
assert!(!rig.scoped);
assert_eq!((rig.distance, rig.target_distance), (5.55, 5.55));
}
+136 -6
View File
@@ -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<f32>,
/// 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<u32>,
/// 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<SelfPlayer>>,
arrived: Query<(), Added<SelfPlayer>>,
spells: Option<Res<Spells>>,
mut scoped: ResMut<ScopedView>,
mut fov_zoom: ResMut<FovZoom>,
) {
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| 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::<ScopedView>();
world.init_resource::<FovZoom>();
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::<ScopedView>().arrival_end);
assert!(!world.resource::<ScopedView>().active());
world.run_system(apply).expect("the look runs");
assert!(!world.resource::<ScopedView>().arrival_end);
assert!(!world.resource::<ScopedView>().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::<ScopedView>().active());
let zoom = world.resource::<FovZoom>().0;
assert_eq!(zoom, 15.0 / REFERENCE_DEFAULT_DEGREES);
}
}
@@ -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::<UiScript>() 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
@@ -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::<Cvars>();
world.init_resource::<CameraViews>();
world.resource_mut::<Cvars>().set("cameraDistanceA", "20");
log_in_as(&mut world, "Onehunter", 1);
assert!(
world.resource::<CameraViews>().current_customised(),
"the login's new camera read the row"
);
world.resource_mut::<Cvars>().set("cameraDistanceA", "5.55");
reload(&mut world, crate::client_state::ClientState::InWorld);
assert!(
!world.resource::<CameraViews>().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::<crate::player::CameraControl>();
log_in_as(&mut world, "Onehunter", 1);
reload(&mut world, crate::client_state::ClientState::InWorld);
let rig = world.resource::<crate::player::CameraControl>();
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]
+7 -7
View File
@@ -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<Style><State>` CVars, because the matrix is a law, not a setting. ([`src/player/camera_dynamics.rs:285`](../crates/benilla-app/src/player/camera_dynamics.rs#L285))
- The reference sweeps the camera collision against the bobbed eye (`0x50ed47`); benilla adds the offset after the sweep, because it shares camera shake's accumulator there. ([`src/player/camera_dynamics.rs:450`](../crates/benilla-app/src/player/camera_dynamics.rs#L450))
- Both values clamp to the rig's range, because a hand-edited file should not land a pose play cannot reach; the reference stores them unbounded, the pitch until a mouse-look re-clamps it. ([`src/player/camera_saved.rs:64`](../crates/benilla-app/src/player/camera_saved.rs#L64))
- A code table, not the reference's ninety `cameraTerrainTilt<Style><State>` CVars, because the matrix is a law, not a setting. ([`src/player/camera_dynamics.rs:323`](../crates/benilla-app/src/player/camera_dynamics.rs#L323))
- The reference sweeps the camera collision against the bobbed eye (`0x50ed47`); benilla adds the offset after the sweep, because it shares camera shake's accumulator there. ([`src/player/camera_dynamics.rs:488`](../crates/benilla-app/src/player/camera_dynamics.rs#L488))
- Both values clamp to the rig's range, because a hand-edited file should not land a pose play cannot reach; the reference stores them unbounded, the pitch until a mouse-look re-clamps it. ([`src/player/camera_saved.rs:66`](../crates/benilla-app/src/player/camera_saved.rs#L66))
- `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. ([`src/player/camera_view.rs:15`](../crates/benilla-app/src/player/camera_view.rs#L15))
- The starting zoom, `CAM_DIST_DEFAULT`, is 15 yd where the reference starts at 5.55, the third-person-A row of its default table `0x84f488` that `cameraView` 1 picks (`0x50c17a`, `0x50c18b`; the `cameraDistance` CVar itself registers "0.0"), because that gives a wider view. ([`src/player/camera_zoom.rs:10`](../crates/benilla-app/src/player/camera_zoom.rs#L10))
- No drunk field-of-view lane, because the reference's is invisible in play: its target `(179° − 90°)·fraction` (camera `+0x10c`, setter `0x511250`) eases at `cameraFoVSmoothSpeed`, 0.5°/s, and restarts on every drunk-byte change, so it creeps under 1°. Not built: entering the world already drunk, the reference snaps the full fisheye (`0x50d0f0`). ([`src/player/drunk.rs:46`](../crates/benilla-app/src/player/drunk.rs#L46))