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
{
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);
}
///
/// Campaign CH slice CH6c target seam for the Chat tab's transparency sliders,
/// mirroring 's shape.
///
internal interface IRuntimeChatOpacityTarget
{
void Apply(float defaultOpacity, float activeOpacity);
}
/// The window properties the display apply touches — a narrow
/// seam so the #388 state machine is testable without faking all of
/// (same idiom as FakePacingSurface's
/// surface).
internal interface IWindowedSizeSurface
{
Vector2D Size { get; set; }
/// #388 blast M3: a maximized window silently ignores a Size
/// write — the apply un-maximizes first (the deleted
/// WindowState = Normal write used to do this incidentally).
bool IsMaximized { get; }
void Restore();
}
internal sealed class SilkWindowSizeSurface(IWindow window) : IWindowedSizeSurface
{
private readonly IWindow _window = window
?? throw new ArgumentNullException(nameof(window));
public Vector2D 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 _isOfferedMode;
public SilkRuntimeDisplayWindowTarget(IWindow window)
: this(
new SilkWindowSizeSurface(window),
new GlfwDisplayModeSwitcher(window),
// #391's catalog is the validation source. With no catalog
// installed, the dropdown falls back to the static preset
// ladder — the validator must fall back to the SAME list
// (blast M2: an asymmetric fallback made Full Screen a permanent
// silent no-op on catalog-less hosts). 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 isOfferedMode)
{
_window = window ?? throw new ArgumentNullException(nameof(window));
_modeSwitcher = modeSwitcher
?? throw new ArgumentNullException(nameof(modeSwitcher));
_isOfferedMode = isOfferedMode
?? throw new ArgumentNullException(nameof(isOfferedMode));
}
///
/// #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
/// Device::ForceDisplayResolution semantics via
/// glfwSetWindowMonitor). A raw Size 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.
///
public void 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;
}
// 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;
if (!_isOfferedMode.Invoke($"{width}x{height}"))
{
Console.WriteLine(
$"display: fullscreen {width}x{height} refused — not an offered mode");
return;
}
if (!_modeSwitcher.TryEnterFullscreen(width, height, out string? error))
Console.WriteLine(
$"display: fullscreen {width}x{height} failed ({error}) — window state unchanged (#392 tracks the persisted-flag divergence)");
return;
}
// 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;
}
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(width, height);
}
}
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);
/// #389: the stored Field of View is retail's m_fGameFOV
/// in DEGREES (registered range [10,160], default 90 —
/// gmClient::InitUIPreferences @0x004035b0), converted exactly as
/// retail's option setter does (× 0.017453292519943295,
/// 0x00451e6a) and applied through the smartbox law in
/// — 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).
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;
}
///
/// Campaign OP slice OP6 (2026-08-11), CORRECTED at the OP6 rework round
/// (review docs/research/2026-08-11-op6-review.md finding M2): the
/// Config tab's toggle halves of the Sound/Ambient volume trios
/// (/
/// — retail's own ENABLED-sense SoundManager::effect_sounds_enabled/
/// ambient_sounds_enabled statics, see '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
/// 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).
///
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)
{
}
}
///
/// Complete late-bound target for changes made after startup. Construction and
/// binding are inert; only an explicit controller command mutates borrowers.
///
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 _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? 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? 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 void ApplyDisplayWindowState(DisplaySettings display)
{
_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);
}
/// Campaign OP slice OP6: reuses the SAME static helper the
/// startup path ()
/// already runs — one mixer-apply implementation, two call sites (once
/// at process start, now also on every RuntimeSettingsController.
/// SaveAudio). engine (a headless/no-audio
/// host) is a silent no-op, matching every other optional target in
/// this class.
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);
///
/// CH3 (2026-08-09): publishes through the SAME
/// 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 ICommandBus.Publish call site. N6 (CH3 Opus review,
/// 2026-08-09): this silent drop is safe because
/// RuntimeSettingsController.SaveChat 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
/// SyncChatFromServerOptions, 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).
///
public void SetSingleCharacterOption(uint optionId, bool value) =>
_commands.Publish(new SetSingleCharacterOptionRuntimeCmd(optionId, value));
public void SetChatOpacity(float defaultOpacity, float activeOpacity) =>
_chatOpacity.Apply(defaultOpacity, activeOpacity);
}