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>
95 lines
4.7 KiB
C#
95 lines
4.7 KiB
C#
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;
|
||
}
|
||
}
|