acdream/tests/AcDream.App.Tests/Rendering/RetailFieldOfViewTests.cs
Erik d13d63d0a5 fix #389 review round: settings v3 FOV migration + live apply; AD-90
Dual-lens Opus review of 7e0c1303 (reports committed under
docs/research/). The law, gate, and vertical application are CONFIRMED
at instruction-byte level against the PDB-paired acclient.exe (the BN
text FPU-elides this whole area); the fix round addresses the findings:

- Blast MUST-FIX 1: real schema migration instead of a hand-edited dev
  file. SettingsStore v2->v3: a pre-v3 display.fieldOfView was the
  applied vertical FOV in degrees; v3 means retail's m_fGameFOV.
  LoadDisplay migrates on read - the untouched old default 60 maps to
  the retail default 90; a deliberate other value preserves its visible
  16:9 framing (x (16/9 - 0.1)), clamped to the registered [10,160];
  the next save stamps v3 and migration never reruns. The dev
  settings.json hand-edit was reverted so the migration owns it.
- Blast MUST-FIX 2 / mechanism M2: the Field of View now applies LIVE on
  Save (retail: Render::GRPCallback_OnRenderPreferenceChanged @0x0054d999
  -> SmartBox::SetDefaultFov). RuntimeSettingsTargets gains the camera
  graph and applies through ApplyDisplayWindowState - the update-phase
  seam, deliberately NOT the render-phase preview path (the review's
  WATCH-3 cull-vs-raster landmine).
- Mechanism M1 -> register row AD-90: retail's divisor aspect runs
  through the Render.AspectRatio preference (ComputeAspectForViewport
  @0x0054f150, (w/h) x pref x 0.75) - exactly raw w/h at the registered
  default, which is what acdream assumes; retail's NaN-through-the-gate
  quirk (M3) is folded into the same row as deliberately not reproduced.
- Docs: RetailFieldOfView now cites the decisive vertical proof
  (D3DXMatrixPerspectiveFovLH fovy slot @0x0059ab71), the unconditional
  SmartBox::RenderNormalMode site, and M4's exact horizontal numbers
  (89.0/83.9/80.6 deg); the Config FOV row comment updated to LIVE.
- Blast WATCH 4 disposition: the 15 replay-harness PI/3 constants stay -
  they are CAPTURE-TIME camera parameters for recorded fixtures, not
  production framing; changing them would invalidate the replays.

Tests: +6 SettingsStore migration facts, +1 live-apply fact.
App suite 4,962/3 skips; UI.Abstractions 922.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 17:27:15 +02:00

146 lines
6 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 within
// 8090° across every aspect at the 90° default (89.0° at 4:3,
// 83.9° at 16:9, 80.6° at 21:9 — mechanism review M4's exact
// numbers), 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);
}
}