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;
}
}