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>
22 KiB
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
Straight read. No range check, no version check —FieldOfView: ReadFloat (disp, "fieldOfView", d.FieldOfView),LoadDisplay(:56-95) never inspectsroot["version"]at all.src/AcDream.UI.Abstractions/Panels/Settings/SettingsStore.cs:596—["fieldOfView"] = d.FieldOfViewis written unconditionally byBuildDisplayObject, so everysettings.jsonthat has ever had adisplaysection 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 adisplaymigration.
- 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):
The preference-changed callback pushes the new value immediately.0054d984 float FieldOfView = Render::m_RenderPrefs.FieldOfView; 0054d98c Current_Render_FieldOfView = FieldOfView; 0054d999 SmartBox::SetDefaultFov(SmartBox::smartbox, FieldOfView);acclient_2013_pseudo_c.txt:90988(SmartBox::SetDefaultFov@0x00451e60) writesthis->m_fGameFOVand nothing else.:91727/:92660(0x00452b2f/0x00453b14) re-evaluatem_fGameFOV / (m_ViewportAspectRatio − 0.1)inside the render path, so the newm_fGameFOVtakes effect on the very next frame. There is no restart.
acdream is next-launch-only.
src/AcDream.App/Settings/RuntimeSettingsController.cs:171-176—Startupis snapshotted in the constructor.:208-218—ApplyStartupcallstarget.ApplyDisplay(Startup.Display), i.e. the ctor-time snapshot, and is guarded by_startupAppliedso it runs once.src/AcDream.App/UI/Layout/ConfigOptionsPageController.cs:571— the FOV row'sapplyonly callsbindings.SaveDisplay(...). Nothing on that path reachesCameraController.ConfigOptionsPageController.cs:563-566documents this honestly (// Field of View: NEXT-LAUNCH ...), andDisplaySettings.cs:27-31repeats it — but neither is a register row, and a search ofdocs/architecture/retail-divergence-register.mdfornext launch/NEXT-LAUNCH/next-launchreturns 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:
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, 280all passcamera.Camera(the liveICamera), andTerrainModernRenderer.cs:207,Wb/WbDrawDispatcher.cs:2011,ParticleRenderer.cs:253,ParticleRenderer.Rhi.cs:296,Sky/SkyRenderer.cs:189each recomputecamera.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— citesRetailChaseCamera.cs:203 / :52for 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._camerais a privatePortalTunnelCamera(declared:87), not one of the four and never attached toCameraController;ApplyProjectioncannot reach it and it cannot fight back. Its FOV comes fromUseSmartBoxFov(smartBoxProjection)(:314), where the projection is_camera.Active.Projectionread live atPrivatePresentationRenderer.cs:128. That is exactly retail'sCreatureMode::UseSmartboxFOVreading the active SmartBox projection, so the tunnel now inherits the smartbox law for free — previously it inherited the invented 60°.PrivateEntityViewportRenderer.cs:203— CLEAN._camerais anIPrivateEntityViewportCamera(:15,:73); the implementations areDollViewportCamera(DollCamera.cs:59, wrapping a privateDollCamerawith its ownFovRadians = MathF.PI / 4f,DollCamera.cs:41) andCreatureAppraisalPresentation(:315,:343). Both are paperdoll/portrait surfaces, outside the law by design, andDollCamera's own doc already cites retail'sUseSharpModebranch — which the decomp confirms atacclient_2013_pseudo_c.txt:91718(Render::SetFOVRad(this->m_fFOVRadians), the non-smartbox arm of the same function that carries the smartbox site at0x00452b2f).TeleportViewPlaneController.cs:145-146— CLEAN, with a noted footgun.ProjectionOverrideCamera.Aspectis a write-through into_source.Aspect, and_sourceis the active world camera (ApplyTo(_cameras.Active)viaWorldRenderFrameBuilder.cs:198). Nothing in production writes it — the only writer anywhere istests/.../PortalTunnelAssetTests.cs:229, against its own fixture source. ItsApply(:94-118) recovers aspect asbaseProjection.M22 / baseProjection.M11, which forCreatePerspectiveFieldOfViewis exactlyaspectregardless of FOV — the changed FovY does not perturb it. Footgun: a future caller that sets.Aspecton the wrapper would be silently reverted by the nextApplyProjection, 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.Projectionlive at pick time, andWorldSelectionQuery.PickAt(:145-154) feeds that same snapshot'sView+Projectioninto_selectionScene.Pick. There is no cached pick projection, so the pick ray cannot desync from the camera. (Pre-existing, unchanged: the candidate frustum handed toRetailSelectionScene.SetViewFrustumis 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.RetailChaseCameraconsumers (Phase W single-viewpoint) — CLEAN.ViewerCellId(:41) is the single viewpoint and is untouched.CameraFrameController.cs:75,MouseLookController.cs:184,CameraPointerInputController.cs:304, 399andWorldRenderFrameBuilder.cs:255-259all read pose / cell / the diagnostics flag — none readsFovYorAspect. 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 sameCreatePerspectiveFieldOfViewcall.- 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.0falready matched retail's registered row (UIPreferences::SetPreferenceRange(&Render_FieldOfView, 10f, 160f)@0x004043b2,acclient_2013_pseudo_c.txt:3261). Under the old semantics the row'sdefaultValue: 90silently contradictedDisplaySettings.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, literal0.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|SettingsStoreoversrc/AcDream.Headless,src/AcDream.Cli,src/AcDream.Runtimereturns 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). Noui-studiosettings surface readsFieldOfView. Nosettings.jsonfixture 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. Uses100fandDisplaySettings.Default.FieldOfView; both semantics-neutral.
Axis 3: CameraController lifecycle
- Constructor (
CameraController.cs:57-62) — CLEAN. C# runs field/property initializers before the constructor body, soGameFovRadians(:53) and_aspect(:55) are both set whenApplyProjection()runs. - Aspect seeding — CLEAN.
HostInputCameraComposition.cs:317-321binds the camera target and then immediately callsFramebufferResize.Resize(InitialFramebufferSize), so_aspectis 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 byFramebufferResizeController.cs:124— "Late binding never replays an earlier resize transition" — is answered by that explicit initialResize). EnterChaseMode(:74-88) — CLEAN.ApplyProjection()runs before_modeis assigned and beforeModeChangedfires, so no listener can observe a half-converged camera. Both construction sites call it immediately afternew:PlayerModeController.cs:350-357(first entry) and:185-190(ToggleFlyOrChase's_chase.Retail ??= new ...). No camera escapes the law. Note:350/:353still seedAspect = _viewport.AspectandEnterChaseModeimmediately overwrites it fromCameraController._aspect; harmless becauseFramebufferResizeController.Resize:126-127feeds 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_modeand beforeModeChanged, and it null-guards both chase slots (:130-131,:137-138), so restoring aChase = nullprior state is safe. Its only caller is the entry-failure rollback atPlayerModeController.cs:385;CameraControllerTests.RestoreState_ReestablishesPriorCameraAfterNotificationFailure(:126-150) still covers the throwing-listener path unchanged.ExitChaseMode(:90-100) — CLEAN. Deliberately does not re-apply;Orbit/Flyare already converged and the cleared chase cameras are garbage.- Re-entrancy — CLEAN.
ApplyProjectioninvokes no callbacks, soSetAspect/SetGameFovcannot 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
- MUST-FIX —
SettingsStore: bumpCurrentSchemaVersionto 3 and migratedisplay.fieldOfViewon load for files written at version ≤ 2. (SettingsStore.cs:42, 56-95, 397, 596) - MUST-FIX — either make the Config FOV row live
(
ConfigOptionsPageController.cs:571→ alsocameras.SetGameFov(...)) to matchRender::GRPCallback_OnRenderPreferenceChanged @0x0054d999, or file the AD row for the next-launch divergence. Retiring AD-89 without one leaves an unrowed deviation. - WATCH → MUST-FIX if #2 is taken live — do not route the live apply through
WorldRenderFrameBuilder's preview seam; either callSetGameFovfrom the Config apply lambda or move_settings.Applyabove_camera.Resolve()(WorldRenderFrameBuilder.cs:162-164). - WATCH — retag the 15
MathF.PI / 3fharness sites and 4 stale comments as pinned pre-#389 constants (§WATCH 4 table). - WATCH (documentation) — add the
0x0059ab71D3DX citation toRetailFieldOfView's class doc; add a one-line note onTeleportViewPlaneController.ProjectionOverrideCamera.Aspectthat the controller is now the sole aspect authority and a write here will be reverted.