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>
This commit is contained in:
Erik 2026-08-13 17:27:15 +02:00
parent 6b844c142f
commit d13d63d0a5
11 changed files with 1032 additions and 21 deletions

View file

@ -29,9 +29,11 @@ public sealed class RetailFieldOfViewTests
[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.
// 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(

View file

@ -154,6 +154,31 @@ public sealed class RuntimeSettingsControllerTests
Assert.Equal(expectedFov, cameras.Fly.FovY, precision: 5);
}
[Fact]
public void RuntimeTarget_ApplyDisplayWindowState_AppliesFieldOfViewLive()
{
// #389 blast-review MUST-FIX 2: retail's FOV preference applies LIVE
// (Render::GRPCallback_OnRenderPreferenceChanged @0x0054d999) — a
// Save must reach the cameras through the same seam that resizes the
// window, not wait for the next launch.
var cameras = new CameraController(new OrbitCamera(), new FlyCamera());
var target = new RuntimeSettingsTargets(
new InspectingDisplayWindowTarget(static _ => { }),
new RecordingQualityApplicationTarget([]),
new RecordingUiLockTarget([]),
NullCommandBus.Instance,
static _ => { },
cameras: cameras);
target.ApplyDisplayWindowState(
DisplaySettings.Default with { FieldOfView = 120f });
Assert.Equal(120f * (MathF.PI / 180f), cameras.GameFovRadians, precision: 5);
Assert.True(RetailFieldOfView.TryAppliedVerticalFov(
120f * (MathF.PI / 180f), 16f / 9f, out float expectedFov));
Assert.Equal(expectedFov, cameras.Orbit.FovY, precision: 5);
}
[Fact]
public void ConcreteRuntimeTargetAppliesEveryQualityDimensionInOrder()
{

View file

@ -87,6 +87,82 @@ public sealed class SettingsStoreTests : System.IDisposable
Assert.Equal(ParticleRange.Extended, loaded.ParticleRange);
}
// ── #389 schema-v3 fieldOfView migration ────────────────────────────
// Pre-v3 the stored number was the applied vertical FOV in degrees;
// v3 makes it retail's m_fGameFOV. LoadDisplay migrates pre-v3 files
// on read (blast-review MUST-FIX 1).
[Theory]
// The untouched old default (acdream's invented 60) → the retail
// registered default 90.
[InlineData(60f, 90f)]
// A deliberate old choice preserves its visible 16:9 framing:
// gameFOV = vFOV × (16/9 0.1). 45 × 1.67778 = 75.5.
[InlineData(45f, 75.5f)]
// Clamped into the slider's registered [10,160]: 120 × 1.67778 = 201.3.
[InlineData(120f, 160f)]
public void LoadDisplay_migrates_pre_v3_fieldOfView(float stored, float expected)
{
File.WriteAllText(_tempPath, $$"""
{
"version": 2,
"display": { "fieldOfView": {{stored}} }
}
""");
var store = new SettingsStore(_tempPath);
Assert.Equal(expected, store.LoadDisplay().FieldOfView, precision: 1);
}
[Fact]
public void LoadDisplay_does_not_migrate_a_v3_fieldOfView()
{
// A post-migration 60 is a deliberate gameFOV — it must survive.
File.WriteAllText(_tempPath, """
{
"version": 3,
"display": { "fieldOfView": 60 }
}
""");
var store = new SettingsStore(_tempPath);
Assert.Equal(60f, store.LoadDisplay().FieldOfView);
}
[Fact]
public void LoadDisplay_absent_fieldOfView_needs_no_migration()
{
// The per-field fallback is DisplaySettings.Default (already v3
// semantics, 90) — migrating it would corrupt the default.
File.WriteAllText(_tempPath, """
{
"version": 2,
"display": { "resolution": "1920x1080" }
}
""");
var store = new SettingsStore(_tempPath);
Assert.Equal(90f, store.LoadDisplay().FieldOfView);
}
[Fact]
public void SaveDisplay_stamps_v3_so_the_migration_runs_once()
{
File.WriteAllText(_tempPath, """
{
"version": 2,
"display": { "fieldOfView": 60 }
}
""");
var store = new SettingsStore(_tempPath);
DisplaySettings migrated = store.LoadDisplay(); // 60 → 90
store.SaveDisplay(migrated); // stamps version 3
// A second load must NOT re-migrate the already-migrated 90.
Assert.Equal(90f, store.LoadDisplay().FieldOfView);
}
[Fact]
public void LoadDisplay_invalid_particle_range_falls_back_to_default()
{