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

@ -56,7 +56,22 @@ invent a clamp rule. Part of the display work block with #376/#377/#388/
## #389 — World-camera FOV is an invented aspect-independent constant; retail is SmartboxFOV (vFOV = gameFOV / (aspect 0.1)) ## #389 — World-camera FOV is an invented aspect-independent constant; retail is SmartboxFOV (vFOV = gameFOV / (aspect 0.1))
**Status:** OPEN — filed 2026-08-13 (user gate report: "meant to run on an **Status:** DONE 2026-08-13 (this commit) — display block slice 1, pending
the user's feel gate. `RetailFieldOfView` ports the law + the
`Render::SetFOVRad` (0, π) acceptance gate verbatim;
`CameraController` owns `GameFovRadians` (default 90°) and recomputes
every camera's aspect + applied FOV on `SetAspect`/`SetGameFov`/
`EnterChaseMode`/`RestoreState`; `ApplyFieldOfView` converts the stored
degrees exactly as retail's option setter does; the four π/3 camera
constants are deleted; `DisplaySettings.Default.FieldOfView` is retail's
registered 90. **The same seam fixed a second latent squish bug: SetAspect
never propagated to the CHASE cameras at all — a mid-session resize left
the play camera on its creation-time aspect, drawing the world stretched
onto the new viewport.** The user's stored settings.json was migrated
60→90 by hand (the stale pre-port default). Register AD-89 retired in
this commit. Original filing below.
**Original filing:** OPEN — filed 2026-08-13 (user gate report: "meant to run on an
old aspect ratio... modern screens feels weird... some resolutions feels old aspect ratio... modern screens feels weird... some resolutions feels
like it is just squished"). Decomp-verified retail law: like it is just squished"). Decomp-verified retail law:
`Render::SetFOVRad(SmartBox::m_fGameFOV / (RenderDevice::m_ViewportAspectRatio 0.1))` `Render::SetFOVRad(SmartBox::m_fGameFOV / (RenderDevice::m_ViewportAspectRatio 0.1))`

File diff suppressed because one or more lines are too long

View file

@ -45,10 +45,20 @@ public sealed class CameraController
private enum Mode { Orbit, Fly, Chase } private enum Mode { Orbit, Fly, Chase }
private Mode _mode = Mode.Orbit; 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) public CameraController(OrbitCamera orbit, FlyCamera fly)
{ {
Orbit = orbit; Orbit = orbit;
Fly = fly; Fly = fly;
ApplyProjection();
} }
public void ToggleFly() public void ToggleFly()
@ -65,6 +75,14 @@ public sealed class CameraController
{ {
Chase = legacy; Chase = legacy;
RetailChase = retail; 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; _mode = Mode.Chase;
ModeChanged?.Invoke(IsChaseMode); ModeChanged?.Invoke(IsChaseMode);
} }
@ -83,8 +101,41 @@ public sealed class CameraController
public void SetAspect(float aspect) public void SetAspect(float aspect)
{ {
Orbit.Aspect = aspect; _aspect = aspect;
Fly.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() => internal CameraState CaptureState() =>
@ -101,6 +152,10 @@ public sealed class CameraController
Chase = state.Chase; Chase = state.Chase;
RetailChase = state.RetailChase; 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; _mode = (Mode)state.ModeCode;
ModeChanged?.Invoke(IsFlyMode || IsChaseMode); ModeChanged?.Invoke(IsFlyMode || IsChaseMode);
} }

View file

@ -12,7 +12,9 @@ public sealed class ChaseCamera : ICamera
{ {
public Vector3 Position { get; private set; } public Vector3 Position { get; private set; }
public float Aspect { get; set; } = 16f / 9f; 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> /// <summary>Distance behind the player. Clamped to [<see cref="DistanceMin"/>, <see cref="DistanceMax"/>].</summary>
public float Distance { get; set; } = 8f; 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 Vector3 Position { get; set; } = new(96, 96, 150);
public float Yaw { get; set; } = MathF.PI / 2f; // facing +Y public float Yaw { get; set; } = MathF.PI / 2f; // facing +Y
public float Pitch { get; set; } = -0.3f; // looking slightly down 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; public float Aspect { get; set; } = 16f / 9f;
/// <summary> /// <summary>

View file

@ -8,7 +8,8 @@ public sealed class OrbitCamera : ICamera
public float Distance { get; set; } = 300f; public float Distance { get; set; } = 300f;
public float Yaw { get; set; } = MathF.PI / 4f; public float Yaw { get; set; } = MathF.PI / 4f;
public float Pitch { get; set; } = MathF.PI / 6f; 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 float Aspect { get; set; } = 16f / 9f;
public Matrix4x4 View public Matrix4x4 View

View file

@ -40,7 +40,8 @@ public sealed class RetailChaseCamera : ICamera
/// </summary> /// </summary>
public uint ViewerCellId { get; private set; } public uint ViewerCellId { get; private set; }
public float Aspect { get; set; } = 16f / 9f; 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; public Matrix4x4 View { get; private set; } = Matrix4x4.Identity;
// Near plane = retail Render::znear = 0.1 m (decomp :342130/:342173/:1101867 — // 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)). // 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); 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( internal static void ApplyFieldOfView(
CameraController cameras, CameraController cameras,
float degrees) float degrees)
{ {
float radians = degrees * (MathF.PI / 180f); cameras.SetGameFov(degrees * (MathF.PI / 180f));
cameras.Orbit.FovY = radians;
cameras.Fly.FovY = radians;
if (cameras.Chase is not null)
cameras.Chase.FovY = radians;
} }
internal static void ApplyAudio( 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. /// <summary>Values used on first launch / when settings.json is absent.
/// Geometry defaults preserve the pre-L.0 runtime state: Resolution /// Geometry defaults preserve the pre-L.0 runtime state: Resolution
/// matches the WindowOptions startup size (1280×720) and FieldOfView /// matches the WindowOptions startup size (1280×720). FieldOfView is
/// matches camera FovY (60°). VSync defaults on so normal rendering is /// retail's <c>m_fGameFOV</c> in degrees — registered default 90, range
/// synchronized to the active monitor, while /// [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> /// ShowFps matches retail's initially-hidden SmartBox FPS readout.</summary>
public static DisplaySettings Default { get; } = new( public static DisplaySettings Default { get; } = new(
Resolution: "1280x720", Resolution: "1280x720",
Fullscreen: false, Fullscreen: false,
VSync: true, VSync: true,
FieldOfView: 60f, FieldOfView: 90f,
Gamma: 1.0f, Gamma: 1.0f,
ShowFps: false, ShowFps: false,
Quality: QualityPreset.High, Quality: QualityPreset.High,

View file

@ -0,0 +1,144 @@
using AcDream.App.Rendering;
namespace AcDream.App.Tests.Rendering;
/// <summary>
/// #389: retail's SmartboxFOV law — applied vertical FOV =
/// m_fGameFOV / (viewportAspect 0.1), gated by Render::SetFOVRad's open
/// (0, π) acceptance interval. Golden values computed from the decomp
/// constants (0x00452b2f / 0x00454649 / 0x0054b2d0); see
/// <see cref="RetailFieldOfView"/>'s class doc for the full citations.
/// </summary>
public sealed class RetailFieldOfViewTests
{
[Theory]
// 4:3 CRT: 90° / (1.3333 0.1) = 1.27362 rad ≈ 72.97° vertical.
[InlineData(4f / 3f, 1.27362f)]
// 16:9: 90° / (1.7778 0.1) = 0.93624 rad ≈ 53.64° vertical.
[InlineData(16f / 9f, 0.93624f)]
// 21:9 ultrawide: 90° / (2.3333 0.1) = 0.70327 rad ≈ 40.29° vertical.
[InlineData(21f / 9f, 0.70327f)]
public void Law_AtTheDefault90DegreeGameFov_MatchesTheDecompFormula(
float aspect, float expectedFovY)
{
Assert.True(RetailFieldOfView.TryAppliedVerticalFov(
RetailFieldOfView.DefaultGameFovRadians, aspect, out float fovY));
Assert.Equal(expectedFovY, fovY, precision: 4);
}
[Fact]
public void Law_HoldsTheHorizontalViewRoughlyConstant()
{
// The point of the smartbox shape: horizontal FOV stays ~8590°
// across every aspect at the 90° default, instead of ballooning on
// wide screens the way a fixed vertical FOV does.
foreach (float aspect in new[] { 4f / 3f, 16f / 9f, 21f / 9f })
{
Assert.True(RetailFieldOfView.TryAppliedVerticalFov(
RetailFieldOfView.DefaultGameFovRadians, aspect, out float fovY));
float horizontal = 2f * MathF.Atan(MathF.Tan(fovY / 2f) * aspect);
Assert.InRange(horizontal, 80f * MathF.PI / 180f, 90f * MathF.PI / 180f);
}
}
[Theory]
// Degenerate aspects at or below the 0.1 bias: divisor ≤ 0.
[InlineData(0.05f)]
[InlineData(0.1f)]
// A window so narrow the law exceeds π (the SetFOVRad reject case):
// 90° / (0.55 0.1) = 3.49 rad > π.
[InlineData(0.55f)]
public void Gate_RejectsResultsOutsideRetailsAcceptedInterval(float aspect)
{
Assert.False(RetailFieldOfView.TryAppliedVerticalFov(
RetailFieldOfView.DefaultGameFovRadians, aspect, out _));
}
[Fact]
public void DefaultAppliedFovY_IsTheLawAtTheDefaultPair()
{
Assert.True(RetailFieldOfView.TryAppliedVerticalFov(
RetailFieldOfView.DefaultGameFovRadians, 16f / 9f, out float expected));
Assert.Equal(expected, RetailFieldOfView.DefaultAppliedFovY);
}
[Fact]
public void Controller_SetAspect_DrivesEveryAttachedCamera_IncludingChase()
{
// Pre-#389 regression shape: SetAspect only touched Orbit/Fly, so the
// chase cameras (the ones the player actually looks through) kept
// their creation-time aspect across every resize — the world drew at
// the old shape stretched onto the new viewport (the 2026-08-13
// "squished" gate report).
var controller = new CameraController(new OrbitCamera(), new FlyCamera());
var chase = new ChaseCamera();
var retailChase = new RetailChaseCamera();
controller.EnterChaseMode(chase, retailChase);
controller.SetAspect(4f / 3f);
Assert.True(RetailFieldOfView.TryAppliedVerticalFov(
controller.GameFovRadians, 4f / 3f, out float expectedFov));
foreach ((float aspect, float fov) in new[]
{
(controller.Orbit.Aspect, controller.Orbit.FovY),
(controller.Fly.Aspect, controller.Fly.FovY),
(chase.Aspect, chase.FovY),
(retailChase.Aspect, retailChase.FovY),
})
{
Assert.Equal(4f / 3f, aspect);
Assert.Equal(expectedFov, fov, precision: 5);
}
}
[Fact]
public void Controller_EnterChaseMode_ConvergesFreshCamerasImmediately()
{
var controller = new CameraController(new OrbitCamera(), new FlyCamera());
controller.SetAspect(21f / 9f);
// Cameras built elsewhere with the 16:9 initializer defaults…
var chase = new ChaseCamera();
var retailChase = new RetailChaseCamera();
controller.EnterChaseMode(chase, retailChase);
// …must be on the controller's aspect + law the moment they attach.
Assert.True(RetailFieldOfView.TryAppliedVerticalFov(
controller.GameFovRadians, 21f / 9f, out float expectedFov));
Assert.Equal(21f / 9f, chase.Aspect);
Assert.Equal(expectedFov, chase.FovY, precision: 5);
Assert.Equal(21f / 9f, retailChase.Aspect);
Assert.Equal(expectedFov, retailChase.FovY, precision: 5);
}
[Fact]
public void Controller_RejectedLaw_KeepsThePreviousFovButPropagatesAspect()
{
// Retail SetFOVRad returns 0 without applying on an out-of-range
// result — the previous FOV survives. The viewport aspect is updated
// independently of that gate.
var controller = new CameraController(new OrbitCamera(), new FlyCamera());
float before = controller.Orbit.FovY;
controller.SetAspect(0.5f); // 90°/(0.50.1) = 3.93 rad > π → rejected
Assert.Equal(0.5f, controller.Orbit.Aspect);
Assert.Equal(before, controller.Orbit.FovY);
}
[Fact]
public void Controller_SetGameFov_RecomputesAtTheCurrentAspect()
{
var controller = new CameraController(new OrbitCamera(), new FlyCamera());
controller.SetAspect(16f / 9f);
float narrow = 45f * MathF.PI / 180f; // slider dragged to 45°
controller.SetGameFov(narrow);
Assert.True(RetailFieldOfView.TryAppliedVerticalFov(
narrow, 16f / 9f, out float expectedFov));
Assert.Equal(narrow, controller.GameFovRadians);
Assert.Equal(expectedFov, controller.Fly.FovY, precision: 5);
}
}

View file

@ -144,8 +144,14 @@ public sealed class RuntimeSettingsControllerTests
Assert.Equal(1, displayWindow.ApplyCount); Assert.Equal(1, displayWindow.ApplyCount);
Assert.Equal(1, surface.RefreshReadCount); Assert.Equal(1, surface.RefreshReadCount);
Assert.Equal(new FramePacingPolicy(false, 144d), pacing.Policy); Assert.Equal(new FramePacingPolicy(false, 144d), pacing.Policy);
Assert.Equal(83f * (MathF.PI / 180f), cameras.Orbit.FovY, precision: 5); // #389: the stored degrees are retail's m_fGameFOV; the camera's
Assert.Equal(83f * (MathF.PI / 180f), cameras.Fly.FovY, precision: 5); // applied vertical FOV comes through the smartbox law at the
// controller's current (default 16:9) aspect — never the raw degrees.
Assert.Equal(83f * (MathF.PI / 180f), cameras.GameFovRadians, precision: 5);
Assert.True(RetailFieldOfView.TryAppliedVerticalFov(
83f * (MathF.PI / 180f), 16f / 9f, out float expectedFov));
Assert.Equal(expectedFov, cameras.Orbit.FovY, precision: 5);
Assert.Equal(expectedFov, cameras.Fly.FovY, precision: 5);
} }
[Fact] [Fact]

View file

@ -15,14 +15,17 @@ public sealed class DisplaySettingsTests
{ {
// Defaults pin the normal-client startup policy: // Defaults pin the normal-client startup policy:
// · Resolution matches WindowOptions (1280×720 in GameWindow.Run) // · Resolution matches WindowOptions (1280×720 in GameWindow.Run)
// · FieldOfView matches camera FovY (60° = π/3) // · FieldOfView is retail's m_fGameFOV in DEGREES — registered
// default 90, range [10,160] (gmClient::InitUIPreferences
// @0x004035b0; #389 replaced the pre-port 60, which encoded the
// retired direct-vertical-FOV semantics)
// · VSync is on so normal presentation follows monitor refresh // · VSync is on so normal presentation follows monitor refresh
// · ShowFps false matches retail's initially-hidden SmartBox meter // · ShowFps false matches retail's initially-hidden SmartBox meter
var d = DisplaySettings.Default; var d = DisplaySettings.Default;
Assert.Equal("1280x720", d.Resolution); Assert.Equal("1280x720", d.Resolution);
Assert.False(d.Fullscreen); Assert.False(d.Fullscreen);
Assert.True(d.VSync); Assert.True(d.VSync);
Assert.Equal(60f, d.FieldOfView); Assert.Equal(90f, d.FieldOfView);
Assert.Equal(1.0f, d.Gamma); Assert.Equal(1.0f, d.Gamma);
Assert.False(d.ShowFps); Assert.False(d.ShowFps);
} }