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); } 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(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); /// #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; 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) : 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? 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); /// 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); }