diff --git a/docs/ISSUES.md b/docs/ISSUES.md
index 05baf8bd..b151c3f6 100644
--- a/docs/ISSUES.md
+++ b/docs/ISSUES.md
@@ -56,7 +56,22 @@ invent a clamp rule. Part of the display work block with #376/#377/#388/
## #389 — World-camera FOV is an invented aspect-independent constant; retail is SmartboxFOV (vFOV = gameFOV / (aspect − 0.1))
-**Status:** OPEN — filed 2026-08-13 (user gate report: "meant to run on an
+**Status:** DONE 2026-08-13 (this commit) — display block slice 1, pending
+the user's feel gate. `RetailFieldOfView` ports the law + the
+`Render::SetFOVRad` (0, π) acceptance gate verbatim;
+`CameraController` owns `GameFovRadians` (default 90°) and recomputes
+every camera's aspect + applied FOV on `SetAspect`/`SetGameFov`/
+`EnterChaseMode`/`RestoreState`; `ApplyFieldOfView` converts the stored
+degrees exactly as retail's option setter does; the four π/3 camera
+constants are deleted; `DisplaySettings.Default.FieldOfView` is retail's
+registered 90. **The same seam fixed a second latent squish bug: SetAspect
+never propagated to the CHASE cameras at all — a mid-session resize left
+the play camera on its creation-time aspect, drawing the world stretched
+onto the new viewport.** The user's stored settings.json was migrated
+60→90 by hand (the stale pre-port default). Register AD-89 retired in
+this commit. Original filing below.
+
+**Original filing:** OPEN — filed 2026-08-13 (user gate report: "meant to run on an
old aspect ratio... modern screens feels weird... some resolutions feels
like it is just squished"). Decomp-verified retail law:
`Render::SetFOVRad(SmartBox::m_fGameFOV / (RenderDevice::m_ViewportAspectRatio − 0.1))`
diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md
index fd048f6a..c7150251 100644
--- a/docs/architecture/retail-divergence-register.md
+++ b/docs/architecture/retail-divergence-register.md
@@ -62,7 +62,7 @@ accepted-divergence entries (#96, #49, #50).
---
-## 2. Adaptation (AD) — 69 active rows (AD-89 filed 2026-08-13 at the display gate session — the world cameras' invented aspect-independent 60° vertical FOV versus retail's SmartboxFOV law `gameFOV/(aspect−0.1)` with 90° default set in degrees by the Field of View option (ISSUES #389, port planned); AD-88 filed 2026-08-13 at the #385 dropdown fix — the vendor category dropdown keeps G5's fixed 6-row scrollable window although its authored popup ListBox is edge-docked, the condition that arms retail's `RecalculatePopupSize` size-to-content resize; classification UNCLEAR pending a retail side-by-side (ISSUES #386); AD-87 filed 2026-08-12 at Campaign FA slice FA6 — the allegiance-swear half of the two-bot headless gate is written+wired but `AllegianceGateEnabled=false` (disabled by default), unverified end-to-end over the wire because ACE returns nothing to the `0x001D` swear (ISSUES #384); the FELLOWSHIP two-session gate passed live and ships as FA6's automated proof; AD-86 filed 2026-08-12 at Campaign FA slice FA5, item 4 — ACE's deliberate zeroing of officers/officer titles/MOTD/MOTD-set-by/name-last-set-time/lock/approved-vassal/timeOnline/allegianceAge, dropped past acdream's own parse layer to match retail's own no-widget presentation; AD-85 filed 2026-08-12 at Campaign FA slice FA5 — the Allegiance page's numeric-only fields and its three local confirmation dialogs' unsubstituted-verbatim-or-bare-name text, the same unported `StringInfo` gap AD-81 filed for Fellowship; AD-84 filed 2026-08-12 at Campaign FA slice FA5 — the Swear button's missing "target is a player" gate, the same class as AD-83's Recruit-button gap; AD-83 filed 2026-08-12 at the Campaign FA slice FA4 fix round (mechanism MUST-FIX 5) — the Recruit button's missing "target is a player" gate, previously an inline comment not a row; AD-82 filed 2026-08-12 at the Campaign FA slice FA4 fix round (mechanism MUST-FIX 4/5) — the invented leader-tint/selection-tint colors, the name-text-only row click target, and the page-local (not generic-`UiTemplateListBox`) world→panel selection sync; AD-81 filed 2026-08-12 at Campaign FA slice FA4 — the fellowship roster/create-flow text-composition gap (unported `StringInfo` variable substitution + `ACCharGenData::FormatName`); AD-80 filed 2026-08-12 at Campaign FA slice FA4, D5 — the panel's retail-exact XP-share percentage display versus the currently-targeted ACE server's slightly different actual grant; AD-79 filed 2026-08-12 at Campaign FA slice FA3, D1 — the social panel's Friends/Squelch page action buttons (add/remove friend, appear offline, squelch add/remove/clear) are honest INERT, no wire implemented this campaign; AD-78 filed 2026-08-11 at Campaign OP's gate-2 follow-up (user-directed, verbatim "mark all options that are not implemented now, so I can clearly see what is not implemented") — the shared store-only-caption-dimming convention across the Character/Config option tabs and Configure Keyboard; AD-77 filed 2026-08-11 at the Campaign OP OP3 review-fix round — the client-wide floating-only `gmPanelUI` host divergence (retail also exposes a docked `0x21000017` host) the plan's §5 delegated to the OP3 dual review, scoped to every main panel not just Options; AD-76/AD-75/AD-74 filed 2026-08-11 at Campaign OP slice OP3 — the Options panel's Exit to Character Selection "behaves as Exit Game" adaptation (D6), the Urgent Assistance/Report Abuse dead-URL interface-text short-circuit (D5), and In-Game Help Files' asset-missing inert button (D5); AD-73 filed 2026-08-11 at the Campaign OP OP2 rework — `UiTabPanel`'s dormant-until-`ActivateTabBehavior()` activation model, replacing retail's unconditional per-instance tab-table wiring, so the four already-shipped Type-8 hosts keep their existing controller-owned switching without a double-driver race; AD-72 filed 2026-08-08 at the Slice 5.3 review corrections — `VendorPricing`'s double-precision narrowing versus retail's x87 extended precision, same class as AD-33; AD-65 RETIRED and AD-69 FILED 2026-08-07 at Campaign S S4 — the away-arm now snaps per retail @0x00509c50, while AD-66's byte-confirmed sibling landing is WITHHELD pending #341's measurement-anomaly apparatus, and AD-69 records the seam-frame dist gap the same pass discovered; AD-56 RESTORED 2026-08-07 — the a8a7d64b revert had collaterally DELETED it, the inverse of the AD-55 zombie it also created; its plumb-fall-freeze condition is live again since TS-4’s real retirement at Slice 2B; AD-55 RE-RETIRED 2026-08-07 — its 2026-07-30 retirement at 252e8068 was collaterally resurrected by the a8a7d64b revert of the unrelated TS-4 commit; the code kept the cos(10°) fix throughout; AD-68 filed 2026-08-07 at the #338 closure — the async-residency placeholder mover shape (0.4/0.4 steps + capsule) has no retail counterpart because retail loads synchronously; AD-67 filed 2026-08-07 at the #32 closeout — the narrowed `SetContactPlane` keeps its per-write `ContactPlaneCellId`, which retail writes only at `init_contact_plane`; AD-49 filed 2026-08-06 at the #334 fix — the BSP part-array flood runs its outdoor cell rectangle at seed time rather than only from retail’s residency-gated walk, keeping both registration floods on one residency rule; AD-64 filed 2026-08-05 at the C5b architecture review's D1 fix — AD-60's W2 wire-cell REACHABILITY decision is expressed once per host because the two hosts run parallel non-shared inbound routes; the committed VALUE is single-sourced at `RuntimeEntityObjectLifetime.CommitWireCellRebucket`, and unification is filed as #324; AD-60 CORRECTED the same day — its surviving-channel enumeration presented "the local force path, the missile arm" as exhaustive when the entire no-window host belonged in it; AD-1 RETIRED 2026-08-05, C5a deletion sweep — the legacy outdoor demote/restore lift this row described was `PhysicsEngine.Resolve`'s own body, deleted with zero production callers; AD-42 DELETED 2026-08-04, C4 route 3 — its last surviving citation, the headless portal-arrival resync's two-call Resolve/ResolvePlacement split, was retired by the canonical `RuntimeAcceptedPositionDriveController` portal arm; AD-2 amended same route with the deferred-place timing adaptation, the T8 tolerated-overwrite note, and the leash-anchor nuance; AD-63 filed 2026-08-04, cancelled-park presentation rollback — the rollback restores every presentation registration the park's Withdraw removed EXCEPT the player's selection, which is user intent rather than a projection; AD-62 filed 2026-08-03, C4 route 2 round 2 — a deferred ForcePosition retired without committing is not re-applied and its ack is not sent; AD-61 filed 2026-08-02, C3c review round 1 — the #270 settle compression now covers the local player; AD-59/AD-60 filed 2026-08-02, continuation-executor slice)
+## 2. Adaptation (AD) — 68 active rows (AD-89 RETIRED same-day 2026-08-13 — the SmartboxFOV port landed (#389): `RetailFieldOfView` + `CameraController.SetGameFov` now apply retail's `gameFOV/(aspect−0.1)` law with the 90°-degrees option semantics, and the invented 60° camera constants are deleted; AD-88 filed 2026-08-13 at the #385 dropdown fix — the vendor category dropdown keeps G5's fixed 6-row scrollable window although its authored popup ListBox is edge-docked, the condition that arms retail's `RecalculatePopupSize` size-to-content resize; classification UNCLEAR pending a retail side-by-side (ISSUES #386); AD-87 filed 2026-08-12 at Campaign FA slice FA6 — the allegiance-swear half of the two-bot headless gate is written+wired but `AllegianceGateEnabled=false` (disabled by default), unverified end-to-end over the wire because ACE returns nothing to the `0x001D` swear (ISSUES #384); the FELLOWSHIP two-session gate passed live and ships as FA6's automated proof; AD-86 filed 2026-08-12 at Campaign FA slice FA5, item 4 — ACE's deliberate zeroing of officers/officer titles/MOTD/MOTD-set-by/name-last-set-time/lock/approved-vassal/timeOnline/allegianceAge, dropped past acdream's own parse layer to match retail's own no-widget presentation; AD-85 filed 2026-08-12 at Campaign FA slice FA5 — the Allegiance page's numeric-only fields and its three local confirmation dialogs' unsubstituted-verbatim-or-bare-name text, the same unported `StringInfo` gap AD-81 filed for Fellowship; AD-84 filed 2026-08-12 at Campaign FA slice FA5 — the Swear button's missing "target is a player" gate, the same class as AD-83's Recruit-button gap; AD-83 filed 2026-08-12 at the Campaign FA slice FA4 fix round (mechanism MUST-FIX 5) — the Recruit button's missing "target is a player" gate, previously an inline comment not a row; AD-82 filed 2026-08-12 at the Campaign FA slice FA4 fix round (mechanism MUST-FIX 4/5) — the invented leader-tint/selection-tint colors, the name-text-only row click target, and the page-local (not generic-`UiTemplateListBox`) world→panel selection sync; AD-81 filed 2026-08-12 at Campaign FA slice FA4 — the fellowship roster/create-flow text-composition gap (unported `StringInfo` variable substitution + `ACCharGenData::FormatName`); AD-80 filed 2026-08-12 at Campaign FA slice FA4, D5 — the panel's retail-exact XP-share percentage display versus the currently-targeted ACE server's slightly different actual grant; AD-79 filed 2026-08-12 at Campaign FA slice FA3, D1 — the social panel's Friends/Squelch page action buttons (add/remove friend, appear offline, squelch add/remove/clear) are honest INERT, no wire implemented this campaign; AD-78 filed 2026-08-11 at Campaign OP's gate-2 follow-up (user-directed, verbatim "mark all options that are not implemented now, so I can clearly see what is not implemented") — the shared store-only-caption-dimming convention across the Character/Config option tabs and Configure Keyboard; AD-77 filed 2026-08-11 at the Campaign OP OP3 review-fix round — the client-wide floating-only `gmPanelUI` host divergence (retail also exposes a docked `0x21000017` host) the plan's §5 delegated to the OP3 dual review, scoped to every main panel not just Options; AD-76/AD-75/AD-74 filed 2026-08-11 at Campaign OP slice OP3 — the Options panel's Exit to Character Selection "behaves as Exit Game" adaptation (D6), the Urgent Assistance/Report Abuse dead-URL interface-text short-circuit (D5), and In-Game Help Files' asset-missing inert button (D5); AD-73 filed 2026-08-11 at the Campaign OP OP2 rework — `UiTabPanel`'s dormant-until-`ActivateTabBehavior()` activation model, replacing retail's unconditional per-instance tab-table wiring, so the four already-shipped Type-8 hosts keep their existing controller-owned switching without a double-driver race; AD-72 filed 2026-08-08 at the Slice 5.3 review corrections — `VendorPricing`'s double-precision narrowing versus retail's x87 extended precision, same class as AD-33; AD-65 RETIRED and AD-69 FILED 2026-08-07 at Campaign S S4 — the away-arm now snaps per retail @0x00509c50, while AD-66's byte-confirmed sibling landing is WITHHELD pending #341's measurement-anomaly apparatus, and AD-69 records the seam-frame dist gap the same pass discovered; AD-56 RESTORED 2026-08-07 — the a8a7d64b revert had collaterally DELETED it, the inverse of the AD-55 zombie it also created; its plumb-fall-freeze condition is live again since TS-4’s real retirement at Slice 2B; AD-55 RE-RETIRED 2026-08-07 — its 2026-07-30 retirement at 252e8068 was collaterally resurrected by the a8a7d64b revert of the unrelated TS-4 commit; the code kept the cos(10°) fix throughout; AD-68 filed 2026-08-07 at the #338 closure — the async-residency placeholder mover shape (0.4/0.4 steps + capsule) has no retail counterpart because retail loads synchronously; AD-67 filed 2026-08-07 at the #32 closeout — the narrowed `SetContactPlane` keeps its per-write `ContactPlaneCellId`, which retail writes only at `init_contact_plane`; AD-49 filed 2026-08-06 at the #334 fix — the BSP part-array flood runs its outdoor cell rectangle at seed time rather than only from retail’s residency-gated walk, keeping both registration floods on one residency rule; AD-64 filed 2026-08-05 at the C5b architecture review's D1 fix — AD-60's W2 wire-cell REACHABILITY decision is expressed once per host because the two hosts run parallel non-shared inbound routes; the committed VALUE is single-sourced at `RuntimeEntityObjectLifetime.CommitWireCellRebucket`, and unification is filed as #324; AD-60 CORRECTED the same day — its surviving-channel enumeration presented "the local force path, the missile arm" as exhaustive when the entire no-window host belonged in it; AD-1 RETIRED 2026-08-05, C5a deletion sweep — the legacy outdoor demote/restore lift this row described was `PhysicsEngine.Resolve`'s own body, deleted with zero production callers; AD-42 DELETED 2026-08-04, C4 route 3 — its last surviving citation, the headless portal-arrival resync's two-call Resolve/ResolvePlacement split, was retired by the canonical `RuntimeAcceptedPositionDriveController` portal arm; AD-2 amended same route with the deferred-place timing adaptation, the T8 tolerated-overwrite note, and the leash-anchor nuance; AD-63 filed 2026-08-04, cancelled-park presentation rollback — the rollback restores every presentation registration the park's Withdraw removed EXCEPT the player's selection, which is user intent rather than a projection; AD-62 filed 2026-08-03, C4 route 2 round 2 — a deferred ForcePosition retired without committing is not re-applied and its ack is not sent; AD-61 filed 2026-08-02, C3c review round 1 — the #270 settle compression now covers the local player; AD-59/AD-60 filed 2026-08-02, continuation-executor slice)
Recent retirements: AD-3/AD-4 retired 2026-07-31 by exact active/per-candidate
visible-cell availability, full-catalog containment-root validation, and the
@@ -183,7 +183,6 @@ readiness/requeue adaptation. See
| AD-86 | **Filed 2026-08-12 at Campaign FA slice FA5, item 4.** ACE deliberately zeroes or empties NINE `AllegianceProfile`/`AllegianceData` fields on the wire — officers, officer titles, MOTD, MOTD-set-by, name-last-set-time, lock state, and approved vassal are always empty/false/zero regardless of the allegiance's real state; `timeOnline`/`allegianceAge` (the remaining two) are hard-coded 0 forever (lane C §5.1). acdream's FA1 parser reads all of these (to keep the byte cursor aligned for the fields after them) but drops most at increasing layers: `AllegianceMemberRecord` never surfaces `timeOnline`/`allegianceAge` as fields at all; `RuntimeAllegianceState.ApplyUpdate` (FA2) does not forward `Motd`/`MotdSetBy`/`ChatRoomId`/`NameLastSetTime`/`IsLocked`/`ApprovedVassal` from the parsed `AllegianceUpdate` record to `RuntimeAllegianceSnapshot` even though the C# record itself carries them; retail's own `gmAllegianceUI` (FA5) has no widget for any of the seven either (lane A §3.3: "No allegiance MOTD / officer / ban / hometown UI" — they are chat-verb-only in the 2013 client, out of this campaign's scope per the plan's §4). | `src/AcDream.Core.Net/Messages/ClientCommandResponses.cs` (`ReadAllegianceProfileBody`, `AllegianceMemberRecord`, `AllegianceUpdate`); `src/AcDream.Runtime/Gameplay/RuntimeAllegianceState.cs` (`ApplyUpdate`) | Retail's own client renders nothing for these seven fields either (no panel widget consumes them) — dropping them past the parse layer matches retail's OWN presentation exactly, and is strictly safer than surfacing values that are always wrong/empty against ACE. | Any FUTURE consumer (the chat-verb-only officer/MOTD/lock/ban management features, §2 master table features #11-31 of the allegiance wire research, explicitly out of Campaign FA's scope) that reads these fields off the Runtime layer will find them permanently zero/empty against ACE regardless of the allegiance's real server-side state — do not chase this as a parser bug; it is ACE's own zeroing. | ACE `Network/Structure/AllegianceHierarchy.cs:53-56,62-64,74-75,78-83,86-89,153-155` (broadcast counters/isLocked/officers/officerTitles/motd/approvedVassal); ACE `Network/Structure/AllegianceData.cs:59-60,86-89,111-112` (timeOnline/allegianceAge); `docs/research/2026-08-11-fa-allegiance-wire.md` §5.1 |
| AD-87 | **Filed 2026-08-12 at Campaign FA slice FA6.** The allegiance-swear half of the two-bot headless connected gate (`FellowshipAllegianceLeaderBotPolicy`/`FellowshipAllegianceRecruitBotPolicy`) is written and wired end-to-end (proximity, `0x001D` swear, the confirmation-relay seam, `0x0020` tree-reseed assertions, break, reconnect-idempotence) but has never actually been verified to complete over the wire — `AllegianceGateEnabled = false` in both classes keeps it unreachable by default. Six live runs against local ACE all reproduced the same result: the fellowship half passes decisively (the Recruit bot's own `RuntimeFellowshipState` flips, proven three separate times), but ACE returns nothing at all to the `0x001D` swear (no `0x0274` confirmation, no `0x0020`, no error) even at 0.005 m separation — see docs/ISSUES.md #384 for the full evidence trail. So while the FELLOWSHIP two-session machinery is proven live, the ALLEGIANCE two-session machinery (Runtime commands, wire builders, `RuntimeAllegianceState` reseed) remains unverified end-to-end over a real connection — only its unit/fixture-level tests and its (successful) LOCAL echo on the swearer's own client are exercised. | `src/AcDream.Headless/Policies/HeadlessBotPolicy.cs` (`FellowshipAllegianceLeaderBotPolicy.AllegianceGateEnabled`, `FellowshipAllegianceRecruitBotPolicy.AllegianceGateEnabled`, both `false`) | Shipping the fellowship gate ALONE (rather than blocking the whole slice on the allegiance blocker) matches the campaign's own D8/item-6 split — fellowship and allegiance are independent retail systems with independent wire families, and the fellowship half's proof stands on its own regardless of the allegiance outcome. Disabling rather than deleting the allegiance code keeps a reviewed-quality, ready-to-run harness in place for whoever closes #384. | Anyone reading "the FA6 bot-vs-ACE gate passed" without the qualifier could assume the allegiance swear/break/reconnect path is proven over the wire when it is not — only its LOCAL send-and-echo behavior is proven; ACE's actual acceptance of the swear is the open question #384 tracks. | docs/ISSUES.md #384; `docs/research/2026-08-11-fa-allegiance-wire.md` §1.3 (the expected `0x0274`/`0x0275`/`0x0020` handshake); run6 evidence (0.005 m distance, zero inbound after swear) |
| AD-88 | **Filed 2026-08-13 at the #385 dropdown fix (classification: UNCLEAR).** The vendor category dropdown ships G5's fixed 6-row scrollable popup window, but its authored popup ListBox (`0x21000043/0x10000350`) is edge-docked on all four sides (L=T=R=B=1, measured by menuprobe3 `OptionsPanelLiveMountProbeTests.ProbeMenuPopupSizingAndTextStyle`) — the exact authored condition that arms retail `UIElement_Menu::RecalculatePopupSize @0x0046caf0`, which resizes the popup to the ListBox's summed content height, uncapped (`0x0046e5f4..0046e66c`). The Config option-menus' identical docked shape now drives `UiMenu.PopupSizeToContent=true` (#385); vendor deliberately keeps `false`. | `src/AcDream.App/UI/Layout/VendorUiController.cs` (its UiMenu wiring leaves `PopupSizeToContent` at the class-default false) | The G5 vendor-gate retail screenshot was read as a ~6-row-with-scrollbar look and the vendor connected gate USER-PASSED on that shape — reworking a user-gated surface on decomp inference alone would invert the retail-oracle rule. The two pieces of evidence conflict; the row records the conflict rather than silently picking a side. | If retail actually opens the category popup full-height, our vendor dropdown shows a 6-row scroll window where retail shows every category at once — visible at any vendor with >6 categories. If retail truly shows 6 rows, the mechanism question (why the docked ListBox does not trigger RecalculatePopupSize there) is unanswered and could mislead the next dropdown port. | docs/ISSUES.md #386 (the retail side-by-side to run + the two candidate resolutions); #385 (the Config fix that exposed the conflict) |
-| AD-89 | **Filed 2026-08-13 at the display-handling gate session (classification: stopgap, port planned).** The world cameras hardcode an aspect-independent vertical FOV (`ChaseCamera.FovY = MathF.PI / 3f` = 60°, `FlyCamera` likewise) with no retail anchor. Retail computes the applied FOV per aspect: `Render::SetFOVRad(m_fGameFOV / (ViewportAspectRatio − 0.1))` (`0x00452b2f`/`0x00453b14`), `m_fGameFOV` default π/2 (`0x00454649`), set in degrees by the Field of View option (`0x00451e6a`) — holding the horizontal view roughly constant across aspects while narrowing vertical on wide screens. | `src/AcDream.App/Rendering/ChaseCamera.cs:15` (FovY constant), `src/AcDream.App/Rendering/FlyCamera.cs` (same), `src/AcDream.App/Settings/RuntimeSettingsTargets.cs` (`ApplyFieldOfView`) | The constant predates the FOV research; nothing chose it against retail — it was simply never anchored. Filed as a row instead of silently patched because the port must also re-map the Config Field of View slider to retail's degree semantics in the same change. | Every aspect ratio renders a different framing than retail — widescreen shows too much horizontal world, 4:3 too little vertical; the user reports it as "squished"/"weird on modern screens" (ISSUES #389). | docs/ISSUES.md #389; decomp sites above |
---
diff --git a/src/AcDream.App/Rendering/CameraController.cs b/src/AcDream.App/Rendering/CameraController.cs
index 60b84ac5..a0de494f 100644
--- a/src/AcDream.App/Rendering/CameraController.cs
+++ b/src/AcDream.App/Rendering/CameraController.cs
@@ -45,10 +45,20 @@ public sealed class CameraController
private enum Mode { Orbit, Fly, Chase }
private Mode _mode = Mode.Orbit;
+ /// Retail m_fGameFOV (#389): the user-facing FOV the Config
+ /// slider sets in degrees, default 90°. The APPLIED per-camera vertical FOV
+ /// is derived from it and the current aspect via
+ /// — see that class's
+ /// doc for the decomp anchors.
+ public float GameFovRadians { get; private set; } = RetailFieldOfView.DefaultGameFovRadians;
+
+ private float _aspect = 16f / 9f;
+
public CameraController(OrbitCamera orbit, FlyCamera fly)
{
Orbit = orbit;
Fly = fly;
+ ApplyProjection();
}
public void ToggleFly()
@@ -65,6 +75,14 @@ public sealed class CameraController
{
Chase = legacy;
RetailChase = retail;
+ // #389: freshly attached chase cameras carry their own initialiser
+ // aspect/FOV — bring them onto the controller's current aspect +
+ // smartbox FOV immediately (before this, a resize while chase cameras
+ // were attached never reached them at all: SetAspect only updated
+ // Orbit/Fly, so the world rendered at the creation-time aspect
+ // stretched onto the new viewport — the 2026-08-13 "squished" gate
+ // report's second mechanism).
+ ApplyProjection();
_mode = Mode.Chase;
ModeChanged?.Invoke(IsChaseMode);
}
@@ -83,8 +101,41 @@ public sealed class CameraController
public void SetAspect(float aspect)
{
- Orbit.Aspect = aspect;
- Fly.Aspect = aspect;
+ _aspect = aspect;
+ ApplyProjection();
+ }
+
+ /// Sets retail's m_fGameFOV (#389) and recomputes every
+ /// camera's applied FOV. Driven by the Config tab's Field of View value
+ /// through RuntimeSettingsStartupTargets.ApplyFieldOfView.
+ public void SetGameFov(float gameFovRadians)
+ {
+ GameFovRadians = gameFovRadians;
+ ApplyProjection();
+ }
+
+ /// Pushes the current aspect + smartbox-derived vertical FOV onto
+ /// every attached camera. When the law's result fails retail's
+ /// SetFOVRad gate (see ),
+ /// the cameras keep their previous FOV — retail's exact behavior — but the
+ /// aspect still propagates (retail's viewport aspect is likewise updated
+ /// independently of the FOV gate).
+ private void ApplyProjection()
+ {
+ bool fovAccepted = RetailFieldOfView.TryAppliedVerticalFov(
+ GameFovRadians, _aspect, out float fovY);
+
+ Orbit.Aspect = _aspect;
+ Fly.Aspect = _aspect;
+ if (Chase is { } chase) chase.Aspect = _aspect;
+ if (RetailChase is { } retailChase) retailChase.Aspect = _aspect;
+
+ if (!fovAccepted)
+ return;
+ Orbit.FovY = fovY;
+ Fly.FovY = fovY;
+ if (Chase is { } chaseFov) chaseFov.FovY = fovY;
+ if (RetailChase is { } retailChaseFov) retailChaseFov.FovY = fovY;
}
internal CameraState CaptureState() =>
@@ -101,6 +152,10 @@ public sealed class CameraController
Chase = state.Chase;
RetailChase = state.RetailChase;
+ // #389: the aspect/game-FOV may have changed while these cameras were
+ // captured (a resize during a reset) — reconverge them before anyone
+ // reads a projection.
+ ApplyProjection();
_mode = (Mode)state.ModeCode;
ModeChanged?.Invoke(IsFlyMode || IsChaseMode);
}
diff --git a/src/AcDream.App/Rendering/ChaseCamera.cs b/src/AcDream.App/Rendering/ChaseCamera.cs
index f58476f2..10bf2111 100644
--- a/src/AcDream.App/Rendering/ChaseCamera.cs
+++ b/src/AcDream.App/Rendering/ChaseCamera.cs
@@ -12,7 +12,9 @@ public sealed class ChaseCamera : ICamera
{
public Vector3 Position { get; private set; }
public float Aspect { get; set; } = 16f / 9f;
- public float FovY { get; set; } = MathF.PI / 3f;
+ // #389: smartbox law at the 16:9 default aspect — see RetailFieldOfView.
+ // CameraController owns the live recompute on every aspect/game-FOV change.
+ public float FovY { get; set; } = RetailFieldOfView.DefaultAppliedFovY;
/// Distance behind the player. Clamped to [, ].
public float Distance { get; set; } = 8f;
diff --git a/src/AcDream.App/Rendering/FlyCamera.cs b/src/AcDream.App/Rendering/FlyCamera.cs
index 49a5341b..cb1fc63c 100644
--- a/src/AcDream.App/Rendering/FlyCamera.cs
+++ b/src/AcDream.App/Rendering/FlyCamera.cs
@@ -8,7 +8,8 @@ public sealed class FlyCamera : ICamera
public Vector3 Position { get; set; } = new(96, 96, 150);
public float Yaw { get; set; } = MathF.PI / 2f; // facing +Y
public float Pitch { get; set; } = -0.3f; // looking slightly down
- public float FovY { get; set; } = MathF.PI / 3f;
+ // #389: smartbox law at the 16:9 default aspect — see RetailFieldOfView.
+ public float FovY { get; set; } = RetailFieldOfView.DefaultAppliedFovY;
public float Aspect { get; set; } = 16f / 9f;
///
diff --git a/src/AcDream.App/Rendering/OrbitCamera.cs b/src/AcDream.App/Rendering/OrbitCamera.cs
index 358dc9c2..0d043c01 100644
--- a/src/AcDream.App/Rendering/OrbitCamera.cs
+++ b/src/AcDream.App/Rendering/OrbitCamera.cs
@@ -8,7 +8,8 @@ public sealed class OrbitCamera : ICamera
public float Distance { get; set; } = 300f;
public float Yaw { get; set; } = MathF.PI / 4f;
public float Pitch { get; set; } = MathF.PI / 6f;
- public float FovY { get; set; } = MathF.PI / 3f;
+ // #389: smartbox law at the 16:9 default aspect — see RetailFieldOfView.
+ public float FovY { get; set; } = RetailFieldOfView.DefaultAppliedFovY;
public float Aspect { get; set; } = 16f / 9f;
public Matrix4x4 View
diff --git a/src/AcDream.App/Rendering/RetailChaseCamera.cs b/src/AcDream.App/Rendering/RetailChaseCamera.cs
index c2eefc8b..991466e1 100644
--- a/src/AcDream.App/Rendering/RetailChaseCamera.cs
+++ b/src/AcDream.App/Rendering/RetailChaseCamera.cs
@@ -40,7 +40,8 @@ public sealed class RetailChaseCamera : ICamera
///
public uint ViewerCellId { get; private set; }
public float Aspect { get; set; } = 16f / 9f;
- public float FovY { get; set; } = MathF.PI / 3f;
+ // #389: smartbox law at the 16:9 default aspect — see RetailFieldOfView.
+ public float FovY { get; set; } = RetailFieldOfView.DefaultAppliedFovY;
public Matrix4x4 View { get; private set; } = Matrix4x4.Identity;
// Near plane = retail Render::znear = 0.1 m (decomp :342130/:342173/:1101867 —
// Render::SetFOVRad sets 0.1 flat; the legacy set_vdst variant is max(0.1, vdst·0.25)).
diff --git a/src/AcDream.App/Rendering/RetailFieldOfView.cs b/src/AcDream.App/Rendering/RetailFieldOfView.cs
new file mode 100644
index 00000000..48119fb4
--- /dev/null
+++ b/src/AcDream.App/Rendering/RetailFieldOfView.cs
@@ -0,0 +1,78 @@
+using System;
+
+namespace AcDream.App.Rendering;
+
+///
+/// Retail's aspect-coupled field-of-view law (#389, "SmartboxFOV") — the world
+/// camera's applied vertical FOV is NOT a constant: retail recomputes it from
+/// the viewport aspect every time either changes,
+///
+/// Render::SetFOVRad(m_fGameFOV / (RenderDevice::m_ViewportAspectRatio - 0.1))
+///
+/// (CreatureMode smartbox sites 0x00452b2f and 0x00453b14;
+/// the same expression feeds a tan at 0x00451c0e). The net
+/// effect: the HORIZONTAL view stays roughly constant (~85–90° 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°.
+///
+/// m_fGameFOV is the user-facing number: ctor default
+/// 1.57079637f = π/2 = 90° (0x00454649), set in DEGREES by the
+/// Field of View option (× 0.017453292519943295, 0x00451e6a;
+/// registered range [10,160] — gmClient::InitUIPreferences @0x004035b0,
+/// the exact range/default the Config tab's slider row already authored).
+///
+/// The applied value passes retail's Render::SetFOVRad @0x0054b2d0
+/// gate — accepted only strictly inside (0, π); an out-of-range result is
+/// REJECTED and the previous FOV stays (SetFOVRad returns 0 without
+/// calling SetFOVInternal). ports
+/// that exact contract: callers keep their current FOV on false.
+///
+/// The paperdoll camera is deliberately OUTSIDE this law — retail's
+/// portrait mode uses UseSharpMode, not smartbox (see
+/// 's own doc), so it keeps its fixed authored
+/// FOV.
+///
+public static class RetailFieldOfView
+{
+ /// π/2 = 90°: retail m_fGameFOV ctor default (0x00454649,
+ /// literal 1.57079637f).
+ public const float DefaultGameFovRadians = 1.57079637f;
+
+ /// The smartbox divisor bias (0x00452b2f, literal
+ /// 0.100000001f — the float nearest 0.1).
+ public const float AspectBias = 0.100000001f;
+
+ /// The law evaluated at the default 90° game FOV and the 16:9
+ /// default aspect every camera initialises with — the correct standalone
+ /// FovY for a camera that has not yet been driven by
+ /// /.
+ public static readonly float DefaultAppliedFovY = ComputeDefault();
+
+ private static float ComputeDefault()
+ {
+ bool ok = TryAppliedVerticalFov(DefaultGameFovRadians, 16f / 9f, out float fovY);
+ System.Diagnostics.Debug.Assert(ok, "the default game FOV/aspect pair must satisfy the SetFOVRad gate");
+ return fovY;
+ }
+
+ ///
+ /// The smartbox law + the SetFOVRad acceptance gate. Returns false
+ /// (leaving at the raw computed value) when the
+ /// result falls outside retail's accepted open interval (0, π) — including
+ /// the degenerate aspects ≤ whose divisor is zero
+ /// or negative. Callers keep their previous FOV in that case, exactly like
+ /// retail keeps Render::fov.
+ ///
+ public static bool TryAppliedVerticalFov(float gameFovRadians, float aspect, out float fovY)
+ {
+ float divisor = aspect - AspectBias;
+ if (divisor <= 0f)
+ {
+ fovY = float.NaN;
+ return false;
+ }
+
+ fovY = gameFovRadians / divisor;
+ return fovY > 0f && fovY < MathF.PI;
+ }
+}
diff --git a/src/AcDream.App/Settings/RuntimeSettingsTargets.cs b/src/AcDream.App/Settings/RuntimeSettingsTargets.cs
index e1729a07..db80efc2 100644
--- a/src/AcDream.App/Settings/RuntimeSettingsTargets.cs
+++ b/src/AcDream.App/Settings/RuntimeSettingsTargets.cs
@@ -132,15 +132,19 @@ internal sealed class RuntimeSettingsStartupTargets : IRuntimeSettingsStartupTar
public void ApplyAudio(AudioSettings audio) => ApplyAudio(_audio, audio);
+ /// #389: the stored Field of View is retail's m_fGameFOV
+ /// in DEGREES (registered range [10,160], default 90 —
+ /// gmClient::InitUIPreferences @0x004035b0), converted exactly as
+ /// retail's option setter does (× 0.017453292519943295,
+ /// 0x00451e6a) and applied through the smartbox law in
+ /// — NEVER written to a camera's
+ /// vertical FOV directly (the pre-#389 behavior, which made the slider
+ /// mean a different, aspect-ignorant thing than retail's).
internal static void ApplyFieldOfView(
CameraController cameras,
float degrees)
{
- float radians = degrees * (MathF.PI / 180f);
- cameras.Orbit.FovY = radians;
- cameras.Fly.FovY = radians;
- if (cameras.Chase is not null)
- cameras.Chase.FovY = radians;
+ cameras.SetGameFov(degrees * (MathF.PI / 180f));
}
internal static void ApplyAudio(
diff --git a/src/AcDream.UI.Abstractions/Panels/Settings/DisplaySettings.cs b/src/AcDream.UI.Abstractions/Panels/Settings/DisplaySettings.cs
index f247f7b6..abb2a3c5 100644
--- a/src/AcDream.UI.Abstractions/Panels/Settings/DisplaySettings.cs
+++ b/src/AcDream.UI.Abstractions/Panels/Settings/DisplaySettings.cs
@@ -79,15 +79,18 @@ public sealed record DisplaySettings(
{
/// Values used on first launch / when settings.json is absent.
/// Geometry defaults preserve the pre-L.0 runtime state: Resolution
- /// matches the WindowOptions startup size (1280×720) and FieldOfView
- /// matches camera FovY (60°). VSync defaults on so normal rendering is
- /// synchronized to the active monitor, while
+ /// matches the WindowOptions startup size (1280×720). FieldOfView is
+ /// retail's m_fGameFOV in degrees — registered default 90, range
+ /// [10,160] (gmClient::InitUIPreferences @0x004035b0; #389
+ /// corrected the pre-port 60, which encoded the old direct-vertical-FOV
+ /// semantics this record no longer means). VSync defaults on so normal
+ /// rendering is synchronized to the active monitor, while
/// ShowFps matches retail's initially-hidden SmartBox FPS readout.
public static DisplaySettings Default { get; } = new(
Resolution: "1280x720",
Fullscreen: false,
VSync: true,
- FieldOfView: 60f,
+ FieldOfView: 90f,
Gamma: 1.0f,
ShowFps: false,
Quality: QualityPreset.High,
diff --git a/tests/AcDream.App.Tests/Rendering/RetailFieldOfViewTests.cs b/tests/AcDream.App.Tests/Rendering/RetailFieldOfViewTests.cs
new file mode 100644
index 00000000..9961c522
--- /dev/null
+++ b/tests/AcDream.App.Tests/Rendering/RetailFieldOfViewTests.cs
@@ -0,0 +1,144 @@
+using AcDream.App.Rendering;
+
+namespace AcDream.App.Tests.Rendering;
+
+///
+/// #389: retail's SmartboxFOV law — applied vertical FOV =
+/// m_fGameFOV / (viewportAspect − 0.1), gated by Render::SetFOVRad's open
+/// (0, π) acceptance interval. Golden values computed from the decomp
+/// constants (0x00452b2f / 0x00454649 / 0x0054b2d0); see
+/// 's class doc for the full citations.
+///
+public sealed class RetailFieldOfViewTests
+{
+ [Theory]
+ // 4:3 CRT: 90° / (1.3333 − 0.1) = 1.27362 rad ≈ 72.97° vertical.
+ [InlineData(4f / 3f, 1.27362f)]
+ // 16:9: 90° / (1.7778 − 0.1) = 0.93624 rad ≈ 53.64° vertical.
+ [InlineData(16f / 9f, 0.93624f)]
+ // 21:9 ultrawide: 90° / (2.3333 − 0.1) = 0.70327 rad ≈ 40.29° vertical.
+ [InlineData(21f / 9f, 0.70327f)]
+ public void Law_AtTheDefault90DegreeGameFov_MatchesTheDecompFormula(
+ float aspect, float expectedFovY)
+ {
+ Assert.True(RetailFieldOfView.TryAppliedVerticalFov(
+ RetailFieldOfView.DefaultGameFovRadians, aspect, out float fovY));
+ Assert.Equal(expectedFovY, fovY, precision: 4);
+ }
+
+ [Fact]
+ public void Law_HoldsTheHorizontalViewRoughlyConstant()
+ {
+ // The point of the smartbox shape: horizontal FOV stays ~85–90°
+ // across every aspect at the 90° default, 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(
+ RetailFieldOfView.DefaultGameFovRadians, aspect, out float fovY));
+ float horizontal = 2f * MathF.Atan(MathF.Tan(fovY / 2f) * aspect);
+ Assert.InRange(horizontal, 80f * MathF.PI / 180f, 90f * MathF.PI / 180f);
+ }
+ }
+
+ [Theory]
+ // Degenerate aspects at or below the 0.1 bias: divisor ≤ 0.
+ [InlineData(0.05f)]
+ [InlineData(0.1f)]
+ // A window so narrow the law exceeds π (the SetFOVRad reject case):
+ // 90° / (0.55 − 0.1) = 3.49 rad > π.
+ [InlineData(0.55f)]
+ public void Gate_RejectsResultsOutsideRetailsAcceptedInterval(float aspect)
+ {
+ Assert.False(RetailFieldOfView.TryAppliedVerticalFov(
+ RetailFieldOfView.DefaultGameFovRadians, aspect, out _));
+ }
+
+ [Fact]
+ public void DefaultAppliedFovY_IsTheLawAtTheDefaultPair()
+ {
+ Assert.True(RetailFieldOfView.TryAppliedVerticalFov(
+ RetailFieldOfView.DefaultGameFovRadians, 16f / 9f, out float expected));
+ Assert.Equal(expected, RetailFieldOfView.DefaultAppliedFovY);
+ }
+
+ [Fact]
+ public void Controller_SetAspect_DrivesEveryAttachedCamera_IncludingChase()
+ {
+ // Pre-#389 regression shape: SetAspect only touched Orbit/Fly, so the
+ // chase cameras (the ones the player actually looks through) kept
+ // their creation-time aspect across every resize — the world drew at
+ // the old shape stretched onto the new viewport (the 2026-08-13
+ // "squished" gate report).
+ var controller = new CameraController(new OrbitCamera(), new FlyCamera());
+ var chase = new ChaseCamera();
+ var retailChase = new RetailChaseCamera();
+ controller.EnterChaseMode(chase, retailChase);
+
+ controller.SetAspect(4f / 3f);
+
+ Assert.True(RetailFieldOfView.TryAppliedVerticalFov(
+ controller.GameFovRadians, 4f / 3f, out float expectedFov));
+ foreach ((float aspect, float fov) in new[]
+ {
+ (controller.Orbit.Aspect, controller.Orbit.FovY),
+ (controller.Fly.Aspect, controller.Fly.FovY),
+ (chase.Aspect, chase.FovY),
+ (retailChase.Aspect, retailChase.FovY),
+ })
+ {
+ Assert.Equal(4f / 3f, aspect);
+ Assert.Equal(expectedFov, fov, precision: 5);
+ }
+ }
+
+ [Fact]
+ public void Controller_EnterChaseMode_ConvergesFreshCamerasImmediately()
+ {
+ var controller = new CameraController(new OrbitCamera(), new FlyCamera());
+ controller.SetAspect(21f / 9f);
+
+ // Cameras built elsewhere with the 16:9 initializer defaults…
+ var chase = new ChaseCamera();
+ var retailChase = new RetailChaseCamera();
+ controller.EnterChaseMode(chase, retailChase);
+
+ // …must be on the controller's aspect + law the moment they attach.
+ Assert.True(RetailFieldOfView.TryAppliedVerticalFov(
+ controller.GameFovRadians, 21f / 9f, out float expectedFov));
+ Assert.Equal(21f / 9f, chase.Aspect);
+ Assert.Equal(expectedFov, chase.FovY, precision: 5);
+ Assert.Equal(21f / 9f, retailChase.Aspect);
+ Assert.Equal(expectedFov, retailChase.FovY, precision: 5);
+ }
+
+ [Fact]
+ public void Controller_RejectedLaw_KeepsThePreviousFovButPropagatesAspect()
+ {
+ // Retail SetFOVRad returns 0 without applying on an out-of-range
+ // result — the previous FOV survives. The viewport aspect is updated
+ // independently of that gate.
+ var controller = new CameraController(new OrbitCamera(), new FlyCamera());
+ float before = controller.Orbit.FovY;
+
+ controller.SetAspect(0.5f); // 90°/(0.5−0.1) = 3.93 rad > π → rejected
+
+ Assert.Equal(0.5f, controller.Orbit.Aspect);
+ Assert.Equal(before, controller.Orbit.FovY);
+ }
+
+ [Fact]
+ public void Controller_SetGameFov_RecomputesAtTheCurrentAspect()
+ {
+ var controller = new CameraController(new OrbitCamera(), new FlyCamera());
+ controller.SetAspect(16f / 9f);
+
+ float narrow = 45f * MathF.PI / 180f; // slider dragged to 45°
+ controller.SetGameFov(narrow);
+
+ Assert.True(RetailFieldOfView.TryAppliedVerticalFov(
+ narrow, 16f / 9f, out float expectedFov));
+ Assert.Equal(narrow, controller.GameFovRadians);
+ Assert.Equal(expectedFov, controller.Fly.FovY, precision: 5);
+ }
+}
diff --git a/tests/AcDream.App.Tests/Settings/RuntimeSettingsControllerTests.cs b/tests/AcDream.App.Tests/Settings/RuntimeSettingsControllerTests.cs
index de00dfbb..49dcd041 100644
--- a/tests/AcDream.App.Tests/Settings/RuntimeSettingsControllerTests.cs
+++ b/tests/AcDream.App.Tests/Settings/RuntimeSettingsControllerTests.cs
@@ -144,8 +144,14 @@ public sealed class RuntimeSettingsControllerTests
Assert.Equal(1, displayWindow.ApplyCount);
Assert.Equal(1, surface.RefreshReadCount);
Assert.Equal(new FramePacingPolicy(false, 144d), pacing.Policy);
- Assert.Equal(83f * (MathF.PI / 180f), cameras.Orbit.FovY, precision: 5);
- Assert.Equal(83f * (MathF.PI / 180f), cameras.Fly.FovY, precision: 5);
+ // #389: the stored degrees are retail's m_fGameFOV; the camera's
+ // applied vertical FOV comes through the smartbox law at the
+ // controller's current (default 16:9) aspect — never the raw degrees.
+ Assert.Equal(83f * (MathF.PI / 180f), cameras.GameFovRadians, precision: 5);
+ Assert.True(RetailFieldOfView.TryAppliedVerticalFov(
+ 83f * (MathF.PI / 180f), 16f / 9f, out float expectedFov));
+ Assert.Equal(expectedFov, cameras.Orbit.FovY, precision: 5);
+ Assert.Equal(expectedFov, cameras.Fly.FovY, precision: 5);
}
[Fact]
diff --git a/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/DisplaySettingsTests.cs b/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/DisplaySettingsTests.cs
index c49353ca..2c5531ff 100644
--- a/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/DisplaySettingsTests.cs
+++ b/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/DisplaySettingsTests.cs
@@ -15,14 +15,17 @@ public sealed class DisplaySettingsTests
{
// Defaults pin the normal-client startup policy:
// · Resolution matches WindowOptions (1280×720 in GameWindow.Run)
- // · FieldOfView matches camera FovY (60° = π/3)
+ // · FieldOfView is retail's m_fGameFOV in DEGREES — registered
+ // default 90, range [10,160] (gmClient::InitUIPreferences
+ // @0x004035b0; #389 replaced the pre-port 60, which encoded the
+ // retired direct-vertical-FOV semantics)
// · VSync is on so normal presentation follows monitor refresh
// · ShowFps false matches retail's initially-hidden SmartBox meter
var d = DisplaySettings.Default;
Assert.Equal("1280x720", d.Resolution);
Assert.False(d.Fullscreen);
Assert.True(d.VSync);
- Assert.Equal(60f, d.FieldOfView);
+ Assert.Equal(90f, d.FieldOfView);
Assert.Equal(1.0f, d.Gamma);
Assert.False(d.ShowFps);
}