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

@ -1,5 +1,6 @@
using AcDream.Core.Plugins;
using AcDream.App.Physics;
using AcDream.App.Settings;
using AcDream.App.World;
using AcDream.Content;
using DatReaderWriter;
@ -97,6 +98,7 @@ public sealed class GameWindow : IDisposable
new(Console.WriteLine);
private ResourceShutdownTransaction? _shutdown;
private readonly DisplayFramePacingController _displayFramePacing;
private readonly RuntimeSettingsController _runtimeSettings;
// Phase A.1: streaming fields replacing the one-shot _entities list.
private AcDream.App.Streaming.LandblockStreamer? _streamer;
@ -124,14 +126,6 @@ public sealed class GameWindow : IDisposable
get => _renderRange.FarRadius;
set => _renderRange.FarRadius = value;
}
// A.5 T22.5: resolved quality settings (preset + env-var overrides).
// Set once in OnLoad after LoadAndApplyPersistedSettings(); re-set on
// ReapplyQualityPreset(). Default matches QualityPreset.High so the field
// is valid before OnLoad fires (no GL calls are made before OnLoad anyway).
private AcDream.UI.Abstractions.Settings.QualitySettings _resolvedQuality =
AcDream.UI.Abstractions.Settings.QualitySettings.From(
AcDream.UI.Abstractions.Settings.QualityPreset.High);
// Phase B.3: physics engine — populated from the streaming pipeline.
private readonly AcDream.Core.Physics.PhysicsEngine _physicsEngine = new();
@ -563,6 +557,10 @@ public sealed class GameWindow : IDisposable
_displayFramePacing = new DisplayFramePacingController(
options.UncappedRendering,
_frameProfiler);
_runtimeSettings = new RuntimeSettingsController(
new JsonRuntimeSettingsStorage(
AcDream.UI.Abstractions.Panels.Settings.SettingsStore.DefaultPath()),
log: Console.WriteLine);
_selection = selection ?? throw new System.ArgumentNullException(nameof(selection));
_animationDiagnostics = AnimationPresentationDiagnostics.FromEnvironment();
_uiRegistry = uiRegistry;
@ -594,17 +592,13 @@ public sealed class GameWindow : IDisposable
public void Run()
{
// A.5 T22.5: resolve quality preset BEFORE creating the window so
// Samples (MSAA) is baked into WindowOptions correctly. GL context
// sample count cannot change at runtime; all other quality fields are
// applied again in OnLoad after the full settings load.
var startupStore = new AcDream.UI.Abstractions.Panels.Settings.SettingsStore(
AcDream.UI.Abstractions.Panels.Settings.SettingsStore.DefaultPath());
var startupDisplay = startupStore.LoadDisplay();
var startupBase = AcDream.UI.Abstractions.Settings.QualitySettings.From(startupDisplay.Quality);
var startupQuality = AcDream.UI.Abstractions.Settings.QualitySettings.WithEnvOverrides(startupBase);
// The runtime-settings owner was constructed before Window.Create and
// exposes the one immutable startup snapshot. MSAA is a context
// attribute, so it must come from this same snapshot rather than a
// second settings load during OnLoad.
RuntimeSettingsSnapshot startup = _runtimeSettings.Startup;
FramePacingPolicy startupPacing =
_displayFramePacing.InitializeStartup(startupDisplay.VSync);
_displayFramePacing.InitializeStartup(startup.Display.VSync);
var options = WindowOptions.Default with
{
Size = new Vector2D<int>(1280, 720),
@ -619,7 +613,7 @@ public sealed class GameWindow : IDisposable
// Silk.NET passes this to SDL_GL_SetAttribute(SDL_GL_MULTISAMPLESAMPLES).
// Cannot be changed at runtime; Quality changes mid-session that would
// alter MsaaSamples are logged as a restart-required warning.
Samples = startupQuality.MsaaSamples,
Samples = startup.Quality.MsaaSamples,
// #117 (2026-06-11): the aperture punch's depth gate needs a
// stencil buffer (PortalDepthMaskRenderer two-pass mark+punch).
// GLFW defaults to 8 stencil bits, but make the requirement
@ -843,28 +837,15 @@ public sealed class GameWindow : IDisposable
}
}
// L.0 follow-up — load + apply persisted Display / Audio settings
// BEFORE the DevToolsEnabled block. The settings.json values
// (resolution, vsync, FOV, master volume, etc) are runtime
// settings, not devtools settings — a user running without
// ACDREAM_DEVTOOLS=1 still expects their saved values to take
// effect. The Settings PANEL (editing UI) is gated on devtools;
// the persisted state is not. Caches values into fields so the
// SettingsVM construction in the devtools block reads them
// without re-loading.
LoadAndApplyPersistedSettings();
// A.5 T22.5: resolve quality preset immediately after settings load so
// _resolvedQuality is available for TerrainAtlas.SetAnisotropic,
// WbDrawDispatcher.AlphaToCoverage, and StreamingController wiring below.
{
var qBase = AcDream.UI.Abstractions.Settings.QualitySettings.From(_persistedDisplay.Quality);
_resolvedQuality = AcDream.UI.Abstractions.Settings.QualitySettings.WithEnvOverrides(qBase);
if (!_resolvedQuality.Equals(qBase))
Console.WriteLine($"[QUALITY] Preset {_persistedDisplay.Quality} overridden by env vars: {_resolvedQuality}");
else
Console.WriteLine($"[QUALITY] Preset {_persistedDisplay.Quality} → {_resolvedQuality}");
}
// Settings are runtime state, not devtools state. Apply the immutable
// pre-window snapshot once after camera/audio exist and before any
// optional frontend or world/render factory consumes it.
_runtimeSettings.ApplyStartup(
new RuntimeSettingsStartupTargets(
new SilkRuntimeDisplayWindowTarget(_window!),
_displayFramePacing,
_cameraController!,
_audioEngine));
// Phase D.2a — ImGui devtools overlay. Zero cost when the env var
// isn't set: no context creation, no per-frame branches hit.
@ -872,6 +853,7 @@ public sealed class GameWindow : IDisposable
if (DevToolsEnabled)
{
AcDream.UI.ImGui.ImGuiBootstrapper? imguiBootstrap = null;
AcDream.UI.Abstractions.Panels.Settings.SettingsVM? settingsVm = null;
try
{
_devToolsInputContext = new AcDream.App.Input.QuiescentInputContext(
@ -984,19 +966,12 @@ public sealed class GameWindow : IDisposable
// dispatcher because the dispatcher is built earlier in
// the same OnLoad path (see _inputDispatcher field).
AcDream.UI.Abstractions.Panels.Settings.SettingsPanel? settingsPanel = null;
if (_inputDispatcher is not null && _settingsStore is not null)
if (_inputDispatcher is not null)
{
// L.0 — SettingsStore + persisted-settings load + apply
// happened earlier in OnLoad via
// LoadAndApplyPersistedSettings (settings are runtime
// state, not devtools state — they take effect even
// when ACDREAM_DEVTOOLS=0). Here we just construct the
// Settings PANEL on top of the already-loaded values.
var settingsStore = _settingsStore;
_settingsVm = new AcDream.UI.Abstractions.Panels.Settings.SettingsVM(
persisted: _keyBindings,
dispatcher: _inputDispatcher,
onSave: bindings =>
settingsVm = _runtimeSettings.CreateViewModel(
_keyBindings,
_inputDispatcher,
bindings =>
{
_inputDispatcher.SetBindings(bindings);
try
@ -1011,120 +986,9 @@ public sealed class GameWindow : IDisposable
{
Console.WriteLine($"keybinds: save failed: {ex.Message}");
}
},
persistedDisplay: _persistedDisplay,
onSaveDisplay: display =>
{
try
{
settingsStore.SaveDisplay(display);
Console.WriteLine(
"settings: display saved to "
+ AcDream.UI.Abstractions.Panels.Settings.SettingsStore.DefaultPath());
// Apply window-level changes that are too
// jarring to live-preview (resolution +
// fullscreen). VSync / FOV / ShowFps
// already track DisplayDraft via the
// per-frame push.
ApplyDisplayWindowState(display);
// A.5 T22.5: apply quality preset if it changed.
// MSAA changes log a restart-required warning
// inside ReapplyQualityPreset; all other fields
// apply immediately.
_persistedDisplay = display;
ReapplyQualityPreset(display.Quality);
}
catch (Exception ex)
{
Console.WriteLine($"settings: display save failed: {ex.Message}");
}
},
persistedAudio: _persistedAudio,
onSaveAudio: audio =>
{
try
{
settingsStore.SaveAudio(audio);
Console.WriteLine(
"settings: audio saved to "
+ AcDream.UI.Abstractions.Panels.Settings.SettingsStore.DefaultPath());
}
catch (Exception ex)
{
Console.WriteLine($"settings: audio save failed: {ex.Message}");
}
},
persistedGameplay: _persistedGameplay,
onSaveGameplay: gameplay =>
{
try
{
settingsStore.SaveGameplay(gameplay);
// Runtime consumers (including retained gmRadarUI) read
// the live persisted snapshot, not SettingsVM's draft.
_persistedGameplay = gameplay;
if (_uiHost is not null)
_uiHost.Root.UiLocked = gameplay.LockUI;
Console.WriteLine(
"settings: gameplay saved to "
+ AcDream.UI.Abstractions.Panels.Settings.SettingsStore.DefaultPath());
// Local-only this phase. Server-sync packet
// (CharacterOption bitmask) goes in here when
// the protocol round-trip is in place.
}
catch (Exception ex)
{
Console.WriteLine($"settings: gameplay save failed: {ex.Message}");
}
},
persistedChat: _persistedChat,
onSaveChat: chat =>
{
try
{
settingsStore.SaveChat(chat);
Console.WriteLine(
"settings: chat saved to "
+ AcDream.UI.Abstractions.Panels.Settings.SettingsStore.DefaultPath());
// Channel filters affect client-side display
// only this phase. ChatPanel will read them
// off SettingsVM.ChatDraft when filtering is
// wired into the chat-line render path.
}
catch (Exception ex)
{
Console.WriteLine($"settings: chat save failed: {ex.Message}");
}
},
persistedCharacter: _persistedCharacter,
onSaveCharacter: character =>
{
try
{
// _activeToonKey is updated by
// LiveSessionHost entered-world transition
// so saving character settings always
// writes under the chosen character's
// name (or "default" pre-login).
settingsStore.SaveCharacter(_activeToonKey, character);
if (string.Equals(
_activeToonKey,
"default",
StringComparison.OrdinalIgnoreCase))
{
_persistedCharacter = character;
}
Console.WriteLine(
$"settings: character[{_activeToonKey}] saved to "
+ AcDream.UI.Abstractions.Panels.Settings.SettingsStore.DefaultPath());
}
catch (Exception ex)
{
Console.WriteLine($"settings: character save failed: {ex.Message}");
}
});
settingsPanel =
new AcDream.UI.Abstractions.Panels.Settings.SettingsPanel(_settingsVm);
new AcDream.UI.Abstractions.Panels.Settings.SettingsPanel(settingsVm);
panelHost.Register(settingsPanel);
}
@ -1185,7 +1049,7 @@ public sealed class GameWindow : IDisposable
_vitalsVm = null;
_combatFeedback.ViewModel = null;
_debugVm = null;
_settingsVm = null;
_runtimeSettings.UnbindViewModel(settingsVm);
}
}
@ -1240,7 +1104,7 @@ public sealed class GameWindow : IDisposable
// A.5 T22.5: apply anisotropic level from quality preset. Build()
// hard-codes 16x; override here to match the resolved quality so Low
// (4x) and Medium (8x) actually take effect.
terrainAtlas.SetAnisotropic(_resolvedQuality.AnisotropicLevel);
terrainAtlas.SetAnisotropic(_runtimeSettings.ResolvedQuality.AnisotropicLevel);
_terrain = new TerrainModernRenderer(
_gl,
@ -1302,7 +1166,7 @@ public sealed class GameWindow : IDisposable
_combatTargetController = new AcDream.App.Combat.CombatTargetController(
Combat,
_selection,
autoTarget: () => _persistedGameplay.AutoTarget,
autoTarget: () => _runtimeSettings.Gameplay.AutoTarget,
selectClosestTarget: () => SelectClosestCombatTarget(showToast: false));
_externalContainerLifecycle = new AcDream.App.World.ExternalContainerLifecycleController(
_externalContainers,
@ -1372,7 +1236,7 @@ public sealed class GameWindow : IDisposable
_debugFont,
_hostQuiescence);
_retainedInputCapture.Root = _uiHost.Root;
_uiHost.Root.UiLocked = _persistedGameplay.LockUI;
_uiHost.Root.UiLocked = _runtimeSettings.Gameplay.LockUI;
var cursorFeedbackController = new AcDream.App.UI.CursorFeedbackController(
_itemInteractionController,
// Retail UpdateCursorState (0x00564630) keys target-mode
@ -1384,7 +1248,7 @@ public sealed class GameWindow : IDisposable
_characterSheetProvider = new AcDream.App.UI.Layout.CharacterSheetProvider(
Objects, LocalPlayer,
playerGuid: () => _playerServerGuid,
activeToonName: () => _activeToonKey,
activeToonName: () => _runtimeSettings.ActiveToonKey,
fallbackSheet: AcDream.App.Studio.SampleData.SampleCharacter,
canSendRaise: () => _liveSessionHost?.IsInWorld == true,
sendRaiseAttribute: (statId, cost) => LiveSession?.SendRaiseAttribute(statId, cost),
@ -1463,17 +1327,19 @@ public sealed class GameWindow : IDisposable
playerYawRadians: () => _playerController?.Yaw ?? 0f,
playerCellId: () => _playerController?.CellId ?? 0u,
selectedGuid: () => _selection.SelectedObjectId,
coordinatesOnRadar: () => _persistedGameplay.CoordinatesOnRadar,
uiLocked: () => _persistedGameplay.LockUI,
coordinatesOnRadar: () => _runtimeSettings.Gameplay.CoordinatesOnRadar,
uiLocked: () => _runtimeSettings.Gameplay.LockUI,
playerEntities: () => _entitiesByServerGuid,
spatialQuery: () => _worldState);
var retailChatVm = new AcDream.UI.Abstractions.Panels.Chat.ChatVM(Chat, displayLimit: 200);
_retailChatVm = retailChatVm;
AcDream.App.UI.RetailUiPersistenceBindings? persistence = _settingsStore is null
AcDream.UI.Abstractions.Panels.Settings.SettingsStore? layoutStore =
_runtimeSettings.LayoutStore;
AcDream.App.UI.RetailUiPersistenceBindings? persistence = layoutStore is null
? null
: new AcDream.App.UI.RetailUiPersistenceBindings(
_settingsStore,
CharacterKey: () => _activeToonKey,
layoutStore,
CharacterKey: () => _runtimeSettings.ActiveToonKey,
ScreenSize: () => (_window.Size.X, _window.Size.Y));
void UiProbeLog(string message) => Console.WriteLine("[UI-PROBE] " + message);
@ -1508,12 +1374,12 @@ public sealed class GameWindow : IDisposable
Radar: new AcDream.App.UI.RadarRuntimeBindings(
radarSnapshotProvider.BuildSnapshot,
_selection,
SetRetailUiLocked),
_runtimeSettings.SetUiLocked),
Combat: new AcDream.App.UI.CombatRuntimeBindings(
Combat,
_combatAttackController,
() => _persistedGameplay,
SetRetailCombatGameplay),
() => _runtimeSettings.Gameplay,
_runtimeSettings.SetCombatGameplay),
Magic: new AcDream.App.UI.MagicRuntimeBindings(
SpellBook,
_magicRuntime.Casting,
@ -1549,12 +1415,11 @@ public sealed class GameWindow : IDisposable
// adaptive distance degradation (tracked by TS-15), so its
// effective multiplier is exactly 1.0.
() => 1.0,
() => _settingsVm?.DisplayDraft.ShowFps
?? _persistedDisplay.ShowFps),
() => _runtimeSettings.DisplayPreview.ShowFps),
VividTarget: new AcDream.App.UI.Layout.VividTargetRuntimeBindings(
_selection,
() => _playerServerGuid,
() => _persistedGameplay.VividTargetingIndicator,
() => _runtimeSettings.Gameplay.VividTargetingIndicator,
ResolveVividTargetInfo,
GetSelectionCamera),
Indicators: new AcDream.App.UI.IndicatorRuntimeBindings(
@ -2019,7 +1884,8 @@ public sealed class GameWindow : IDisposable
_retainedUiGameplayBinding.Attach();
}
// A.5 T22.5: apply A2C gate from quality preset.
_wbDrawDispatcher.AlphaToCoverage = _resolvedQuality.AlphaToCoverage;
_wbDrawDispatcher.AlphaToCoverage =
_runtimeSettings.ResolvedQuality.AlphaToCoverage;
// Slice 2: now that the dispatcher exists, build the paperdoll doll RTT renderer and hand it to
// the viewport widget. Reuses the dispatcher + the scene-lighting UBO + _gl. The widget only
@ -2163,12 +2029,11 @@ public sealed class GameWindow : IDisposable
_options.UiProbeDump,
resourceDiagnostics);
// A.5 T22.5: apply radii from the already-resolved _resolvedQuality.
// _resolvedQuality was set by the quality block immediately after
// LoadAndApplyPersistedSettings() above, absorbing all env-var overrides.
// Apply radii from the same immutable quality snapshot used for the
// window's MSAA, terrain anisotropy, and dispatcher A2C setup.
// Legacy ACDREAM_STREAM_RADIUS is still honoured for backward-compat.
_nearRadius = _resolvedQuality.NearRadius;
_farRadius = _resolvedQuality.FarRadius;
_nearRadius = _runtimeSettings.ResolvedQuality.NearRadius;
_farRadius = _runtimeSettings.ResolvedQuality.FarRadius;
// Legacy override: ACDREAM_STREAM_RADIUS acts as nearRadius and
// ensures farRadius >= streamRadius. Parsed once into
@ -2233,7 +2098,17 @@ public sealed class GameWindow : IDisposable
_streamingController,
_liveWorldOrigin);
// A.5 T22.5: apply max-completions from resolved quality.
_streamingController.MaxCompletionsPerFrame = _resolvedQuality.MaxCompletionsPerFrame;
_streamingController.MaxCompletionsPerFrame =
_runtimeSettings.ResolvedQuality.MaxCompletionsPerFrame;
_runtimeSettings.BindRuntimeTargets(
new RuntimeSettingsTargets(
new SilkRuntimeDisplayWindowTarget(_window!),
_wbDrawDispatcher!,
terrainAtlas,
_streamingController,
_renderRange,
_uiHost?.Root,
Console.WriteLine));
_worldReveal = new AcDream.App.Streaming.WorldRevealCoordinator(
isRenderNeighborhoodReady: _streamingController.IsRenderNeighborhoodResident,
isSpawnCellReady: _physicsEngine.IsSpawnCellReady,
@ -2397,7 +2272,7 @@ public sealed class GameWindow : IDisposable
_liveEntities!,
Objects,
_localPlayerIdentity),
_gameplaySettings,
_runtimeSettings,
_playerControllerSlot,
_localPlayerOutbound,
_liveSessionController,
@ -2452,8 +2327,7 @@ public sealed class GameWindow : IDisposable
_scriptRunner!,
_updateFrameClock,
new AcDream.App.Update.SettingsParticleRangeSource(
_settingsVm,
_persistedDisplay.ParticleRange));
_runtimeSettings));
var liveSpatialReconciler =
new AcDream.App.Update.LiveSpatialPresentationReconciler(
_entityEffects!,
@ -2659,7 +2533,7 @@ public sealed class GameWindow : IDisposable
_worldReveal,
_envCellFrustum),
new AcDream.App.Rendering.RuntimeWorldFrameSettingsPreview(
_settingsVm,
_runtimeSettings,
_audioEngine,
_cameraController!,
_displayFramePacing),
@ -2803,7 +2677,7 @@ public sealed class GameWindow : IDisposable
localPlayerFrame,
liveSpatialReconciler,
new AcDream.App.Combat.CombatCameraTargetSource(
_gameplaySettings,
_runtimeSettings,
Combat,
_selection,
_worldSelectionQuery!));
@ -2929,10 +2803,10 @@ public sealed class GameWindow : IDisposable
AcDream.App.Streaming.EntityVanishProbe.PlayerGuid = id,
ClearCombat: Combat.Clear),
EnteredWorld: new(
SetActiveCharacter: name => _activeToonKey = name,
SetActiveCharacter: _runtimeSettings.SetActiveCharacter,
RestoreLayout: () => _retailUiRuntime?.RestoreLayout(),
SyncToolbar: SyncToolbarWindowButtons,
LoadCharacterSettings: LoadLiveSessionCharacterSettings,
LoadCharacterSettings: _runtimeSettings.LoadCharacterContext,
ArmPlayerModeAutoEntry: () => _playerModeAutoEntry?.Arm()),
Connecting: (host, port, user) =>
Chat.OnSystemMessage(
@ -2943,17 +2817,6 @@ public sealed class GameWindow : IDisposable
"connected — character list received",
chatType: 1)));
private void LoadLiveSessionCharacterSettings(string characterName)
{
if (_settingsStore is not null && _settingsVm is not null)
{
var toonBag = _settingsStore.LoadCharacter(characterName);
_settingsVm.LoadCharacterContext(toonBag);
Console.WriteLine(
$"settings: loaded character[{characterName}] preferences");
}
}
private AcDream.App.Net.LiveSessionResetBindings
CreateLiveSessionResetBindings() => new()
{
@ -2962,8 +2825,7 @@ public sealed class GameWindow : IDisposable
TeleportTransit = _localPlayerTeleportSink.ResetSession,
SessionDialogs = () => _retailUiRuntime?.ResetSessionDialogs(),
ChatCommandTargets = () => _retailChatVm?.ResetSessionTargets(),
SettingsCharacterContext = () =>
_settingsVm?.LoadCharacterContext(_persistedCharacter),
SettingsCharacterContext = _runtimeSettings.RestoreDefaultCharacterContext,
EquippedChildren = () => _equippedChildRenderer?.Clear(),
ExternalContainer = () => _externalContainers.Reset(),
InteractionAndSelection = ResetSessionInteraction,
@ -3011,7 +2873,7 @@ public sealed class GameWindow : IDisposable
_vitalsVm?.SetLocalPlayerGuid(0u);
Chat.ResetSessionIdentity();
AcDream.App.Streaming.EntityVanishProbe.PlayerGuid = 0u;
_activeToonKey = "default";
_runtimeSettings.ResetActiveCharacterKey();
_characterOptions1 =
AcDream.Core.Net.Messages.PlayerDescriptionParser.CharacterOptions1.Default;
_localPlayerSkills.ResetSession();
@ -3132,8 +2994,9 @@ public sealed class GameWindow : IDisposable
TeleportToMansion: session.SendTeleportToMansion,
QueryAge: session.SendQueryAge,
QueryBirth: session.SendQueryBirth,
ToggleFrameRate: ToggleRetailFrameRate,
ToggleUiLock: () => SetRetailUiLocked(!_persistedGameplay.LockUI),
ToggleFrameRate: _runtimeSettings.ToggleFrameRate,
ToggleUiLock: () =>
_runtimeSettings.SetUiLocked(!_runtimeSettings.Gameplay.LockUI),
ShowSystemMessage: text => Chat.OnSystemMessage(text, 0u),
ShowWeenieError: code => Chat.OnWeenieError(code, null),
PlayerPublicWeenieBitfield: () =>
@ -3155,8 +3018,8 @@ public sealed class GameWindow : IDisposable
Objects.Get(_playerServerGuid)?.Properties.GetBool(0x6Eu) == true,
SetAway: away => SetRetailAway(session, away),
SetAwayMessage: session.SendSetAfkMessage,
AcceptLootPermits: () => _persistedGameplay.AcceptLootPermits,
SetAcceptLootPermits: SetRetailAcceptLootPermits,
AcceptLootPermits: () => _runtimeSettings.Gameplay.AcceptLootPermits,
SetAcceptLootPermits: _runtimeSettings.SetAcceptLootPermits,
DisplayConsent: session.SendDisplayConsent,
ClearConsent: session.SendClearConsent,
RemoveConsent: session.SendRemoveConsent,
@ -3327,238 +3190,20 @@ public sealed class GameWindow : IDisposable
private bool GetDebugPlayerOnGround() =>
_playerMode && _playerController is not null && !_playerController.IsAirborne;
// Phase K.3 settings state remains runtime-owned; the presenter owns the
// optional SettingsPanel and its visibility/input operations.
private AcDream.UI.Abstractions.Panels.Settings.SettingsVM? _settingsVm;
// L.0: settings.json store + active toon key. The store is held as
// a field so LiveSessionHost can re-load the chosen toon's
// bag once we know its name (post-EnterWorld). Toon key starts as
// "default" and gets swapped to the actual character name on the
// first EnterWorld.
private AcDream.UI.Abstractions.Panels.Settings.SettingsStore? _settingsStore;
private string _activeToonKey = "default";
private void SyncToolbarWindowButtons()
{
_retailUiRuntime?.SyncToolbarWindowButtons();
}
private void SetRetailUiLocked(bool locked)
{
if (_persistedGameplay.LockUI == locked)
return;
_persistedGameplay = _persistedGameplay with { LockUI = locked };
if (_uiHost is not null)
_uiHost.Root.UiLocked = locked;
if (_settingsVm is not null)
_settingsVm.SetGameplay(_settingsVm.GameplayDraft with { LockUI = locked });
try
{
_settingsStore?.SaveGameplay(_persistedGameplay);
}
catch (Exception ex)
{
Console.WriteLine($"settings: radar lock save failed: {ex.Message}");
}
}
private void SetRetailAway(AcDream.Core.Net.WorldSession session, bool away)
{
session.SendSetAfkMode(away);
}
private void SetRetailAcceptLootPermits(bool enabled)
{
_persistedGameplay = _persistedGameplay with { AcceptLootPermits = enabled };
_settingsVm?.SetGameplay(
_settingsVm.GameplayDraft with { AcceptLootPermits = enabled });
_settingsStore?.SaveGameplay(_persistedGameplay);
}
/// <summary>
/// Retail <c>ClientCommunicationSystem::DoFrameRate @ 0x005707D0</c>:
/// flip the live display flag and persist it through the same settings
/// path used by the Display panel.
/// </summary>
private void ToggleRetailFrameRate()
{
_persistedDisplay = _persistedDisplay with
{
ShowFps = !_persistedDisplay.ShowFps,
};
if (_settingsVm is not null)
_settingsVm.ApplyExternalDisplayChange(display => display with
{
ShowFps = _persistedDisplay.ShowFps,
});
try
{
_settingsStore?.SaveDisplay(_persistedDisplay);
}
catch (Exception ex)
{
Console.WriteLine($"settings: framerate display save failed: {ex.Message}");
}
}
private void SetRetailCombatGameplay(
AcDream.UI.Abstractions.Panels.Settings.GameplaySettings gameplay)
{
_persistedGameplay = gameplay;
_settingsVm?.SetGameplay(gameplay);
try
{
_settingsStore?.SaveGameplay(gameplay);
}
catch (Exception ex)
{
Console.WriteLine($"settings: combat option save failed: {ex.Message}");
}
}
// L.0 follow-up: persisted-settings cache populated by
// LoadAndApplyPersistedSettings (runs unconditionally in OnLoad,
// not gated on DevToolsEnabled). The Settings PANEL construction
// — which IS gated on devtools — reads these fields when wiring
// SettingsVM. Defaults are placeholders; LoadAndApplyPersistedSettings
// overwrites them with values from settings.json (or per-section
// defaults when the file is missing/corrupt).
private AcDream.UI.Abstractions.Panels.Settings.DisplaySettings _persistedDisplay
= AcDream.UI.Abstractions.Panels.Settings.DisplaySettings.Default;
private AcDream.UI.Abstractions.Panels.Settings.AudioSettings _persistedAudio
= AcDream.UI.Abstractions.Panels.Settings.AudioSettings.Default;
private readonly AcDream.App.Combat.GameplaySettingsState
_gameplaySettings = new();
private AcDream.UI.Abstractions.Panels.Settings.GameplaySettings _persistedGameplay
{
get => _gameplaySettings.Value;
set => _gameplaySettings.Value = value;
}
private AcDream.UI.Abstractions.Panels.Settings.ChatSettings _persistedChat
= AcDream.UI.Abstractions.Panels.Settings.ChatSettings.Default;
private AcDream.UI.Abstractions.Panels.Settings.CharacterSettings _persistedCharacter
= AcDream.UI.Abstractions.Panels.Settings.CharacterSettings.Default;
/// <summary>
/// L.0 follow-up: load every section from settings.json + apply the
/// runtime-affecting ones (Display window state + Audio engine
/// volumes) at startup. Runs unconditionally — settings are runtime
/// state, not devtools state. Without this, a user running with
/// <c>ACDREAM_DEVTOOLS=0</c> would silently get WindowOptions
/// defaults instead of their saved Display/Audio preferences.
/// </summary>
private void LoadAndApplyPersistedSettings()
{
_settingsStore = new AcDream.UI.Abstractions.Panels.Settings.SettingsStore(
AcDream.UI.Abstractions.Panels.Settings.SettingsStore.DefaultPath());
_persistedDisplay = _settingsStore.LoadDisplay();
_persistedAudio = _settingsStore.LoadAudio();
_persistedGameplay = _settingsStore.LoadGameplay();
_persistedChat = _settingsStore.LoadChat();
// _activeToonKey is "default" pre-EnterWorld; the post-login
// LiveSessionHost swaps to the chosen toon's
// name and re-loads via SettingsVM.LoadCharacterContext.
_persistedCharacter = _settingsStore.LoadCharacter(_activeToonKey);
// Apply Display to the Silk.NET window. VSync and its bounded
// refresh-rate fallback go through the shared pacing owner;
// resolution + fullscreen use the on-Save path below.
if (_window is not null)
{
_displayFramePacing.RefreshActiveMonitor();
_displayFramePacing.ApplyPreference(_persistedDisplay.VSync);
ApplyDisplayWindowState(_persistedDisplay);
}
// Apply Audio to the OpenAL engine. Master + Sfx are wired
// through to the engine; Music + Ambient are stored but inert
// until R5 MIDI/ambient-loop engines exist (assigning them is
// harmless — the engine just doesn't read them yet).
if (_audioEngine is not null && _audioEngine.IsAvailable)
{
_audioEngine.MasterVolume = _persistedAudio.Master;
_audioEngine.MusicVolume = _persistedAudio.Music;
_audioEngine.SfxVolume = _persistedAudio.Sfx;
_audioEngine.AmbientVolume = _persistedAudio.Ambient;
}
}
/// <summary>
/// A.5 T22.5: apply a new quality preset mid-session (called from the
/// Settings panel Save path when <see cref="DisplaySettings.Quality"/>
/// changes).
///
/// <para>
/// What changes immediately:
/// <list type="bullet">
/// <item>Streaming radii: transactionally reconciles the existing
/// <see cref="_streamingController"/> against its published world;
/// the worker and controller retain their identity.</item>
/// <item>Anisotropic filtering: calls
/// <c>TerrainAtlas.SetAnisotropic</c>.</item>
/// <item>Alpha-to-coverage gate: sets
/// <c>WbDrawDispatcher.AlphaToCoverage</c>.</item>
/// <item>Max completions per frame: updates
/// <c>StreamingController.MaxCompletionsPerFrame</c>.</item>
/// </list>
/// </para>
///
/// <para>
/// What requires a restart:
/// MSAA samples are baked into the GL context via <c>WindowOptions.Samples</c>
/// at window creation time and cannot change at runtime. If the new preset
/// would change <c>MsaaSamples</c>, a warning is logged and MSAA is left
/// at its current level until the next launch.
/// </para>
/// </summary>
public void ReapplyQualityPreset(AcDream.UI.Abstractions.Settings.QualityPreset newPreset)
{
var newBase = AcDream.UI.Abstractions.Settings.QualitySettings.From(newPreset);
var newResolved = AcDream.UI.Abstractions.Settings.QualitySettings.WithEnvOverrides(newBase);
Console.WriteLine($"[QUALITY] ReapplyQualityPreset: {newPreset} → {newResolved}");
// MSAA samples cannot change at runtime — warn if preset would differ.
if (newResolved.MsaaSamples != _resolvedQuality.MsaaSamples)
{
Console.WriteLine(
$"[QUALITY] MSAA samples change ({_resolvedQuality.MsaaSamples} → " +
$"{newResolved.MsaaSamples}) requires a restart — skipped for this session.");
}
_resolvedQuality = newResolved;
// A2C gate — immediate toggle, no GL context restart needed.
if (_wbDrawDispatcher is not null)
_wbDrawDispatcher.AlphaToCoverage = newResolved.AlphaToCoverage;
// Anisotropic — immediate GL TexParameter call on the terrain atlas.
_terrain?.Atlas?.SetAnisotropic(newResolved.AnisotropicLevel);
// Reconcile streaming radii against the current published world. This
// preserves resident blocks that remain in range and never rebuilds
// the worker merely to change a completion budget.
if (_streamingController is not null)
{
_nearRadius = newResolved.NearRadius;
_farRadius = newResolved.FarRadius;
_streamingController.ReconfigureRadii(_nearRadius, _farRadius);
_streamingController.MaxCompletionsPerFrame = newResolved.MaxCompletionsPerFrame;
Console.WriteLine(
$"[QUALITY] Streaming reconciled: nearRadius={_nearRadius}, " +
$"farRadius={_farRadius}, maxCompletions={newResolved.MaxCompletionsPerFrame}");
}
}
/// <summary>
/// L.0 Display tab: framebuffer-resize handler — update GL viewport
/// + camera aspect when the window is resized (by the user dragging
/// the corner OR by ApplyDisplayWindowState applying a saved
/// the corner OR by the runtime display target applying a saved
/// Resolution). Without this, the viewport stays pinned at the
/// startup size, producing a small render inside a big window.
/// Also force-resets ImGui panel layout so panels that were
@ -3567,47 +3212,6 @@ public sealed class GameWindow : IDisposable
private void OnFramebufferResize(Silk.NET.Maths.Vector2D<int> newSize)
=> _framebufferResize.Resize(newSize);
/// <summary>
/// L.0 Display tab: apply the window-state-dependent settings
/// (Resolution + Fullscreen) from a <see cref="AcDream.UI.Abstractions.Panels.Settings.DisplaySettings"/>
/// to the live Silk.NET window. Called at startup (with persisted
/// values) and on every Save (with the saved values). Resolution
/// parses "<c>WIDTHxHEIGHT</c>" (e.g. <c>"1920x1080"</c>); a malformed
/// or unparseable string is silently ignored to avoid crashing the
/// client mid-session.
/// </summary>
private void ApplyDisplayWindowState(
AcDream.UI.Abstractions.Panels.Settings.DisplaySettings display)
{
if (_window is null) return;
// Resolution: parse and resize if changed.
if (TryParseResolution(display.Resolution, out int w, out int h))
{
if (_window.Size.X != w || _window.Size.Y != h)
_window.Size = new Silk.NET.Maths.Vector2D<int>(w, h);
}
// Fullscreen: borderless via Silk.NET's WindowState.Fullscreen
// (no exclusive-mode DXGI dance needed).
var desiredState = display.Fullscreen
? Silk.NET.Windowing.WindowState.Fullscreen
: Silk.NET.Windowing.WindowState.Normal;
if (_window.WindowState != desiredState)
_window.WindowState = desiredState;
}
private static bool TryParseResolution(string spec, out int width, out int height)
{
width = height = 0;
if (string.IsNullOrWhiteSpace(spec)) return false;
var parts = spec.Split('x', 2);
if (parts.Length != 2) return false;
return int.TryParse(parts[0], out width)
&& int.TryParse(parts[1], out height)
&& width > 0
&& height > 0;
}
/// <summary>
/// Item-target-mode world pick at the current cursor. The renderer supplies the
/// exact visible CPhysicsPart equivalents and RetailWorldPicker performs
@ -3763,6 +3367,7 @@ public sealed class GameWindow : IDisposable
]),
new ResourceShutdownStage("input callback detach",
[
new("settings view model", () => _runtimeSettings.UnbindViewModel()),
new("retained gameplay", () =>
{
AcDream.App.Input.RetainedUiGameplayBinding? binding =
@ -3836,6 +3441,7 @@ public sealed class GameWindow : IDisposable
// the later render-frontend stage still disposes the GL owners.
new ResourceShutdownStage("frame borrowers",
[
new("runtime settings targets", _runtimeSettings.UnbindRuntimeTargets),
new("world frame composition", () =>
{
_renderFrameOrchestrator = null;

View file

@ -444,7 +444,8 @@ public sealed unsafe class TerrainAtlas : IDisposable
/// <summary>
/// A.5 T22.5: update GL_TEXTURE_MAX_ANISOTROPY on the terrain atlas at
/// runtime (called by <see cref="GameWindow.ReapplyQualityPreset"/> when
/// runtime (called by
/// <see cref="AcDream.App.Settings.RuntimeSettingsController.ReapplyQualityPreset"/> when
/// the user changes Quality preset mid-session). Idempotent — calling with
/// the same level as the current setting is safe and produces no visual
/// change. The texture must not be resident-bindless when its parameters

View file

@ -378,7 +378,8 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
/// Default true matches T20 behavior. Set false for Low/Medium presets that
/// have MsaaSamples=0 (A2C is a no-op without MSAA, but turning it off
/// avoids the unnecessary GL state thrash and is cleaner diagnostics).
/// Can be toggled mid-session via <see cref="GameWindow.ReapplyQualityPreset"/>.
/// Can be toggled mid-session via
/// <see cref="AcDream.App.Settings.RuntimeSettingsController.ReapplyQualityPreset"/>.
/// </summary>
public bool AlphaToCoverage { get; set; } = true;

View file

@ -4,13 +4,13 @@ using AcDream.App.Input;
using AcDream.App.Rendering.Selection;
using AcDream.App.Rendering.Vfx;
using AcDream.App.Rendering.Wb;
using AcDream.App.Settings;
using AcDream.App.Streaming;
using AcDream.App.World;
using AcDream.Core.Lighting;
using AcDream.Core.Physics;
using AcDream.Core.Rendering;
using AcDream.Core.World;
using AcDream.UI.Abstractions.Panels.Settings;
namespace AcDream.App.Rendering;
@ -348,18 +348,18 @@ internal sealed class RuntimeWorldFrameVisibilityPreparation
internal sealed class RuntimeWorldFrameSettingsPreview : IWorldFrameSettingsPreview
{
private readonly SettingsVM? _settings;
private readonly IRuntimeSettingsPreviewSource _settings;
private readonly OpenAlAudioEngine? _audio;
private readonly CameraController _cameras;
private readonly DisplayFramePacingController _pacing;
public RuntimeWorldFrameSettingsPreview(
SettingsVM? settings,
IRuntimeSettingsPreviewSource settings,
OpenAlAudioEngine? audio,
CameraController cameras,
DisplayFramePacingController pacing)
{
_settings = settings;
_settings = settings ?? throw new ArgumentNullException(nameof(settings));
_audio = audio;
_cameras = cameras ?? throw new ArgumentNullException(nameof(cameras));
_pacing = pacing ?? throw new ArgumentNullException(nameof(pacing));
@ -367,23 +367,15 @@ internal sealed class RuntimeWorldFrameSettingsPreview : IWorldFrameSettingsPrev
public void Apply(in WorldCameraFrame camera)
{
if (_audio is { IsAvailable: true } && _settings is not null)
if (_settings.HasDraftPreview)
{
var audio = _settings.AudioDraft;
_audio.MasterVolume = audio.Master;
_audio.MusicVolume = audio.Music;
_audio.SfxVolume = audio.Sfx;
_audio.AmbientVolume = audio.Ambient;
}
if (_settings is not null)
{
var display = _settings.DisplayDraft;
float fieldOfView = display.FieldOfView * (MathF.PI / 180f);
_cameras.Orbit.FovY = fieldOfView;
_cameras.Fly.FovY = fieldOfView;
if (_cameras.Chase is not null)
_cameras.Chase.FovY = fieldOfView;
RuntimeSettingsStartupTargets.ApplyAudio(
_audio,
_settings.AudioPreview);
var display = _settings.DisplayPreview;
RuntimeSettingsStartupTargets.ApplyFieldOfView(
_cameras,
display.FieldOfView);
_pacing.ApplyPreference(display.VSync);
}

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

View file

@ -3,6 +3,7 @@ using AcDream.App.Input;
using AcDream.App.Interaction;
using AcDream.App.Rendering;
using AcDream.App.Rendering.Vfx;
using AcDream.App.Settings;
using AcDream.App.World;
using AcDream.Core.Physics;
using AcDream.Core.Rendering;
@ -39,17 +40,15 @@ internal interface IParticleRangeSource
internal sealed class SettingsParticleRangeSource : IParticleRangeSource
{
private readonly SettingsVM? _settings;
private readonly ParticleRange _fallback;
private readonly IRuntimeSettingsPreviewSource _settings;
public SettingsParticleRangeSource(SettingsVM? settings, ParticleRange fallback)
public SettingsParticleRangeSource(IRuntimeSettingsPreviewSource settings)
{
_settings = settings;
_fallback = fallback;
_settings = settings ?? throw new ArgumentNullException(nameof(settings));
}
public float RangeMultiplier =>
(_settings?.DisplayDraft.ParticleRange ?? _fallback) == ParticleRange.Extended
_settings.DisplayPreview.ParticleRange == ParticleRange.Extended
? ParticleVisibilityController.ExtendedRangeMultiplier
: 1f;
}