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

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