acdream/src/AcDream.App/Settings/RuntimeSettingsTargets.cs
Erik 7e0c130344 fix #389: port retail's SmartboxFOV law; retire AD-89 (display slice 1)
Retail's world-camera FOV is not a constant: the applied vertical FOV is
m_fGameFOV / (viewportAspect - 0.1), recomputed on every aspect or
game-FOV change (CreatureMode smartbox sites 0x00452b2f/0x00453b14),
gated by Render::SetFOVRad's open (0, pi) acceptance (0x0054b2d0 -
rejected results keep the previous FOV). m_fGameFOV defaults to pi/2 =
90 degrees (0x00454649) and is what the Field of View option sets in
degrees (0x00451e6a; registered range [10,160] default 90 -
gmClient::InitUIPreferences @0x004035b0). Net effect: the horizontal
view stays ~85-90 degrees across aspect ratios; wide screens trim the
vertical slice instead of ballooning the sides.

acdream hardcoded FovY = pi/3 = 60 degrees on all four world cameras,
aspect-independent, and the Config slider wrote raw vertical-FOV
degrees. New: RetailFieldOfView (the law + gate, decomp-cited),
CameraController.GameFovRadians + SetGameFov + one ApplyProjection
chokepoint recomputing every camera on SetAspect/SetGameFov/
EnterChaseMode/RestoreState; ApplyFieldOfView now feeds the law;
DisplaySettings.Default.FieldOfView 60 -> 90 (the retail registered
default; the stored number changed MEANING with this commit).

The same seam closes a second latent bug the 2026-08-13 "squished" gate
report exposed: SetAspect only ever updated Orbit/Fly - the CHASE
cameras (the ones the player looks through) kept their creation-time
aspect across every mid-session resize, drawing the world at the old
shape stretched onto the new viewport.

The paperdoll camera stays outside the law by design (retail portrait
mode is UseSharpMode, not smartbox - DollCamera's own doc).

Tests: RetailFieldOfViewTests (golden law values at 4:3/16:9/21:9, the
constant-horizontal property, the rejection gate, controller propagation
incl. chase attach/restore + rejected-law aspect-still-propagates);
DisplaySettingsTests + RuntimeSettingsControllerTests updated to the new
semantics. App suite 4,953/3 skips; UI.Abstractions 916/0. AD-89 retired
in this commit; user settings.json migrated 60->90 by hand (stale
pre-port default).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 17:08:41 +02:00

390 lines
15 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 AcDream.App.Audio;
using AcDream.App.Net;
using AcDream.App.Rendering;
using AcDream.App.Rendering.Wb;
using AcDream.App.Streaming;
using AcDream.App.UI;
using AcDream.UI.Abstractions;
using AcDream.UI.Abstractions.Panels.Settings;
using AcDream.UI.Abstractions.Settings;
using Silk.NET.Maths;
using Silk.NET.Windowing;
namespace AcDream.App.Settings;
internal interface IRuntimeDisplayWindowTarget
{
void Apply(DisplaySettings display);
}
internal interface IRuntimeQualityApplicationTarget
{
void SetAlphaToCoverage(bool enabled);
void SetAnisotropic(int level);
void PublishRenderRange(int nearRadius, int farRadius);
void ReconfigureStreamingRadii(int nearRadius, int farRadius);
void SetCompletionBudget(int maxCompletionsPerFrame);
}
internal interface IRuntimeUiLockTarget
{
void Apply(bool locked);
}
/// <summary>
/// Campaign CH slice CH6c target seam for the Chat tab's transparency sliders,
/// mirroring <see cref="IRuntimeUiLockTarget"/>'s shape.
/// </summary>
internal interface IRuntimeChatOpacityTarget
{
void Apply(float defaultOpacity, float activeOpacity);
}
internal sealed class SilkRuntimeDisplayWindowTarget : IRuntimeDisplayWindowTarget
{
private readonly IWindow _window;
public SilkRuntimeDisplayWindowTarget(IWindow window)
{
_window = window ?? throw new ArgumentNullException(nameof(window));
}
public void Apply(DisplaySettings display)
{
ArgumentNullException.ThrowIfNull(display);
bool haveResolution =
TryParseResolution(display.Resolution, out int width, out int height);
if (haveResolution && (_window.Size.X != width || _window.Size.Y != height))
{
// #387 evidence line (permanent): the resolution-pick write path.
Console.WriteLine(
$"display: resolution pick {width}x{height} " +
$"(window was {_window.Size.X}x{_window.Size.Y})");
_window.Size = new Vector2D<int>(width, height);
}
WindowState desired = display.Fullscreen
? WindowState.Fullscreen
: WindowState.Normal;
// #374 investigation note: while FULLSCREEN, the visible resolution
// is the display's video mode, and Silk's abstract windowing API
// cannot change it (IViewProperties.VideoMode is read-only; Silk
// fullscreen is desktop-mode borderless). The Size write above is
// therefore only visible in windowed mode — a resolution pick while
// fullscreen changes what a later return to windowed restores, not
// the fullscreen mode itself. Retail's own fullscreen resolution
// switch (Device::ForceDisplayResolution) needs a native
// glfwSetWindowMonitor port — issue #376.
if (_window.WindowState != desired)
_window.WindowState = desired;
}
internal static bool TryParseResolution(
string spec,
out int width,
out int height)
{
width = height = 0;
if (string.IsNullOrWhiteSpace(spec))
return false;
string[] parts = spec.Split('x', 2);
return parts.Length == 2
&& int.TryParse(parts[0], out width)
&& int.TryParse(parts[1], out height)
&& width > 0
&& height > 0;
}
}
internal sealed class RuntimeSettingsStartupTargets : IRuntimeSettingsStartupTarget
{
private readonly IRuntimeDisplayWindowTarget _displayWindow;
private readonly DisplayFramePacingController _pacing;
private readonly CameraController _cameras;
private readonly OpenAlAudioEngine? _audio;
public RuntimeSettingsStartupTargets(
IRuntimeDisplayWindowTarget displayWindow,
DisplayFramePacingController pacing,
CameraController cameras,
OpenAlAudioEngine? audio)
{
_displayWindow = displayWindow
?? throw new ArgumentNullException(nameof(displayWindow));
_pacing = pacing ?? throw new ArgumentNullException(nameof(pacing));
_cameras = cameras ?? throw new ArgumentNullException(nameof(cameras));
_audio = audio;
}
public void ApplyDisplay(DisplaySettings display)
{
ArgumentNullException.ThrowIfNull(display);
_pacing.RefreshActiveMonitor();
_pacing.ApplyPreference(display.VSync);
_displayWindow.Apply(display);
ApplyFieldOfView(_cameras, display.FieldOfView);
}
public void ApplyAudio(AudioSettings audio) => ApplyAudio(_audio, audio);
/// <summary>#389: the stored Field of View is retail's <c>m_fGameFOV</c>
/// in DEGREES (registered range [10,160], default 90 —
/// <c>gmClient::InitUIPreferences @0x004035b0</c>), converted exactly as
/// retail's option setter does (<c>× 0.017453292519943295</c>,
/// <c>0x00451e6a</c>) and applied through the smartbox law in
/// <see cref="CameraController.SetGameFov"/> — NEVER written to a camera's
/// vertical FOV directly (the pre-#389 behavior, which made the slider
/// mean a different, aspect-ignorant thing than retail's).</summary>
internal static void ApplyFieldOfView(
CameraController cameras,
float degrees)
{
cameras.SetGameFov(degrees * (MathF.PI / 180f));
}
internal static void ApplyAudio(
OpenAlAudioEngine? engine,
AudioSettings audio)
{
ArgumentNullException.ThrowIfNull(audio);
if (engine is not { IsAvailable: true })
return;
engine.MasterVolume = audio.Master;
(float sfx, float ambient) = ComputeEffectiveCategoryVolumes(audio);
engine.SfxVolume = sfx;
engine.AmbientVolume = ambient;
}
/// <summary>
/// Campaign OP slice OP6 (2026-08-11), CORRECTED at the OP6 rework round
/// (review <c>docs/research/2026-08-11-op6-review.md</c> finding M2): the
/// Config tab's toggle halves of the Sound/Ambient volume trios
/// (<see cref="AudioSettings.SfxEnabled"/>/<see cref="AudioSettings.AmbientEnabled"/>
/// — retail's own ENABLED-sense <c>SoundManager::effect_sounds_enabled</c>/
/// <c>ambient_sounds_enabled</c> statics, see <see cref="AudioSettings"/>'s
/// class doc for the byte evidence) gate the SAME slider value: the
/// effective volume sent to the engine is the slider value when enabled,
/// zero when not — exactly as if the user dragged the slider to zero.
/// Extracted as a pure function (no <see cref="OpenAlAudioEngine"/>
/// dependency) so the mapping itself — not just that some target was
/// called — is unit-testable without OpenAL hardware/mocking (S5: the
/// rejected slice's only audio test asserted event ORDER, never the
/// VALUE that reached the engine, which is exactly how M2's muted-by-
/// default inversion shipped unnoticed).
/// </summary>
internal static (float Sfx, float Ambient) ComputeEffectiveCategoryVolumes(AudioSettings audio)
{
ArgumentNullException.ThrowIfNull(audio);
float sfx = audio.SfxEnabled ? audio.Sfx : 0f;
float ambient = audio.AmbientEnabled ? audio.Ambient : 0f;
return (sfx, ambient);
}
}
internal sealed class RuntimeQualityApplicationTarget
: IRuntimeQualityApplicationTarget
{
// Campaign V slice V6h: absent on a backend that composes no world
// renderers. Alpha-to-coverage and anisotropy are properties of renderers
// that do not exist there; render range and streaming radii still apply.
private readonly WbDrawDispatcher? _dispatcher;
private readonly TerrainAtlas? _terrainAtlas;
private readonly StreamingController _streaming;
private readonly WorldRenderRangeState _renderRange;
public RuntimeQualityApplicationTarget(
WbDrawDispatcher? dispatcher,
TerrainAtlas? terrainAtlas,
StreamingController streaming,
WorldRenderRangeState renderRange)
{
_dispatcher = dispatcher;
_terrainAtlas = terrainAtlas;
_streaming = streaming ?? throw new ArgumentNullException(nameof(streaming));
_renderRange = renderRange ?? throw new ArgumentNullException(nameof(renderRange));
}
public void SetAlphaToCoverage(bool enabled)
{
if (_dispatcher is not null)
_dispatcher.AlphaToCoverage = enabled;
}
public void SetAnisotropic(int level) => _terrainAtlas?.SetAnisotropic(level);
public void PublishRenderRange(int nearRadius, int farRadius)
{
_renderRange.NearRadius = nearRadius;
_renderRange.FarRadius = farRadius;
}
public void ReconfigureStreamingRadii(int nearRadius, int farRadius) =>
_streaming.ReconfigureRadii(nearRadius, farRadius);
public void SetCompletionBudget(int maxCompletionsPerFrame) =>
_streaming.MaxCompletionsPerFrame = maxCompletionsPerFrame;
}
internal sealed class RuntimeUiLockTarget(UiRoot root) : IRuntimeUiLockTarget
{
private readonly UiRoot _root = root ?? throw new ArgumentNullException(nameof(root));
public void Apply(bool locked) => _root.UiLocked = locked;
}
internal sealed class NullRuntimeUiLockTarget : IRuntimeUiLockTarget
{
public static NullRuntimeUiLockTarget Instance { get; } = new();
private NullRuntimeUiLockTarget()
{
}
public void Apply(bool locked)
{
}
}
internal sealed class RuntimeChatOpacityTarget(RetailWindowOpacityController controller)
: IRuntimeChatOpacityTarget
{
private readonly RetailWindowOpacityController _controller =
controller ?? throw new ArgumentNullException(nameof(controller));
public void Apply(float defaultOpacity, float activeOpacity) =>
_controller.SetOpacity(defaultOpacity, activeOpacity);
}
internal sealed class NullRuntimeChatOpacityTarget : IRuntimeChatOpacityTarget
{
public static NullRuntimeChatOpacityTarget Instance { get; } = new();
private NullRuntimeChatOpacityTarget()
{
}
public void Apply(float defaultOpacity, float activeOpacity)
{
}
}
/// <summary>
/// Complete late-bound target for changes made after startup. Construction and
/// binding are inert; only an explicit controller command mutates borrowers.
/// </summary>
internal sealed class RuntimeSettingsTargets : IRuntimeSettingsTargets
{
private readonly IRuntimeDisplayWindowTarget _displayWindow;
private readonly IRuntimeQualityApplicationTarget _quality;
private readonly IRuntimeUiLockTarget _uiLock;
private readonly IRuntimeChatOpacityTarget _chatOpacity;
private readonly ICommandBus _commands;
private readonly Action<string> _log;
private readonly OpenAlAudioEngine? _audio;
public RuntimeSettingsTargets(
IRuntimeDisplayWindowTarget displayWindow,
WbDrawDispatcher? dispatcher,
TerrainAtlas? terrainAtlas,
StreamingController streaming,
WorldRenderRangeState renderRange,
UiRoot? uiRoot,
ICommandBus commands,
RetailWindowOpacityController? chatOpacity = null,
Action<string>? log = null,
// Campaign OP slice OP6: the live engine reference — see
// 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)
: this(
displayWindow,
new RuntimeQualityApplicationTarget(
dispatcher,
terrainAtlas,
streaming,
renderRange),
uiRoot is null
? NullRuntimeUiLockTarget.Instance
: new RuntimeUiLockTarget(uiRoot),
commands,
log,
chatOpacity is null
? NullRuntimeChatOpacityTarget.Instance
: new RuntimeChatOpacityTarget(chatOpacity),
audio)
{
}
internal RuntimeSettingsTargets(
IRuntimeDisplayWindowTarget displayWindow,
IRuntimeQualityApplicationTarget quality,
IRuntimeUiLockTarget uiLock,
ICommandBus commands,
Action<string>? log = null,
IRuntimeChatOpacityTarget? chatOpacity = null,
OpenAlAudioEngine? audio = null)
{
_displayWindow = displayWindow
?? throw new ArgumentNullException(nameof(displayWindow));
_quality = quality ?? throw new ArgumentNullException(nameof(quality));
_uiLock = uiLock ?? throw new ArgumentNullException(nameof(uiLock));
_chatOpacity = chatOpacity ?? NullRuntimeChatOpacityTarget.Instance;
_commands = commands ?? throw new ArgumentNullException(nameof(commands));
_log = log ?? Console.WriteLine;
_audio = audio;
}
public void ApplyDisplayWindowState(DisplaySettings display) =>
_displayWindow.Apply(display);
/// <summary>Campaign OP slice OP6: reuses the SAME static helper the
/// startup path (<see cref="RuntimeSettingsStartupTargets.ApplyAudio"/>)
/// already runs — one mixer-apply implementation, two call sites (once
/// at process start, now also on every <c>RuntimeSettingsController.
/// SaveAudio</c>). <see langword="null"/> engine (a headless/no-audio
/// host) is a silent no-op, matching every other optional target in
/// this class.</summary>
public void ApplyAudio(AudioSettings audio) =>
RuntimeSettingsStartupTargets.ApplyAudio(_audio, audio);
public void ApplyQuality(QualitySettings quality)
{
_quality.SetAlphaToCoverage(quality.AlphaToCoverage);
_quality.SetAnisotropic(quality.AnisotropicLevel);
_quality.PublishRenderRange(quality.NearRadius, quality.FarRadius);
_quality.ReconfigureStreamingRadii(quality.NearRadius, quality.FarRadius);
_quality.SetCompletionBudget(quality.MaxCompletionsPerFrame);
_log(
$"[QUALITY] Streaming reconciled: nearRadius={quality.NearRadius}, " +
$"farRadius={quality.FarRadius}, " +
$"maxCompletions={quality.MaxCompletionsPerFrame}");
}
public void ApplyUiLock(bool locked) => _uiLock.Apply(locked);
/// <summary>
/// CH3 (2026-08-09): publishes through the SAME
/// <see cref="LiveSessionCommandRouter"/> generation-gated route every
/// other outbound Settings/chat command uses — a no-op when no route is
/// currently attached (disconnected / reconnecting), exactly like every
/// other <c>ICommandBus.Publish</c> call site. N6 (CH3 Opus review,
/// 2026-08-09): this silent drop is safe because
/// <c>RuntimeSettingsController.SaveChat</c> already wrote the toggle to
/// settings.json BEFORE calling here — the local preference is never
/// lost — and the next successful connect's PlayerDescription re-runs
/// <c>SyncChatFromServerOptions</c>, reconciling the draft/persisted
/// snapshot back to whatever the server actually has (which may or may
/// not match the dropped toggle, since the wire send never landed).
/// </summary>
public void SetSingleCharacterOption(uint optionId, bool value) =>
_commands.Publish(new SetSingleCharacterOptionRuntimeCmd(optionId, value));
public void SetChatOpacity(float defaultOpacity, float activeOpacity) =>
_chatOpacity.Apply(defaultOpacity, activeOpacity);
}