diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs
index b892658..7d1214f 100644
--- a/src/AcDream.App/Rendering/GameWindow.cs
+++ b/src/AcDream.App/Rendering/GameWindow.cs
@@ -901,12 +901,24 @@ public sealed class GameWindow : IDisposable
// the same OnLoad path (see _inputDispatcher field).
if (_inputDispatcher is not null)
{
- // L.0 — settings.json (display + future audio / gameplay /
+ // L.0 — settings.json (display + audio + future gameplay /
// chat / character tabs). Coexists with keybinds.json,
// which keeps its own load/save path.
var settingsStore = new AcDream.UI.Abstractions.Panels.Settings.SettingsStore(
AcDream.UI.Abstractions.Panels.Settings.SettingsStore.DefaultPath());
var persistedDisplay = settingsStore.LoadDisplay();
+ var persistedAudio = settingsStore.LoadAudio();
+
+ // 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;
+ }
_settingsVm = new AcDream.UI.Abstractions.Panels.Settings.SettingsVM(
persisted: _keyBindings,
@@ -941,6 +953,21 @@ public sealed class GameWindow : IDisposable
{
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}");
+ }
});
_settingsPanel = new AcDream.UI.Abstractions.Panels.Settings.SettingsPanel(_settingsVm);
_panelHost.Register(_settingsPanel);
@@ -4105,6 +4132,22 @@ public sealed class GameWindow : IDisposable
System.Numerics.Matrix4x4.Invert(camera.View, out var invView);
var camPos = new System.Numerics.Vector3(invView.M41, invView.M42, invView.M43);
+ // L.0 Audio tab: push the SettingsVM's live AudioDraft into the
+ // engine each frame, so volume sliders preview audibly while
+ // the user drags. Cancel reverts the draft and the engine
+ // catches up on the very next frame; Save persists to
+ // settings.json without changing engine state (already
+ // applied). Cheap enough to run unconditionally on every
+ // tick — four float assignments.
+ if (_audioEngine is not null && _audioEngine.IsAvailable && _settingsVm is not null)
+ {
+ var a = _settingsVm.AudioDraft;
+ _audioEngine.MasterVolume = a.Master;
+ _audioEngine.MusicVolume = a.Music;
+ _audioEngine.SfxVolume = a.Sfx;
+ _audioEngine.AmbientVolume = a.Ambient;
+ }
+
// Phase E.2 audio: update listener pose so 3D sounds pan/attenuate
// correctly relative to where we're looking.
if (_audioEngine is not null && _audioEngine.IsAvailable)
diff --git a/src/AcDream.UI.Abstractions/Panels/Settings/AudioSettings.cs b/src/AcDream.UI.Abstractions/Panels/Settings/AudioSettings.cs
new file mode 100644
index 0000000..43a1b47
--- /dev/null
+++ b/src/AcDream.UI.Abstractions/Panels/Settings/AudioSettings.cs
@@ -0,0 +1,28 @@
+namespace AcDream.UI.Abstractions.Panels.Settings;
+
+///
+/// Audio mixer preferences persisted to settings.json. Drives the
+/// existing Phase E.2 OpenAL engine — the host wires these values into
+/// OpenAlAudioEngine.MasterVolume / SfxVolume /
+/// MusicVolume / AmbientVolume on Save and on startup.
+///
+///
+/// Defaults match the engine's hard-coded starting values so a user
+/// who never opens the Audio tab gets identical behaviour to the
+/// previous env-var-only world.
+///
+///
+public sealed record AudioSettings(
+ float Master,
+ float Music,
+ float Sfx,
+ float Ambient)
+{
+ /// Values used on first launch. Mirror the engine's
+ /// constructor-default Volume properties.
+ public static AudioSettings Default { get; } = new(
+ Master: 1.0f,
+ Music: 0.7f,
+ Sfx: 1.0f,
+ Ambient: 0.8f);
+}
diff --git a/src/AcDream.UI.Abstractions/Panels/Settings/SettingsPanel.cs b/src/AcDream.UI.Abstractions/Panels/Settings/SettingsPanel.cs
index 7af322e..7d8c78e 100644
--- a/src/AcDream.UI.Abstractions/Panels/Settings/SettingsPanel.cs
+++ b/src/AcDream.UI.Abstractions/Panels/Settings/SettingsPanel.cs
@@ -88,7 +88,7 @@ public sealed class SettingsPanel : IPanel
}
if (renderer.BeginTabItem("Audio"))
{
- RenderPlaceholder(renderer, "Audio");
+ RenderAudioTab(renderer);
renderer.EndTabItem();
}
if (renderer.BeginTabItem("Gameplay"))
@@ -239,6 +239,39 @@ public sealed class SettingsPanel : IPanel
+ "preview live as you drag; Cancel reverts to the saved value.");
}
+ ///
+ /// 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.
+ ///
+ private void RenderAudioTab(IPanelRenderer renderer)
+ {
+ var a = _vm.AudioDraft;
+
+ float master = a.Master;
+ 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 });
+
+ renderer.Spacing();
+ renderer.TextWrapped(
+ "Volume changes preview live as you drag. Save persists the "
+ + "values to settings.json; Cancel reverts to the saved values.");
+ }
+
private void RenderSection(IPanelRenderer renderer, string label, InputAction[] actions)
{
// Movement defaults open; other sections collapsed for first-run UX.
diff --git a/src/AcDream.UI.Abstractions/Panels/Settings/SettingsStore.cs b/src/AcDream.UI.Abstractions/Panels/Settings/SettingsStore.cs
index 0e961a9..ad07c35 100644
--- a/src/AcDream.UI.Abstractions/Panels/Settings/SettingsStore.cs
+++ b/src/AcDream.UI.Abstractions/Panels/Settings/SettingsStore.cs
@@ -82,11 +82,79 @@ public sealed class SettingsStore
/// builds don't silently drop them.
///
public void SaveDisplay(DisplaySettings display)
+ => SaveSection("display", BuildDisplayObject(display));
+
+ ///
+ /// Load Audio settings. Same fall-back behaviour as
+ /// : missing file → defaults, missing fields
+ /// → per-field defaults, corrupt JSON → defaults.
+ ///
+ public AudioSettings LoadAudio()
+ {
+ if (!File.Exists(_path)) return AudioSettings.Default;
+ try
+ {
+ using var stream = File.OpenRead(_path);
+ var doc = JsonDocument.Parse(stream);
+ var root = doc.RootElement;
+ if (!root.TryGetProperty("audio", out var audio)
+ || audio.ValueKind != JsonValueKind.Object)
+ return AudioSettings.Default;
+
+ var d = AudioSettings.Default;
+ return new AudioSettings(
+ Master: ReadFloat(audio, "master", d.Master),
+ Music: ReadFloat(audio, "music", d.Music),
+ Sfx: ReadFloat(audio, "sfx", d.Sfx),
+ Ambient: ReadFloat(audio, "ambient", d.Ambient));
+ }
+ catch (Exception ex)
+ {
+ Console.WriteLine($"settings: failed to load {_path}: {ex.Message} — using defaults");
+ return AudioSettings.Default;
+ }
+ }
+
+ ///
+ /// Save Audio settings, preserving every other top-level key
+ /// (display, future gameplay/chat/character). Same round-trip
+ /// guarantee as .
+ ///
+ public void SaveAudio(AudioSettings audio)
+ => SaveSection("audio", BuildAudioObject(audio));
+
+ private static SortedDictionary BuildDisplayObject(DisplaySettings d)
+ => new(StringComparer.Ordinal)
+ {
+ ["fieldOfView"] = d.FieldOfView,
+ ["fullscreen"] = d.Fullscreen,
+ ["gamma"] = d.Gamma,
+ ["resolution"] = d.Resolution,
+ ["showFps"] = d.ShowFps,
+ ["vsync"] = d.VSync,
+ };
+
+ private static SortedDictionary BuildAudioObject(AudioSettings a)
+ => new(StringComparer.Ordinal)
+ {
+ ["ambient"] = a.Ambient,
+ ["master"] = a.Master,
+ ["music"] = a.Music,
+ ["sfx"] = a.Sfx,
+ };
+
+ ///
+ /// Generic atomic-section save: writes the named section and preserves
+ /// all other top-level keys from the existing file, replacing only the
+ /// version + the targeted section. Avoids duplication between the
+ /// per-section Save methods.
+ ///
+ private void SaveSection(string sectionName, SortedDictionary sectionPayload)
{
var dir = Path.GetDirectoryName(_path);
if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir);
- // Preserve any non-display top-level keys from the existing file.
+ // Preserve any non-target top-level keys from the existing file.
var preservedKeys = new SortedDictionary(StringComparer.Ordinal);
if (File.Exists(_path))
{
@@ -96,7 +164,7 @@ public sealed class SettingsStore
var doc = JsonDocument.Parse(stream);
foreach (var prop in doc.RootElement.EnumerateObject())
{
- if (prop.Name == "display" || prop.Name == "version") continue;
+ if (prop.Name == sectionName || prop.Name == "version") continue;
preservedKeys[prop.Name] = prop.Value.GetRawText();
}
}
@@ -108,28 +176,19 @@ public sealed class SettingsStore
}
}
- var displayObj = new SortedDictionary(StringComparer.Ordinal)
- {
- ["fieldOfView"] = display.FieldOfView,
- ["fullscreen"] = display.Fullscreen,
- ["gamma"] = display.Gamma,
- ["resolution"] = display.Resolution,
- ["showFps"] = display.ShowFps,
- ["vsync"] = display.VSync,
- };
-
- // Build the output by hand so preserved-keys keep their raw JSON.
var sb = new System.Text.StringBuilder();
sb.Append('{').AppendLine();
- sb.Append(" \"display\": ")
- .Append(JsonSerializer.Serialize(displayObj, new JsonSerializerOptions { WriteIndented = true })
- .Replace("\n", "\n "))
- .Append(',').AppendLine();
+ // Preserved keys come first (sorted by name) then the section, then
+ // version last. Preserves alphabetical-style top-level ordering.
foreach (var kv in preservedKeys)
{
sb.Append(" \"").Append(kv.Key).Append("\": ")
.Append(kv.Value).Append(',').AppendLine();
}
+ sb.Append(" \"").Append(sectionName).Append("\": ")
+ .Append(JsonSerializer.Serialize(sectionPayload, new JsonSerializerOptions { WriteIndented = true })
+ .Replace("\n", "\n "))
+ .Append(',').AppendLine();
sb.Append(" \"version\": ").Append(CurrentSchemaVersion).AppendLine();
sb.Append('}').AppendLine();
diff --git a/src/AcDream.UI.Abstractions/Panels/Settings/SettingsVM.cs b/src/AcDream.UI.Abstractions/Panels/Settings/SettingsVM.cs
index 2cf233b..d90bf0a 100644
--- a/src/AcDream.UI.Abstractions/Panels/Settings/SettingsVM.cs
+++ b/src/AcDream.UI.Abstractions/Panels/Settings/SettingsVM.cs
@@ -36,6 +36,11 @@ public sealed class SettingsVM
private DisplaySettings _displayDraft;
private readonly Action _onSaveDisplay;
+ // L.0 — Audio tab. Same shape as Display.
+ private AudioSettings _audioPersisted;
+ private AudioSettings _audioDraft;
+ private readonly Action _onSaveAudio;
+
/// The action currently being rebound, or null when idle.
public InputAction? RebindInProgress { get; private set; }
@@ -58,26 +63,36 @@ public sealed class SettingsVM
/// rebinds are pending.
public bool HasUnsavedChanges
=> !KeyBindingsEqual(_persisted, _draft)
- || _displayPersisted != _displayDraft;
+ || _displayPersisted != _displayDraft
+ || _audioPersisted != _audioDraft;
/// The current Display draft. Panel reads from here;
/// mutation goes through .
public DisplaySettings DisplayDraft => _displayDraft;
+ /// The current Audio draft. Panel reads from here;
+ /// mutation goes through .
+ public AudioSettings AudioDraft => _audioDraft;
+
public SettingsVM(
KeyBindings persisted,
InputDispatcher dispatcher,
Action onSave,
DisplaySettings persistedDisplay,
- Action onSaveDisplay)
+ Action onSaveDisplay,
+ AudioSettings persistedAudio,
+ Action onSaveAudio)
{
_persisted = persisted ?? throw new ArgumentNullException(nameof(persisted));
_dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher));
_onSave = onSave ?? throw new ArgumentNullException(nameof(onSave));
_displayPersisted = persistedDisplay ?? throw new ArgumentNullException(nameof(persistedDisplay));
_onSaveDisplay = onSaveDisplay ?? throw new ArgumentNullException(nameof(onSaveDisplay));
+ _audioPersisted = persistedAudio ?? throw new ArgumentNullException(nameof(persistedAudio));
+ _onSaveAudio = onSaveAudio ?? throw new ArgumentNullException(nameof(onSaveAudio));
_draft = CloneBindings(persisted);
_displayDraft = persistedDisplay;
+ _audioDraft = persistedAudio;
}
///
@@ -90,6 +105,18 @@ public sealed class SettingsVM
_displayDraft = value ?? throw new ArgumentNullException(nameof(value));
}
+ ///
+ /// Replace the entire Audio draft with .
+ /// Live audio preview is achieved at the host layer by pushing
+ /// into the running OpenAL engine each frame
+ /// — this method only mutates VM state. Cancel reverts the draft and
+ /// the host's next-frame push restores the pre-edit engine volumes.
+ ///
+ public void SetAudio(AudioSettings value)
+ {
+ _audioDraft = value ?? throw new ArgumentNullException(nameof(value));
+ }
+
///
/// Begin rebinding . The supplied
/// binding will be removed when the new
@@ -199,6 +226,7 @@ public sealed class SettingsVM
{
_draft = KeyBindings.RetailDefaults();
_displayDraft = DisplaySettings.Default;
+ _audioDraft = AudioSettings.Default;
}
///
@@ -213,8 +241,10 @@ public sealed class SettingsVM
{
_onSave(_draft);
_onSaveDisplay(_displayDraft);
+ _onSaveAudio(_audioDraft);
_persisted = CloneBindings(_draft);
_displayPersisted = _displayDraft;
+ _audioPersisted = _audioDraft;
}
///
@@ -226,6 +256,7 @@ public sealed class SettingsVM
{
_draft = CloneBindings(_persisted);
_displayDraft = _displayPersisted;
+ _audioDraft = _audioPersisted;
CancelRebind();
}
diff --git a/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/AudioSettingsTests.cs b/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/AudioSettingsTests.cs
new file mode 100644
index 0000000..42d6f81
--- /dev/null
+++ b/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/AudioSettingsTests.cs
@@ -0,0 +1,44 @@
+using AcDream.UI.Abstractions.Panels.Settings;
+
+namespace AcDream.UI.Abstractions.Tests.Panels.Settings;
+
+///
+/// L.0: default-pin tests. Defaults must
+/// match the OpenAL engine's hard-coded constructor values so a user
+/// who has never opened the Audio tab gets identical behaviour to the
+/// pre-Phase-L world.
+///
+public sealed class AudioSettingsTests
+{
+ [Fact]
+ public void Default_values_match_engine_constructor_defaults()
+ {
+ // OpenAlAudioEngine ctor: Master=1.0, Music=0.7, Sfx=1.0,
+ // Ambient=0.8 — see src/AcDream.App/Audio/OpenAlAudioEngine.cs.
+ var d = AudioSettings.Default;
+ Assert.Equal(1.0f, d.Master);
+ Assert.Equal(0.7f, d.Music);
+ Assert.Equal(1.0f, d.Sfx);
+ Assert.Equal(0.8f, d.Ambient);
+ }
+
+ [Fact]
+ public void Equality_is_value_based()
+ {
+ var a = AudioSettings.Default;
+ var b = AudioSettings.Default with { Master = 0.5f };
+ var c = AudioSettings.Default with { Master = 0.5f };
+ Assert.NotEqual(a, b);
+ Assert.Equal(b, c);
+ }
+
+ [Fact]
+ public void With_expression_clones_one_field()
+ {
+ var d = AudioSettings.Default with { Music = 0.25f };
+ Assert.Equal(0.25f, d.Music);
+ // Other fields untouched.
+ Assert.Equal(AudioSettings.Default.Master, d.Master);
+ Assert.Equal(AudioSettings.Default.Sfx, d.Sfx);
+ }
+}
diff --git a/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsPanelTests.cs b/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsPanelTests.cs
index ec05c4c..4181f88 100644
--- a/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsPanelTests.cs
+++ b/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsPanelTests.cs
@@ -31,7 +31,8 @@ public sealed class SettingsPanelTests
var dispatcher = new InputDispatcher(kb, mouse, persisted);
var vm = new SettingsVM(
persisted, dispatcher, _ => { },
- DisplaySettings.Default, _ => { });
+ DisplaySettings.Default, _ => { },
+ AudioSettings.Default, _ => { });
var panel = new SettingsPanel(vm);
return (panel, vm, kb, dispatcher);
}
@@ -226,14 +227,17 @@ public sealed class SettingsPanelTests
[Fact]
public void Placeholder_tabs_render_coming_soon_text_when_active()
{
+ // Gameplay is still a placeholder (next in build order). Display
+ // and Audio have shipped — they have real widgets, not "coming
+ // soon" text.
var (panel, _, _, _) = Build();
- var r = new FakePanelRenderer { ActiveTabLabel = "Audio" };
+ var r = new FakePanelRenderer { ActiveTabLabel = "Gameplay" };
panel.Render(new PanelContext(0.016f, new NullBus()), r);
var wrapped = r.Calls.Where(c => c.Method == "TextWrapped")
.Select(c => (string)c.Args[0]!).ToList();
- Assert.Contains(wrapped, t => t.Contains("Audio settings coming soon"));
+ Assert.Contains(wrapped, t => t.Contains("Gameplay settings coming soon"));
}
// -- Display tab content ---------------------------------------------
@@ -284,6 +288,51 @@ public sealed class SettingsPanelTests
Assert.Contains("3840x2160", items);
}
+ // -- Audio tab content -----------------------------------------------
+
+ [Fact]
+ public void Audio_tab_when_active_renders_four_volume_sliders()
+ {
+ var (panel, _, _, _) = Build();
+ var r = new FakePanelRenderer { ActiveTabLabel = "Audio" };
+
+ panel.Render(new PanelContext(0.016f, new NullBus()), r);
+
+ 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);
+ }
+
+ [Fact]
+ public void Audio_tab_does_not_render_when_a_different_tab_is_active()
+ {
+ var (panel, _, _, _) = Build();
+ var r = new FakePanelRenderer { ActiveTabLabel = "Display" };
+
+ panel.Render(new PanelContext(0.016f, new NullBus()), r);
+
+ var sliders = r.Calls.Where(c => c.Method == "SliderFloat")
+ .Select(c => (string)c.Args[0]!).ToList();
+ Assert.DoesNotContain("Master", sliders);
+ Assert.DoesNotContain("Music", sliders);
+ }
+
+ [Fact]
+ public void Audio_sliders_are_clamped_to_zero_one_range()
+ {
+ var (panel, _, _, _) = Build();
+ var r = new FakePanelRenderer { ActiveTabLabel = "Audio" };
+
+ panel.Render(new PanelContext(0.016f, new NullBus()), r);
+
+ var masterCall = r.Calls.First(c => c.Method == "SliderFloat" && (string)c.Args[0]! == "Master");
+ Assert.Equal(0f, (float)masterCall.Args[2]!);
+ Assert.Equal(1f, (float)masterCall.Args[3]!);
+ }
+
[Fact]
public void Save_Cancel_buttons_render_outside_the_tab_bar()
{
diff --git a/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsStoreTests.cs b/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsStoreTests.cs
index 226454c..bef09fc 100644
--- a/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsStoreTests.cs
+++ b/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsStoreTests.cs
@@ -116,4 +116,68 @@ public sealed class SettingsStoreTests : System.IDisposable
var path = SettingsStore.DefaultPath();
Assert.EndsWith("acdream" + Path.DirectorySeparatorChar + "settings.json", path);
}
+
+ // -- Audio section round-trip ----------------------------------------
+
+ [Fact]
+ public void LoadAudio_returns_defaults_when_file_is_missing()
+ {
+ var store = new SettingsStore(_tempPath);
+ Assert.Equal(AudioSettings.Default, store.LoadAudio());
+ }
+
+ [Fact]
+ public void SaveAudio_then_LoadAudio_round_trips_all_fields()
+ {
+ var store = new SettingsStore(_tempPath);
+ var original = new AudioSettings(Master: 0.3f, Music: 0.45f, Sfx: 0.9f, Ambient: 0.6f);
+
+ store.SaveAudio(original);
+ var loaded = store.LoadAudio();
+
+ Assert.Equal(original, loaded);
+ }
+
+ [Fact]
+ public void LoadAudio_falls_back_per_field_when_keys_missing()
+ {
+ File.WriteAllText(_tempPath, """
+ {
+ "version": 1,
+ "audio": { "master": 0.25 }
+ }
+ """);
+ var store = new SettingsStore(_tempPath);
+
+ var loaded = store.LoadAudio();
+
+ Assert.Equal(0.25f, loaded.Master);
+ Assert.Equal(AudioSettings.Default.Music, loaded.Music);
+ Assert.Equal(AudioSettings.Default.Sfx, loaded.Sfx);
+ Assert.Equal(AudioSettings.Default.Ambient, loaded.Ambient);
+ }
+
+ [Fact]
+ public void SaveAudio_preserves_display_section()
+ {
+ // Save display first, then audio — display values must survive.
+ var store = new SettingsStore(_tempPath);
+ store.SaveDisplay(DisplaySettings.Default with { Resolution = "2560x1440" });
+ store.SaveAudio(AudioSettings.Default with { Master = 0.4f });
+
+ Assert.Equal("2560x1440", store.LoadDisplay().Resolution);
+ Assert.Equal(0.4f, store.LoadAudio().Master);
+ }
+
+ [Fact]
+ public void SaveDisplay_after_SaveAudio_preserves_audio_section()
+ {
+ // Reverse order — audio must survive a subsequent display save.
+ var store = new SettingsStore(_tempPath);
+ store.SaveAudio(AudioSettings.Default with { Music = 0.1f });
+ store.SaveDisplay(DisplaySettings.Default with { ShowFps = true });
+
+ Assert.Equal(0.1f, store.LoadAudio().Music);
+ Assert.True(store.LoadDisplay().ShowFps);
+ }
}
diff --git a/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsVMTests.cs b/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsVMTests.cs
index 595ff38..25e5c69 100644
--- a/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsVMTests.cs
+++ b/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsVMTests.cs
@@ -16,8 +16,8 @@ namespace AcDream.UI.Abstractions.Tests.Panels.Settings;
///
public sealed class SettingsVMTests
{
- private static (SettingsVM vm, FakeKeyboardSource kb, InputDispatcher dispatcher, KeyBindings persisted, System.Collections.Generic.List savedHistory, System.Collections.Generic.List savedDisplayHistory)
- Build(KeyBindings? persisted = null, DisplaySettings? persistedDisplay = null)
+ private static (SettingsVM vm, FakeKeyboardSource kb, InputDispatcher dispatcher, KeyBindings persisted, System.Collections.Generic.List savedHistory, System.Collections.Generic.List savedDisplayHistory, System.Collections.Generic.List savedAudioHistory)
+ Build(KeyBindings? persisted = null, DisplaySettings? persistedDisplay = null, AudioSettings? persistedAudio = null)
{
persisted ??= MakeMinimalBindings();
var kb = new FakeKeyboardSource();
@@ -25,12 +25,15 @@ public sealed class SettingsVMTests
var dispatcher = new InputDispatcher(kb, mouse, persisted);
var savedHistory = new System.Collections.Generic.List();
var savedDisplayHistory = new System.Collections.Generic.List();
+ var savedAudioHistory = new System.Collections.Generic.List();
var vm = new SettingsVM(
persisted, dispatcher,
b => savedHistory.Add(b),
persistedDisplay ?? DisplaySettings.Default,
- d => savedDisplayHistory.Add(d));
- return (vm, kb, dispatcher, persisted, savedHistory, savedDisplayHistory);
+ d => savedDisplayHistory.Add(d),
+ persistedAudio ?? AudioSettings.Default,
+ a => savedAudioHistory.Add(a));
+ return (vm, kb, dispatcher, persisted, savedHistory, savedDisplayHistory, savedAudioHistory);
}
private static KeyBindings MakeMinimalBindings()
@@ -45,7 +48,7 @@ public sealed class SettingsVMTests
[Fact]
public void Constructor_clones_persisted_into_draft()
{
- var (vm, _, _, persisted, _, _) = Build();
+ var (vm, _, _, persisted, _, _, _) = Build();
Assert.Equal(persisted.All.Count, vm.Draft.All.Count);
Assert.False(vm.HasUnsavedChanges);
}
@@ -53,7 +56,7 @@ public sealed class SettingsVMTests
[Fact]
public void BeginRebind_enters_capture_mode()
{
- var (vm, _, dispatcher, _, _, _) = Build();
+ var (vm, _, dispatcher, _, _, _, _) = Build();
var original = vm.Draft.ForAction(InputAction.MovementForward).First();
vm.BeginRebind(InputAction.MovementForward, original);
@@ -66,7 +69,7 @@ public sealed class SettingsVMTests
[Fact]
public void BeginRebind_then_chord_with_no_conflict_applies_rebind()
{
- var (vm, kb, _, _, _, _) = Build();
+ var (vm, kb, _, _, _, _, _) = Build();
var original = vm.Draft.ForAction(InputAction.MovementForward).First();
vm.BeginRebind(InputAction.MovementForward, original);
@@ -84,7 +87,7 @@ public sealed class SettingsVMTests
[Fact]
public void BeginRebind_then_Escape_cancels_with_no_change()
{
- var (vm, kb, _, _, _, _) = Build();
+ var (vm, kb, _, _, _, _, _) = Build();
var original = vm.Draft.ForAction(InputAction.MovementForward).First();
vm.BeginRebind(InputAction.MovementForward, original);
@@ -101,7 +104,7 @@ public sealed class SettingsVMTests
[Fact]
public void BeginRebind_with_conflict_surfaces_PendingConflict()
{
- var (vm, kb, _, _, _, _) = Build();
+ var (vm, kb, _, _, _, _, _) = Build();
var original = vm.Draft.ForAction(InputAction.MovementForward).First();
// Bind chord that conflicts with MovementTurnLeft (which has Key.A).
@@ -121,7 +124,7 @@ public sealed class SettingsVMTests
[Fact]
public void ResolveConflict_replace_true_removes_conflict_and_applies_rebind()
{
- var (vm, kb, _, _, _, _) = Build();
+ var (vm, kb, _, _, _, _, _) = Build();
var original = vm.Draft.ForAction(InputAction.MovementForward).First();
vm.BeginRebind(InputAction.MovementForward, original);
@@ -142,7 +145,7 @@ public sealed class SettingsVMTests
[Fact]
public void ResolveConflict_replace_false_cancels_rebind()
{
- var (vm, kb, _, _, _, _) = Build();
+ var (vm, kb, _, _, _, _, _) = Build();
var original = vm.Draft.ForAction(InputAction.MovementForward).First();
vm.BeginRebind(InputAction.MovementForward, original);
@@ -164,7 +167,7 @@ public sealed class SettingsVMTests
{
// Build a draft that's been mutated for MovementForward; ensure
// ResetActionToDefault restores W (and Up-arrow per retail).
- var (vm, kb, _, _, _, _) = Build(KeyBindings.RetailDefaults());
+ var (vm, kb, _, _, _, _, _) = Build(KeyBindings.RetailDefaults());
var original = vm.Draft.ForAction(InputAction.MovementForward).First();
vm.BeginRebind(InputAction.MovementForward, original);
// F7 is unbound in retail-default (only Ctrl+F7 is acdream debug);
@@ -184,7 +187,7 @@ public sealed class SettingsVMTests
[Fact]
public void ResetAllToDefaults_replaces_entire_draft()
{
- var (vm, _, _, _, _, _) = Build();
+ var (vm, _, _, _, _, _, _) = Build();
vm.ResetAllToDefaults();
// Should now include retail-default size set (~149 bindings).
@@ -195,7 +198,7 @@ public sealed class SettingsVMTests
[Fact]
public void Save_invokes_callback_with_draft()
{
- var (vm, kb, _, _, savedHistory, _) = Build();
+ var (vm, kb, _, _, savedHistory, _, _) = Build();
var original = vm.Draft.ForAction(InputAction.MovementForward).First();
vm.BeginRebind(InputAction.MovementForward, original);
kb.EmitKeyDown(Key.Q, ModifierMask.None);
@@ -211,7 +214,7 @@ public sealed class SettingsVMTests
[Fact]
public void Cancel_reverts_draft_to_persisted()
{
- var (vm, kb, _, _, _, _) = Build();
+ var (vm, kb, _, _, _, _, _) = Build();
var original = vm.Draft.ForAction(InputAction.MovementForward).First();
vm.BeginRebind(InputAction.MovementForward, original);
kb.EmitKeyDown(Key.Q, ModifierMask.None);
@@ -227,7 +230,7 @@ public sealed class SettingsVMTests
[Fact]
public void Cancel_during_active_capture_clears_dispatcher_capture_state()
{
- var (vm, _, dispatcher, _, _, _) = Build();
+ var (vm, _, dispatcher, _, _, _, _) = Build();
var original = vm.Draft.ForAction(InputAction.MovementForward).First();
vm.BeginRebind(InputAction.MovementForward, original);
@@ -240,7 +243,7 @@ public sealed class SettingsVMTests
[Fact]
public void HasUnsavedChanges_false_initially_and_after_save_sync()
{
- var (vm, _, _, _, _, _) = Build();
+ var (vm, _, _, _, _, _, _) = Build();
Assert.False(vm.HasUnsavedChanges);
}
@@ -250,7 +253,7 @@ public sealed class SettingsVMTests
public void DisplayDraft_initial_value_matches_persisted()
{
var custom = DisplaySettings.Default with { FieldOfView = 90f, ShowFps = true };
- var (vm, _, _, _, _, _) = Build(persistedDisplay: custom);
+ var (vm, _, _, _, _, _, _) = Build(persistedDisplay: custom);
Assert.Equal(custom, vm.DisplayDraft);
Assert.False(vm.HasUnsavedChanges);
}
@@ -258,7 +261,7 @@ public sealed class SettingsVMTests
[Fact]
public void SetDisplay_marks_unsaved_changes()
{
- var (vm, _, _, _, _, _) = Build();
+ var (vm, _, _, _, _, _, _) = Build();
vm.SetDisplay(vm.DisplayDraft with { ShowFps = true });
Assert.True(vm.HasUnsavedChanges);
}
@@ -266,7 +269,7 @@ public sealed class SettingsVMTests
[Fact]
public void Save_invokes_display_callback_with_draft()
{
- var (vm, _, _, _, _, savedDisplayHistory) = Build();
+ var (vm, _, _, _, _, savedDisplayHistory, _) = Build();
vm.SetDisplay(vm.DisplayDraft with { Resolution = "2560x1440", FieldOfView = 100f });
vm.Save();
@@ -281,7 +284,7 @@ public sealed class SettingsVMTests
public void Cancel_reverts_display_draft_to_persisted()
{
var custom = DisplaySettings.Default with { FieldOfView = 90f };
- var (vm, _, _, _, _, _) = Build(persistedDisplay: custom);
+ var (vm, _, _, _, _, _, _) = Build(persistedDisplay: custom);
vm.SetDisplay(vm.DisplayDraft with { FieldOfView = 30f, ShowFps = true });
Assert.True(vm.HasUnsavedChanges);
@@ -295,7 +298,7 @@ public sealed class SettingsVMTests
public void ResetAllToDefaults_resets_display_to_default()
{
var custom = DisplaySettings.Default with { FieldOfView = 30f, ShowFps = true };
- var (vm, _, _, _, _, _) = Build(persistedDisplay: custom);
+ var (vm, _, _, _, _, _, _) = Build(persistedDisplay: custom);
Assert.NotEqual(DisplaySettings.Default, vm.DisplayDraft);
vm.ResetAllToDefaults();
@@ -310,7 +313,7 @@ public sealed class SettingsVMTests
// After Save the persisted snapshot equals the draft, so Cancel
// is a no-op. This guards the Save/Cancel ordering — a regression
// would surface as Cancel reverting to pre-Save values.
- var (vm, _, _, _, _, _) = Build();
+ var (vm, _, _, _, _, _, _) = Build();
vm.SetDisplay(vm.DisplayDraft with { ShowFps = true });
vm.Save();
Assert.False(vm.HasUnsavedChanges);
@@ -320,4 +323,64 @@ public sealed class SettingsVMTests
Assert.True(vm.DisplayDraft.ShowFps);
Assert.False(vm.HasUnsavedChanges);
}
+
+ // -- Audio tab state --------------------------------------------------
+
+ [Fact]
+ public void AudioDraft_initial_value_matches_persisted()
+ {
+ var custom = AudioSettings.Default with { Master = 0.3f, Music = 0.1f };
+ var (vm, _, _, _, _, _, _) = Build(persistedAudio: custom);
+ Assert.Equal(custom, vm.AudioDraft);
+ Assert.False(vm.HasUnsavedChanges);
+ }
+
+ [Fact]
+ public void SetAudio_marks_unsaved_changes()
+ {
+ var (vm, _, _, _, _, _, _) = Build();
+ vm.SetAudio(vm.AudioDraft with { Master = 0.5f });
+ Assert.True(vm.HasUnsavedChanges);
+ }
+
+ [Fact]
+ public void Save_invokes_audio_callback_with_draft()
+ {
+ var (vm, _, _, _, _, _, savedAudioHistory) = Build();
+ vm.SetAudio(vm.AudioDraft with { Master = 0.4f, Sfx = 0.6f });
+
+ vm.Save();
+
+ Assert.Single(savedAudioHistory);
+ Assert.Equal(0.4f, savedAudioHistory[0].Master);
+ Assert.Equal(0.6f, savedAudioHistory[0].Sfx);
+ Assert.False(vm.HasUnsavedChanges);
+ }
+
+ [Fact]
+ public void Cancel_reverts_audio_draft_to_persisted()
+ {
+ var custom = AudioSettings.Default with { Music = 0.2f };
+ var (vm, _, _, _, _, _, _) = Build(persistedAudio: custom);
+ vm.SetAudio(vm.AudioDraft with { Music = 0.9f, Master = 0.3f });
+ Assert.True(vm.HasUnsavedChanges);
+
+ vm.Cancel();
+
+ Assert.Equal(custom, vm.AudioDraft);
+ Assert.False(vm.HasUnsavedChanges);
+ }
+
+ [Fact]
+ public void ResetAllToDefaults_resets_audio_to_default()
+ {
+ var custom = AudioSettings.Default with { Master = 0.1f };
+ var (vm, _, _, _, _, _, _) = Build(persistedAudio: custom);
+ Assert.NotEqual(AudioSettings.Default, vm.AudioDraft);
+
+ vm.ResetAllToDefaults();
+
+ Assert.Equal(AudioSettings.Default, vm.AudioDraft);
+ Assert.True(vm.HasUnsavedChanges);
+ }
}