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]