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,