Files
benilla-octo/crates/benilla-ui/src/script/camera_view.rs
T
gromandClaude Opus 5 f185f3b549 camera: CameraZoomIn and CameraZoomOut, and a bare one is a yard
The first two rows of the reference's camera table `0x84f7a0` (`0x50b400`, `0x50b450`), and the one
verb the chain's own barbershop, arena and guild-bank windows call that we did not publish —
`Turtle_Barbershop.lua:211` zooms the world camera out two yards as its preview opens.

Read off the binary rather than guessed, because the argument handling is not the one every sibling
in this file uses: both thunks test is-number (`0x6f34d0`), take `tonumber` (`0x6f3620`) when it
holds, and otherwise store `1.0f` outright (`0x50b42d`, `0x50b47d`). So `CameraZoomOut()` steps a
yard out, where `SetView()` and `FlipCameraYaw()` return silently. A test pins that, since it is
exactly what reading the callers would have got wrong.

Deviation, named in the module: the reference turns the yards into a timed glide — divide by the
rate at `[0xbe0fbc]+0x24`, scale, truncate (`0x40a2b0`) into a step count — and a call during a
running glide adds into its pending-zoom field (`0x50fca9`). Our rig holds one target distance and
glides to it every frame, so a second call re-aims the same target instead of queueing behind the
first. The clamp is the rig's own, the one the wheel and the zoom keys already share.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
2026-10-18 07:51:53 -06:00

244 lines
9.9 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! The camera-view verbs from the reference's Lua table at `0x84f7a0`: `CameraZoomIn`,
//! `CameraZoomOut`, `SetView`, `SaveView`, `ResetView`, `NextView`, `PrevView` and
//! `FlipCameraYaw`. Each acts on engine state the VM does not hold, so each call queues a
//! [`CameraViewRequest`] for the app's camera rig.
//!
//! The indexed verbs share one prologue and return silently, never reaching `luaL_error`
//! (`0x6f4940`), on anything but a number that truncates to 1 to 5; `FlipCameraYaw` keeps its
//! argument's fraction. `SaveView(1)` and `ResetView(1)` act on first person (`0x50fa30` has no
//! view-0 gate; only `Bindings.xml` lacks the bindings). `NextView` and `PrevView` stop at the
//! ends and never wrap (`0x50faa0`, `0x50fac0`).
//!
//! The two zoom verbs are the table's first two rows (`0x50b400` and `0x50b450`, thunking to
//! `0x50fc60` and `0x50fcd0`) and share a prologue of their own: is-number (`0x6f34d0`) then
//! `tonumber` (`0x6f3620`), and **no argument at all substitutes 1.0** (`0x50b42d`, `0x50b47d`,
//! `movl $0x3f800000`) rather than returning. So a bare `CameraZoomOut()` is one yard out, which
//! is how `Bindings.xml:707`'s `CameraZoomIn(1.0)` reads without its argument.
use mlua::{Lua, Value};
use super::Model;
/// The number of camera views, the rows of the reference's default-string table `0x84f488`.
pub const CAMERA_VIEW_COUNT: u8 = 5;
/// A camera-view intent for the app. Indices are internal, `0..CAMERA_VIEW_COUNT`, range-checked
/// here where the reference checks them, in the Lua handler.
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum CameraViewRequest {
/// `SetView(n)`: make view `n-1` live (distance, pitch and the saved yaw offset).
Set(u8),
/// `SaveView(n)`: store the live pose into view `n-1`.
Save(u8),
/// `ResetView(n)`: put view `n-1` back to its shipped default.
Reset(u8),
/// `NextView()`: the view above the current one, if any.
Next,
/// `PrevView()`: the view below the current one, if any.
Prev,
/// `FlipCameraYaw(degrees)`: add degrees to the camera's yaw; `Bindings.xml:795` passes 180.
FlipYaw(f32),
/// `CameraZoomIn(yards)` as a positive delta and `CameraZoomOut(yards)` as a negative one:
/// move the orbit distance that far closer. One signed variant because the reference's two
/// bodies are the same shape pointed opposite ways: `0x50fc60` and `0x50fcd0` open with the
/// identical six instructions and part only at which pending-zoom lane they drive.
Zoom(f32),
}
impl super::UiScript {
/// Drain the camera-view intents queued since the last call.
pub fn take_camera_view_requests(&mut self) -> Vec<CameraViewRequest> {
std::mem::take(&mut self.model_mut().camera_view_requests)
}
}
/// The indexed verbs' shared prologue: the internal index, or `None` for a silent return.
fn view_arg(lua: &Lua, v: Value) -> Option<u8> {
// Is-number (`0x6f34d0`) and `tonumber` (`0x6f3620`) take a numeric string; the cast
// truncates toward zero, as `0x40a2b0` does.
let n = lua.coerce_number(v).ok().flatten()? as i64 as i32;
(1..=i32::from(CAMERA_VIEW_COUNT))
.contains(&n)
.then(|| (n - 1) as u8)
}
/// The yards a zoom verb steps when its argument is not a number: `0x50b42d` and `0x50b47d` both
/// store `1.0f` where the is-number test fell through.
const ZOOM_DEFAULT_YARDS: f32 = 1.0;
/// The two zoom verbs' shared prologue: the yards to step, 1.0 for anything but a number.
fn zoom_arg(lua: &Lua, v: Value) -> f32 {
lua.coerce_number(v)
.ok()
.flatten()
.map_or(ZOOM_DEFAULT_YARDS, |n| n as f32)
}
/// Register the eight camera-view globals.
pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
let g = lua.globals();
// CameraZoomIn(yards), `0x50b400` → `0x50fc60`, and CameraZoomOut(yards), `0x50b450` →
// `0x50fcd0`: the first two rows of the table. The reference turns the yards into a timed
// glide — `0x50fc6c` divides them by the rate at `[0xbe0fbc]+0x24`, scales by `[0x8022cc]`
// and truncates (`0x40a2b0`) into a step count, then either adds into the pending-zoom field
// (`0x50fca9`, `+0x15c`) when one is already running or starts a fresh one. Deviation: our rig
// holds one target distance and glides to it every frame
// (`player::camera::apply_zoom_scroll`), so a second call during a glide re-aims the same
// target rather than queueing behind the first. The clamp is the rig's, the one the wheel and
// the zoom keys already share.
g.set(
"CameraZoomIn",
lua.create_function(|lua, yards: Value| {
let d = zoom_arg(lua, yards);
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
model.camera_view_requests.push(CameraViewRequest::Zoom(d));
Ok(())
})?,
)?;
g.set(
"CameraZoomOut",
lua.create_function(|lua, yards: Value| {
let d = zoom_arg(lua, yards);
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
model.camera_view_requests.push(CameraViewRequest::Zoom(-d));
Ok(())
})?,
)?;
// SetView(n), `0x50b5b0`. The reference's `0x512e90` also writes the `cameraView` CVar, so
// the view survives a restart; the app does both.
g.set(
"SetView",
lua.create_function(|lua, n: Value| {
if let Some(view) = view_arg(lua, n) {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
model
.camera_view_requests
.push(CameraViewRequest::Set(view));
}
Ok(())
})?,
)?;
// SaveView(n), `0x50b600` → `0x50fa30`: the camera's target distance, pitch and yaw go into
// the slot and each to its archived CVar.
g.set(
"SaveView",
lua.create_function(|lua, n: Value| {
if let Some(view) = view_arg(lua, n) {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
model
.camera_view_requests
.push(CameraViewRequest::Save(view));
}
Ok(())
})?,
)?;
// ResetView(n), `0x50b640` → `0x50fae0`: re-parses the view's default string (`0x84f488`)
// into the slot and re-applies the view if it is live.
g.set(
"ResetView",
lua.create_function(|lua, n: Value| {
if let Some(view) = view_arg(lua, n) {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
model
.camera_view_requests
.push(CameraViewRequest::Reset(view));
}
Ok(())
})?,
)?;
// NextView() and PrevView(), `0x50b680` and `0x50b690`: neither reads the Lua stack.
g.set(
"NextView",
lua.create_function(|lua, ()| {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
model.camera_view_requests.push(CameraViewRequest::Next);
Ok(())
})?,
)?;
g.set(
"PrevView",
lua.create_function(|lua, ()| {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
model.camera_view_requests.push(CameraViewRequest::Prev);
Ok(())
})?,
)?;
// FlipCameraYaw(degrees), `0x50b6a0`: adds `degrees × π/180` to the camera's yaw, fraction
// kept; a non-number returns silently.
g.set(
"FlipCameraYaw",
lua.create_function(|lua, degrees: Value| {
if let Some(d) = lua.coerce_number(degrees).ok().flatten() {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
model
.camera_view_requests
.push(CameraViewRequest::FlipYaw(d as f32));
}
Ok(())
})?,
)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::script::UiScript;
/// The one thing the two zoom verbs do that reading their callers would never show: a call
/// with no argument is **one yard**, not a no-op. `0x50b400` and `0x50b450` both fall through
/// their is-number test (`0x6f34d0`) into `movl $0x3f800000` — so `CameraZoomOut()` steps out
/// a yard, where every other verb in this file returns silently on a bad argument.
#[test]
fn a_zoom_with_no_argument_steps_one_yard_where_a_view_verb_would_do_nothing() {
let mut s = UiScript::new().unwrap();
s.run("CameraZoomIn() CameraZoomOut() CameraZoomIn('frog') SetView() FlipCameraYaw()")
.unwrap();
assert_eq!(
s.take_camera_view_requests(),
vec![
CameraViewRequest::Zoom(1.0),
CameraViewRequest::Zoom(-1.0),
CameraViewRequest::Zoom(1.0),
],
"a bare zoom is a yard and a bare view verb is nothing"
);
}
/// In is toward the player and out is away, and a numeric string coerces as the reference's
/// `tonumber` (`0x6f3620`) takes one.
#[test]
fn in_is_positive_and_out_is_negative() {
let mut s = UiScript::new().unwrap();
s.run("CameraZoomIn(2.5) CameraZoomOut(4) CameraZoomOut('1.5')")
.unwrap();
assert_eq!(
s.take_camera_view_requests(),
vec![
CameraViewRequest::Zoom(2.5),
CameraViewRequest::Zoom(-4.0),
CameraViewRequest::Zoom(-1.5),
]
);
}
/// The barbershop's own call (`Turtle_Barbershop.lua:211`), the row that sent us looking: the
/// chain calls it bare-global, so it must exist by the time its frame shows.
#[test]
fn the_chains_barbershop_call_resolves() {
let s = UiScript::new().unwrap();
assert_eq!(
s.eval::<String>("return type(CameraZoomOut)").unwrap(),
"function"
);
assert_eq!(s.arity("CameraZoomOut(2)").unwrap(), 0);
}
}