using AcDream.App.Rendering; namespace AcDream.App.Tests.Rendering; /// /// #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 /// 's class doc for the full citations. /// 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); } }