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 site 0x00452b2f and /// SmartBox::RenderNormalMode @0x00453b14 — the latter has NO /// m_bUseSmartboxFOV gate at all, so the world view is unconditionally /// on this law; the same expression feeds a tan at 0x00451c0e). /// The applied value is decisively the VERTICAL FOV: the projection consumer /// is D3DXMatrixPerspectiveFovLH with it in the fovy slot /// (0x0059ab71; 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°. /// /// Aspect nuance (mechanism review M1, register row AD-90): retail's /// divisor aspect is not raw width/height — /// RenderDevice::ComputeAspectForViewport @0x0054f150 yields /// (w/h) × m_DisplayAspectRatio × 0.75, where /// m_DisplayAspectRatio comes from the registered /// Render.AspectRatio 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. /// /// 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; } }