diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs
index ef9b749..6b498db 100644
--- a/src/AcDream.App/Rendering/GameWindow.cs
+++ b/src/AcDream.App/Rendering/GameWindow.cs
@@ -816,6 +816,17 @@ 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();
+
// Phase D.2a — ImGui devtools overlay. Zero cost when the env var
// isn't set: no context creation, no per-frame branches hit.
// See docs/plans/2026-04-24-ui-framework.md + memory/project_ui_architecture.md.
@@ -905,48 +916,15 @@ public sealed class GameWindow : IDisposable
// the draft. Construction is null-safe vs. the
// dispatcher because the dispatcher is built earlier in
// the same OnLoad path (see _inputDispatcher field).
- if (_inputDispatcher is not null)
+ if (_inputDispatcher is not null && _settingsStore is not null)
{
- // L.0 — settings.json (display + audio + gameplay + chat
- // + character). Coexists with keybinds.json, which
- // keeps its own load/save path. Field-stored so the
- // post-EnterWorld branch can re-load the chosen
- // toon's character bag.
- _settingsStore = new AcDream.UI.Abstractions.Panels.Settings.SettingsStore(
- AcDream.UI.Abstractions.Panels.Settings.SettingsStore.DefaultPath());
- var settingsStore = _settingsStore;
- var persistedDisplay = settingsStore.LoadDisplay();
- var persistedAudio = settingsStore.LoadAudio();
- var persistedGameplay = settingsStore.LoadGameplay();
- var persistedChat = settingsStore.LoadChat();
- // Character bag is loaded against _activeToonKey ("default"
- // until BeginLiveSessionAsync swaps in the real name).
- var persistedCharacter = settingsStore.LoadCharacter(_activeToonKey);
-
- // L.0 Display tab — apply persisted window-level
- // settings BEFORE the first frame so the user's saved
- // VSync / Resolution / Fullscreen take effect from
- // the start instead of flashing the WindowOptions
- // defaults. FOV and ShowFps come through the
- // per-frame push in OnRender so live preview works.
- if (_window is not null)
- {
- if (_window.VSync != persistedDisplay.VSync)
- _window.VSync = persistedDisplay.VSync;
- ApplyDisplayWindowState(persistedDisplay);
- }
-
- // Apply persisted audio to the engine BEFORE the panel
- // host starts pushing per-frame so the first frame uses
- // the user's saved values instead of engine defaults.
- if (_audioEngine is not null && _audioEngine.IsAvailable)
- {
- _audioEngine.MasterVolume = persistedAudio.Master;
- _audioEngine.MusicVolume = persistedAudio.Music;
- _audioEngine.SfxVolume = persistedAudio.Sfx;
- _audioEngine.AmbientVolume = persistedAudio.Ambient;
- }
-
+ // 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,
@@ -966,7 +944,7 @@ public sealed class GameWindow : IDisposable
Console.WriteLine($"keybinds: save failed: {ex.Message}");
}
},
- persistedDisplay: persistedDisplay,
+ persistedDisplay: _persistedDisplay,
onSaveDisplay: display =>
{
try
@@ -987,7 +965,7 @@ public sealed class GameWindow : IDisposable
Console.WriteLine($"settings: display save failed: {ex.Message}");
}
},
- persistedAudio: persistedAudio,
+ persistedAudio: _persistedAudio,
onSaveAudio: audio =>
{
try
@@ -1002,7 +980,7 @@ public sealed class GameWindow : IDisposable
Console.WriteLine($"settings: audio save failed: {ex.Message}");
}
},
- persistedGameplay: persistedGameplay,
+ persistedGameplay: _persistedGameplay,
onSaveGameplay: gameplay =>
{
try
@@ -1020,7 +998,7 @@ public sealed class GameWindow : IDisposable
Console.WriteLine($"settings: gameplay save failed: {ex.Message}");
}
},
- persistedChat: persistedChat,
+ persistedChat: _persistedChat,
onSaveChat: chat =>
{
try
@@ -1039,7 +1017,7 @@ public sealed class GameWindow : IDisposable
Console.WriteLine($"settings: chat save failed: {ex.Message}");
}
},
- persistedCharacter: persistedCharacter,
+ persistedCharacter: _persistedCharacter,
onSaveCharacter: character =>
{
try
@@ -5430,6 +5408,67 @@ public sealed class GameWindow : IDisposable
// first EnterWorld.
private AcDream.UI.Abstractions.Panels.Settings.SettingsStore? _settingsStore;
private string _activeToonKey = "default";
+ // 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 AcDream.UI.Abstractions.Panels.Settings.GameplaySettings _persistedGameplay
+ = AcDream.UI.Abstractions.Panels.Settings.GameplaySettings.Default;
+ 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;
+
+ ///
+ /// 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
+ /// ACDREAM_DEVTOOLS=0 would silently get WindowOptions
+ /// defaults instead of their saved Display/Audio preferences.
+ ///
+ 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
+ // branch in BeginLiveSessionAsync 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 goes via the
+ // window property; resolution + fullscreen go through
+ // ApplyDisplayWindowState which is shared with the on-Save path.
+ if (_window is not null)
+ {
+ if (_window.VSync != _persistedDisplay.VSync)
+ _window.VSync = _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;
+ }
+ }
///
/// L.0 Display tab: framebuffer-resize handler — update GL viewport
diff --git a/src/AcDream.UI.Abstractions/Panels/Settings/SettingsPanel.cs b/src/AcDream.UI.Abstractions/Panels/Settings/SettingsPanel.cs
index b0fec97..a8a8034 100644
--- a/src/AcDream.UI.Abstractions/Panels/Settings/SettingsPanel.cs
+++ b/src/AcDream.UI.Abstractions/Panels/Settings/SettingsPanel.cs
@@ -226,11 +226,15 @@ public sealed class SettingsPanel : IPanel
}
///
- /// Render the Audio tab — four volume sliders (Master / Music / SFX /
- /// Ambient). Volumes update live: the host pushes the VM's
- /// AudioDraft into the running OpenAL engine each frame, so dragging
- /// a slider is audible immediately. Cancel reverts the draft and the
- /// engine catches up on the next frame.
+ /// Render the Audio tab — Master + SFX volume sliders (live preview
+ /// against the running OpenAL engine). Music + Ambient fields exist
+ /// in and persist round-trip, but their
+ /// sliders are intentionally hidden here because the underlying
+ /// engine paths (PlayMusic / StartAmbient) are stubbed for R5 MIDI
+ /// playback that hasn't shipped yet — exposing the sliders would be
+ /// "moving a knob that does nothing." When R5 lands, restore the
+ /// hidden sliders below and the JSON-persisted values will already
+ /// be in place.
///
private void RenderAudioTab(IPanelRenderer renderer)
{
@@ -240,22 +244,26 @@ public sealed class SettingsPanel : IPanel
if (renderer.SliderFloat("Master", ref master, 0f, 1f))
_vm.SetAudio(a with { Master = master });
- float music = a.Music;
- if (renderer.SliderFloat("Music", ref music, 0f, 1f))
- _vm.SetAudio(a with { Music = music });
-
float sfx = a.Sfx;
if (renderer.SliderFloat("SFX", ref sfx, 0f, 1f))
_vm.SetAudio(a with { Sfx = sfx });
- float ambient = a.Ambient;
- if (renderer.SliderFloat("Ambient", ref ambient, 0f, 1f))
- _vm.SetAudio(a with { Ambient = ambient });
+ // Music + Ambient hidden until R5 MIDI / ambient-loop engines
+ // exist. AudioSettings still carries the fields so the JSON
+ // round-trips and a future client doesn't drop them on save.
+ //
+ // float music = a.Music;
+ // if (renderer.SliderFloat("Music", ref music, 0f, 1f))
+ // _vm.SetAudio(a with { Music = music });
+ // float ambient = a.Ambient;
+ // if (renderer.SliderFloat("Ambient", ref ambient, 0f, 1f))
+ // _vm.SetAudio(a with { Ambient = ambient });
renderer.Spacing();
renderer.TextWrapped(
"Volume changes preview live as you drag. Save persists the "
- + "values to settings.json; Cancel reverts to the saved values.");
+ + "values to settings.json; Cancel reverts to the saved values. "
+ + "Music + Ambient mixing arrives with R5 MIDI playback.");
}
///
diff --git a/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsPanelTests.cs b/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsPanelTests.cs
index 7e8c1ef..6d51c51 100644
--- a/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsPanelTests.cs
+++ b/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsPanelTests.cs
@@ -346,8 +346,13 @@ public sealed class SettingsPanelTests
// -- Audio tab content -----------------------------------------------
[Fact]
- public void Audio_tab_when_active_renders_four_volume_sliders()
+ public void Audio_tab_when_active_renders_implemented_volume_sliders()
{
+ // L.0 ships Master + SFX only — Music + Ambient sliders are
+ // hidden until R5 MIDI / ambient-loop engines exist. The
+ // AudioSettings record still carries those fields so the
+ // JSON round-trips, but the panel doesn't surface a slider
+ // that wouldn't actually do anything.
var (panel, _, _, _) = Build();
var r = new FakePanelRenderer { ActiveTabLabel = "Audio" };
@@ -356,9 +361,9 @@ public sealed class SettingsPanelTests
var sliders = r.Calls.Where(c => c.Method == "SliderFloat")
.Select(c => (string)c.Args[0]!).ToList();
Assert.Contains("Master", sliders);
- Assert.Contains("Music", sliders);
Assert.Contains("SFX", sliders);
- Assert.Contains("Ambient", sliders);
+ Assert.DoesNotContain("Music", sliders);
+ Assert.DoesNotContain("Ambient", sliders);
}
[Fact]