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