acdream/docs/research/2026-08-13-389-fov-blast-review.md
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

22 KiB
Raw Blame History

Blast-radius review — commit 7e0c1303 (#389 SmartboxFOV port)

Reviewer lens: BLAST RADIUS (consumers of the changed surfaces), 2026-08-13. Commit: 7e0c1303 — "fix #389: port retail's SmartboxFOV law; retire AD-89 (display slice 1)". Scope: report only. No files edited outside this document.

Verdict summary

The mechanism is right and the consumer sweep is largely clean — every camera computes Projection on demand, nothing caches a projection or a FOV across a SetAspect/SetGameFov, and the three "writes .Aspect directly" suspects are all private cameras outside the controller. Two real defects survive, both on the axis the commit did not traverse (the stored value, not the code):

# Verdict Surface One-line
1 MUST-FIX SettingsStore.LoadDisplay The stored fieldOfView changed MEANING with no schema bump and no migration; existing users silently get 35.8° vertical where they had 60°. Migration machinery + precedent already exist and were not used.
2 MUST-FIX divergence register / Config apply Retail's FOV preference is LIVE (SmartBox::SetDefaultFov from the preference callback, re-applied every render); acdream's is next-launch-only. AD-89 was retired without a replacement row for the half that did not land.
3 WATCH (conditional MUST-FIX) WorldRenderFrameBuilder.Build ordering The per-frame preview path mutates camera FOV after the frame snapshotted its frustum. Dead in production today (HasDraftPreview => false); becomes a live cull-vs-raster mismatch the moment anyone makes the slider live — i.e. while fixing #2.
4 WATCH 15 test harness sites + 4 comments Still pin MathF.PI / 3f as "the production / RetailChaseCamera projection". Production is now 0.9363 rad.

Reference values for the rest of this document (law = gameFOV / (aspect 0.1), gameFOV = π/2):

aspect applied FovY vs. old constant
4:3 1.2736 rad = 72.97° +12.97°
16:9 0.9363 rad = 53.64° 6.36°
21:9 0.7033 rad = 40.30° 19.70°
16:9, stored 60 reinterpreted 0.6242 rad = 35.76° 24.24° ← MUST-FIX 1

MUST-FIX 1 — the persisted fieldOfView was reinterpreted with no migration

Evidence

  • src/AcDream.UI.Abstractions/Panels/Settings/SettingsStore.cs:73
    FieldOfView: ReadFloat       (disp, "fieldOfView", d.FieldOfView),
    
    Straight read. No range check, no version check — LoadDisplay (:56-95) never inspects root["version"] at all.
  • src/AcDream.UI.Abstractions/Panels/Settings/SettingsStore.cs:596["fieldOfView"] = d.FieldOfView is written unconditionally by BuildDisplayObject, so every settings.json that has ever had a display section carries the key. It is not an "only if the user touched the slider" case.
  • The schema-version machinery the fix needs already exists and is already used for exactly this class of change:
    • SettingsStore.cs:42private const int CurrentSchemaVersion = 2;
    • SettingsStore.cs:397// Version-1 migration: radar stored only X/Y and had no resolution key. The commit bumped neither the constant nor added a display migration.
  • The commit message acknowledges the problem and solves it for one machine: "user settings.json migrated 60→90 by hand (stale pre-port default)".

Consequence. A pre-7e0c1303 settings.json holds fieldOfView: 60 (the old DisplaySettings.Default). Post-commit that 60 is read as retail m_fGameFOV degrees, so at 16:9 the applied vertical FOV is 60° / (1.7778 0.1) = 35.76° — a 40 % reduction from the 60° the same file used to produce. The visible symptom is a hard zoom-in that survives restarts and looks nothing like either the old build or retail, and the user has no way to know the number in their file changed meaning. It is the exact inverse of the "squished" report that opened #389.

Shape of the fix (not applied). Bump CurrentSchemaVersion to 3 and, in LoadDisplay, when the file's version is < 3 and a display.fieldOfView key is present, replace it with DisplaySettings.Default.FieldOfView (or map it through the inverse law if preserving the user's framing is preferred: gameFOV = storedVerticalDegrees × (aspect 0.1) — but the honest choice is the retail registered default, since the old number never had a retail meaning to preserve). Note the round-trip preservation contract in the class doc (SettingsStore.cs:31-37): unknown top-level keys are carried forward, so the version bump must be written on save through the existing root["version"] = CurrentSchemaVersion sites (:296, :339, :446, :500, :683).


MUST-FIX 2 — AD-89 retired while half its own required scope is still unported

AD-89's deleted text (from the commit's own diff) said the port "must also re-map the Config Field of View slider to retail's degree semantics in the same change". The semantics landed. The liveness did not, and there is now no register row for the gap.

Retail is live. Three decomp facts, two of which the commit already cites:

  • docs/research/named-retail/acclient_2013_pseudo_c.txt:344363-344365 (Render::GRPCallback_OnRenderPreferenceChanged @ 0x0054d982-0x0054d999):
    0054d984  float FieldOfView = Render::m_RenderPrefs.FieldOfView;
    0054d98c  Current_Render_FieldOfView = FieldOfView;
    0054d999  SmartBox::SetDefaultFov(SmartBox::smartbox, FieldOfView);
    
    The preference-changed callback pushes the new value immediately.
  • acclient_2013_pseudo_c.txt:90988 (SmartBox::SetDefaultFov @ 0x00451e60) writes this->m_fGameFOV and nothing else.
  • :91727 / :92660 (0x00452b2f / 0x00453b14) re-evaluate m_fGameFOV / (m_ViewportAspectRatio 0.1) inside the render path, so the new m_fGameFOV takes effect on the very next frame. There is no restart.

acdream is next-launch-only.

  • src/AcDream.App/Settings/RuntimeSettingsController.cs:171-176Startup is snapshotted in the constructor.
  • :208-218ApplyStartup calls target.ApplyDisplay(Startup.Display), i.e. the ctor-time snapshot, and is guarded by _startupApplied so it runs once.
  • src/AcDream.App/UI/Layout/ConfigOptionsPageController.cs:571 — the FOV row's apply only calls bindings.SaveDisplay(...). Nothing on that path reaches CameraController.
  • ConfigOptionsPageController.cs:563-566 documents this honestly (// Field of View: NEXT-LAUNCH ...), and DisplaySettings.cs:27-31 repeats it — but neither is a register row, and a search of docs/architecture/retail-divergence-register.md for next launch / NEXT-LAUNCH / next-launch returns zero hits.

Why this is not "pre-existing, therefore out of scope". Before this commit the slider was a no-op on the camera the player actually looks through (CameraDiagnostics.UseRetailChaseCamera defaults truesrc/AcDream.Core/Rendering/CameraDiagnostics.cs:27-28 — and the old ApplyFieldOfView wrote only Orbit/Fly/Chase, never RetailChase). The setting was inert, so its latency was immaterial. This commit made the value matter, which is precisely when the register rule bites: the deviation is now observable. Either add the AD row, or make it live — the live fix is one call (cameras.SetGameFov(value * MathF.PI / 180f)) alongside the existing SaveDisplay in the apply lambda. If you take the live route, read MUST-FIX 3 first — do not route it through the per-frame preview seam.


MUST-FIX 3 (conditional) — the preview seam mutates cameras after the frame snapshot

src/AcDream.App/Rendering/WorldRenderFrameBuilder.cs:162-164:

WorldCameraFrame camera = _camera.Resolve();   // snapshots Projection/ViewProjection/Frustum
_visibility.Begin(in camera, waitingForLogin);
_settings.Apply(in camera);                    // ← calls ApplyFieldOfView → SetGameFov → ApplyProjection

Resolve() (:196-211) freezes Projection, ViewProjection and Frustum. RuntimeWorldFrameSettingsPreview.Apply (:368-380) then mutates every camera's FovY and now also Aspect. Downstream:

  • Culling / visibility use the snapshot: _visibility.BeginRetailSelectionScene.SetViewFrustum(camera.Frustum) (:335), _buildings.Gather(..., in frustum) (:170-173), _environmentFrustum.Update(camera.ViewProjection) (:346).
  • Rasterization re-reads the camera live: WorldScenePassExecutor.cs:128, 158, 171, 186, 208, 218, 227, 250, 280 all pass camera.Camera (the live ICamera), and TerrainModernRenderer.cs:207, Wb/WbDrawDispatcher.cs:2011, ParticleRenderer.cs:253, ParticleRenderer.Rhi.cs:296, Sky/SkyRenderer.cs:189 each recompute camera.View * camera.Projection.

So a FOV change applied at line 164 produces one frame culled at the old frustum and rasterized at the new one — pop-in / holes at the screen edge on every frame the value moves.

Why this is only a WATCH today. RuntimeSettingsController.cs:202public bool HasDraftPreview => false;, with the OP9 comment at :195-201 stating it is "always false in production" since SettingsVM was retired. The branch is unreachable outside tests.

Why it is a landmine. MUST-FIX 2's natural-looking fix is "make the preview path live". Doing that turns this dormant ordering bug into a live artifact on the retail chase camera. The safe fixes are (a) drive SetGameFov from the Config apply lambda, outside the frame graph entirely, or (b) move _settings.Apply above _camera.Resolve() in Build.


WATCH 4 — harness constants and comments still pin the deleted 60°

These compile and pass (explicit initializers override the changed defaults), but they now describe a projection no production camera uses. Two of them are load-bearing replay harnesses whose whole point is to reproduce the production frustum, and a 60° test frustum is 6.4° wider than production at 16:9 — a flood/clip regression that only shows at the production frustum can hide here.

Constructed at MathF.PI / 3f:

File Lines
tests/AcDream.App.Tests/Rendering/Issue177StairDescentCameraFloodTests.cs 194, 250, 272, 378, 423, 478
tests/AcDream.App.Tests/Rendering/PortalProjectionTests.cs 121, 154, 266, 418 — each tagged // RetailChaseCamera
tests/AcDream.App.Tests/Rendering/HouseExitWalkReplayTests.cs 479
tests/AcDream.App.Tests/Rendering/Issue181VisFlapReplayTests.cs 38
tests/AcDream.App.Tests/Rendering/OutdoorCellNodeTests.cs 41
tests/AcDream.App.Tests/Rendering/TerrainCullOrientationTests.cs 42
tests/AcDream.App.Tests/Rendering/TowerAscentReplayTests.cs 352

Comment-only drift (claims a production FOV that no longer exists):

  • CornerFloodReplayTests.cs:174"Production projection: ChaseCamera/FlyCamera use FovY ~1.2"
  • Issue130DoorwayStripTests.cs:49"FovY 1.2 rad"
  • Issue95DungeonFloodDiagnosticTests.cs:35"FovY ~1.2"
  • TerrainCullOrientationTests.cs:15 — cites RetailChaseCamera.cs:203 / :52 for the projection convention (line numbers also stale after the edit)

Recommended: leave the numeric constants (changing them re-baselines flood fixtures, which is its own gate) but retag them as "pinned pre-#389 constant, not the live production FOV", and point the replay harnesses at RetailFieldOfView.DefaultAppliedFovY where the value is not fixture-pinned.


CLEAN — per-surface enumeration

Axis 1: readers of ChaseCamera / RetailChaseCamera / OrbitCamera / FlyCamera .FovY / .Aspect / .Projection

No projection or FOV is cached anywhere. All four cameras compute Projection in a get-accessor from live FovY/Aspect: ChaseCamera.cs:66, OrbitCamera.cs:29, FlyCamera.cs:38, RetailChaseCamera.cs:56-57. A grep for cached-projection fields (_cachedProjection / _lastProjection / Matrix4x4 _projection) returns nothing in src/AcDream.App. There is no stale-matrix surface to fix.

The named suspects:

  • PortalTunnelPresentation.cs:313CLEAN, and now more faithful. _camera is a private PortalTunnelCamera (declared :87), not one of the four and never attached to CameraController; ApplyProjection cannot reach it and it cannot fight back. Its FOV comes from UseSmartBoxFov(smartBoxProjection) (:314), where the projection is _camera.Active.Projection read live at PrivatePresentationRenderer.cs:128. That is exactly retail's CreatureMode::UseSmartboxFOV reading the active SmartBox projection, so the tunnel now inherits the smartbox law for free — previously it inherited the invented 60°.
  • PrivateEntityViewportRenderer.cs:203CLEAN. _camera is an IPrivateEntityViewportCamera (:15, :73); the implementations are DollViewportCamera (DollCamera.cs:59, wrapping a private DollCamera with its own FovRadians = MathF.PI / 4f, DollCamera.cs:41) and CreatureAppraisalPresentation (:315, :343). Both are paperdoll/portrait surfaces, outside the law by design, and DollCamera's own doc already cites retail's UseSharpMode branch — which the decomp confirms at acclient_2013_pseudo_c.txt:91718 (Render::SetFOVRad(this->m_fFOVRadians), the non-smartbox arm of the same function that carries the smartbox site at 0x00452b2f).
  • TeleportViewPlaneController.cs:145-146CLEAN, with a noted footgun. ProjectionOverrideCamera.Aspect is a write-through into _source.Aspect, and _source is the active world camera (ApplyTo(_cameras.Active) via WorldRenderFrameBuilder.cs:198). Nothing in production writes it — the only writer anywhere is tests/.../PortalTunnelAssetTests.cs:229, against its own fixture source. Its Apply (:94-118) recovers aspect as baseProjection.M22 / baseProjection.M11, which for CreatePerspectiveFieldOfView is exactly aspect regardless of FOV — the changed FovY does not perturb it. Footgun: a future caller that sets .Aspect on the wrapper would be silently reverted by the next ApplyProjection, since the controller is now the sole aspect authority. Worth a one-line comment on the setter.
  • Picking / WorldSelectionQueryCLEAN. SelectionCameraSource.Snapshot() (Composition/InteractionUiRuntimeSources.cs:553-559) reads _camera.Active.Projection live at pick time, and WorldSelectionQuery.PickAt (:145-154) feeds that same snapshot's View + Projection into _selectionScene.Pick. There is no cached pick projection, so the pick ray cannot desync from the camera. (Pre-existing, unchanged: the candidate frustum handed to RetailSelectionScene.SetViewFrustum is the previous frame's snapshot — a one-frame skew across a resize, not introduced here.)
  • FrustumCuller / FrustumPlanesCLEAN. FrustumPlanes.FromViewProjection (FrustumCuller.cs:34-66) is a pure function of the passed matrix; the struct stores planes, never a FOV.
  • RetailChaseCamera consumers (Phase W single-viewpoint)CLEAN. ViewerCellId (:41) is the single viewpoint and is untouched. CameraFrameController.cs:75, MouseLookController.cs:184, CameraPointerInputController.cs:304, 399 and WorldRenderFrameBuilder.cs:255-259 all read pose / cell / the diagnostics flag — none reads FovY or Aspect. The near plane (0.1 m) and its long correctness note (RetailChaseCamera.cs:46-55) are unaffected: the commit changed only the FOV argument to the same CreatePerspectiveFieldOfView call.
  • Name-collision false positives (unrelated .Projection, entity-placement domain): Rendering/Scene/LiveRenderProjectionJournal.cs, Rendering/Scene/CurrentRenderSceneOracle.cs, Runtime/Physics/RuntimeSetPositionState.cs, Runtime/Entities/*, Headless/Hosting/*. CLEAN — not this surface.

Axis 2: readers of DisplaySettings.FieldOfView

  • ConfigOptionsPageController.cs:567-573CLEAN, improved. min: 10f, max: 160f, defaultValue: 90.0f already matched retail's registered row (UIPreferences::SetPreferenceRange(&Render_FieldOfView, 10f, 160f) @ 0x004043b2, acclient_2013_pseudo_c.txt:3261). Under the old semantics the row's defaultValue: 90 silently contradicted DisplaySettings.Default.FieldOfView = 60 — clicking "Defaults" wrote a different number than a fresh install. The commit made them agree. No slider change is needed.
  • WorldRenderFrameBuilder.cs:370-379 (preview path) — WATCH, see MUST-FIX 3.
  • RuntimeSettingsTargets.cs:130 / :143-148 (startup) — CLEAN. The degree→radian conversion matches retail's own option setter (SmartBox::SetDefaultFov @ 0x00451e6a, literal 0.017453292519943295).
  • RuntimeSettingsController.cs:165, 171-176, 208-218CLEAN as code, but is the mechanism behind MUST-FIX 2 (ctor-snapshot + once-only apply).
  • Headless / Runtime / CliCLEAN. grep DisplaySettings|SettingsStore over src/AcDream.Headless, src/AcDream.Cli, src/AcDream.Runtime returns nothing. Headless has no FOV surface to reinterpret.
  • Launcher / UI Studio / fixturesCLEAN. No launcher project exists (src/ is App, Bake, Cli, Content, Core, Core.Net, Headless, Plugin.Abstractions, Plugins.Smoke, Runtime, UI.Abstractions). No ui-studio settings surface reads FieldOfView. No settings.json fixture is committed (the only two in the tree are .claude/ and .vscode/). MUST-FIX 1's blast radius is therefore exactly "every real user's %LOCALAPPDATA%\acdream\settings.json" and nothing else.
  • tests/.../SettingsStoreTests.cs:45, 86CLEAN. Uses 100f and DisplaySettings.Default.FieldOfView; both semantics-neutral.

Axis 3: CameraController lifecycle

  • Constructor (CameraController.cs:57-62)CLEAN. C# runs field/property initializers before the constructor body, so GameFovRadians (:53) and _aspect (:55) are both set when ApplyProjection() runs.
  • Aspect seedingCLEAN. HostInputCameraComposition.cs:317-321 binds the camera target and then immediately calls FramebufferResize.Resize(InitialFramebufferSize), so _aspect is the real viewport before any camera is read. There is no window in which the controller's 16:9 default could stomp a correct non-16:9 aspect (the concern raised by FramebufferResizeController.cs:124"Late binding never replays an earlier resize transition" — is answered by that explicit initial Resize).
  • EnterChaseMode (:74-88)CLEAN. ApplyProjection() runs before _mode is assigned and before ModeChanged fires, so no listener can observe a half-converged camera. Both construction sites call it immediately after new: PlayerModeController.cs:350-357 (first entry) and :185-190 (ToggleFlyOrChase's _chase.Retail ??= new ...). No camera escapes the law. Note :350 / :353 still seed Aspect = _viewport.Aspect and EnterChaseMode immediately overwrites it from CameraController._aspect; harmless because FramebufferResizeController.Resize:126-127 feeds both from the same width/height in the same method, but the seed is now dead code.
  • RestoreState (:148-161)CLEAN. ApplyProjection() runs after the camera fields are assigned but before _mode and before ModeChanged, and it null-guards both chase slots (:130-131, :137-138), so restoring a Chase = null prior state is safe. Its only caller is the entry-failure rollback at PlayerModeController.cs:385; CameraControllerTests.RestoreState_ReestablishesPriorCameraAfterNotificationFailure (:126-150) still covers the throwing-listener path unchanged.
  • ExitChaseMode (:90-100)CLEAN. Deliberately does not re-apply; Orbit/Fly are already converged and the cleared chase cameras are garbage.
  • Re-entrancyCLEAN. ApplyProjection invokes no callbacks, so SetAspect/SetGameFov cannot re-enter.

Bonus verification the commit did not cite: the divisor's aspect orientation

Worth recording because it is the one input that could have silently inverted the whole law. If retail's m_ViewportAspectRatio were height/width, then at 16:9 the divisor would be 0.5625 0.1 = 0.4625, giving 1.5708 / 0.4625 = 3.396 radgreater than π, so TryAppliedVerticalFov would return false on every modern screen, the cameras would stay pinned at DefaultAppliedFovY forever, and the slider would be inert. The unit tests (which feed 16f/9f directly) would not catch it.

It is width/height, proved by the consumer rather than by the decomp of the producer: acclient_2013_pseudo_c.txt:423669 (PrimD3DRender::SetFOVInternal @ 0x0059ab71) passes m_ViewportAspectRatio straight into D3DXMatrixPerspectiveFovLH's Aspect parameter, which is width/height by the D3D contract. Note that the producer itself, RenderDevice::ComputeAspectForViewport @ 0x0054f150 (acclient_2013_pseudo_c.txt:345715-345722), decompiles to a bare return arg4; / return arg5; — the classic Binary Ninja FPU-elision artifact (see memory/reference_pe_byte_decode.md), so it cannot be read directly. Recommend adding the 0x0059ab71 citation to RetailFieldOfView's class doc — it is the only evidence in the tree that the divisor is not inverted, and the next reader will otherwise land on the mangled ComputeAspectForViewport and have to redo this.


Ranked action list

  1. MUST-FIXSettingsStore: bump CurrentSchemaVersion to 3 and migrate display.fieldOfView on load for files written at version ≤ 2. (SettingsStore.cs:42, 56-95, 397, 596)
  2. MUST-FIX — either make the Config FOV row live (ConfigOptionsPageController.cs:571 → also cameras.SetGameFov(...)) to match Render::GRPCallback_OnRenderPreferenceChanged @0x0054d999, or file the AD row for the next-launch divergence. Retiring AD-89 without one leaves an unrowed deviation.
  3. WATCH → MUST-FIX if #2 is taken live — do not route the live apply through WorldRenderFrameBuilder's preview seam; either call SetGameFov from the Config apply lambda or move _settings.Apply above _camera.Resolve() (WorldRenderFrameBuilder.cs:162-164).
  4. WATCH — retag the 15 MathF.PI / 3f harness sites and 4 stale comments as pinned pre-#389 constants (§WATCH 4 table).
  5. WATCH (documentation) — add the 0x0059ab71 D3DX citation to RetailFieldOfView's class doc; add a one-line note on TeleportViewPlaneController.ProjectionOverrideCamera.Aspect that the controller is now the sole aspect authority and a write here will be reverted.