refactor(settings): own two-phase runtime settings

Move pre-window loading, startup application, live settings mutation, toon context, quality reapply, and SettingsVM loans behind one RuntimeSettingsController. Preserve retail command behavior, ordered target publication, draft semantics, and retryable failure convergence while removing duplicate GameWindow state and feature bodies.

Co-authored-by: Codex <codex@openai.com>
This commit is contained in:
Erik 2026-07-22 13:30:22 +02:00
parent 4eae9b4f5a
commit fec0d94148
24 changed files with 2379 additions and 599 deletions

View file

@ -0,0 +1,471 @@
using AcDream.App.Combat;
using AcDream.UI.Abstractions.Input;
using AcDream.UI.Abstractions.Panels.Settings;
using AcDream.UI.Abstractions.Settings;
namespace AcDream.App.Settings;
internal interface IRuntimeSettingsStorage
{
SettingsStore? LayoutStore { get; }
string Location { get; }
DisplaySettings LoadDisplay();
AudioSettings LoadAudio();
GameplaySettings LoadGameplay();
ChatSettings LoadChat();
CharacterSettings LoadCharacter(string toonKey);
void SaveDisplay(DisplaySettings display);
void SaveAudio(AudioSettings audio);
void SaveGameplay(GameplaySettings gameplay);
void SaveChat(ChatSettings chat);
void SaveCharacter(string toonKey, CharacterSettings character);
}
internal sealed class JsonRuntimeSettingsStorage : IRuntimeSettingsStorage
{
private readonly SettingsStore _store;
public JsonRuntimeSettingsStorage(string path)
{
ArgumentException.ThrowIfNullOrWhiteSpace(path);
Location = path;
_store = new SettingsStore(path);
}
public SettingsStore LayoutStore => _store;
public string Location { get; }
public DisplaySettings LoadDisplay() => _store.LoadDisplay();
public AudioSettings LoadAudio() => _store.LoadAudio();
public GameplaySettings LoadGameplay() => _store.LoadGameplay();
public ChatSettings LoadChat() => _store.LoadChat();
public CharacterSettings LoadCharacter(string toonKey) =>
_store.LoadCharacter(toonKey);
public void SaveDisplay(DisplaySettings display) => _store.SaveDisplay(display);
public void SaveAudio(AudioSettings audio) => _store.SaveAudio(audio);
public void SaveGameplay(GameplaySettings gameplay) =>
_store.SaveGameplay(gameplay);
public void SaveChat(ChatSettings chat) => _store.SaveChat(chat);
public void SaveCharacter(string toonKey, CharacterSettings character) =>
_store.SaveCharacter(toonKey, character);
}
internal sealed record RuntimeSettingsSnapshot(
DisplaySettings Display,
AudioSettings Audio,
GameplaySettings Gameplay,
ChatSettings Chat,
CharacterSettings Character,
QualitySettings Quality);
internal interface IRuntimeSettingsStartupTarget
{
void ApplyDisplay(DisplaySettings display);
void ApplyAudio(AudioSettings audio);
}
internal interface IRuntimeSettingsTargets
{
void ApplyDisplayWindowState(DisplaySettings display);
void ApplyQuality(QualitySettings quality);
void ApplyUiLock(bool locked);
}
internal interface IRuntimeSettingsPreviewSource
{
bool HasDraftPreview { get; }
DisplaySettings DisplayPreview { get; }
AudioSettings AudioPreview { get; }
}
/// <summary>
/// Owns the one persisted settings snapshot, its live mutations, and the active
/// character context. Runtime objects are borrowed through typed targets and
/// are never constructed or disposed here.
/// </summary>
internal sealed class RuntimeSettingsController :
IRuntimeSettingsPreviewSource,
ICombatGameplaySettingsSource
{
private const string DefaultToonKey = "default";
private readonly IRuntimeSettingsStorage _storage;
private readonly Func<QualityPreset, QualitySettings> _resolveQuality;
private readonly Action<string> _log;
private IRuntimeSettingsTargets? _runtimeTargets;
private SettingsVM? _viewModel;
private CharacterSettings _defaultCharacter;
private bool _startupDisplayApplied;
private bool _startupAudioApplied;
private bool _startupApplied;
private bool _uiLockConverged = true;
public RuntimeSettingsController(
IRuntimeSettingsStorage storage,
Func<QualityPreset, QualitySettings>? resolveQuality = null,
Action<string>? log = null)
{
_storage = storage ?? throw new ArgumentNullException(nameof(storage));
_resolveQuality = resolveQuality ?? ResolveQuality;
_log = log ?? Console.WriteLine;
Display = _storage.LoadDisplay();
Audio = _storage.LoadAudio();
Gameplay = _storage.LoadGameplay();
Chat = _storage.LoadChat();
_defaultCharacter = _storage.LoadCharacter(DefaultToonKey);
Character = _defaultCharacter;
ResolvedQuality = _resolveQuality(Display.Quality);
Startup = new RuntimeSettingsSnapshot(
Display,
Audio,
Gameplay,
Chat,
Character,
ResolvedQuality);
}
public RuntimeSettingsSnapshot Startup { get; }
public SettingsStore? LayoutStore => _storage.LayoutStore;
public string ActiveToonKey { get; private set; } = DefaultToonKey;
public DisplaySettings Display { get; private set; }
public AudioSettings Audio { get; private set; }
public GameplaySettings Gameplay { get; private set; }
public ChatSettings Chat { get; private set; }
public CharacterSettings Character { get; private set; }
public QualitySettings ResolvedQuality { get; private set; }
public bool HasDraftPreview => _viewModel is not null;
public DisplaySettings DisplayPreview => _viewModel?.DisplayDraft ?? Display;
public AudioSettings AudioPreview => _viewModel?.AudioDraft ?? Audio;
public bool AutoTarget => Gameplay.AutoTarget;
public bool AutoRepeatAttack => Gameplay.AutoRepeatAttack;
public bool ViewCombatTarget => Gameplay.ViewCombatTarget;
public void ApplyStartup(IRuntimeSettingsStartupTarget target)
{
ArgumentNullException.ThrowIfNull(target);
if (_startupApplied)
throw new InvalidOperationException("Runtime settings startup was already applied.");
if (!_startupDisplayApplied)
{
target.ApplyDisplay(Startup.Display);
_startupDisplayApplied = true;
}
if (!_startupAudioApplied)
{
target.ApplyAudio(Startup.Audio);
_startupAudioApplied = true;
}
_startupApplied = true;
QualitySettings baseQuality = QualitySettings.From(Startup.Display.Quality);
_log(Startup.Quality.Equals(baseQuality)
? $"[QUALITY] Preset {Startup.Display.Quality} -> {Startup.Quality}"
: $"[QUALITY] Preset {Startup.Display.Quality} overridden by env vars: {Startup.Quality}");
}
/// <summary>
/// Installs the complete future-change target. Deliberately does not replay
/// startup display, quality, or UI-lock values; those were consumed by the
/// factories that created the borrowed target objects.
/// </summary>
public void BindRuntimeTargets(IRuntimeSettingsTargets targets)
{
ArgumentNullException.ThrowIfNull(targets);
if (_runtimeTargets is not null)
throw new InvalidOperationException("Runtime settings targets are already bound.");
_runtimeTargets = targets;
}
public void UnbindRuntimeTargets() => _runtimeTargets = null;
public SettingsVM CreateViewModel(
KeyBindings persistedBindings,
InputDispatcher dispatcher,
Action<KeyBindings> saveBindings)
{
ArgumentNullException.ThrowIfNull(persistedBindings);
ArgumentNullException.ThrowIfNull(dispatcher);
ArgumentNullException.ThrowIfNull(saveBindings);
if (_viewModel is not null)
throw new InvalidOperationException("A settings view model is already bound.");
_viewModel = new SettingsVM(
persistedBindings,
dispatcher,
saveBindings,
Display,
SaveDisplay,
Audio,
SaveAudio,
Gameplay,
SaveGameplay,
Chat,
SaveChat,
Character,
SaveCharacter);
return _viewModel;
}
public void UnbindViewModel(SettingsVM? expected = null)
{
if (expected is null || ReferenceEquals(_viewModel, expected))
_viewModel = null;
}
public void SetUiLocked(bool locked)
{
if (Gameplay.LockUI == locked && _uiLockConverged)
return;
_uiLockConverged = false;
Gameplay = Gameplay with { LockUI = locked };
_runtimeTargets?.ApplyUiLock(locked);
_viewModel?.SetGameplay(
_viewModel.GameplayDraft with { LockUI = locked });
try
{
_storage.SaveGameplay(Gameplay);
_viewModel?.ApplyExternalGameplayChange(gameplay => gameplay with
{
LockUI = locked,
});
_uiLockConverged = true;
}
catch (Exception ex)
{
_log($"settings: radar lock save failed: {ex.Message}");
}
}
public void SetAcceptLootPermits(bool enabled)
{
Gameplay = Gameplay with { AcceptLootPermits = enabled };
_viewModel?.SetGameplay(
_viewModel.GameplayDraft with { AcceptLootPermits = enabled });
_storage.SaveGameplay(Gameplay);
_viewModel?.ApplyExternalGameplayChange(gameplay => gameplay with
{
AcceptLootPermits = enabled,
});
}
/// <summary>
/// Retail <c>ClientCommunicationSystem::DoFrameRate @ 0x005707D0</c>
/// flips the live flag and sends the framerate-display UI notice. acdream
/// additionally persists that live value through its modern Display bag
/// (registered as AP-121).
/// </summary>
public void ToggleFrameRate()
{
Display = Display with { ShowFps = !Display.ShowFps };
_viewModel?.ApplyExternalDisplayChange(display => display with
{
ShowFps = Display.ShowFps,
});
try
{
_storage.SaveDisplay(Display);
}
catch (Exception ex)
{
_log($"settings: framerate display save failed: {ex.Message}");
}
}
public void SetCombatGameplay(GameplaySettings gameplay)
{
Gameplay = gameplay ?? throw new ArgumentNullException(nameof(gameplay));
if (_viewModel is not null)
{
_viewModel.SetGameplay(_viewModel.GameplayDraft with
{
AutoTarget = gameplay.AutoTarget,
AutoRepeatAttack = gameplay.AutoRepeatAttack,
ViewCombatTarget = gameplay.ViewCombatTarget,
});
}
try
{
_storage.SaveGameplay(gameplay);
_viewModel?.ApplyExternalGameplayChange(current => current with
{
AutoTarget = gameplay.AutoTarget,
AutoRepeatAttack = gameplay.AutoRepeatAttack,
ViewCombatTarget = gameplay.ViewCombatTarget,
});
}
catch (Exception ex)
{
_log($"settings: combat option save failed: {ex.Message}");
}
}
public void SetActiveCharacter(string characterName)
{
ArgumentException.ThrowIfNullOrWhiteSpace(characterName);
ActiveToonKey = characterName;
}
public void LoadCharacterContext(string characterName)
{
ArgumentException.ThrowIfNullOrWhiteSpace(characterName);
ActiveToonKey = characterName;
Character = _storage.LoadCharacter(characterName);
_viewModel?.LoadCharacterContext(Character);
_log($"settings: loaded character[{characterName}] preferences");
}
public void RestoreDefaultCharacterContext()
{
Character = _defaultCharacter;
_viewModel?.LoadCharacterContext(Character);
}
public void ResetActiveCharacterKey() => ActiveToonKey = DefaultToonKey;
public void ReapplyQualityPreset(QualityPreset preset)
{
QualitySettings resolved = _resolveQuality(preset);
_log($"[QUALITY] ReapplyQualityPreset: {preset} -> {resolved}");
if (resolved.MsaaSamples != ResolvedQuality.MsaaSamples)
{
_log(
$"[QUALITY] MSAA samples change ({ResolvedQuality.MsaaSamples} -> " +
$"{resolved.MsaaSamples}) requires a restart - skipped for this session.");
}
ResolvedQuality = resolved;
_runtimeTargets?.ApplyQuality(resolved);
}
private void SaveDisplay(DisplaySettings display)
{
try
{
_storage.SaveDisplay(display);
_log($"settings: display saved to {_storage.Location}");
_runtimeTargets?.ApplyDisplayWindowState(display);
Display = display;
ReapplyQualityPreset(display.Quality);
}
catch (Exception ex)
{
_log($"settings: display save failed: {ex.Message}");
}
}
private void SaveAudio(AudioSettings audio)
{
try
{
_storage.SaveAudio(audio);
Audio = audio;
_log($"settings: audio saved to {_storage.Location}");
}
catch (Exception ex)
{
_log($"settings: audio save failed: {ex.Message}");
}
}
private void SaveGameplay(GameplaySettings gameplay)
{
try
{
_storage.SaveGameplay(gameplay);
Gameplay = gameplay;
_uiLockConverged = false;
_runtimeTargets?.ApplyUiLock(gameplay.LockUI);
_uiLockConverged = true;
_log($"settings: gameplay saved to {_storage.Location}");
}
catch (Exception ex)
{
_log($"settings: gameplay save failed: {ex.Message}");
}
}
private void SaveChat(ChatSettings chat)
{
try
{
_storage.SaveChat(chat);
Chat = chat;
_log($"settings: chat saved to {_storage.Location}");
}
catch (Exception ex)
{
_log($"settings: chat save failed: {ex.Message}");
}
}
private void SaveCharacter(CharacterSettings character)
{
try
{
_storage.SaveCharacter(ActiveToonKey, character);
Character = character;
if (string.Equals(
ActiveToonKey,
DefaultToonKey,
StringComparison.OrdinalIgnoreCase))
{
_defaultCharacter = character;
}
_log($"settings: character[{ActiveToonKey}] saved to {_storage.Location}");
}
catch (Exception ex)
{
_log($"settings: character save failed: {ex.Message}");
}
}
private static QualitySettings ResolveQuality(QualityPreset preset) =>
QualitySettings.WithEnvOverrides(QualitySettings.From(preset));
}

View file

@ -0,0 +1,255 @@
using AcDream.App.Audio;
using AcDream.App.Rendering;
using AcDream.App.Rendering.Wb;
using AcDream.App.Streaming;
using AcDream.App.UI;
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);
}
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);
if (TryParseResolution(display.Resolution, out int width, out int height)
&& (_window.Size.X != width || _window.Size.Y != height))
{
_window.Size = new Vector2D<int>(width, height);
}
WindowState desired = display.Fullscreen
? WindowState.Fullscreen
: WindowState.Normal;
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);
internal static void ApplyFieldOfView(
CameraController cameras,
float degrees)
{
float radians = degrees * (MathF.PI / 180f);
cameras.Orbit.FovY = radians;
cameras.Fly.FovY = radians;
if (cameras.Chase is not null)
cameras.Chase.FovY = radians;
}
internal static void ApplyAudio(
OpenAlAudioEngine? engine,
AudioSettings audio)
{
ArgumentNullException.ThrowIfNull(audio);
if (engine is not { IsAvailable: true })
return;
engine.MasterVolume = audio.Master;
engine.MusicVolume = audio.Music;
engine.SfxVolume = audio.Sfx;
engine.AmbientVolume = audio.Ambient;
}
}
internal sealed class RuntimeQualityApplicationTarget
: IRuntimeQualityApplicationTarget
{
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 ?? throw new ArgumentNullException(nameof(dispatcher));
_terrainAtlas = terrainAtlas ?? throw new ArgumentNullException(nameof(terrainAtlas));
_streaming = streaming ?? throw new ArgumentNullException(nameof(streaming));
_renderRange = renderRange ?? throw new ArgumentNullException(nameof(renderRange));
}
public void SetAlphaToCoverage(bool enabled) =>
_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)
{
}
}
/// <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 Action<string> _log;
public RuntimeSettingsTargets(
IRuntimeDisplayWindowTarget displayWindow,
WbDrawDispatcher dispatcher,
TerrainAtlas terrainAtlas,
StreamingController streaming,
WorldRenderRangeState renderRange,
UiRoot? uiRoot,
Action<string>? log = null)
: this(
displayWindow,
new RuntimeQualityApplicationTarget(
dispatcher,
terrainAtlas,
streaming,
renderRange),
uiRoot is null
? NullRuntimeUiLockTarget.Instance
: new RuntimeUiLockTarget(uiRoot),
log)
{
}
internal RuntimeSettingsTargets(
IRuntimeDisplayWindowTarget displayWindow,
IRuntimeQualityApplicationTarget quality,
IRuntimeUiLockTarget uiLock,
Action<string>? log = null)
{
_displayWindow = displayWindow
?? throw new ArgumentNullException(nameof(displayWindow));
_quality = quality ?? throw new ArgumentNullException(nameof(quality));
_uiLock = uiLock ?? throw new ArgumentNullException(nameof(uiLock));
_log = log ?? Console.WriteLine;
}
public void ApplyDisplayWindowState(DisplaySettings display) =>
_displayWindow.Apply(display);
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);
}