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:
parent
a1efc8bcb3
commit
7e0c130344
13 changed files with 334 additions and 22 deletions
|
|
@ -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))
|
||||
|
||||
**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
|
||||
like it is just squished"). Decomp-verified retail law:
|
||||
`Render::SetFOVRad(SmartBox::m_fGameFOV / (RenderDevice::m_ViewportAspectRatio − 0.1))`
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)).
|
||||
|
|
|
|||
78
src/AcDream.App/Rendering/RetailFieldOfView.cs
Normal file
78
src/AcDream.App/Rendering/RetailFieldOfView.cs
Normal 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 (~85–90° 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
144
tests/AcDream.App.Tests/Rendering/RetailFieldOfViewTests.cs
Normal file
144
tests/AcDream.App.Tests/Rendering/RetailFieldOfViewTests.cs
Normal 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 ~85–90°
|
||||
// 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.5−0.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);
|
||||
}
|
||||
}
|
||||
|
|
@ -144,8 +144,14 @@ public sealed class RuntimeSettingsControllerTests
|
|||
Assert.Equal(1, displayWindow.ApplyCount);
|
||||
Assert.Equal(1, surface.RefreshReadCount);
|
||||
Assert.Equal(new FramePacingPolicy(false, 144d), pacing.Policy);
|
||||
Assert.Equal(83f * (MathF.PI / 180f), cameras.Orbit.FovY, precision: 5);
|
||||
Assert.Equal(83f * (MathF.PI / 180f), cameras.Fly.FovY, precision: 5);
|
||||
// #389: the stored degrees are retail's m_fGameFOV; the camera's
|
||||
// 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]
|
||||
|
|
|
|||
|
|
@ -15,14 +15,17 @@ public sealed class DisplaySettingsTests
|
|||
{
|
||||
// Defaults pin the normal-client startup policy:
|
||||
// · 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
|
||||
// · ShowFps false matches retail's initially-hidden SmartBox meter
|
||||
var d = DisplaySettings.Default;
|
||||
Assert.Equal("1280x720", d.Resolution);
|
||||
Assert.False(d.Fullscreen);
|
||||
Assert.True(d.VSync);
|
||||
Assert.Equal(60f, d.FieldOfView);
|
||||
Assert.Equal(90f, d.FieldOfView);
|
||||
Assert.Equal(1.0f, d.Gamma);
|
||||
Assert.False(d.ShowFps);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue