acdream/tests/AcDream.App.Tests/Rendering/RetailFieldOfViewTests.cs
Erik 7e0c130344 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>
2026-08-13 17:08:41 +02:00

144 lines
5.9 KiB
C#
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.

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);
}
}