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