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

386 lines
22 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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.