acdream/src/AcDream.App/Settings/RuntimeSettingsTargets.cs

542 lines
22 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.Globalization;
using System.Linq;
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
{
RuntimeDisplayApplyResult Apply(DisplaySettings display);
}
/// <summary>
/// Native display state observed after a requested settings apply. The
/// controller owns persistence and uses this result to keep the checkbox and
/// settings file aligned with the GLFW post-condition (#392).
/// </summary>
internal readonly record struct RuntimeDisplayApplyResult(bool Fullscreen);
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);
}
/// <summary>The window properties the display apply touches — a narrow
/// seam so the #388 state machine is testable without faking all of
/// <see cref="IWindow"/> (same idiom as <c>FakePacingSurface</c>'s
/// surface).</summary>
internal interface IWindowedSizeSurface
{
Vector2D<int> Size { get; set; }
/// <summary>#388 blast M3: a maximized window silently ignores a Size
/// write — the apply un-maximizes first (the deleted
/// <c>WindowState = Normal</c> write used to do this incidentally).</summary>
bool IsMaximized { get; }
void Restore();
}
internal sealed class SilkWindowSizeSurface(IWindow window) : IWindowedSizeSurface
{
private readonly IWindow _window = window
?? throw new ArgumentNullException(nameof(window));
public Vector2D<int> Size
{
get => _window.Size;
set => _window.Size = value;
}
public bool IsMaximized => _window.WindowState == WindowState.Maximized;
public void Restore() => _window.WindowState = WindowState.Normal;
}
internal sealed class SilkRuntimeDisplayWindowTarget : IRuntimeDisplayWindowTarget
{
private readonly IWindowedSizeSurface _window;
private readonly IDisplayModeSwitcher _modeSwitcher;
private readonly Func<string, bool> _isOfferedMode;
public SilkRuntimeDisplayWindowTarget(IWindow window)
: this(
new SilkWindowSizeSurface(window),
new GlfwDisplayModeSwitcher(window),
// F9 correction (Campaign CC gate round 1 closeout, 2026-08-16):
// this used to claim the validator "must fall back to the SAME
// list" the Config dropdown offers — true when this comment was
// written (#391, one catalog for both), but #407 split the
// catalog in two: WindowedResolutions (the dropdown's fuller
// union offering, since a windowed pick needs no real video
// mode) versus Resolutions (the narrower, fullscreen-SAFE
// hardware list this validator deliberately reads). Post-#407 a
// windowed-only entry submitted for fullscreen is EXPECTED to
// fail this check and refuse gracefully (log-and-stay,
// #388/#392's own documented behavior) — that is no longer the
// blast-M2 silent-no-op bug, it is the correct outcome. With no
// catalog installed at all (fixture/headless/UI-Studio hosts),
// Resolutions is null and this still falls back to the static
// preset ladder, matching every offering DisplayModeCatalog
// makes in that state. The switcher's own monitor-mode-list
// check remains the hard guard either way.
spec => (Rendering.DisplayModeCatalog.Resolutions
?? DisplaySettings.AvailableResolutions).Contains(spec))
{
}
internal SilkRuntimeDisplayWindowTarget(
IWindowedSizeSurface window,
IDisplayModeSwitcher modeSwitcher,
Func<string, bool> isOfferedMode)
{
_window = window ?? throw new ArgumentNullException(nameof(window));
_modeSwitcher = modeSwitcher
?? throw new ArgumentNullException(nameof(modeSwitcher));
_isOfferedMode = isOfferedMode
?? throw new ArgumentNullException(nameof(isOfferedMode));
}
/// <summary>
/// #388: the state-aware display apply. Windowed target = a window
/// resize (the proven #387 chain); fullscreen target = a validated
/// native display-mode switch (#376, retail's
/// <c>Device::ForceDisplayResolution</c> semantics via
/// <c>glfwSetWindowMonitor</c>). A raw <c>Size</c> write NEVER happens
/// against a fullscreen window — on GLFW that is a video-mode request
/// and an unsupported one killed the client mid-session (the 2026-08-13
/// gate crash). Every failure path logs and leaves the window in a
/// usable state instead of throwing.
/// </summary>
public RuntimeDisplayApplyResult Apply(DisplaySettings display)
{
ArgumentNullException.ThrowIfNull(display);
bool haveResolution =
TryParseResolution(display.Resolution, out int width, out int height);
if (display.Fullscreen)
{
if (!haveResolution)
{
// Mechanism M5: never a SILENT bail-out — §D6's acceptance is
// "any refused/failed switch logs a line".
Console.WriteLine(
$"display: fullscreen refused — unparseable resolution '{display.Resolution}'");
return CurrentResult();
}
// Mechanism/blast M2: idempotence BEFORE any native work — every
// Display-backed Config row applies per change (sliders per drag
// tick), and only this guard keeps those from re-issuing a real
// display-mode change per mouse sample.
if (_modeSwitcher.CurrentFullscreenMode is (int curW, int curH)
&& curW == width && curH == height)
return CurrentResult();
if (!_isOfferedMode.Invoke($"{width}x{height}"))
{
Console.WriteLine(
$"display: fullscreen {width}x{height} refused — not an offered mode");
return CurrentResult();
}
if (!_modeSwitcher.TryEnterFullscreen(width, height, out string? error))
Console.WriteLine(
$"display: fullscreen {width}x{height} failed ({error}) — window state unchanged");
return CurrentResult();
}
// Windowed target: leave fullscreen first if needed (the native exit
// sets the client size itself), otherwise plain window resize.
if (_modeSwitcher.IsFullscreen)
{
if (!haveResolution)
{
width = _window.Size.X;
height = _window.Size.Y;
}
if (!_modeSwitcher.TryLeaveFullscreen(width, height, out string? error))
Console.WriteLine(
$"display: leaving fullscreen failed ({error})");
return CurrentResult();
}
if (haveResolution && (_window.Size.X != width || _window.Size.Y != height))
{
// Blast M3: a maximized window ignores Size writes — restore
// first (the deleted WindowState=Normal write did this
// incidentally; now it is explicit and only-when-needed).
if (_window.IsMaximized)
_window.Restore();
// #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);
}
return CurrentResult();
}
private RuntimeDisplayApplyResult CurrentResult() =>
new(_modeSwitcher.IsFullscreen);
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);
// Invariant: a resolution spec is a machine-readable settings key, not
// localized input. Parsing it with the ambient culture made "1920x-1"
// fail outright under ar-SA, whose negative sign is not ASCII "-".
return parts.Length == 2
&& int.TryParse(
parts[0], NumberStyles.Integer, CultureInfo.InvariantCulture, out width)
&& int.TryParse(
parts[1], NumberStyles.Integer, CultureInfo.InvariantCulture, 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 RuntimeDisplayApplyResult ApplyDisplay(DisplaySettings display)
{
ArgumentNullException.ThrowIfNull(display);
_pacing.RefreshActiveMonitor();
_pacing.ApplyPreference(display.VSync);
RuntimeDisplayApplyResult result = _displayWindow.Apply(display);
ApplyFieldOfView(_cameras, display.FieldOfView);
return result;
}
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;
private readonly CameraController? _cameras;
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,
// #389 blast-review MUST-FIX 2: retail's FOV preference applies LIVE
// (Render::GRPCallback_OnRenderPreferenceChanged @0x0054d999 →
// SmartBox::SetDefaultFov → m_fGameFOV, re-read by the smartbox
// sites every render) — the saved Field of View must reach the
// cameras on Save, not on the next launch. Null on hosts with no
// camera graph (headless / fixture callers).
CameraController? cameras = 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,
cameras)
{
}
internal RuntimeSettingsTargets(
IRuntimeDisplayWindowTarget displayWindow,
IRuntimeQualityApplicationTarget quality,
IRuntimeUiLockTarget uiLock,
ICommandBus commands,
Action<string>? log = null,
IRuntimeChatOpacityTarget? chatOpacity = null,
OpenAlAudioEngine? audio = null,
CameraController? cameras = 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;
_cameras = cameras;
}
public RuntimeDisplayApplyResult ApplyDisplayWindowState(DisplaySettings display)
{
RuntimeDisplayApplyResult result = _displayWindow.Apply(display);
// #389 blast MUST-FIX 2 (see the ctor's cameras doc): the Field of
// View applies live on Save, from the update-phase save handler —
// deliberately NOT from the render-phase preview seam
// (WorldRenderFrameBuilder.Apply), whose mid-frame camera mutation
// is the review's WATCH-3 cull-vs-raster landmine.
if (_cameras is not null)
RuntimeSettingsStartupTargets.ApplyFieldOfView(_cameras, display.FieldOfView);
return result;
}
/// <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);
}