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

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,386 @@
# 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.

View file

@ -0,0 +1,437 @@
# #389 SmartboxFOV — MECHANISM review of commit `7e0c1303`
**Date:** 2026-08-13
**Reviewer lens:** MECHANISM (is the ported law the law retail actually runs?)
**Subject:** `7e0c1303` — *fix #389: port retail's SmartboxFOV law; retire AD-89 (display slice 1)*
**Oracle:** `docs/research/named-retail/acclient_2013_pseudo_c.txt` +
`docs/research/named-retail/acclient.h`, **plus byte-level disassembly** of the
PDB-paired binary `C:\Users\erikn\Downloads\acclient.exe`
(`check_exe_pdb.py``MATCH`, GUID `9e847e2f-777c-4bd9-886c-22256bb87f32`,
linker 2013-09-06). Every x87 comparison and division in this area is
FPU-elided or ambiguous in the Binary Ninja pseudo-C, so **all polarity and
operand-order claims below are decided from the instruction bytes**, not from
BN's rendering. Raw disassembly is in the appendix.
Report only — no files under `src/` or `tests/` were modified.
---
## Verdict summary
| # | Question | Verdict |
|---|---|---|
| 1 | Formula transcription exact (operand order, 0.1, aspect variable, direct feed) | **CONFIRMED**, with one qualification: retail's `m_ViewportAspectRatio` is **not** raw `w/h` in general — see M1 |
| 2 | What `m_bUseSmartboxFOV == 0` selects; does acdream apply the law where retail uses the fixed branch | **CONFIRMED** — no misapplication. Paperdoll/appraisal/char-gen = fixed branch (π/4, byte-verified); portal space = smartbox law, and acdream's portal camera derives from the world projection |
| 3 | Does `0x00453b14` differ from the CreatureMode site | **CONFIRMED (differs, benignly)** — it is `SmartBox::RenderNormalMode`, a different class, with **no** `m_bUseSmartboxFOV` gate at all; the arithmetic is byte-identical |
| 4 | Gate is open (0, π), silent keep-previous; `SetFOVInternal` applies it **vertically** | **CONFIRMED** — open on both ends, `return 0` without touching `Render::fov`; and the value reaches `D3DXMatrixPerspectiveFovLH`'s **fovy** slot. The port is **not** inverted. One NaN corner: M3 |
| 5 | `0x00451e6a` is the FOV option's setter; `[10,160]`/default 90 registered | **CONFIRMED**`SmartBox::SetDefaultFov`, fed by `Render::UpdateFromPreferences`; range and default byte-verified |
| 6 | Other writers of `Render::fov` / other `SetFOVRad` callers the port ignores | **CONFIRMED (none ignored)** — only 3 `SetFOVRad` call sites exist; the one other FOV writer (`Render::set_vdst`, the teleport override) is already ported in `TeleportViewPlaneController`, and my byte-decode **vindicates** its `2·atan(1/d)` against BN's misleading `__fpatan(arg1, 1.0)` |
**No MUST-FIX defect was found in the law, the gate, or its application.** Three
bookkeeping/doc items are ranked below; the highest (M1) is a divergence-register
obligation created by this commit, not a wrong number at retail's defaults.
---
## Q1 — Is the formula transcription exact?
**CONFIRMED for the expression; QUALIFIED for what "the aspect" is.**
Both smartbox sites are byte-identical arithmetic. `CreatureMode::Render`
(pseudo-C line 91727, `0x00452b0d``0x00452b2f`):
```
00452b0d mov ecx, [0x86f330] ; RenderDevice::render_device
00452b13 fld dword [ecx + 0xa8] ; ST0 = m_ViewportAspectRatio
00452b1a fsub dword [0x7948cc] ; ST0 = aspect - 0.1f (0x3dcccccd)
00452b20 fdivr dword [eax + 0xc0] ; ST0 = m_fGameFOV / ST0 <-- fdivr: mem / ST0
00452b26 fstp dword [esp] ; float-rounded quotient
00452b2f call 0x54b2d0 ; Render::SetFOVRad(quotient)
```
- **Operand order:** `fdivr` is *reverse* divide — `ST0 = memory / ST0`, i.e.
`m_fGameFOV / (aspect 0.1)`. The numerator is the game FOV. **Correct in the
port.** (This is exactly the operand-order trap the question was aimed at; BN's
`((long double)m_fGameFOV) / (aspect - 0.1)` happens to agree, but only the
bytes prove it.)
- **The 0.1 literal:** `[0x7948cc] = 0x3dcccccd = 0.10000000149011612`. The port's
`AspectBias = 0.100000001f` parses to that identical float. **Exact.**
- **Direct feed:** the quotient is stored to the stack slot and immediately passed
to `SetFOVRad`; no clamp, scale, or half-angle in between. `SetFOVRad`
`SetFOVInternal(arg)``D3DXMatrixPerspectiveFovLH(pOut, arg, aspect, zn, zf)`
(`0x0059ab71`). **No further transformation. Correct in the port.**
- Retail evaluates in x87 80-bit and stores one `float`; acdream evaluates in
`float`. Sub-ULP; immaterial.
### The qualification (see M1): which variable holds the aspect
The divisor's left operand is `RenderDevice::m_ViewportAspectRatio` (offset
`0xa8`, confirmed by the store in `RenderDevice::SetViewport` at
`0054f22e fstp dword [esi+0xa8]`). That field is **not** `width/height` in the
path the game view uses. `RenderDevice::ComputeAspectForViewport` (`0x0054f150`,
pseudo-C line 345708) is FPU-elided in BN to a meaningless `return arg4 / arg5`.
The bytes say:
```
0054f150 mov al, [esp+0x14] ; arg6
0054f154 fild dword [esp+0xc] ; width
0054f15a je 0x54f181 ; arg6 == 0 -> second path
; arg6 != 0 : ST0 = width / height <-- raw viewport aspect
0054f17c fdivp st(1)
0054f17e ret 0x14
0054f181: ; arg6 == 0 :
0054f1a1 fdivp st(1) ; ST0 = width / height
0054f1a3 fmul dword [ecx+0xa4] ; * m_DisplayAspectRatio
0054f1a9 fmul dword [0x79b6dc] ; * 0.75
0054f1af ret 0x14
```
`m_DisplayAspectRatio` (offset `0xa4`, confirmed by the stores at `0059fc19` /
`0059fc24` / `0059fc33`) comes from the **`Render.AspectRatio` user preference**
(`RenderDeviceD3D::SetupDisplayAspectRatio` `0x0059fbd0`, pseudo-C 428476):
`1 → 1.33333337f`, `2 → 1.77777779f`, otherwise the display's own `w/h`.
The game view's viewport is installed with `arg6 = 0`
(`gmSmartBoxUI::RecvNotice_UpdateGameView`, `0x004d62f5`), so in normal gameplay:
```
m_ViewportAspectRatio = (viewportW / viewportH) × m_DisplayAspectRatio × 0.75
```
At the registered default `AspectRatio = 1` (static initializer
`Render::m_RenderPrefs.AspectRatio = 0x1` at `0x0081efa8`; also the safe-settings
reset at `0054ef25`), `1.33333337f × 0.75f` rounds to **exactly 1.0f**, so
`m_ViewportAspectRatio == w/h` and **the port matches retail bit-for-bit**.
It diverges only if the user selects the "widescreen" preference (× 4/3, a
noticeably narrower vertical FOV) — a preference acdream does not implement.
That is the M1 bookkeeping item, not a wrong number today.
---
## Q2 — What `m_bUseSmartboxFOV == 0` selects, and does acdream misapply the law?
**CONFIRMED — the port does not apply the smartbox law anywhere retail uses the
fixed branch.**
The flag lives on `CreatureMode` (`acclient.h:52561`), which is the base of
`UIElement_Viewport` (`acclient.h:52574`) — i.e. **it only exists for embedded 3-D
UI viewports**, never for the world. `CreatureMode::Render` `0x00452af0`:
```
00452ae8 mov al, [esi+0x6c] ; m_bUseSmartboxFOV
00452af0 je 0x452b2b ; == 0 -> fixed branch
00452b2b mov edx, [esi+0x68] ; m_fFOVRadians
00452b2f call SetFOVRad
```
`m_fFOVRadians` (offset `0x68`) has exactly **one** read and **zero** writes in the
whole binary — its value is the constructor default, byte-decoded at
`004543de mov dword [esi+0x68], 0x3f490fdb` = **π/4 = 45°** (BN hid this inside a
17-byte `memcpy` literal). So the fixed branch is a constant, aspect-independent
45° vertical FOV.
Who takes which branch (every caller in the binary):
| Site | Caller | Branch |
|---|---|---|
| `0x004a5aa9` | `gmPaperDollUI` (paperdoll) — `UseSharpMode` | fixed 45° |
| `0x004e0440` | char-gen 3-D view — `UseSharpMode` | fixed 45° |
| `0x004ee9bc` | `gmCG3DView``UseSharpMode` | fixed 45° |
| `0x004d6db3` | `gmSmartBoxUI::PostInit``m_pPortalSpace``UseSmartboxFOV` | **smartbox law** |
`CreatureMode::UseSmartboxFOV` (`0x00452380`, line 91348) is the *only* writer of
the flag and only ever sets it to 1.
acdream's side, checked exhaustively (`grep FovY`, `grep CreatePerspectiveFieldOfView`):
- The four cameras driven by `CameraController` (`Orbit`, `Fly`, `Chase`,
`RetailChase`) are all **world** cameras — retail's world render is
`SmartBox::RenderNormalMode`, which is *unconditionally* on the smartbox law.
Correct.
- `DollCamera.FovRadians = MathF.PI / 4f` — matches the byte-decoded fixed-branch
constant. The commit's "paperdoll is exempt by design" claim is not just
plausible, it is **numerically right**, and it is outside `CameraController`, so
the law can never reach it.
- `CreatureAppraisalPresentation`'s camera is likewise `π/4` and outside the
controller — the same fixed-branch class of viewport. Correct.
- **Portal space:** retail's portal space *does* take the smartbox law
(`0x004d6db3`). acdream's `PortalTunnelCamera.UseSmartBoxFov(smartBoxProjection)`
recovers the FOV from the world camera's projection matrix
(`fov = 2·atan(1/M22)`) each draw (`PortalTunnelPresentation.Draw`, line 314), so
it now inherits the new law automatically. Correct — and the "read the SmartBox
each draw" shape matches retail's per-frame re-application.
- `VulkanRhiScene`'s hardcoded `MathF.PI / 3f` is the RHI smoke-test scene
(quadrant markers, wobble backdrop), not a world path. Not a finding.
Startup ordering was also checked, because the law makes FOV aspect-dependent:
`HostInputCameraCompositionPhase` calls
`FramebufferResize.Resize(InitialFramebufferSize)` immediately after binding the
camera target (`HostInputCameraComposition.cs:320`), so the real framebuffer
aspect — not `DefaultAppliedFovY`'s assumed 16:9 — is in force before the first
frame.
---
## Q3 — Does `0x00453b14` differ from the CreatureMode site?
**CONFIRMED — different class, one structural difference, identical arithmetic.**
`0x00453b14` is inside **`SmartBox::RenderNormalMode`** (`0x00453aa0`, pseudo-C
line 92639) — the world render, not `CreatureMode`. Differences:
1. **No `m_bUseSmartboxFOV` gate exists there at all.** The function goes straight
to the `m_bUseViewDistance` test (`00453ae6 mov cl,[esi+0xc8]`). The world is
*always* on the smartbox law (modulo the teleport override). This is the
stronger statement the port relies on, and it holds.
2. `m_fGameFOV` is read from `this` (the SmartBox, `[esi+0xc0]`) rather than from
the `SmartBox::smartbox` global (`[eax+0xc0]` after
`a158ca8300 mov eax,[0x83ca58]`). Same object in practice — `CreatureMode::Render`
loads the global explicitly because `this` is a viewport, not the SmartBox.
3. Otherwise the instruction sequence is the same four ops
(`fld [dev+0xa8]` / `fsub 0.1f` / `fdivr m_fGameFOV` / `fstp`), then
`call SetFOVRad`.
Both sites share the second gate the commit message does not mention: if
`m_bUseViewDistance != 0`, retail calls `Render::set_vdst(m_fViewDistFOV)`
**instead** — see Q6.
---
## Q4 — The gate, and is the value vertical?
**CONFIRMED on both halves. The port is not inverted.**
### The acceptance interval — open (0, π), silent keep-previous
`Render::SetFOVRad` (`0x0054b2d0`, pseudo-C line 342158), bytes:
```
0054b2d0 fld dword [esp+4]
0054b2d4 fcomp dword [0x795344] ; 0x795344 = 0.0f
0054b2da fnstsw ax
0054b2dc test ah, 0x41 ; C0 (less) | C3 (equal)
0054b2df jnp 0x54b312 ; PF=0 <=> exactly one of C0/C3 <=> arg<0 or arg==0 -> reject
0054b2e1 fld dword [esp+4]
0054b2e5 fcomp qword [0x7bdd30] ; 0x7bdd30 = 3.141592653589793
0054b2ed test ah, 1 ; C0 (less)
0054b2f0 je 0x54b312 ; not-less (>= pi) -> reject
0054b2fc mov dword [0x81ec84], 0x3dcccccd ; Render::znear = 0.1f
0054b306 call [eax+0x1c] ; SetFOVInternal(arg)
0054b30c mov eax, 1
0054b311 ret
0054b312 xor eax, eax ; return 0 — no SetFOVInternal, Render::fov untouched
0054b314 ret
```
- `arg == 0` is **rejected** (C3 path) and `arg == π` is **rejected** (needs strict
C0). Interval is **open on both ends**. Matches
`fovY > 0f && fovY < MathF.PI`.
- The reject path is `xor eax,eax; ret` — it never reaches `SetFOVInternal`, so
`Render::fov`, `Render::vdst`, `Render::znear` and the view-to-clip matrix all
keep their previous values, **silently** (the `int32_t` return is discarded at
every call site). `CameraController.ApplyProjection`'s "return without touching
`FovY`" is the exact contract.
- Note the port also short-circuits `divisor <= 0`. Retail reaches the same verdict
by arithmetic: a negative divisor yields a negative quotient (rejected by test 1),
a zero divisor yields ±∞ (rejected by test 2). Behaviourally equivalent — except
for NaN, see **M3**.
### Vertical, not horizontal — three independent proofs
1. **`Render::SetFOVInternal` (`0x0054b340`)**: `Render::vdst = ty / tan(arg × 0.5)`
where `ty = (viewportHeight 1) × 0.5 × yinvscale` — half the viewport
**height** over a distance is `tan(half **vertical** FOV)`.
2. **`PrimD3DRender::SetFOVInternal` (`0x0059ab40`)** passes the same `arg2`
straight into `D3DXMatrixPerspectiveFovLH(&m, arg2, m_ViewportAspectRatio,
znear, zfar)`. That API's second parameter is **fovy** and its third is
width/height. Decisive.
3. **`SmartBox::GetOverrideFovDistance` (`0x00451be0`)** halves the *same*
smartbox expression before `fptan` and reciprocates it
(`fmul 0.5``fptan``fdivr 1.0`), i.e. it returns `cot(fov/2)` — only
meaningful if the expression is a full vertical angle. (It also proves
`view-plane distance == M22` of a perspective matrix, which is what
`TeleportViewPlaneController.Begin` assumes.)
acdream feeds the value to `Matrix4x4.CreatePerspectiveFieldOfView(FovY, Aspect, …)`
— same slot, same meaning. **Not inverted.**
---
## Q5 — Is `0x00451e6a` the Field of View option's setter, with [10,160]/default 90?
**CONFIRMED on every part.**
- Enclosing function: **`SmartBox::SetDefaultFov(SmartBox* this, float degrees)`**
at `0x00451e60` (pseudo-C line 90988) —
`m_fGameFOV = degrees × 0.017453292519943295`.
- Its **only** caller in the binary is `Render::UpdateFromPreferences()`
(`0x0054d850`, call at `0x0054d999`, pseudo-C 344365), guarded by a change test
against `Current_Render_FieldOfView` and fed from
`Render::m_RenderPrefs.FieldOfView`. So the chain is
option slider → preference → `UpdateFromPreferences``SetDefaultFov``m_fGameFOV`.
- **Range:** `UIPreferences::SetPreferenceRange(&Render_FieldOfView, 10f, 160f)` at
`0x004043b2`, inside **`gmClient::InitUIPreferences` (`0x004035b0`, pseudo-C line
2740)** — exactly as the commit claims. Corroborated by the console command at
`0x00455d20` which validates `>= 0xa && <= 0xa0` before
`UIPreferences::ModifyPreference(&Render_FieldOfView, …)`.
- **Default:** the static initializer `Render::m_RenderPrefs` at `0x0081efb8` reads
`float FieldOfView = 90`. The UI row is registered via
`PlayerOptionPage::AddSliderOption(&Render_FieldOfView, 1)` (`0x0049e53e`).
- **Ctor default of `m_fGameFOV`:** `0x00454649` in `SmartBox::SmartBox` sets
`1.57079637f` = π/2 = 90°, consistent with the degrees default. Also
`m_fViewDistFOV = 0`, `m_bUseViewDistance = 0`.
- acdream's `DisplaySettings.Default.FieldOfView = 90f` and the Config row
(`ConfigOptionsPageController` `min: 10f, max: 160f, defaultValue: 90.0f`) now
agree with each other and with retail. The pre-#389 mismatch (record default 60,
slider default 90) is genuinely closed.
**One behavioural difference, pre-existing (see M4):** retail applies an FOV change
**immediately** — `Render::UpdateFromPreferences` is reached from
`SceneTool::PrepareGraphicsDevice` (`0x0043e4f0`), a per-frame device-prep step.
acdream applies `DisplaySettings.FieldOfView` only through
`RuntimeSettingsController.ApplyStartup` (next launch), except while a Config
draft preview is open (`WorldRenderFrameBuilder.Apply``ApplyFieldOfView`).
---
## Q6 — Any other writer of `Render::fov` / `SetFOVRad` caller the port ignores?
**CONFIRMED — none ignored. The one "other law" is already ported, and my
byte-decode corrects the decomp in the port's favour.**
`SetFOVRad` has exactly **three** call sites in the binary (grep of the full
pseudo-C): `0x00452b2f` (CreatureMode, both branches converge on this one call)
and `0x00453b14` (SmartBox::RenderNormalMode). Nothing else calls it.
`Render::fov` is written only inside `Render::SetFOVInternal`, reached from:
1. `SetFOVRad` — the ported path.
2. **`Render::set_vdst(float)` (`0x0054b240`)** — the *view-distance override*.
Selected by `m_bUseViewDistance != 0` at **both** smartbox sites, i.e. it
*replaces* the smartbox law while armed. Armed/disarmed by
`SmartBox::SetOverrideFovDistance` (`0x00451bc0`), whose only callers are the
**teleport animation** in `gmSmartBoxUI`: `EndTeleportAnimation`
(`0x004d65d5`, disarm) and `UseTime` (`0x004d71c4`, `0x004d725f`, `0x004d73ce`
arm; `0x004d747f` disarm), interpolating `teleportCurVDist` between the game's
value and `TRANSITION_VIEW_PLANE_DISTANCE` over `TELEPORT_ANIM_FADE_TIME`.
3. `Render::set_zfar` (`0x0054b320`) — re-applies the *existing* `Render::fov`
after changing the far plane. Not an independent FOV writer.
acdream already ports (2) as `TeleportViewPlaneController`, and it is correct:
```
0054b240 fld qword [0x7928c0] ; 1.0
0054b246 fld dword [esp+4] ; d (ST0=d, ST1=1.0)
0054b24a fpatan ; atan(ST1/ST0) = atan(1/d)
0054b24c fadd st(0), st(0) ; fov = 2*atan(1/d)
0054b252 fcomp dword [0x7ca9f0] ; 0.4f
0054b25d jp -> znear = 0.1f ; d < 0.4
0054b263 fmul dword [0x7c8a04] ; else znear = d * 0.25
; then clamp fov to [0.001 (0x794720), pi (0x7bdd30)] and call SetFOVInternal
```
BN renders line 342123 as `__fpatan(arg1, 1.0)`, which reads as `atan(d)` and would
have made the port's `2f * MathF.Atan(1f / distance)` look wrong. The bytes show
x87 `fpatan` computes `atan(ST1/ST0)` = **`atan(1/d)`** — **the port is right and
the decomp text is the misleading party.** `near = MathF.Max(0.1f, distance*0.25f)`
is likewise equivalent to retail's `d < 0.4 ? 0.1 : d*0.25` at every input.
Residual (pre-existing, not #389): acdream's world cameras use a 5000 m far plane
where `Render::zfar` defaults to 4000 (`0x0081ec88`); `PortalTunnelCamera` already
documents 4000.
---
## Findings, ranked
### M1 — REGISTER/DOC (highest): "the viewport aspect" is a preference-scaled quantity
`RetailFieldOfView`'s class doc and the commit message both describe the divisor's
left operand as the viewport aspect ratio. Retail's `m_ViewportAspectRatio` for the
game view is `(w/h) × m_DisplayAspectRatio × 0.75`, where `m_DisplayAspectRatio` is
driven by the `Render.AspectRatio` preference (`Normal`→4:3, widescreen→16:9,
otherwise the display's own ratio). At the **registered default (1)** the factor is
exactly 1.0f and acdream matches retail bit-for-bit; at the widescreen setting
retail's effective aspect is 4/3 larger and the vertical FOV correspondingly
narrower. acdream implements no aspect-ratio preference.
*Why it matters:* #389 is the commit that made this field load-bearing, and the
project rule is that a commit introducing a deviation carries its register row.
**Action:** add a divergence-register row ("acdream feeds the raw framebuffer
aspect; retail scales it by the `Render.AspectRatio` preference — identical at
retail's default, divergent if that preference is ever implemented") and correct
the one-line description in `RetailFieldOfView`'s doc to name
`ComputeAspectForViewport @0x0054f150` as the definition. No behaviour change.
### M2 — NOTE: FOV changes do not take effect until next launch
Retail's option applies within one frame (`SceneTool::PrepareGraphicsDevice`
`Render::UpdateFromPreferences``SetDefaultFov`; the smartbox re-reads
`m_fGameFOV` every `RenderNormalMode`). acdream applies it via `ApplyStartup` only
(live only during a Config draft preview). Pre-existing and documented in
`DisplaySettings`/`ConfigOptionsPageController`, but the semantics change in this
commit makes the lag more visible (the slider now changes the *shape* of the view,
not just its zoom). Worth an issue or a register row; not a #389 defect.
### M3 — NOTE: NaN corner of the gate is inverted
Retail's first test is `test ah,0x41 / jnp`: an unordered compare sets both C0 and
C3 → even parity → **not** taken, and the second test's `test ah,1` sees C0 set →
**accepted**. So retail would pass a NaN FOV through to `SetFOVInternal`. acdream's
`fovY > 0f && fovY < MathF.PI` rejects NaN. Reaching it requires `m_fGameFOV` NaN or
an exact `0/0`, neither of which the option range permits. Record it, do not
"fix" it — rejecting NaN is strictly safer and unreachable.
### M4 — DOC nit: the "~8590° horizontal" claim
At the 90° default the law yields horizontal FOV ≈ **89.0°** (4:3), **83.9°**
(16:9), **80.6°** (21:9). The class doc says "~8590°"; the test asserts
`[80°, 90°]` (correct). Suggest "~8089°, near-constant" in the doc so the doc and
the test agree.
### Verified-good (no action)
- `AspectBias = 0.100000001f``0x3dcccccd`. Exact.
- `DefaultGameFovRadians = 1.57079637f` ≡ the `0x00454649` ctor literal. Exact.
- Golden test values recomputed from the bytes: 4:3 → 1.27362 rad, 16:9 → 0.93624 rad,
21:9 → 0.70334 rad (test asserts 0.70327 at `precision: 4` — both round to 0.7033).
- `DollCamera` / `CreatureAppraisalPresentation` at π/4 match the byte-decoded
`CreatureMode::m_fFOVRadians` default `0x3f490fdb`.
- Startup applies the real framebuffer aspect before the first frame.
- `TeleportViewPlaneController`'s `2·atan(1/d)` and `max(0.1, 0.25d)` are correct
against the bytes, contradicting the BN text.
---
## Appendix — primary citations
| Address | Pseudo-C line | Symbol / meaning |
|---|---|---|
| `0x00452af0``0x00452b2f` | 9171791727 | `CreatureMode::Render` FOV branch (fixed vs smartbox vs vdst) |
| `0x00453ae6``0x00453b14` | 9265592660 | `SmartBox::RenderNormalMode` smartbox site (no smartbox-flag gate) |
| `0x00452380` | 91348 | `CreatureMode::UseSmartboxFOV` — sets the flag to 1 (only writer) |
| `0x00452390` | 91356 | `CreatureMode::UseSharpMode` |
| `0x004a5aa9` / `0x004e0440` / `0x004ee9bc` | 175534 / 228563 / 241997 | paperdoll + char-gen viewports (fixed branch) |
| `0x004d6db3` | 219378 | `gmSmartBoxUI::PostInit` — portal space takes the smartbox law |
| `0x004543de` | (in 93102 ctor) | `m_fFOVRadians = 0x3f490fdb` = π/4 (byte-decoded) |
| `0x00451be0``0x00451c18` | 9079290806 | `SmartBox::GetOverrideFovDistance` = `cot(fov/2)` |
| `0x00451bc0` | 90783 | `SmartBox::SetOverrideFovDistance` |
| `0x00451e60`/`0x00451e6a` | 9098890991 | `SmartBox::SetDefaultFov` — degrees → radians |
| `0x00454649` | 93302 | `SmartBox` ctor `m_fGameFOV = 1.57079637f` |
| `0x0054b240` | 342121 | `Render::set_vdst``fov = 2·atan(1/d)`, `znear` rule, `[0.001, π]` clamp |
| `0x0054b2d0` | 342158 | `Render::SetFOVRad` — open `(0, π)` gate, silent reject |
| `0x0054b340` | 342196 | `Render::SetFOVInternal``vdst = ty / tan(fov/2)` (vertical) |
| `0x0059ab40` | 423666 | `PrimD3DRender::SetFOVInternal``D3DXMatrixPerspectiveFovLH(fovy, aspect, …)` |
| `0x0054f150` | 345708 | `RenderDevice::ComputeAspectForViewport``(w/h)·DAR·0.75` when `arg6 == 0` |
| `0x0054f1c0`/`0x0054f22e` | 345720/345749 | `RenderDevice::SetViewport` — writes `m_ViewportAspectRatio` (`+0xa8`) |
| `0x0059fbd0` | 428476 | `RenderDeviceD3D::SetupDisplayAspectRatio` — preference → `m_DisplayAspectRatio` |
| `0x0054d850`/`0x0054d999` | 344244/344365 | `Render::UpdateFromPreferences``SetDefaultFov` |
| `0x0043e4f0` | 69105 | `SceneTool::PrepareGraphicsDevice``UpdateFromPreferences` (per-frame) |
| `0x004035b0`/`0x004043b2` | 2740/3261 | `gmClient::InitUIPreferences``SetPreferenceRange(10, 160)` |
| `0x0081efb8` / `0x0081efa8` | 1102208 ff. | `m_RenderPrefs.FieldOfView = 90`, `.AspectRatio = 1` |
| `0x004d65d5`, `0x004d71c4`, `0x004d725f`, `0x004d73ce`, `0x004d747f` | 219002219775 | teleport-animation FOV override arm/disarm |
Constants byte-read from the paired binary: `[0x7948cc] = 0.1f (0x3dcccccd)`,
`[0x79b6dc] = 0.75f`, `[0x795344] = 0.0f`, `[0x7bdd30] = π (f64)`,
`[0x794720] = 0.001 (f64)`, `[0x7928c0] = 1.0 (f64)`, `[0x7ca9f0] = 0.4f`,
`[0x7c8a04] = 0.25f`.

View file

@ -341,6 +341,9 @@ internal sealed class SessionPlayerCompositionPhase
// CH6c: null when no retained UI exists (e.g. a no-window host) — the
// Chat tab's opacity sliders then apply through NullRuntimeChatOpacityTarget.
chatOpacity: interaction.RetainedUi?.Runtime.WindowOpacity,
// #389 blast MUST-FIX 2: the live Field-of-View apply target —
// see RuntimeSettingsTargets' cameras ctor doc.
cameras: host.CameraController,
log: d.Log,
// OP6: null on a no-audio/headless host — ApplyAudio then
// silently no-ops, same shape as chatOpacity above.

View file

@ -9,11 +9,28 @@ namespace AcDream.App.Rendering;
/// <code>
/// Render::SetFOVRad(m_fGameFOV / (RenderDevice::m_ViewportAspectRatio - 0.1))
/// </code>
/// (<c>CreatureMode</c> smartbox sites <c>0x00452b2f</c> and <c>0x00453b14</c>;
/// the same expression feeds a <c>tan</c> at <c>0x00451c0e</c>). The net
/// effect: the HORIZONTAL view stays roughly constant (~8590° of world at the
/// 90° default) across aspect ratios while wide screens trim the vertical
/// slice — 4:3 ≈ 73° vertical, 16:9 ≈ 53.6°, 21:9 ≈ 40°.
/// (<c>CreatureMode</c> smartbox site <c>0x00452b2f</c> and
/// <c>SmartBox::RenderNormalMode @0x00453b14</c> — the latter has NO
/// <c>m_bUseSmartboxFOV</c> gate at all, so the world view is unconditionally
/// on this law; the same expression feeds a <c>tan</c> at <c>0x00451c0e</c>).
/// The applied value is decisively the VERTICAL FOV: the projection consumer
/// is <c>D3DXMatrixPerspectiveFovLH</c> with it in the fovy slot
/// (<c>0x0059ab71</c>; instruction-byte-verified by the 2026-08-13 mechanism
/// review — the BN text FPU-elides every comparison in this area). The net
/// effect: the HORIZONTAL view stays roughly constant across aspect ratios
/// (89.0° at 4:3, 83.9° at 16:9, 80.6° at 21:9, at the 90° default) while
/// wide screens trim the vertical slice — 4:3 ≈ 73° vertical,
/// 16:9 ≈ 53.6°, 21:9 ≈ 40°.
///
/// <para>Aspect nuance (mechanism review M1, register row AD-90): retail's
/// divisor aspect is not raw width/height —
/// <c>RenderDevice::ComputeAspectForViewport @0x0054f150</c> yields
/// <c>(w/h) × m_DisplayAspectRatio × 0.75</c>, where
/// <c>m_DisplayAspectRatio</c> comes from the registered
/// <c>Render.AspectRatio</c> preference. At that preference's DEFAULT
/// (4:3 → factor exactly 1.0f) the expression collapses to raw w/h, which is
/// what acdream uses; acdream has no AspectRatio preference, so a retail
/// user who changed it would see a framing this port does not reproduce.</para>
///
/// <para><c>m_fGameFOV</c> is the user-facing number: ctor default
/// <c>1.57079637f</c> = π/2 = 90° (<c>0x00454649</c>), set in DEGREES by the

View file

@ -286,6 +286,7 @@ internal sealed class RuntimeSettingsTargets : IRuntimeSettingsTargets
private readonly ICommandBus _commands;
private readonly Action<string> _log;
private readonly OpenAlAudioEngine? _audio;
private readonly CameraController? _cameras;
public RuntimeSettingsTargets(
IRuntimeDisplayWindowTarget displayWindow,
@ -301,7 +302,14 @@ internal sealed class RuntimeSettingsTargets : IRuntimeSettingsTargets
// ApplyAudio's doc. Optional/trailing so every pre-existing
// construction site keeps compiling unchanged (matches
// chatOpacity/log's own optional-trailing shape).
OpenAlAudioEngine? audio = null)
OpenAlAudioEngine? audio = null,
// #389 blast-review MUST-FIX 2: retail's FOV preference applies LIVE
// (Render::GRPCallback_OnRenderPreferenceChanged @0x0054d999 →
// SmartBox::SetDefaultFov → m_fGameFOV, re-read by the smartbox
// sites every render) — the saved Field of View must reach the
// cameras on Save, not on the next launch. Null on hosts with no
// camera graph (headless / fixture callers).
CameraController? cameras = null)
: this(
displayWindow,
new RuntimeQualityApplicationTarget(
@ -317,7 +325,8 @@ internal sealed class RuntimeSettingsTargets : IRuntimeSettingsTargets
chatOpacity is null
? NullRuntimeChatOpacityTarget.Instance
: new RuntimeChatOpacityTarget(chatOpacity),
audio)
audio,
cameras)
{
}
@ -328,7 +337,8 @@ internal sealed class RuntimeSettingsTargets : IRuntimeSettingsTargets
ICommandBus commands,
Action<string>? log = null,
IRuntimeChatOpacityTarget? chatOpacity = null,
OpenAlAudioEngine? audio = null)
OpenAlAudioEngine? audio = null,
CameraController? cameras = null)
{
_displayWindow = displayWindow
?? throw new ArgumentNullException(nameof(displayWindow));
@ -338,10 +348,20 @@ internal sealed class RuntimeSettingsTargets : IRuntimeSettingsTargets
_commands = commands ?? throw new ArgumentNullException(nameof(commands));
_log = log ?? Console.WriteLine;
_audio = audio;
_cameras = cameras;
}
public void ApplyDisplayWindowState(DisplaySettings display) =>
public void ApplyDisplayWindowState(DisplaySettings display)
{
_displayWindow.Apply(display);
// #389 blast MUST-FIX 2 (see the ctor's cameras doc): the Field of
// View applies live on Save, from the update-phase save handler —
// deliberately NOT from the render-phase preview seam
// (WorldRenderFrameBuilder.Apply), whose mid-frame camera mutation
// is the review's WATCH-3 cull-vs-raster landmine.
if (_cameras is not null)
RuntimeSettingsStartupTargets.ApplyFieldOfView(_cameras, display.FieldOfView);
}
/// <summary>Campaign OP slice OP6: reuses the SAME static helper the
/// startup path (<see cref="RuntimeSettingsStartupTargets.ApplyAudio"/>)

View file

@ -575,10 +575,11 @@ public static class ConfigOptionsPageController
storeOnly: true, // TS-74
rangeLowKey: "ID_Graphics_Value_Slow", rangeHighKey: "ID_Graphics_Value_Fast");
// Field of View: NEXT-LAUNCH via DisplaySettings.FieldOfView + the
// existing RuntimeSettingsController.ApplyStartup path — matches
// the pre-existing (dev-tools Settings panel era) behaviour, not a
// new gap this slice introduces.
// Field of View: LIVE as of the #389 fix round — the stored value is
// retail's m_fGameFOV in degrees and applies on Save through
// RuntimeSettingsTargets.ApplyDisplayWindowState (retail applies its
// FOV preference live too: Render::GRPCallback_OnRenderPreferenceChanged
// @0x0054d999 → SmartBox::SetDefaultFov).
BuildSliderRow(
listBox, RangedSliderTemplateIndex, "ID_Graphics_FieldOfView",
min: 10f, max: 160f, defaultValue: 90.0f, page, resolveString,

View file

@ -20,10 +20,14 @@ public readonly record struct UiWindowPosition(float X, float Y);
/// <see cref="Input.KeyBindings.LoadOrDefault"/> path.
///
/// <para>
/// Schema (current version 2):
/// Schema (current version 3 — v3 changed the MEANING of
/// <c>display.fieldOfView</c>: it was the applied vertical FOV in degrees,
/// it is now retail's <c>m_fGameFOV</c> in degrees (#389, the SmartboxFOV
/// port). <see cref="LoadDisplay"/> migrates pre-v3 values on read — see
/// <see cref="MigrateLegacyVerticalFovDegrees"/>):
/// <code>
/// {
/// "version": 2,
/// "version": 3,
/// "display": { "resolution": "1920x1080", "fullscreen": false, ... }
/// "windowLayouts": { "Character": { "1920x1080": { "chat": { ... } } } }
/// }
@ -39,7 +43,7 @@ public readonly record struct UiWindowPosition(float X, float Y);
/// </summary>
public sealed class SettingsStore
{
private const int CurrentSchemaVersion = 2;
private const int CurrentSchemaVersion = 3;
private readonly string _path;
public SettingsStore(string path)
@ -66,11 +70,18 @@ public sealed class SettingsStore
return DisplaySettings.Default;
var d = DisplaySettings.Default;
// #389 (schema v3): a pre-v3 file's stored fieldOfView was the
// applied vertical FOV; the same number now means retail's
// gameFOV. Migrate on read — an absent key needs none (it falls
// back to the already-v3 default).
float fieldOfView = ReadFloat(disp, "fieldOfView", d.FieldOfView);
if (ReadSchemaVersion(root) < 3 && disp.TryGetProperty("fieldOfView", out _))
fieldOfView = MigrateLegacyVerticalFovDegrees(fieldOfView);
return new DisplaySettings(
Resolution: ReadString (disp, "resolution", d.Resolution),
Fullscreen: ReadBool (disp, "fullscreen", d.Fullscreen),
VSync: ReadBool (disp, "vsync", d.VSync),
FieldOfView: ReadFloat (disp, "fieldOfView", d.FieldOfView),
FieldOfView: fieldOfView,
Gamma: ReadFloat (disp, "gamma", d.Gamma),
ShowFps: ReadBool (disp, "showFps", d.ShowFps),
Quality: ReadQuality (disp, "quality", d.Quality),
@ -94,6 +105,38 @@ public sealed class SettingsStore
}
}
/// <summary>The file's top-level schema version; a file predating the
/// version key reads as 1 (the same convention the v1 radar-layout
/// migration below already uses).</summary>
private static int ReadSchemaVersion(JsonElement root) =>
root.TryGetProperty("version", out var v) && v.ValueKind == JsonValueKind.Number
? v.GetInt32()
: 1;
/// <summary>
/// #389 blast-review MUST-FIX 1 — the v3 fieldOfView migration. The
/// pre-v3 number was the applied VERTICAL FOV in degrees (written
/// straight onto the cameras); the v3 number is retail's
/// <c>m_fGameFOV</c> in degrees, run through the smartbox law. Two
/// cases:
/// - exactly the old default 60 → the new default 90: 60 was acdream's
/// own invented constant, not a user preference, and mapping it to
/// retail's registered default gives every untouched file the retail
/// experience;
/// - anything else was a DELIBERATE choice → preserve the user's
/// visible framing at the era's default 16:9 aspect:
/// gameFOV = vFOV × (16/9 0.1) (the smartbox law inverted at that
/// aspect), clamped into the slider's registered [10,160].
/// Runs on READ for any pre-v3 file; the next save stamps version 3 and
/// the value stops migrating.
/// </summary>
internal static float MigrateLegacyVerticalFovDegrees(float legacyVerticalFovDegrees)
{
if (legacyVerticalFovDegrees == 60f)
return 90f;
return Math.Clamp(legacyVerticalFovDegrees * (16f / 9f - 0.1f), 10f, 160f);
}
/// <summary>
/// Save Display settings, preserving any other top-level keys the file
/// already contains (e.g. an <c>audio</c> section written by a newer

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()
{