acdream/src/AcDream.App/Rendering/RetailFieldOfView.cs
Erik d13d63d0a5 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>
2026-08-13 17:27:15 +02:00

95 lines
4.7 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System;
namespace AcDream.App.Rendering;
/// <summary>
/// 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,
/// <code>
/// Render::SetFOVRad(m_fGameFOV / (RenderDevice::m_ViewportAspectRatio - 0.1))
/// </code>
/// (<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
/// Field of View option (<c>× 0.017453292519943295</c>, <c>0x00451e6a</c>;
/// registered range [10,160] — <c>gmClient::InitUIPreferences @0x004035b0</c>,
/// the exact range/default the Config tab's slider row already authored).</para>
///
/// <para>The applied value passes retail's <c>Render::SetFOVRad @0x0054b2d0</c>
/// gate — accepted only strictly inside (0, π); an out-of-range result is
/// REJECTED and the previous FOV stays (<c>SetFOVRad</c> returns 0 without
/// calling <c>SetFOVInternal</c>). <see cref="TryAppliedVerticalFov"/> ports
/// that exact contract: callers keep their current FOV on <c>false</c>.</para>
///
/// <para>The paperdoll camera is deliberately OUTSIDE this law — retail's
/// portrait mode uses <c>UseSharpMode</c>, not smartbox (see
/// <see cref="DollCamera"/>'s own doc), so it keeps its fixed authored
/// FOV.</para>
/// </summary>
public static class RetailFieldOfView
{
/// <summary>π/2 = 90°: retail <c>m_fGameFOV</c> ctor default (<c>0x00454649</c>,
/// literal <c>1.57079637f</c>).</summary>
public const float DefaultGameFovRadians = 1.57079637f;
/// <summary>The smartbox divisor bias (<c>0x00452b2f</c>, literal
/// <c>0.100000001f</c> — the float nearest 0.1).</summary>
public const float AspectBias = 0.100000001f;
/// <summary>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
/// <see cref="CameraController.SetAspect"/>/<see cref="CameraController.SetGameFov"/>.</summary>
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;
}
/// <summary>
/// The smartbox law + the <c>SetFOVRad</c> acceptance gate. Returns false
/// (leaving <paramref name="fovY"/> at the raw computed value) when the
/// result falls outside retail's accepted open interval (0, π) — including
/// the degenerate aspects ≤ <see cref="AspectBias"/> whose divisor is zero
/// or negative. Callers keep their previous FOV in that case, exactly like
/// retail keeps <c>Render::fov</c>.
/// </summary>
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;
}
}