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>
144 lines
5.9 KiB
C#
144 lines
5.9 KiB
C#
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);
|
||
}
|
||
}
|