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;