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>
146 lines
6 KiB
C#
146 lines
6 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 within
|
||
// 80–90° 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.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);
|
||
}
|
||
}
|