fix #389: port retail's SmartboxFOV law; retire AD-89 (display slice 1)

Retail's world-camera FOV is not a constant: the applied vertical FOV is
m_fGameFOV / (viewportAspect - 0.1), recomputed on every aspect or
game-FOV change (CreatureMode smartbox sites 0x00452b2f/0x00453b14),
gated by Render::SetFOVRad's open (0, pi) acceptance (0x0054b2d0 -
rejected results keep the previous FOV). m_fGameFOV defaults to pi/2 =
90 degrees (0x00454649) and is what the Field of View option sets in
degrees (0x00451e6a; registered range [10,160] default 90 -
gmClient::InitUIPreferences @0x004035b0). Net effect: the horizontal
view stays ~85-90 degrees across aspect ratios; wide screens trim the
vertical slice instead of ballooning the sides.

acdream hardcoded FovY = pi/3 = 60 degrees on all four world cameras,
aspect-independent, and the Config slider wrote raw vertical-FOV
degrees. New: RetailFieldOfView (the law + gate, decomp-cited),
CameraController.GameFovRadians + SetGameFov + one ApplyProjection
chokepoint recomputing every camera on SetAspect/SetGameFov/
EnterChaseMode/RestoreState; ApplyFieldOfView now feeds the law;
DisplaySettings.Default.FieldOfView 60 -> 90 (the retail registered
default; the stored number changed MEANING with this commit).

The same seam closes a second latent bug the 2026-08-13 "squished" gate
report exposed: SetAspect only ever updated Orbit/Fly - the CHASE
cameras (the ones the player looks through) kept their creation-time
aspect across every mid-session resize, drawing the world at the old
shape stretched onto the new viewport.

The paperdoll camera stays outside the law by design (retail portrait
mode is UseSharpMode, not smartbox - DollCamera's own doc).

Tests: RetailFieldOfViewTests (golden law values at 4:3/16:9/21:9, the
constant-horizontal property, the rejection gate, controller propagation
incl. chase attach/restore + rejected-law aspect-still-propagates);
DisplaySettingsTests + RuntimeSettingsControllerTests updated to the new
semantics. App suite 4,953/3 skips; UI.Abstractions 916/0. AD-89 retired
in this commit; user settings.json migrated 60->90 by hand (stale
pre-port default).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-13 17:08:41 +02:00
parent a1efc8bcb3
commit 7e0c130344
13 changed files with 334 additions and 22 deletions

View file

@ -45,10 +45,20 @@ public sealed class CameraController
private enum Mode { Orbit, Fly, Chase }
private Mode _mode = Mode.Orbit;
/// <summary>Retail <c>m_fGameFOV</c> (#389): the user-facing FOV the Config
/// slider sets in degrees, default 90°. The APPLIED per-camera vertical FOV
/// is derived from it and the current aspect via
/// <see cref="RetailFieldOfView.TryAppliedVerticalFov"/> — see that class's
/// doc for the decomp anchors.</summary>
public float GameFovRadians { get; private set; } = RetailFieldOfView.DefaultGameFovRadians;
private float _aspect = 16f / 9f;
public CameraController(OrbitCamera orbit, FlyCamera fly)
{
Orbit = orbit;
Fly = fly;
ApplyProjection();
}
public void ToggleFly()
@ -65,6 +75,14 @@ public sealed class CameraController
{
Chase = legacy;
RetailChase = retail;
// #389: freshly attached chase cameras carry their own initialiser
// aspect/FOV — bring them onto the controller's current aspect +
// smartbox FOV immediately (before this, a resize while chase cameras
// were attached never reached them at all: SetAspect only updated
// Orbit/Fly, so the world rendered at the creation-time aspect
// stretched onto the new viewport — the 2026-08-13 "squished" gate
// report's second mechanism).
ApplyProjection();
_mode = Mode.Chase;
ModeChanged?.Invoke(IsChaseMode);
}
@ -83,8 +101,41 @@ public sealed class CameraController
public void SetAspect(float aspect)
{
Orbit.Aspect = aspect;
Fly.Aspect = aspect;
_aspect = aspect;
ApplyProjection();
}
/// <summary>Sets retail's <c>m_fGameFOV</c> (#389) and recomputes every
/// camera's applied FOV. Driven by the Config tab's Field of View value
/// through <c>RuntimeSettingsStartupTargets.ApplyFieldOfView</c>.</summary>
public void SetGameFov(float gameFovRadians)
{
GameFovRadians = gameFovRadians;
ApplyProjection();
}
/// <summary>Pushes the current aspect + smartbox-derived vertical FOV onto
/// every attached camera. When the law's result fails retail's
/// <c>SetFOVRad</c> gate (see <see cref="RetailFieldOfView.TryAppliedVerticalFov"/>),
/// the cameras keep their previous FOV — retail's exact behavior — but the
/// aspect still propagates (retail's viewport aspect is likewise updated
/// independently of the FOV gate).</summary>
private void ApplyProjection()
{
bool fovAccepted = RetailFieldOfView.TryAppliedVerticalFov(
GameFovRadians, _aspect, out float fovY);
Orbit.Aspect = _aspect;
Fly.Aspect = _aspect;
if (Chase is { } chase) chase.Aspect = _aspect;
if (RetailChase is { } retailChase) retailChase.Aspect = _aspect;
if (!fovAccepted)
return;
Orbit.FovY = fovY;
Fly.FovY = fovY;
if (Chase is { } chaseFov) chaseFov.FovY = fovY;
if (RetailChase is { } retailChaseFov) retailChaseFov.FovY = fovY;
}
internal CameraState CaptureState() =>
@ -101,6 +152,10 @@ public sealed class CameraController
Chase = state.Chase;
RetailChase = state.RetailChase;
// #389: the aspect/game-FOV may have changed while these cameras were
// captured (a resize during a reset) — reconverge them before anyone
// reads a projection.
ApplyProjection();
_mode = (Mode)state.ModeCode;
ModeChanged?.Invoke(IsFlyMode || IsChaseMode);
}

View file

@ -12,7 +12,9 @@ public sealed class ChaseCamera : ICamera
{
public Vector3 Position { get; private set; }
public float Aspect { get; set; } = 16f / 9f;
public float FovY { get; set; } = MathF.PI / 3f;
// #389: smartbox law at the 16:9 default aspect — see RetailFieldOfView.
// CameraController owns the live recompute on every aspect/game-FOV change.
public float FovY { get; set; } = RetailFieldOfView.DefaultAppliedFovY;
/// <summary>Distance behind the player. Clamped to [<see cref="DistanceMin"/>, <see cref="DistanceMax"/>].</summary>
public float Distance { get; set; } = 8f;

View file

@ -8,7 +8,8 @@ public sealed class FlyCamera : ICamera
public Vector3 Position { get; set; } = new(96, 96, 150);
public float Yaw { get; set; } = MathF.PI / 2f; // facing +Y
public float Pitch { get; set; } = -0.3f; // looking slightly down
public float FovY { get; set; } = MathF.PI / 3f;
// #389: smartbox law at the 16:9 default aspect — see RetailFieldOfView.
public float FovY { get; set; } = RetailFieldOfView.DefaultAppliedFovY;
public float Aspect { get; set; } = 16f / 9f;
/// <summary>

View file

@ -8,7 +8,8 @@ public sealed class OrbitCamera : ICamera
public float Distance { get; set; } = 300f;
public float Yaw { get; set; } = MathF.PI / 4f;
public float Pitch { get; set; } = MathF.PI / 6f;
public float FovY { get; set; } = MathF.PI / 3f;
// #389: smartbox law at the 16:9 default aspect — see RetailFieldOfView.
public float FovY { get; set; } = RetailFieldOfView.DefaultAppliedFovY;
public float Aspect { get; set; } = 16f / 9f;
public Matrix4x4 View

View file

@ -40,7 +40,8 @@ public sealed class RetailChaseCamera : ICamera
/// </summary>
public uint ViewerCellId { get; private set; }
public float Aspect { get; set; } = 16f / 9f;
public float FovY { get; set; } = MathF.PI / 3f;
// #389: smartbox law at the 16:9 default aspect — see RetailFieldOfView.
public float FovY { get; set; } = RetailFieldOfView.DefaultAppliedFovY;
public Matrix4x4 View { get; private set; } = Matrix4x4.Identity;
// Near plane = retail Render::znear = 0.1 m (decomp :342130/:342173/:1101867 —
// Render::SetFOVRad sets 0.1 flat; the legacy set_vdst variant is max(0.1, vdst·0.25)).

View file

@ -0,0 +1,78 @@
using System;
namespace AcDream.App.Rendering;
/// <summary>
/// Retail's aspect-coupled field-of-view law (#389, "SmartboxFOV") — the world
/// camera's applied vertical FOV is NOT a constant: retail recomputes it from
/// the viewport aspect every time either changes,
/// <code>
/// Render::SetFOVRad(m_fGameFOV / (RenderDevice::m_ViewportAspectRatio - 0.1))
/// </code>
/// (<c>CreatureMode</c> smartbox sites <c>0x00452b2f</c> and <c>0x00453b14</c>;
/// the same expression feeds a <c>tan</c> at <c>0x00451c0e</c>). The net
/// effect: the HORIZONTAL view stays roughly constant (~8590° of world at the
/// 90° default) across aspect ratios while wide screens trim the vertical
/// slice — 4:3 ≈ 73° vertical, 16:9 ≈ 53.6°, 21:9 ≈ 40°.
///
/// <para><c>m_fGameFOV</c> is the user-facing number: ctor default
/// <c>1.57079637f</c> = π/2 = 90° (<c>0x00454649</c>), set in DEGREES by the
/// Field of View option (<c>× 0.017453292519943295</c>, <c>0x00451e6a</c>;
/// registered range [10,160] — <c>gmClient::InitUIPreferences @0x004035b0</c>,
/// the exact range/default the Config tab's slider row already authored).</para>
///
/// <para>The applied value passes retail's <c>Render::SetFOVRad @0x0054b2d0</c>
/// gate — accepted only strictly inside (0, π); an out-of-range result is
/// REJECTED and the previous FOV stays (<c>SetFOVRad</c> returns 0 without
/// calling <c>SetFOVInternal</c>). <see cref="TryAppliedVerticalFov"/> ports
/// that exact contract: callers keep their current FOV on <c>false</c>.</para>
///
/// <para>The paperdoll camera is deliberately OUTSIDE this law — retail's
/// portrait mode uses <c>UseSharpMode</c>, not smartbox (see
/// <see cref="DollCamera"/>'s own doc), so it keeps its fixed authored
/// FOV.</para>
/// </summary>
public static class RetailFieldOfView
{
/// <summary>π/2 = 90°: retail <c>m_fGameFOV</c> ctor default (<c>0x00454649</c>,
/// literal <c>1.57079637f</c>).</summary>
public const float DefaultGameFovRadians = 1.57079637f;
/// <summary>The smartbox divisor bias (<c>0x00452b2f</c>, literal
/// <c>0.100000001f</c> — the float nearest 0.1).</summary>
public const float AspectBias = 0.100000001f;
/// <summary>The law evaluated at the default 90° game FOV and the 16:9
/// default aspect every camera initialises with — the correct standalone
/// FovY for a camera that has not yet been driven by
/// <see cref="CameraController.SetAspect"/>/<see cref="CameraController.SetGameFov"/>.</summary>
public static readonly float DefaultAppliedFovY = ComputeDefault();
private static float ComputeDefault()
{
bool ok = TryAppliedVerticalFov(DefaultGameFovRadians, 16f / 9f, out float fovY);
System.Diagnostics.Debug.Assert(ok, "the default game FOV/aspect pair must satisfy the SetFOVRad gate");
return fovY;
}
/// <summary>
/// The smartbox law + the <c>SetFOVRad</c> acceptance gate. Returns false
/// (leaving <paramref name="fovY"/> at the raw computed value) when the
/// result falls outside retail's accepted open interval (0, π) — including
/// the degenerate aspects ≤ <see cref="AspectBias"/> whose divisor is zero
/// or negative. Callers keep their previous FOV in that case, exactly like
/// retail keeps <c>Render::fov</c>.
/// </summary>
public static bool TryAppliedVerticalFov(float gameFovRadians, float aspect, out float fovY)
{
float divisor = aspect - AspectBias;
if (divisor <= 0f)
{
fovY = float.NaN;
return false;
}
fovY = gameFovRadians / divisor;
return fovY > 0f && fovY < MathF.PI;
}
}

View file

@ -132,15 +132,19 @@ internal sealed class RuntimeSettingsStartupTargets : IRuntimeSettingsStartupTar
public void ApplyAudio(AudioSettings audio) => ApplyAudio(_audio, audio);
/// <summary>#389: the stored Field of View is retail's <c>m_fGameFOV</c>
/// in DEGREES (registered range [10,160], default 90 —
/// <c>gmClient::InitUIPreferences @0x004035b0</c>), converted exactly as
/// retail's option setter does (<c>× 0.017453292519943295</c>,
/// <c>0x00451e6a</c>) and applied through the smartbox law in
/// <see cref="CameraController.SetGameFov"/> — NEVER written to a camera's
/// vertical FOV directly (the pre-#389 behavior, which made the slider
/// mean a different, aspect-ignorant thing than retail's).</summary>
internal static void ApplyFieldOfView(
CameraController cameras,
float degrees)
{
float radians = degrees * (MathF.PI / 180f);
cameras.Orbit.FovY = radians;
cameras.Fly.FovY = radians;
if (cameras.Chase is not null)
cameras.Chase.FovY = radians;
cameras.SetGameFov(degrees * (MathF.PI / 180f));
}
internal static void ApplyAudio(

View file

@ -79,15 +79,18 @@ public sealed record DisplaySettings(
{
/// <summary>Values used on first launch / when settings.json is absent.
/// Geometry defaults preserve the pre-L.0 runtime state: Resolution
/// matches the WindowOptions startup size (1280×720) and FieldOfView
/// matches camera FovY (60°). VSync defaults on so normal rendering is
/// synchronized to the active monitor, while
/// matches the WindowOptions startup size (1280×720). FieldOfView is
/// retail's <c>m_fGameFOV</c> in degrees — registered default 90, range
/// [10,160] (<c>gmClient::InitUIPreferences @0x004035b0</c>; #389
/// corrected the pre-port 60, which encoded the old direct-vertical-FOV
/// semantics this record no longer means). VSync defaults on so normal
/// rendering is synchronized to the active monitor, while
/// ShowFps matches retail's initially-hidden SmartBox FPS readout.</summary>
public static DisplaySettings Default { get; } = new(
Resolution: "1280x720",
Fullscreen: false,
VSync: true,
FieldOfView: 60f,
FieldOfView: 90f,
Gamma: 1.0f,
ShowFps: false,
Quality: QualityPreset.High,