# 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` ```csharp 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:42` — `private 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-176` — `Startup` is snapshotted in the constructor. - `:208-218` — `ApplyStartup` 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 **true** — `src/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`: ```csharp 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.Begin` → `RetailSelectionScene.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:202` — `public 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:313`** — **CLEAN, 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:203`** — **CLEAN.** `_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-146`** — **CLEAN, 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 / `WorldSelectionQuery`** — **CLEAN.** `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` / `FrustumPlanes`** — **CLEAN.** `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-573`** — **CLEAN, 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-218`** — **CLEAN as code**, but is the mechanism behind MUST-FIX 2 (ctor-snapshot + once-only apply). - **Headless / Runtime / Cli** — **CLEAN.** `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 / fixtures** — **CLEAN.** 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, 86`** — **CLEAN.** 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 seeding** — **CLEAN.** `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-entrancy** — **CLEAN.** `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 rad` — **greater 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-FIX** — `SettingsStore`: 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.