diff --git a/docs/plans/2026-04-11-roadmap.md b/docs/plans/2026-04-11-roadmap.md
index 9d3661c..9435f82 100644
--- a/docs/plans/2026-04-11-roadmap.md
+++ b/docs/plans/2026-04-11-roadmap.md
@@ -54,6 +54,8 @@
| I.5 | Holtburger inbound chat parity + Windows-1252 codec — `EmoteText (0x01E0)`, `SoulEmote (0x01E2)`, `ServerMessage (0xF7E0)`, `PlayerKilled (0x019E)` parsers + `WeenieError` routing through `GameEventWiring`. Global string codec switch from `Encoding.ASCII` to `Encoding.GetEncoding(1252)` so accented names round-trip per retail + holtburger. | Tests ✓ |
| I.6 | TurbineChat codec + `ChatChannelInfo` — full `0xF7DE` codec with three payload variants (`EventSendToRoom`, `RequestSendToRoomById`, `Response`), UTF-16LE strings with variable-length prefix, `SetTurbineChatChannels (0x0295)` parser, unified `ChatChannelInfo` (Legacy + Turbine variants), `TurbineChatState`. **ACE doesn't host a TurbineChat server — codec is ready when retail-emulating servers exist.** | Tests ✓ |
| I.7 | `CombatChatTranslator` — retail-faithful combat-text formatters into `ChatLog` ("You hit drudge for 50 slashing damage (87%)"). Subscribes to `CombatState`'s `DamageTaken` / `DamageDealtAccepted` / `EvadedIncoming` / `MissedOutgoing` / `AttackDone` / `KillLanded`; templates ported verbatim from holtburger `panels/chat.rs:221-308`. | Tests ✓ |
+| K | Input architecture — `Action` enum, `KeyChord`, `KeyBindings`, multicast `InputDispatcher` with scope-stack + modal capture, retail-default keymap (152 bindings), `keybinds.json` persistence, F11 Settings panel with click-to-rebind + conflict detection, main menu bar + View menu | Live ✓ |
+| L.0 | Full retail-style Settings interface — F11 tabbed panel with 6 tabs (Keybinds + Display + Audio + Gameplay + Chat + Character). `settings.json` at `%LOCALAPPDATA%\acdream\`, per-toon `Character` keying (swapped on EnterWorld). Display GL knobs (Resolution / Fullscreen / VSync / FOV / ShowFps) + Audio (Master / SFX) live-wired; Gameplay / Chat / Character settings persist for server-sync wiring later. Tab API extension to `IPanelRenderer`; chat Copy mode (read-only multi-line); per-panel layout reset; FramebufferResize handler keeps GL viewport + camera aspect + panel positions in sync. | Live ✓ |
Plus polish that doesn't get its own phase number:
- FlyCamera default speed lowered + Shift-to-boost
diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs
index 26e3ab6..0fbe73d 100644
--- a/src/AcDream.App/Rendering/GameWindow.cs
+++ b/src/AcDream.App/Rendering/GameWindow.cs
@@ -595,6 +595,12 @@ public sealed class GameWindow : IDisposable
_window.Update += OnUpdate;
_window.Render += OnRender;
_window.Closing += OnClosing;
+ // L.0 Display tab: keep the GL viewport + camera aspect in sync
+ // with the window framebuffer. Without this handler, resizing
+ // the window (or applying a Display-tab Resolution change at
+ // startup) leaves the viewport pinned to the original size —
+ // user sees a small render in the corner of a big window.
+ _window.FramebufferResize += OnFramebufferResize;
_window.Run();
}
@@ -833,6 +839,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.
@@ -922,8 +939,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 — 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,
@@ -942,12 +966,113 @@ 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);
+ }
+ 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);
+ 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
+ // BeginLiveSessionAsync after EnterWorld
+ // so saving character settings always
+ // writes under the chosen character's
+ // name (or "default" pre-login).
+ settingsStore.SaveCharacter(_activeToonKey, 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);
_panelHost.Register(_settingsPanel);
}
Console.WriteLine("devtools: ImGui panel host ready (VitalsPanel + ChatPanel + DebugPanel + SettingsPanel registered)");
+
+ // L.0 Display tab: seed sensible default positions for
+ // every registered panel. cond=FirstUseEver means imgui.ini
+ // takes precedence on subsequent launches — the user's
+ // dragged positions persist. Without this, the first-run
+ // experience stacks every panel at (0,0) which looks
+ // broken.
+ ResetPanelLayout(ImGuiNET.ImGuiCond.FirstUseEver);
}
catch (Exception ex)
{
@@ -1464,6 +1589,20 @@ public sealed class GameWindow : IDisposable
_worldState.MarkPersistent(chosen.Id); // player entity survives landblock unloads
Console.WriteLine($"live: entering world as 0x{chosen.Id:X8} {chosen.Name}");
_liveSession.EnterWorld(user, characterIndex: 0);
+
+ // L.0 Character tab: swap the SettingsVM's character bag
+ // from the "default" pre-login bag to the actual chosen
+ // toon's bag. Every Save from now on writes under the
+ // chosen toon's name. LoadCharacterContext rebinds BOTH
+ // persisted + draft so HasUnsavedChanges doesn't flag the
+ // swap as a pending edit.
+ _activeToonKey = chosen.Name;
+ if (_settingsStore is not null && _settingsVm is not null)
+ {
+ var toonBag = _settingsStore.LoadCharacter(_activeToonKey);
+ _settingsVm.LoadCharacterContext(toonBag);
+ Console.WriteLine($"settings: loaded character[{_activeToonKey}] preferences");
+ }
// Phase K.2: arm auto-entry. The guard's predicates won't
// pass yet — the entity stream hasn't started — but the
// OnUpdate tick re-checks every frame and fires once
@@ -4260,6 +4399,41 @@ 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;
+ }
+
+ // L.0 Display tab: push the live DisplayDraft into the
+ // active rendering surfaces each frame. FOV is the live-
+ // preview slider per the brainstorm — dragging it changes
+ // camera FovY immediately. VSync change-detected to avoid
+ // spamming the window. Resolution + Fullscreen apply on
+ // Save (handled by ApplyDisplayWindowState — too jarring
+ // to live-preview a resize).
+ if (_settingsVm is not null && _cameraController is not null)
+ {
+ var d = _settingsVm.DisplayDraft;
+ float fovYRad = d.FieldOfView * (MathF.PI / 180f);
+ _cameraController.Orbit.FovY = fovYRad;
+ _cameraController.Fly.FovY = fovYRad;
+ if (_cameraController.Chase is not null)
+ _cameraController.Chase.FovY = fovYRad;
+ if (_window is not null && _window.VSync != d.VSync)
+ _window.VSync = d.VSync;
+ }
+
// 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)
@@ -4510,6 +4684,15 @@ public sealed class GameWindow : IDisposable
if (_debugPanel is not null
&& ImGuiNET.ImGui.MenuItem("Debug", "Ctrl+F1"))
_debugPanel.IsVisible = !_debugPanel.IsVisible;
+ ImGuiNET.ImGui.Separator();
+ // L.0 Display tab: a manual reset for users whose
+ // imgui.ini has saved a panel position that's now
+ // off-screen (after a window shrink, monitor swap,
+ // or a malformed save). Force-resets every panel
+ // to its default landing position. The same code
+ // path runs automatically on FramebufferResize.
+ if (ImGuiNET.ImGui.MenuItem("Reset window layout"))
+ ResetPanelLayout(ImGuiNET.ImGuiCond.Always);
ImGuiNET.ImGui.EndMenu();
}
// K-fix2 (2026-04-26): Camera submenu — discoverable
@@ -4543,9 +4726,16 @@ public sealed class GameWindow : IDisposable
int entityCount = _worldState.Entities.Count;
int animatedCount = _animatedEntities.Count;
- _window!.Title = $"acdream | {fps:F0} fps | {avgFrameTime:F1} ms | " +
- $"lb {visibleLandblocks}/{totalLandblocks} visible | " +
- $"ent {entityCount} | anim {animatedCount}";
+ // L.0 Display tab: ShowFps gates the perf string in the
+ // title bar. Default is true (matches pre-L.0 behaviour);
+ // unchecking the toggle in Display tab collapses the title
+ // to just "acdream" for a cleaner alt-tab experience.
+ bool showFps = _settingsVm?.DisplayDraft.ShowFps ?? true;
+ _window!.Title = showFps
+ ? $"acdream | {fps:F0} fps | {avgFrameTime:F1} ms | "
+ + $"lb {visibleLandblocks}/{totalLandblocks} visible | "
+ + $"ent {entityCount} | anim {animatedCount}"
+ : "acdream";
_lastFps = fps;
_lastFrameMs = avgFrameTime;
_perfAccum = 0;
@@ -5478,6 +5668,188 @@ public sealed class GameWindow : IDisposable
// default; F11 / View → Settings toggles. Null when devtools are off.
private AcDream.UI.Abstractions.Panels.Settings.SettingsPanel? _settingsPanel;
private AcDream.UI.Abstractions.Panels.Settings.SettingsVM? _settingsVm;
+ // L.0: settings.json store + active toon key. The store is held as
+ // a field so BeginLiveSessionAsync 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";
+ // 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
+ /// + camera aspect when the window is resized (by the user dragging
+ /// the corner OR by ApplyDisplayWindowState 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
+ /// previously off the new viewport snap back to default positions.
+ ///
+ private void OnFramebufferResize(Silk.NET.Maths.Vector2D newSize)
+ {
+ if (newSize.X <= 0 || newSize.Y <= 0) return;
+ _gl?.Viewport(0, 0, (uint)newSize.X, (uint)newSize.Y);
+ _cameraController?.SetAspect(newSize.X / (float)newSize.Y);
+ // Resize is always a force-reset — the alternative ("clamp
+ // existing positions") would require tracking each panel's
+ // current pos+size, which ImGuiNET doesn't expose by name.
+ // Force-reset is acceptable UX because resizing happens rarely
+ // and the user can always drag panels back where they want.
+ if (DevToolsEnabled && _imguiBootstrap is not null)
+ ResetPanelLayout(ImGuiNET.ImGuiCond.Always);
+ }
+
+ ///
+ /// L.0 Display tab: position every registered panel to its default
+ /// landing spot, computed relative to the current window size so
+ /// the layout adapts to any resolution. Called from:
+ ///
+ /// - OnFramebufferResize (cond=Always — force-reset on resize).
+ /// - The View → "Reset window layout" menu item (cond=Always).
+ /// - OnLoad after panel registration (cond=FirstUseEver — only
+ /// applies when imgui.ini has no saved position for that
+ /// panel; on subsequent launches the saved positions win).
+ ///
+ ///
+ private void ResetPanelLayout(ImGuiNET.ImGuiCond cond)
+ {
+ if (_window is null) return;
+ float w = _window.Size.X;
+ float h = _window.Size.Y;
+ // Sane minimums so the math doesn't blow up on a tiny window.
+ if (w < 480) w = 480;
+ if (h < 320) h = 320;
+
+ // Panel positions chosen to be classic-MMO discoverable on a
+ // 1280x720 window: vitals top-left under the menu bar, chat
+ // bottom-left, debug top-right, settings centered. All sizes
+ // are reasonable defaults the user can resize from.
+ SetPanelLayout(_vitalsPanel?.Title, new System.Numerics.Vector2(10f, 30f),
+ new System.Numerics.Vector2(220f, 110f), cond);
+ SetPanelLayout(_chatPanel?.Title, new System.Numerics.Vector2(10f, h - 320f),
+ new System.Numerics.Vector2(450f, 300f), cond);
+ SetPanelLayout(_debugPanel?.Title, new System.Numerics.Vector2(w - 380f, 30f),
+ new System.Numerics.Vector2(370f, 520f), cond);
+ SetPanelLayout(_settingsPanel?.Title, new System.Numerics.Vector2((w - 700f) * 0.5f, (h - 500f) * 0.5f),
+ new System.Numerics.Vector2(700f, 500f), cond);
+ }
+
+ private static void SetPanelLayout(
+ string? title,
+ System.Numerics.Vector2 pos,
+ System.Numerics.Vector2 size,
+ ImGuiNET.ImGuiCond cond)
+ {
+ if (string.IsNullOrEmpty(title)) return;
+ // SetWindowPos/SetWindowSize by name work even when the window
+ // has never been Begin'd — ImGui stores the value for next
+ // appearance.
+ ImGuiNET.ImGui.SetWindowPos(title, pos, cond);
+ ImGuiNET.ImGui.SetWindowSize(title, size, cond);
+ }
+
+ ///
+ /// L.0 Display tab: apply the window-state-dependent settings
+ /// (Resolution + Fullscreen) from a
+ /// to the live Silk.NET window. Called at startup (with persisted
+ /// values) and on every Save (with the saved values). Resolution
+ /// parses "WIDTHxHEIGHT" (e.g. "1920x1080"); a malformed
+ /// or unparseable string is silently ignored to avoid crashing the
+ /// client mid-session.
+ ///
+ 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(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;
+ }
// Vitals panel reference cached for the View menu's toggle entry.
private AcDream.UI.Abstractions.Panels.Vitals.VitalsPanel? _vitalsPanel;
diff --git a/src/AcDream.UI.Abstractions/IPanelRenderer.cs b/src/AcDream.UI.Abstractions/IPanelRenderer.cs
index 1c0cb2c..70696a2 100644
--- a/src/AcDream.UI.Abstractions/IPanelRenderer.cs
+++ b/src/AcDream.UI.Abstractions/IPanelRenderer.cs
@@ -235,4 +235,48 @@ public interface IPanelRenderer
/// frame the user clicks the item; false otherwise.
///
bool MenuItem(string label, string? shortcut = null);
+
+ // -- Tab bar (Settings panel + future tabbed surfaces) ---------------
+
+ ///
+ /// Open a tab bar inside the current window. Returns true
+ /// when the bar is visible — only emit
+ /// calls inside that branch. Always pair with
+ /// when the call returned true. Retail had
+ /// tab bars in the Options UIs (gmGameplayOptionsUI etc), so
+ /// this primitive must be expressible by the future custom
+ /// retail-look backend.
+ ///
+ bool BeginTabBar(string id);
+
+ /// Close the tab bar opened by .
+ void EndTabBar();
+
+ ///
+ /// Begin a single tab inside an open .
+ /// Returns true when the tab is the currently selected one
+ /// — only render this tab's content in that branch. Always pair
+ /// with when the call returned true.
+ ///
+ bool BeginTabItem(string label);
+
+ /// Close the tab opened by .
+ void EndTabItem();
+
+ ///
+ /// Render a read-only multi-line text region the user can
+ /// select with click+drag and copy with Ctrl+C.
+ /// Matches the typical "click into a textbox to grab text" UX —
+ /// chat panels, log viewers, etc. use this to make text
+ /// extractable without the user having to alt-tab + retype.
+ ///
+ ///
+ /// The widget is sized to ; pass
+ /// (0, 0) for "fill the current content region" semantics
+ /// (matches ImGui defaults). is the ImGui
+ /// stable identifier — typically "##chatcopy" or similar
+ /// hidden-label form.
+ ///
+ ///
+ void TextMultilineReadOnly(string id, string content, Vector2 size);
}
diff --git a/src/AcDream.UI.Abstractions/Panels/Chat/ChatPanel.cs b/src/AcDream.UI.Abstractions/Panels/Chat/ChatPanel.cs
index f3a4a07..c8ece99 100644
--- a/src/AcDream.UI.Abstractions/Panels/Chat/ChatPanel.cs
+++ b/src/AcDream.UI.Abstractions/Panels/Chat/ChatPanel.cs
@@ -1,3 +1,4 @@
+using System.Linq;
using System.Numerics;
using AcDream.Core.Chat;
using AcDream.Core.Combat;
@@ -50,6 +51,13 @@ public sealed class ChatPanel : IPanel
// click into another widget.
private bool _focusRequested;
+ // L.0 follow-up: "Copy mode" — when true, render the chat tail as
+ // a read-only multi-line text widget the user can click+drag to
+ // select + Ctrl+C to copy. Trades per-line color for selectability;
+ // user toggles when they want to grab specific text out of the
+ // log (item names, coordinates, NPC dialogue, etc).
+ private bool _copyMode;
+
public ChatPanel(ChatVM vm)
{
_vm = vm ?? throw new ArgumentNullException(nameof(vm));
@@ -82,6 +90,31 @@ public sealed class ChatPanel : IPanel
return;
}
+ // L.0 follow-up: wrap the entire chat panel body in a single
+ // outer BeginChild so empty-space clicks anywhere in the body
+ // (Checkbox row, between Separator and input, etc.) are
+ // absorbed by BeginChild's drag-trap (an InvisibleButton the
+ // ImGui renderer adds inside every BeginChild). Without this
+ // wrapper the chat panel was draggable from any empty body
+ // pixel — only the inner ##chattail area was protected.
+ if (!renderer.BeginChild("##chatbody", new System.Numerics.Vector2(0f, 0f)))
+ {
+ renderer.EndChild();
+ renderer.End();
+ return;
+ }
+
+ // L.0 follow-up: top-of-panel "Copy mode" toggle. When on, the
+ // chat tail rendering swaps to TextMultilineReadOnly so the
+ // user can mark + Ctrl+C any text. Off (default) preserves the
+ // colored per-line render with combat highlights. The checkbox
+ // sits ABOVE the chat tail (not in the footer) so it's always
+ // visible regardless of scroll position.
+ bool copyMode = _copyMode;
+ if (renderer.Checkbox("Copy mode (select text to Ctrl+C)", ref copyMode))
+ _copyMode = copyMode;
+ renderer.Separator();
+
// Phase J Tier 3: keep the input field at the bottom of the
// window across resizes by reserving footer space and putting
// the chat tail in a scrollable child that fills the rest.
@@ -95,7 +128,21 @@ public sealed class ChatPanel : IPanel
// the plain Text path (visually identical to the I.4 panel).
var lines = _vm.RecentLinesDetailed();
- if (renderer.BeginChild("##chattail", new System.Numerics.Vector2(0, -footerHeight)))
+ if (_copyMode)
+ {
+ // Copy mode: one big read-only multiline text widget
+ // holding every visible line, joined with newlines. Loses
+ // per-line color but lets the user click+drag to select
+ // arbitrary spans of text + Ctrl+C to copy. Sized to fill
+ // the available space minus the footer.
+ string joined = lines.Count == 0
+ ? "(no messages yet)"
+ : string.Join("\n", lines.Select(l => l.Text));
+ renderer.TextMultilineReadOnly(
+ "##chattailcopy", joined,
+ new System.Numerics.Vector2(0f, -footerHeight));
+ }
+ else if (renderer.BeginChild("##chattail", new System.Numerics.Vector2(0, -footerHeight)))
{
if (lines.Count == 0)
{
@@ -127,7 +174,7 @@ public sealed class ChatPanel : IPanel
}
_lastRenderedCount = lines.Count;
}
- renderer.EndChild();
+ if (!_copyMode) renderer.EndChild();
// Phase I.4: input field. Backend implementation clears _input
// on submit per the IPanelRenderer contract.
@@ -153,6 +200,7 @@ public sealed class ChatPanel : IPanel
if (TryHandleClientCommand(trimmed))
{
_input = string.Empty;
+ renderer.EndChild(); // outer ##chatbody
renderer.End();
return;
}
@@ -173,6 +221,7 @@ public sealed class ChatPanel : IPanel
_vm.ShowSystemMessage(
$"Unknown command: {verb}. Type /help for the list of supported commands.");
_input = string.Empty;
+ renderer.EndChild(); // outer ##chatbody
renderer.End();
return;
}
@@ -192,6 +241,7 @@ public sealed class ChatPanel : IPanel
_input = string.Empty;
}
+ renderer.EndChild(); // outer ##chatbody
renderer.End();
}
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/CharacterSettings.cs b/src/AcDream.UI.Abstractions/Panels/Settings/CharacterSettings.cs
new file mode 100644
index 0000000..0aa2342
--- /dev/null
+++ b/src/AcDream.UI.Abstractions/Panels/Settings/CharacterSettings.cs
@@ -0,0 +1,48 @@
+namespace AcDream.UI.Abstractions.Panels.Settings;
+
+///
+/// Per-character preferences persisted to settings.json under
+/// character[toonName]. Settings on this tab are scoped to a
+/// single toon; switching characters loads a different bag.
+///
+///
+/// L.0 scope: local-only. The settings here describe how the
+/// client UI behaves for the active toon — they don't yet flow to the
+/// server. When server-sync ships, options like
+/// would be pushed via the retail Player-Options packet.
+///
+///
+///
+/// MVP shape — four settings only. Easy to grow when more per-toon
+/// preferences land. Each is value-typed so equality and Cancel-revert
+/// behave like the other tabs' records.
+///
+///
+public sealed record CharacterSettings(
+ string DefaultChatChannel, // "Local" / "Allegiance" / "Fellowship" / "General" / etc.
+ bool AutoAttack, // Tap-to-attack continues swinging until target dies
+ bool ConfirmSalvage, // Prompt before salvaging valuable items
+ bool ShowPickupMessages) // "You picked up X" lines in chat
+{
+ /// Defaults applied to a fresh character (no settings.json
+ /// entry yet). Conservative — opt-in for AutoAttack, opt-in for
+ /// confirmation prompts, pickup messages on by default.
+ public static CharacterSettings Default { get; } = new(
+ DefaultChatChannel: "Local",
+ AutoAttack: false,
+ ConfirmSalvage: true,
+ ShowPickupMessages: true);
+
+ /// Channel-name presets exposed in the dropdown. Order
+ /// roughly matches retail's chat-channel routing.
+ public static System.Collections.Generic.IReadOnlyList AvailableChannels { get; } = new[]
+ {
+ "Local",
+ "Allegiance",
+ "Fellowship",
+ "General",
+ "Trade",
+ "LFG",
+ "Roleplay",
+ };
+}
diff --git a/src/AcDream.UI.Abstractions/Panels/Settings/ChatSettings.cs b/src/AcDream.UI.Abstractions/Panels/Settings/ChatSettings.cs
new file mode 100644
index 0000000..74972fc
--- /dev/null
+++ b/src/AcDream.UI.Abstractions/Panels/Settings/ChatSettings.cs
@@ -0,0 +1,44 @@
+namespace AcDream.UI.Abstractions.Panels.Settings;
+
+///
+/// Chat-related preferences persisted to settings.json. Mixes
+/// retail's CharacterOptions2 chat-channel filter bits (Hear*Chat
+/// + TimeStamp + FilterLanguage + AppearOffline) with a few visual
+/// preferences (font size) that don't have a retail bitfield.
+/// See docs/research/named-retail/acclient.h:3451+ for the
+/// retail bit values.
+///
+///
+/// L.0 scope: local-only like the rest of L.0. The Hear*Chat
+/// flags affect client-side display filtering of the existing
+/// channels — the server still streams every line; the client decides
+/// what to render. Server-sync arrives in a later phase that flips the
+/// retail-faithful "tell server which channels I'm subscribed to"
+/// switch.
+///
+///
+public sealed record ChatSettings(
+ // CharacterOptions2 (32-bit) channel filters.
+ bool HearGeneralChat, // 0x100 — General channel
+ bool HearTradeChat, // 0x200 — Trade channel
+ bool HearLFGChat, // 0x400 — LFG channel
+ bool HearRoleplayChat, // 0x800 — RP channel
+ bool HearSocietyChat, // 0x80000 — Society chat (CD/EW/RB)
+ bool AppearOffline, // 0x1000 — hide /who status
+ bool ShowTimestamps, // 0x40 — TimeStamp prefix on chat lines
+ bool FilterProfanity, // 0x20000 — FilterLanguage (Turbine's profanity filter)
+ // Visual / UX (no retail bitfield).
+ float FontSize) // chat panel font, 10..20 pt
+{
+ /// Sensible starting values matching the retail "all on" stance.
+ public static ChatSettings Default { get; } = new(
+ HearGeneralChat: true,
+ HearTradeChat: true,
+ HearLFGChat: true,
+ HearRoleplayChat: true,
+ HearSocietyChat: true,
+ AppearOffline: false,
+ ShowTimestamps: true,
+ FilterProfanity: true,
+ FontSize: 12f);
+}
diff --git a/src/AcDream.UI.Abstractions/Panels/Settings/DisplaySettings.cs b/src/AcDream.UI.Abstractions/Panels/Settings/DisplaySettings.cs
new file mode 100644
index 0000000..05438b0
--- /dev/null
+++ b/src/AcDream.UI.Abstractions/Panels/Settings/DisplaySettings.cs
@@ -0,0 +1,50 @@
+using System.Collections.Generic;
+
+namespace AcDream.UI.Abstractions.Panels.Settings;
+
+///
+/// Display-related preferences persisted to settings.json.
+/// Modern addition (no retail equivalent for FOV / vsync etc) — replaces
+/// the various ACDREAM_* environment variables for resolution +
+/// windowed mode with an in-game UI.
+///
+///
+/// Records are immutable; mutation goes through
+/// which assigns a new instance via
+/// with-expressions.
+///
+///
+public sealed record DisplaySettings(
+ string Resolution,
+ bool Fullscreen,
+ bool VSync,
+ float FieldOfView,
+ float Gamma,
+ bool ShowFps)
+{
+ /// Values used on first launch / when settings.json is absent.
+ /// All defaults pinned to the pre-L.0 runtime state — Resolution
+ /// matches the WindowOptions startup size (1280×720), FieldOfView
+ /// matches camera FovY (60°), VSync matches WindowOptions (false),
+ /// ShowFps preserves the perf string in the title bar. Net effect:
+ /// opening Display + Save without touching anything is a complete
+ /// visual no-op.
+ public static DisplaySettings Default { get; } = new(
+ Resolution: "1280x720",
+ Fullscreen: false,
+ VSync: false,
+ FieldOfView: 60f,
+ Gamma: 1.0f,
+ ShowFps: true);
+
+ /// 16:9 resolution presets offered in the dropdown.
+ public static IReadOnlyList AvailableResolutions { get; } = new[]
+ {
+ "1280x720",
+ "1366x768",
+ "1600x900",
+ "1920x1080",
+ "2560x1440",
+ "3840x2160",
+ };
+}
diff --git a/src/AcDream.UI.Abstractions/Panels/Settings/GameplaySettings.cs b/src/AcDream.UI.Abstractions/Panels/Settings/GameplaySettings.cs
new file mode 100644
index 0000000..4b1d43e
--- /dev/null
+++ b/src/AcDream.UI.Abstractions/Panels/Settings/GameplaySettings.cs
@@ -0,0 +1,61 @@
+namespace AcDream.UI.Abstractions.Panels.Settings;
+
+///
+/// Gameplay-related preferences persisted to settings.json.
+/// Mirrors a subset of retail's CharacterOption + CharacterOptions2
+/// bitfield flags (see docs/research/named-retail/acclient.h:3404+).
+/// Retail names are kept verbatim so future server-sync packs these
+/// into the wire-format bitmask without renaming.
+///
+///
+/// L.0 scope: local-only. The brainstorm explicitly deferred
+/// server sync — on Save these values are persisted to settings.json
+/// only. A later phase will marshal them into the retail
+/// CharacterOption packet (0x...) when the protocol work
+/// for player-options round-trip is in place.
+///
+///
+///
+/// Defaults below are chosen as the typical-user starting point, NOT
+/// pinned bit-exact to retail's 0x50C4A54A / 0x948700
+/// masks (those will become the defaults once server-sync ships and
+/// the bitmask round-trip is the load-bearing wire format).
+///
+///
+public sealed record GameplaySettings(
+ // CharacterOption (32-bit) subset — most-used gameplay toggles.
+ bool AutoTarget, // 0x2000 — combat: auto-acquire target on attack
+ bool AutoRepeatAttack, // 0x2 — combat: keep attacking after first hit
+ bool ToggleRun, // 0x400 — run-mode is tap-once vs hold-to-run
+ bool AdvancedCombatUI, // 0x1000 — show extra combat tooltips/panels
+ bool ShowTooltips, // 0x100 — show item tooltips on hover
+ bool VividTargetingIndicator, // 0x8000 — bright targeting reticle
+ bool SideBySideVitals, // 0x200000 — health/stam/mana side-by-side vs stacked
+ bool CoordinatesOnRadar, // 0x400000 — show NS/EW coords on radar
+ bool SpellDuration, // 0x800000 — show remaining duration on enchantment icons
+ bool AllowGive, // 0x40 — accept items handed by other players
+ // CharacterOptions2 (32-bit) subset.
+ bool ShowHelm, // 0x100000 — render helm overlay on character
+ bool ShowCloak, // 0x800000 — render cloak on character
+ bool LockUI, // 0x1000000 — disable panel drag/resize
+ bool UseMouseTurning) // 0x400000 — turn character when right-mouse drags
+{
+ /// Sensible starting values for first launch. NOT bit-exact
+ /// to retail's Default_CharacterOption = 0x50C4A54A +
+ /// Default_CharacterOptions2 = 0x948700 — see class remarks.
+ public static GameplaySettings Default { get; } = new(
+ AutoTarget: true,
+ AutoRepeatAttack: true,
+ ToggleRun: true,
+ AdvancedCombatUI: false,
+ ShowTooltips: true,
+ VividTargetingIndicator: false,
+ SideBySideVitals: false,
+ CoordinatesOnRadar: false,
+ SpellDuration: true,
+ AllowGive: true,
+ ShowHelm: true,
+ ShowCloak: true,
+ LockUI: false,
+ UseMouseTurning: false);
+}
diff --git a/src/AcDream.UI.Abstractions/Panels/Settings/SettingsPanel.cs b/src/AcDream.UI.Abstractions/Panels/Settings/SettingsPanel.cs
index 841b394..a8a8034 100644
--- a/src/AcDream.UI.Abstractions/Panels/Settings/SettingsPanel.cs
+++ b/src/AcDream.UI.Abstractions/Panels/Settings/SettingsPanel.cs
@@ -5,25 +5,23 @@ using AcDream.UI.Abstractions.Input;
namespace AcDream.UI.Abstractions.Panels.Settings;
///
-/// K.3: in-game Settings panel for click-to-rebind keymap editing.
-/// Hidden by default; opens via F11 (which fires the
-/// action) or via the
-/// View → Settings entry on the main menu bar.
+/// In-game Settings panel — F11 toggle (or View → Settings on the main
+/// menu bar). Hidden by default. Tabbed: Keybinds (Phase K), then
+/// Display / Audio / Gameplay / Chat / Character (filling in over the
+/// L.x sub-phases).
///
///
-/// Layout: top row of action buttons (Save / Cancel / Reset all), then
-/// a sequence of sections
-/// matching the retail keymap categories (Movement / Postures / Camera /
-/// Combat / UI panels / Chat / Hotbar / Emotes). Each row inside a
-/// section: action name, current binding(s) summary, "Rebind" button,
-/// per-action "Reset" button. When a rebind is in progress the Rebind
-/// button label changes to "Press a key... (Esc to cancel)".
+/// Top of the panel: Save / Cancel / Reset-all action buttons (global
+/// across all tabs). When is
+/// non-null, a confirmation prompt is rendered above those buttons
+/// (Yes — Reassign / No — Keep existing).
///
///
///
-/// When is non-null, a
-/// confirmation prompt is rendered ABOVE the rest of the panel (Yes —
-/// Reassign / No — Keep existing).
+/// Below the action row a tab bar selects between the six categories.
+/// Only the Keybinds tab is implemented today; the other five render
+/// "Coming soon" placeholders so the structure the user approved in the
+/// design brainstorm is visible immediately.
///
///
public sealed class SettingsPanel : IPanel
@@ -42,7 +40,7 @@ public sealed class SettingsPanel : IPanel
public string Title => "Settings";
///
- /// K.3: hidden by default — opened via F11 / View menu.
+ /// Hidden by default — opened via F11 / View menu.
public bool IsVisible { get; set; } = false;
///
@@ -67,7 +65,7 @@ public sealed class SettingsPanel : IPanel
renderer.Separator();
}
- // Top action buttons.
+ // Top action buttons. Global across all tabs.
if (renderer.Button("Save changes")) _vm.Save();
renderer.SameLine();
if (renderer.Button("Cancel changes")) _vm.Cancel();
@@ -76,7 +74,51 @@ public sealed class SettingsPanel : IPanel
renderer.Separator();
- // Sections (retail keymap categories).
+ if (renderer.BeginTabBar("settings.tabs"))
+ {
+ if (renderer.BeginTabItem("Keybinds"))
+ {
+ RenderKeybindsTab(renderer);
+ renderer.EndTabItem();
+ }
+ if (renderer.BeginTabItem("Display"))
+ {
+ RenderDisplayTab(renderer);
+ renderer.EndTabItem();
+ }
+ if (renderer.BeginTabItem("Audio"))
+ {
+ RenderAudioTab(renderer);
+ renderer.EndTabItem();
+ }
+ if (renderer.BeginTabItem("Gameplay"))
+ {
+ RenderGameplayTab(renderer);
+ renderer.EndTabItem();
+ }
+ if (renderer.BeginTabItem("Chat"))
+ {
+ RenderChatTab(renderer);
+ renderer.EndTabItem();
+ }
+ if (renderer.BeginTabItem("Character"))
+ {
+ RenderCharacterTab(renderer);
+ renderer.EndTabItem();
+ }
+ renderer.EndTabBar();
+ }
+
+ renderer.End();
+ }
+
+ ///
+ /// Render the Keybinds tab — eight collapsing-header sections matching
+ /// the retail keymap categories. Phase K shipped this content; the
+ /// only thing that changed is the wrapping tab item.
+ ///
+ private void RenderKeybindsTab(IPanelRenderer renderer)
+ {
RenderSection(renderer, "Movement", new[]
{
InputAction.MovementForward, InputAction.MovementBackup,
@@ -136,8 +178,272 @@ public sealed class SettingsPanel : IPanel
InputAction.Cry, InputAction.Laugh, InputAction.Wave,
InputAction.Cheer, InputAction.PointState,
});
+ }
- renderer.End();
+ ///
+ /// Render the Display tab — resolution / fullscreen / vsync /
+ /// FOV / gamma / show-FPS. FOV + Gamma are live-preview sliders;
+ /// the others apply on Save (matches the brainstorm UX agreement —
+ /// resolution change live would be too jarring).
+ ///
+ private void RenderDisplayTab(IPanelRenderer renderer)
+ {
+ var d = _vm.DisplayDraft;
+
+ // Resolution dropdown. Index falls back to the highest available
+ // option when the persisted resolution isn't one of the presets
+ // (e.g. user hand-edited settings.json with a non-standard size).
+ var resolutions = DisplaySettings.AvailableResolutions.ToArray();
+ int idx = System.Array.IndexOf(resolutions, d.Resolution);
+ if (idx < 0) idx = resolutions.Length - 1;
+ if (renderer.Combo("Resolution", ref idx, resolutions))
+ _vm.SetDisplay(d with { Resolution = resolutions[idx] });
+
+ bool fullscreen = d.Fullscreen;
+ if (renderer.Checkbox("Fullscreen", ref fullscreen))
+ _vm.SetDisplay(d with { Fullscreen = fullscreen });
+
+ bool vsync = d.VSync;
+ if (renderer.Checkbox("V-Sync", ref vsync))
+ _vm.SetDisplay(d with { VSync = vsync });
+
+ float fov = d.FieldOfView;
+ if (renderer.SliderFloat("Field of View", ref fov, 30f, 120f))
+ _vm.SetDisplay(d with { FieldOfView = fov });
+
+ float gamma = d.Gamma;
+ if (renderer.SliderFloat("Gamma", ref gamma, 0.5f, 2.0f))
+ _vm.SetDisplay(d with { Gamma = gamma });
+
+ bool showFps = d.ShowFps;
+ if (renderer.Checkbox("Show FPS", ref showFps))
+ _vm.SetDisplay(d with { ShowFps = showFps });
+
+ renderer.Spacing();
+ renderer.TextWrapped(
+ "Resolution / Fullscreen / V-Sync apply on Save. FOV + Gamma "
+ + "preview live as you drag; Cancel reverts to the saved value.");
+ }
+
+ ///
+ /// 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)
+ {
+ var a = _vm.AudioDraft;
+
+ float master = a.Master;
+ if (renderer.SliderFloat("Master", ref master, 0f, 1f))
+ _vm.SetAudio(a with { Master = master });
+
+ float sfx = a.Sfx;
+ if (renderer.SliderFloat("SFX", ref sfx, 0f, 1f))
+ _vm.SetAudio(a with { Sfx = sfx });
+
+ // 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. "
+ + "Music + Ambient mixing arrives with R5 MIDI playback.");
+ }
+
+ ///
+ /// Render the Gameplay tab — ~14 toggles ported from retail's
+ /// CharacterOption + CharacterOptions2 bitfields. Local-only this
+ /// phase (no server sync). Grouped into Combat / Display / Interface
+ /// for first-run discoverability.
+ ///
+ private void RenderGameplayTab(IPanelRenderer renderer)
+ {
+ var g = _vm.GameplayDraft;
+
+ renderer.Text("Combat");
+ renderer.Separator();
+
+ bool autoTarget = g.AutoTarget;
+ if (renderer.Checkbox("Auto-target on attack", ref autoTarget))
+ _vm.SetGameplay(g with { AutoTarget = autoTarget });
+
+ bool autoRepeat = g.AutoRepeatAttack;
+ if (renderer.Checkbox("Auto-repeat attacks", ref autoRepeat))
+ _vm.SetGameplay(g with { AutoRepeatAttack = autoRepeat });
+
+ bool toggleRun = g.ToggleRun;
+ if (renderer.Checkbox("Run mode is toggle (vs hold)", ref toggleRun))
+ _vm.SetGameplay(g with { ToggleRun = toggleRun });
+
+ bool advCombat = g.AdvancedCombatUI;
+ if (renderer.Checkbox("Show advanced combat UI", ref advCombat))
+ _vm.SetGameplay(g with { AdvancedCombatUI = advCombat });
+
+ bool vivid = g.VividTargetingIndicator;
+ if (renderer.Checkbox("Vivid targeting indicator", ref vivid))
+ _vm.SetGameplay(g with { VividTargetingIndicator = vivid });
+
+ renderer.Spacing();
+ renderer.Text("Display");
+ renderer.Separator();
+
+ bool tooltips = g.ShowTooltips;
+ if (renderer.Checkbox("Show item tooltips", ref tooltips))
+ _vm.SetGameplay(g with { ShowTooltips = tooltips });
+
+ bool sideBySide = g.SideBySideVitals;
+ if (renderer.Checkbox("Side-by-side vital orbs", ref sideBySide))
+ _vm.SetGameplay(g with { SideBySideVitals = sideBySide });
+
+ bool coords = g.CoordinatesOnRadar;
+ if (renderer.Checkbox("Show coordinates on radar", ref coords))
+ _vm.SetGameplay(g with { CoordinatesOnRadar = coords });
+
+ bool spellDur = g.SpellDuration;
+ if (renderer.Checkbox("Show spell duration on enchantments", ref spellDur))
+ _vm.SetGameplay(g with { SpellDuration = spellDur });
+
+ bool helm = g.ShowHelm;
+ if (renderer.Checkbox("Show helm on character", ref helm))
+ _vm.SetGameplay(g with { ShowHelm = helm });
+
+ bool cloak = g.ShowCloak;
+ if (renderer.Checkbox("Show cloak on character", ref cloak))
+ _vm.SetGameplay(g with { ShowCloak = cloak });
+
+ renderer.Spacing();
+ renderer.Text("Interface");
+ renderer.Separator();
+
+ bool allowGive = g.AllowGive;
+ if (renderer.Checkbox("Accept items handed by other players", ref allowGive))
+ _vm.SetGameplay(g with { AllowGive = allowGive });
+
+ bool lockUI = g.LockUI;
+ if (renderer.Checkbox("Lock UI (disable panel drag/resize)", ref lockUI))
+ _vm.SetGameplay(g with { LockUI = lockUI });
+
+ bool mouseTurn = g.UseMouseTurning;
+ if (renderer.Checkbox("Use mouse turning", ref mouseTurn))
+ _vm.SetGameplay(g with { UseMouseTurning = mouseTurn });
+
+ renderer.Spacing();
+ renderer.TextWrapped(
+ "Local-only this phase — values persist to settings.json but "
+ + "don't yet sync to the server. Server sync arrives in a "
+ + "follow-up phase.");
+ }
+
+ ///
+ /// Render the Chat tab — channel filters (Hear*Chat), display
+ /// preferences (timestamps / profanity filter / appear offline),
+ /// and a font-size slider. Channel filters affect client-side
+ /// display only this phase — the server still sends every line,
+ /// the client decides what to render.
+ ///
+ private void RenderChatTab(IPanelRenderer renderer)
+ {
+ var c = _vm.ChatDraft;
+
+ renderer.Text("Channel filters");
+ renderer.Separator();
+
+ bool general = c.HearGeneralChat;
+ if (renderer.Checkbox("General", ref general))
+ _vm.SetChat(c with { HearGeneralChat = general });
+
+ bool trade = c.HearTradeChat;
+ if (renderer.Checkbox("Trade", ref trade))
+ _vm.SetChat(c with { HearTradeChat = trade });
+
+ bool lfg = c.HearLFGChat;
+ if (renderer.Checkbox("LFG (looking for group)", ref lfg))
+ _vm.SetChat(c with { HearLFGChat = lfg });
+
+ bool rp = c.HearRoleplayChat;
+ if (renderer.Checkbox("Roleplay", ref rp))
+ _vm.SetChat(c with { HearRoleplayChat = rp });
+
+ bool society = c.HearSocietyChat;
+ if (renderer.Checkbox("Society (CD / EW / RB)", ref society))
+ _vm.SetChat(c with { HearSocietyChat = society });
+
+ renderer.Spacing();
+ renderer.Text("Display");
+ renderer.Separator();
+
+ bool timestamps = c.ShowTimestamps;
+ if (renderer.Checkbox("Show timestamps", ref timestamps))
+ _vm.SetChat(c with { ShowTimestamps = timestamps });
+
+ bool profanity = c.FilterProfanity;
+ if (renderer.Checkbox("Filter profanity", ref profanity))
+ _vm.SetChat(c with { FilterProfanity = profanity });
+
+ bool offline = c.AppearOffline;
+ if (renderer.Checkbox("Appear offline (hide from /who)", ref offline))
+ _vm.SetChat(c with { AppearOffline = offline });
+
+ float fontSize = c.FontSize;
+ if (renderer.SliderFloat("Font size (pt)", ref fontSize, 10f, 20f))
+ _vm.SetChat(c with { FontSize = fontSize });
+
+ renderer.Spacing();
+ renderer.TextWrapped(
+ "Channel filters hide messages from the chat window without "
+ + "changing your server-side subscriptions. Save persists; "
+ + "Cancel reverts.");
+ }
+
+ ///
+ /// Render the Character tab — per-toon preferences. The host owns
+ /// the toon-name key; the panel just edits whatever bag the host
+ /// loaded into .
+ ///
+ private void RenderCharacterTab(IPanelRenderer renderer)
+ {
+ var c = _vm.CharacterDraft;
+
+ var channels = CharacterSettings.AvailableChannels.ToArray();
+ int idx = System.Array.IndexOf(channels, c.DefaultChatChannel);
+ if (idx < 0) idx = 0;
+ if (renderer.Combo("Default chat channel", ref idx, channels))
+ _vm.SetCharacter(c with { DefaultChatChannel = channels[idx] });
+
+ bool autoAttack = c.AutoAttack;
+ if (renderer.Checkbox("Auto-attack (continue swinging until target dies)", ref autoAttack))
+ _vm.SetCharacter(c with { AutoAttack = autoAttack });
+
+ bool confirmSalvage = c.ConfirmSalvage;
+ if (renderer.Checkbox("Confirm before salvaging valuable items", ref confirmSalvage))
+ _vm.SetCharacter(c with { ConfirmSalvage = confirmSalvage });
+
+ bool pickup = c.ShowPickupMessages;
+ if (renderer.Checkbox("Show pickup messages in chat", ref pickup))
+ _vm.SetCharacter(c with { ShowPickupMessages = pickup });
+
+ renderer.Spacing();
+ renderer.TextWrapped(
+ "Per-character preferences — saved per toon under "
+ + "settings.json's character[\"\"]. Local-only this "
+ + "phase; server-sync arrives later when the protocol "
+ + "round-trip lands.");
}
private void RenderSection(IPanelRenderer renderer, string label, InputAction[] actions)
diff --git a/src/AcDream.UI.Abstractions/Panels/Settings/SettingsStore.cs b/src/AcDream.UI.Abstractions/Panels/Settings/SettingsStore.cs
new file mode 100644
index 0000000..11264fc
--- /dev/null
+++ b/src/AcDream.UI.Abstractions/Panels/Settings/SettingsStore.cs
@@ -0,0 +1,408 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Text.Json;
+using System.Text.Json.Nodes;
+
+namespace AcDream.UI.Abstractions.Panels.Settings;
+
+///
+/// JSON-backed persistence for non-keybind settings (Display today; future
+/// tabs Audio / Gameplay / Chat / Character will be added to the same
+/// file). Path: %LOCALAPPDATA%\acdream\settings.json. Coexists
+/// with keybinds.json, which retains its own
+/// path.
+///
+///
+/// Schema (current version 1):
+///
+/// {
+/// "version": 1,
+/// "display": { "resolution": "1920x1080", "fullscreen": false, ... }
+/// }
+///
+/// Unknown top-level keys are preserved on save so future tab additions
+/// from a newer client don't get clobbered by an older client writing
+/// out only the sections it knows about.
+///
+///
+public sealed class SettingsStore
+{
+ private const int CurrentSchemaVersion = 1;
+ private readonly string _path;
+
+ public SettingsStore(string path)
+ {
+ _path = path ?? throw new ArgumentNullException(nameof(path));
+ }
+
+ /// Default path: %LOCALAPPDATA%\acdream\settings.json.
+ public static string DefaultPath() => Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
+ "acdream",
+ "settings.json");
+
+ ///
+ /// Load Display settings. Missing file → .
+ /// Missing individual keys fall back to the corresponding default
+ /// field, so a partial file (e.g. only resolution is set) is
+ /// non-fatal.
+ ///
+ public DisplaySettings LoadDisplay()
+ {
+ if (!File.Exists(_path)) return DisplaySettings.Default;
+ try
+ {
+ using var stream = File.OpenRead(_path);
+ var doc = JsonDocument.Parse(stream);
+ var root = doc.RootElement;
+ if (!root.TryGetProperty("display", out var disp)
+ || disp.ValueKind != JsonValueKind.Object)
+ return DisplaySettings.Default;
+
+ var d = DisplaySettings.Default;
+ return new DisplaySettings(
+ Resolution: ReadString (disp, "resolution", d.Resolution),
+ Fullscreen: ReadBool (disp, "fullscreen", d.Fullscreen),
+ VSync: ReadBool (disp, "vsync", d.VSync),
+ FieldOfView: ReadFloat (disp, "fieldOfView", d.FieldOfView),
+ Gamma: ReadFloat (disp, "gamma", d.Gamma),
+ ShowFps: ReadBool (disp, "showFps", d.ShowFps));
+ }
+ catch (Exception ex)
+ {
+ Console.WriteLine($"settings: failed to load {_path}: {ex.Message} — using defaults");
+ return DisplaySettings.Default;
+ }
+ }
+
+ ///
+ /// Save Display settings, preserving any other top-level keys the file
+ /// already contains (e.g. an audio section written by a newer
+ /// client). Unknown keys are round-tripped via raw JSON text so older
+ /// 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));
+
+ ///
+ /// Load Gameplay settings (subset of retail CharacterOption flags).
+ /// Same fall-back behaviour as .
+ ///
+ public GameplaySettings LoadGameplay()
+ {
+ if (!File.Exists(_path)) return GameplaySettings.Default;
+ try
+ {
+ using var stream = File.OpenRead(_path);
+ var doc = JsonDocument.Parse(stream);
+ var root = doc.RootElement;
+ if (!root.TryGetProperty("gameplay", out var gp)
+ || gp.ValueKind != JsonValueKind.Object)
+ return GameplaySettings.Default;
+
+ var d = GameplaySettings.Default;
+ return new GameplaySettings(
+ AutoTarget: ReadBool(gp, "autoTarget", d.AutoTarget),
+ AutoRepeatAttack: ReadBool(gp, "autoRepeatAttack", d.AutoRepeatAttack),
+ ToggleRun: ReadBool(gp, "toggleRun", d.ToggleRun),
+ AdvancedCombatUI: ReadBool(gp, "advancedCombatUI", d.AdvancedCombatUI),
+ ShowTooltips: ReadBool(gp, "showTooltips", d.ShowTooltips),
+ VividTargetingIndicator: ReadBool(gp, "vividTargetingIndicator", d.VividTargetingIndicator),
+ SideBySideVitals: ReadBool(gp, "sideBySideVitals", d.SideBySideVitals),
+ CoordinatesOnRadar: ReadBool(gp, "coordinatesOnRadar", d.CoordinatesOnRadar),
+ SpellDuration: ReadBool(gp, "spellDuration", d.SpellDuration),
+ AllowGive: ReadBool(gp, "allowGive", d.AllowGive),
+ ShowHelm: ReadBool(gp, "showHelm", d.ShowHelm),
+ ShowCloak: ReadBool(gp, "showCloak", d.ShowCloak),
+ LockUI: ReadBool(gp, "lockUI", d.LockUI),
+ UseMouseTurning: ReadBool(gp, "useMouseTurning", d.UseMouseTurning));
+ }
+ catch (Exception ex)
+ {
+ Console.WriteLine($"settings: failed to load {_path}: {ex.Message} — using defaults");
+ return GameplaySettings.Default;
+ }
+ }
+
+ /// Save Gameplay settings, preserving all other top-level keys.
+ public void SaveGameplay(GameplaySettings gameplay)
+ => SaveSection("gameplay", BuildGameplayObject(gameplay));
+
+ /// Load Chat settings. Same fall-back behaviour as .
+ public ChatSettings LoadChat()
+ {
+ if (!File.Exists(_path)) return ChatSettings.Default;
+ try
+ {
+ using var stream = File.OpenRead(_path);
+ var doc = JsonDocument.Parse(stream);
+ var root = doc.RootElement;
+ if (!root.TryGetProperty("chat", out var chat)
+ || chat.ValueKind != JsonValueKind.Object)
+ return ChatSettings.Default;
+
+ var d = ChatSettings.Default;
+ return new ChatSettings(
+ HearGeneralChat: ReadBool (chat, "hearGeneralChat", d.HearGeneralChat),
+ HearTradeChat: ReadBool (chat, "hearTradeChat", d.HearTradeChat),
+ HearLFGChat: ReadBool (chat, "hearLFGChat", d.HearLFGChat),
+ HearRoleplayChat: ReadBool (chat, "hearRoleplayChat", d.HearRoleplayChat),
+ HearSocietyChat: ReadBool (chat, "hearSocietyChat", d.HearSocietyChat),
+ AppearOffline: ReadBool (chat, "appearOffline", d.AppearOffline),
+ ShowTimestamps: ReadBool (chat, "showTimestamps", d.ShowTimestamps),
+ FilterProfanity: ReadBool (chat, "filterProfanity", d.FilterProfanity),
+ FontSize: ReadFloat(chat, "fontSize", d.FontSize));
+ }
+ catch (Exception ex)
+ {
+ Console.WriteLine($"settings: failed to load {_path}: {ex.Message} — using defaults");
+ return ChatSettings.Default;
+ }
+ }
+
+ /// Save Chat settings, preserving all other top-level keys.
+ public void SaveChat(ChatSettings chat)
+ => SaveSection("chat", BuildChatObject(chat));
+
+ ///
+ /// Load per-character settings keyed by .
+ /// Missing file or missing toon entry → .
+ ///
+ public CharacterSettings LoadCharacter(string toonKey)
+ {
+ if (toonKey is null) throw new ArgumentNullException(nameof(toonKey));
+ if (!File.Exists(_path)) return CharacterSettings.Default;
+ try
+ {
+ var root = JsonNode.Parse(File.ReadAllText(_path)) as JsonObject;
+ var toon = root?["character"]?[toonKey] as JsonObject;
+ if (toon is null) return CharacterSettings.Default;
+
+ var d = CharacterSettings.Default;
+ return new CharacterSettings(
+ DefaultChatChannel: toon["defaultChatChannel"]?.GetValue() ?? d.DefaultChatChannel,
+ AutoAttack: toon["autoAttack"]?.GetValue() ?? d.AutoAttack,
+ ConfirmSalvage: toon["confirmSalvage"]?.GetValue() ?? d.ConfirmSalvage,
+ ShowPickupMessages: toon["showPickupMessages"]?.GetValue() ?? d.ShowPickupMessages);
+ }
+ catch (Exception ex)
+ {
+ Console.WriteLine($"settings: failed to load {_path}: {ex.Message} — using defaults");
+ return CharacterSettings.Default;
+ }
+ }
+
+ ///
+ /// Save per-character settings under .
+ /// Preserves every other toon's settings + every other top-level
+ /// section. Uses rather than the raw-text
+ /// preservation pattern of because the
+ /// per-toon write needs to mutate a nested map, not just replace a
+ /// top-level key.
+ ///
+ public void SaveCharacter(string toonKey, CharacterSettings settings)
+ {
+ if (toonKey is null) throw new ArgumentNullException(nameof(toonKey));
+ if (settings is null) throw new ArgumentNullException(nameof(settings));
+
+ var dir = Path.GetDirectoryName(_path);
+ if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir);
+
+ // Read existing file as a mutable JsonObject (or start fresh).
+ JsonObject root;
+ if (File.Exists(_path))
+ {
+ try
+ {
+ root = JsonNode.Parse(File.ReadAllText(_path)) as JsonObject ?? new JsonObject();
+ }
+ catch
+ {
+ root = new JsonObject();
+ }
+ }
+ else
+ {
+ root = new JsonObject();
+ }
+
+ // Build the toon's payload.
+ var toonObj = new JsonObject
+ {
+ ["autoAttack"] = settings.AutoAttack,
+ ["confirmSalvage"] = settings.ConfirmSalvage,
+ ["defaultChatChannel"] = settings.DefaultChatChannel,
+ ["showPickupMessages"] = settings.ShowPickupMessages,
+ };
+
+ // Slot it under character[toonKey], creating the character map if
+ // necessary. Other toons in the map are preserved.
+ if (root["character"] is not JsonObject characterMap)
+ {
+ characterMap = new JsonObject();
+ root["character"] = characterMap;
+ }
+ characterMap[toonKey] = toonObj;
+ root["version"] = CurrentSchemaVersion;
+
+ File.WriteAllText(_path, root.ToJsonString(new JsonSerializerOptions { WriteIndented = true }));
+ }
+
+ private static SortedDictionary BuildChatObject(ChatSettings c)
+ => new(StringComparer.Ordinal)
+ {
+ ["appearOffline"] = c.AppearOffline,
+ ["filterProfanity"] = c.FilterProfanity,
+ ["fontSize"] = c.FontSize,
+ ["hearGeneralChat"] = c.HearGeneralChat,
+ ["hearLFGChat"] = c.HearLFGChat,
+ ["hearRoleplayChat"] = c.HearRoleplayChat,
+ ["hearSocietyChat"] = c.HearSocietyChat,
+ ["hearTradeChat"] = c.HearTradeChat,
+ ["showTimestamps"] = c.ShowTimestamps,
+ };
+
+ private static SortedDictionary BuildGameplayObject(GameplaySettings g)
+ => new(StringComparer.Ordinal)
+ {
+ ["advancedCombatUI"] = g.AdvancedCombatUI,
+ ["allowGive"] = g.AllowGive,
+ ["autoRepeatAttack"] = g.AutoRepeatAttack,
+ ["autoTarget"] = g.AutoTarget,
+ ["coordinatesOnRadar"] = g.CoordinatesOnRadar,
+ ["lockUI"] = g.LockUI,
+ ["showCloak"] = g.ShowCloak,
+ ["showHelm"] = g.ShowHelm,
+ ["showTooltips"] = g.ShowTooltips,
+ ["sideBySideVitals"] = g.SideBySideVitals,
+ ["spellDuration"] = g.SpellDuration,
+ ["toggleRun"] = g.ToggleRun,
+ ["useMouseTurning"] = g.UseMouseTurning,
+ ["vividTargetingIndicator"] = g.VividTargetingIndicator,
+ };
+
+ 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-target top-level keys from the existing file.
+ var preservedKeys = new SortedDictionary(StringComparer.Ordinal);
+ if (File.Exists(_path))
+ {
+ try
+ {
+ using var stream = File.OpenRead(_path);
+ var doc = JsonDocument.Parse(stream);
+ foreach (var prop in doc.RootElement.EnumerateObject())
+ {
+ if (prop.Name == sectionName || prop.Name == "version") continue;
+ preservedKeys[prop.Name] = prop.Value.GetRawText();
+ }
+ }
+ catch
+ {
+ // Corrupt file → fully overwrite; previous content is lost
+ // but the user's session continues with the new save.
+ preservedKeys.Clear();
+ }
+ }
+
+ var sb = new System.Text.StringBuilder();
+ sb.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();
+
+ File.WriteAllText(_path, sb.ToString());
+ }
+
+ private static string ReadString(JsonElement obj, string name, string fallback)
+ => obj.TryGetProperty(name, out var el) && el.ValueKind == JsonValueKind.String
+ ? (el.GetString() ?? fallback) : fallback;
+
+ private static bool ReadBool(JsonElement obj, string name, bool fallback)
+ => obj.TryGetProperty(name, out var el)
+ && (el.ValueKind == JsonValueKind.True || el.ValueKind == JsonValueKind.False)
+ ? el.GetBoolean() : fallback;
+
+ private static float ReadFloat(JsonElement obj, string name, float fallback)
+ => obj.TryGetProperty(name, out var el) && el.ValueKind == JsonValueKind.Number
+ ? el.GetSingle() : fallback;
+}
diff --git a/src/AcDream.UI.Abstractions/Panels/Settings/SettingsVM.cs b/src/AcDream.UI.Abstractions/Panels/Settings/SettingsVM.cs
index 5d33480..f4bcc36 100644
--- a/src/AcDream.UI.Abstractions/Panels/Settings/SettingsVM.cs
+++ b/src/AcDream.UI.Abstractions/Panels/Settings/SettingsVM.cs
@@ -30,6 +30,32 @@ public sealed class SettingsVM
private readonly InputDispatcher _dispatcher;
private readonly Action _onSave;
+ // L.0 — Display tab. Treated as a single immutable record; mutation
+ // through SetDisplay clones via with-expressions on the panel side.
+ private DisplaySettings _displayPersisted;
+ 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;
+
+ // L.0 — Gameplay tab (subset of retail CharacterOption flags).
+ private GameplaySettings _gameplayPersisted;
+ private GameplaySettings _gameplayDraft;
+ private readonly Action _onSaveGameplay;
+
+ // L.0 — Chat tab (CharacterOptions2 channel filters + visual prefs).
+ private ChatSettings _chatPersisted;
+ private ChatSettings _chatDraft;
+ private readonly Action _onSaveChat;
+
+ // L.0 — Character tab (per-toon, host-keyed by toon name).
+ private CharacterSettings _characterPersisted;
+ private CharacterSettings _characterDraft;
+ private readonly Action _onSaveCharacter;
+
/// The action currently being rebound, or null when idle.
public InputAction? RebindInProgress { get; private set; }
@@ -50,14 +76,139 @@ public sealed class SettingsVM
/// True iff the draft differs structurally from the
/// persisted snapshot. Used to grey out the Save button when no
/// rebinds are pending.
- public bool HasUnsavedChanges => !KeyBindingsEqual(_persisted, _draft);
+ public bool HasUnsavedChanges
+ => !KeyBindingsEqual(_persisted, _draft)
+ || _displayPersisted != _displayDraft
+ || _audioPersisted != _audioDraft
+ || _gameplayPersisted != _gameplayDraft
+ || _chatPersisted != _chatDraft
+ || _characterPersisted != _characterDraft;
- public SettingsVM(KeyBindings persisted, InputDispatcher dispatcher, Action onSave)
+ /// 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;
+
+ /// The current Gameplay draft. Panel reads from here;
+ /// mutation goes through .
+ public GameplaySettings GameplayDraft => _gameplayDraft;
+
+ /// The current Chat draft. Panel reads from here;
+ /// mutation goes through .
+ public ChatSettings ChatDraft => _chatDraft;
+
+ /// The current Character draft (per-toon — host owns the
+ /// toon-name key). Panel reads from here; mutation goes through
+ /// .
+ public CharacterSettings CharacterDraft => _characterDraft;
+
+ public SettingsVM(
+ KeyBindings persisted,
+ InputDispatcher dispatcher,
+ Action onSave,
+ DisplaySettings persistedDisplay,
+ Action onSaveDisplay,
+ AudioSettings persistedAudio,
+ Action onSaveAudio,
+ GameplaySettings persistedGameplay,
+ Action onSaveGameplay,
+ ChatSettings persistedChat,
+ Action onSaveChat,
+ CharacterSettings persistedCharacter,
+ Action onSaveCharacter)
{
- _persisted = persisted ?? throw new ArgumentNullException(nameof(persisted));
- _dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher));
- _onSave = onSave ?? throw new ArgumentNullException(nameof(onSave));
- _draft = CloneBindings(persisted);
+ _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));
+ _gameplayPersisted = persistedGameplay ?? throw new ArgumentNullException(nameof(persistedGameplay));
+ _onSaveGameplay = onSaveGameplay ?? throw new ArgumentNullException(nameof(onSaveGameplay));
+ _chatPersisted = persistedChat ?? throw new ArgumentNullException(nameof(persistedChat));
+ _onSaveChat = onSaveChat ?? throw new ArgumentNullException(nameof(onSaveChat));
+ _characterPersisted = persistedCharacter ?? throw new ArgumentNullException(nameof(persistedCharacter));
+ _onSaveCharacter = onSaveCharacter ?? throw new ArgumentNullException(nameof(onSaveCharacter));
+ _draft = CloneBindings(persisted);
+ _displayDraft = persistedDisplay;
+ _audioDraft = persistedAudio;
+ _gameplayDraft = persistedGameplay;
+ _chatDraft = persistedChat;
+ _characterDraft = persistedCharacter;
+ }
+
+ ///
+ /// Replace the entire Display draft with .
+ /// Panel calls this with a DisplayDraft with { Field = newValue }
+ /// so each widget edits exactly one field at a time.
+ ///
+ public void SetDisplay(DisplaySettings value)
+ {
+ _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));
+ }
+
+ ///
+ /// Replace the entire Gameplay draft with .
+ /// Local-only this phase — values persist on Save but don't yet
+ /// flow to the server. When server-sync ships, the host's
+ /// onSaveGameplay callback will marshal the draft into the
+ /// retail CharacterOption wire bitmask.
+ ///
+ public void SetGameplay(GameplaySettings value)
+ {
+ _gameplayDraft = value ?? throw new ArgumentNullException(nameof(value));
+ }
+
+ ///
+ /// Replace the entire Chat draft with .
+ /// Local-only this phase — values persist on Save but the Hear*Chat
+ /// flags affect client-side display filtering, not server-side
+ /// channel subscriptions.
+ ///
+ public void SetChat(ChatSettings value)
+ {
+ _chatDraft = value ?? throw new ArgumentNullException(nameof(value));
+ }
+
+ ///
+ /// Replace the entire Character draft with .
+ /// Per-toon — the host knows which toon's bag we're editing because
+ /// it owned the toonKey when constructing the VM.
+ ///
+ public void SetCharacter(CharacterSettings value)
+ {
+ _characterDraft = value ?? throw new ArgumentNullException(nameof(value));
+ }
+
+ ///
+ /// Replace BOTH the persisted snapshot and the live draft for the
+ /// Character bag. Used when the active toon changes (e.g. on
+ /// EnterWorld with a non-default character) — the host loads that
+ /// toon's settings from disk and pushes them into the VM here so
+ /// doesn't flag the swap as a
+ /// pending edit. Differs from , which
+ /// updates draft only.
+ ///
+ public void LoadCharacterContext(CharacterSettings persisted)
+ {
+ _characterPersisted = persisted ?? throw new ArgumentNullException(nameof(persisted));
+ _characterDraft = persisted;
}
///
@@ -160,32 +311,58 @@ public sealed class SettingsVM
}
///
- /// Replace the entire draft with .
+ /// Replace the keybinds draft with
+ /// AND the display draft with .
+ /// "Reset all" applies to every tab — it's the user's escape hatch
+ /// when they've gotten lost.
///
public void ResetAllToDefaults()
{
- _draft = KeyBindings.RetailDefaults();
+ _draft = KeyBindings.RetailDefaults();
+ _displayDraft = DisplaySettings.Default;
+ _audioDraft = AudioSettings.Default;
+ _gameplayDraft = GameplaySettings.Default;
+ _chatDraft = ChatSettings.Default;
+ _characterDraft = CharacterSettings.Default;
}
///
- /// Commit the draft via the onSave callback supplied at
- /// construction. After save the draft becomes the new persisted
- /// snapshot — resets to false.
+ /// Commit both keybinds + display drafts via the onSave callbacks
+ /// supplied at construction. After save the drafts become the new
+ /// persisted snapshots — resets to
+ /// false. Each callback is invoked exactly once per Save; if the
+ /// caller wants atomicity across both files it has to handle it
+ /// outside the VM.
///
public void Save()
{
_onSave(_draft);
- _persisted = CloneBindings(_draft);
+ _onSaveDisplay(_displayDraft);
+ _onSaveAudio(_audioDraft);
+ _onSaveGameplay(_gameplayDraft);
+ _onSaveChat(_chatDraft);
+ _onSaveCharacter(_characterDraft);
+ _persisted = CloneBindings(_draft);
+ _displayPersisted = _displayDraft;
+ _audioPersisted = _audioDraft;
+ _gameplayPersisted = _gameplayDraft;
+ _chatPersisted = _chatDraft;
+ _characterPersisted = _characterDraft;
}
///
- /// Revert the draft to the persisted snapshot and clear any
+ /// Revert all drafts to their persisted snapshots and clear any
/// in-flight rebind state. Used by the panel's "Cancel" button and
/// when the user closes the settings window without saving.
///
public void Cancel()
{
- _draft = CloneBindings(_persisted);
+ _draft = CloneBindings(_persisted);
+ _displayDraft = _displayPersisted;
+ _audioDraft = _audioPersisted;
+ _gameplayDraft = _gameplayPersisted;
+ _chatDraft = _chatPersisted;
+ _characterDraft = _characterPersisted;
CancelRebind();
}
diff --git a/src/AcDream.UI.ImGui/ImGuiPanelRenderer.cs b/src/AcDream.UI.ImGui/ImGuiPanelRenderer.cs
index ec00037..4aa94ae 100644
--- a/src/AcDream.UI.ImGui/ImGuiPanelRenderer.cs
+++ b/src/AcDream.UI.ImGui/ImGuiPanelRenderer.cs
@@ -155,12 +155,41 @@ public sealed class ImGuiPanelRenderer : IPanelRenderer
///
public bool BeginChild(string id, Vector2 size, bool border = false)
+ {
// ImGuiChildFlags has changed names across ImGui.NET versions
// (Border vs Borders); 0x01 is the stable bit value for "draw
// a border". Casting from a numeric literal sidesteps the
// version-skew without requiring a hard reference to either
// enum spelling.
- => ImGuiNET.ImGui.BeginChild(id, size, (ImGuiChildFlags)(border ? 0x01 : 0));
+ bool open = ImGuiNET.ImGui.BeginChild(id, size, (ImGuiChildFlags)(border ? 0x01 : 0));
+ if (open)
+ {
+ // Title-bar-only drag fix (chat tail specifically): empty
+ // clicks inside a scrollable child fall through to the
+ // parent window for drag-init, which is exactly what the
+ // user reported in the chat panel ("clicking anywhere
+ // moves the window"). An InvisibleButton sized to the
+ // child's content region absorbs those clicks so they
+ // don't propagate. Real widgets drawn afterwards still
+ // claim their own clicks (click priority = "last drawn,
+ // first checked"). Wheel scrolling is window-level, not
+ // item-level, so the absorber doesn't interfere with
+ // the chat tail's auto-scroll.
+ //
+ // Scoped to BeginChild only (NOT Begin) because Begin's
+ // body might host tab bars whose hit-testing competes with
+ // an absorber on equal terms — adding it at Begin level
+ // broke Settings tab clicks.
+ var avail = ImGuiNET.ImGui.GetContentRegionAvail();
+ if (avail.X > 0f && avail.Y > 0f)
+ {
+ var savedCursor = ImGuiNET.ImGui.GetCursorPos();
+ ImGuiNET.ImGui.InvisibleButton("##childbodyabsorb", avail);
+ ImGuiNET.ImGui.SetCursorPos(savedCursor);
+ }
+ }
+ return open;
+ }
///
public void EndChild() => ImGuiNET.ImGui.EndChild();
@@ -193,4 +222,35 @@ public sealed class ImGuiPanelRenderer : IPanelRenderer
=> shortcut is null
? ImGuiNET.ImGui.MenuItem(label)
: ImGuiNET.ImGui.MenuItem(label, shortcut);
+
+ // -- Tab bar -----------------------------------------------------------
+
+ ///
+ public bool BeginTabBar(string id) => ImGuiNET.ImGui.BeginTabBar(id);
+
+ ///
+ public void EndTabBar() => ImGuiNET.ImGui.EndTabBar();
+
+ ///
+ public bool BeginTabItem(string label) => ImGuiNET.ImGui.BeginTabItem(label);
+
+ ///
+ public void EndTabItem() => ImGuiNET.ImGui.EndTabItem();
+
+ // -- Selectable / copyable text ---------------------------------------
+
+ ///
+ public void TextMultilineReadOnly(string id, string content, Vector2 size)
+ {
+ // ImGui's InputTextMultiline takes a `ref string` even with the
+ // ReadOnly flag — we just hand it a local copy. maxLength caps
+ // what the user could type if ReadOnly were ever cleared; we
+ // size it to the current content (+1 for ImGui's internal NUL
+ // terminator in some bindings). Min of 1 keeps the empty case
+ // from confusing native bindings.
+ string buffer = content;
+ uint maxLen = (uint)System.Math.Max(content.Length + 1, 1);
+ ImGuiNET.ImGui.InputTextMultiline(id, ref buffer, maxLen, size,
+ ImGuiInputTextFlags.ReadOnly);
+ }
}
diff --git a/tests/AcDream.UI.Abstractions.Tests/FakePanelRenderer.cs b/tests/AcDream.UI.Abstractions.Tests/FakePanelRenderer.cs
index f1c8c4d..9df8b12 100644
--- a/tests/AcDream.UI.Abstractions.Tests/FakePanelRenderer.cs
+++ b/tests/AcDream.UI.Abstractions.Tests/FakePanelRenderer.cs
@@ -198,4 +198,40 @@ internal sealed class FakePanelRenderer : IPanelRenderer
Calls.Add(("MenuItem", new object?[] { label, shortcut }));
return MenuItemReturns;
}
+
+ // -- Tab bar -----------------------------------------------------------
+
+ /// Pre-set return for .
+ public bool TabBarReturns { get; set; } = true;
+
+ /// The label of the tab the next
+ /// call should report as "selected" (return true). All other tab
+ /// items return false. Defaults to null = the FIRST tab item rendered
+ /// is the selected one.
+ public string? ActiveTabLabel { get; set; }
+
+ private string? _firstTabSeen;
+
+ public bool BeginTabBar(string id)
+ {
+ Calls.Add(("BeginTabBar", new object?[] { id }));
+ _firstTabSeen = null;
+ return TabBarReturns;
+ }
+
+ public void EndTabBar() => Calls.Add(("EndTabBar", Array.Empty
public sealed class SettingsVMTests
{
- private static (SettingsVM vm, FakeKeyboardSource kb, InputDispatcher dispatcher, KeyBindings persisted, System.Collections.Generic.List savedHistory)
- Build(KeyBindings? persisted = 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, System.Collections.Generic.List savedGameplayHistory, System.Collections.Generic.List savedChatHistory, System.Collections.Generic.List savedCharacterHistory)
+ Build(KeyBindings? persisted = null, DisplaySettings? persistedDisplay = null, AudioSettings? persistedAudio = null, GameplaySettings? persistedGameplay = null, ChatSettings? persistedChat = null, CharacterSettings? persistedCharacter = null)
{
persisted ??= MakeMinimalBindings();
var kb = new FakeKeyboardSource();
var mouse = new FakeMouseSource();
var dispatcher = new InputDispatcher(kb, mouse, persisted);
var savedHistory = new System.Collections.Generic.List();
- var vm = new SettingsVM(persisted, dispatcher, b => savedHistory.Add(b));
- return (vm, kb, dispatcher, persisted, savedHistory);
+ var savedDisplayHistory = new System.Collections.Generic.List();
+ var savedAudioHistory = new System.Collections.Generic.List();
+ var savedGameplayHistory = new System.Collections.Generic.List();
+ var savedChatHistory = new System.Collections.Generic.List();
+ var savedCharacterHistory = new System.Collections.Generic.List();
+ var vm = new SettingsVM(
+ persisted, dispatcher,
+ b => savedHistory.Add(b),
+ persistedDisplay ?? DisplaySettings.Default,
+ d => savedDisplayHistory.Add(d),
+ persistedAudio ?? AudioSettings.Default,
+ a => savedAudioHistory.Add(a),
+ persistedGameplay ?? GameplaySettings.Default,
+ g => savedGameplayHistory.Add(g),
+ persistedChat ?? ChatSettings.Default,
+ c => savedChatHistory.Add(c),
+ persistedCharacter ?? CharacterSettings.Default,
+ ch => savedCharacterHistory.Add(ch));
+ return (vm, kb, dispatcher, persisted, savedHistory, savedDisplayHistory, savedAudioHistory, savedGameplayHistory, savedChatHistory, savedCharacterHistory);
}
private static KeyBindings MakeMinimalBindings()
@@ -40,7 +57,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);
}
@@ -48,7 +65,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);
@@ -61,7 +78,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);
@@ -79,7 +96,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);
@@ -96,7 +113,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).
@@ -116,7 +133,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);
@@ -137,7 +154,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);
@@ -159,7 +176,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);
@@ -179,7 +196,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).
@@ -190,7 +207,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);
@@ -206,7 +223,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);
@@ -222,7 +239,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);
@@ -235,7 +252,373 @@ public sealed class SettingsVMTests
[Fact]
public void HasUnsavedChanges_false_initially_and_after_save_sync()
{
- var (vm, _, _, _, _) = Build();
+ var (vm, _, _, _, _, _, _, _, _, _) = Build();
+ Assert.False(vm.HasUnsavedChanges);
+ }
+
+ // -- Display tab state ------------------------------------------------
+
+ [Fact]
+ public void DisplayDraft_initial_value_matches_persisted()
+ {
+ var custom = DisplaySettings.Default with { FieldOfView = 90f, ShowFps = true };
+ var (vm, _, _, _, _, _, _, _, _, _) = Build(persistedDisplay: custom);
+ Assert.Equal(custom, vm.DisplayDraft);
+ Assert.False(vm.HasUnsavedChanges);
+ }
+
+ [Fact]
+ public void SetDisplay_marks_unsaved_changes()
+ {
+ var (vm, _, _, _, _, _, _, _, _, _) = Build();
+ // Default ShowFps is true → flip to false to ensure the with-
+ // expression actually mutates a field.
+ vm.SetDisplay(vm.DisplayDraft with { ShowFps = false });
+ Assert.True(vm.HasUnsavedChanges);
+ }
+
+ [Fact]
+ public void Save_invokes_display_callback_with_draft()
+ {
+ var (vm, _, _, _, _, savedDisplayHistory, _, _, _, _) = Build();
+ vm.SetDisplay(vm.DisplayDraft with { Resolution = "2560x1440", FieldOfView = 100f });
+
+ vm.Save();
+
+ Assert.Single(savedDisplayHistory);
+ Assert.Equal("2560x1440", savedDisplayHistory[0].Resolution);
+ Assert.Equal(100f, savedDisplayHistory[0].FieldOfView);
+ Assert.False(vm.HasUnsavedChanges);
+ }
+
+ [Fact]
+ public void Cancel_reverts_display_draft_to_persisted()
+ {
+ var custom = DisplaySettings.Default with { FieldOfView = 90f };
+ var (vm, _, _, _, _, _, _, _, _, _) = Build(persistedDisplay: custom);
+ vm.SetDisplay(vm.DisplayDraft with { FieldOfView = 30f, ShowFps = true });
+ Assert.True(vm.HasUnsavedChanges);
+
+ vm.Cancel();
+
+ Assert.Equal(custom, vm.DisplayDraft);
+ Assert.False(vm.HasUnsavedChanges);
+ }
+
+ [Fact]
+ public void ResetAllToDefaults_resets_display_to_default()
+ {
+ var custom = DisplaySettings.Default with { FieldOfView = 30f, ShowFps = true };
+ var (vm, _, _, _, _, _, _, _, _, _) = Build(persistedDisplay: custom);
+ Assert.NotEqual(DisplaySettings.Default, vm.DisplayDraft);
+
+ vm.ResetAllToDefaults();
+
+ Assert.Equal(DisplaySettings.Default, vm.DisplayDraft);
+ Assert.True(vm.HasUnsavedChanges);
+ }
+
+ [Fact]
+ public void Save_then_Cancel_does_not_revert()
+ {
+ // 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();
+ vm.SetDisplay(vm.DisplayDraft with { ShowFps = true });
+ vm.Save();
+ Assert.False(vm.HasUnsavedChanges);
+
+ vm.Cancel();
+
+ 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);
+ }
+
+ // -- Gameplay tab state -----------------------------------------------
+
+ [Fact]
+ public void GameplayDraft_initial_value_matches_persisted()
+ {
+ var custom = GameplaySettings.Default with { AutoTarget = false, LockUI = true };
+ var (vm, _, _, _, _, _, _, _, _, _) = Build(persistedGameplay: custom);
+ Assert.Equal(custom, vm.GameplayDraft);
+ Assert.False(vm.HasUnsavedChanges);
+ }
+
+ [Fact]
+ public void SetGameplay_marks_unsaved_changes()
+ {
+ var (vm, _, _, _, _, _, _, _, _, _) = Build();
+ vm.SetGameplay(vm.GameplayDraft with { LockUI = true });
+ Assert.True(vm.HasUnsavedChanges);
+ }
+
+ [Fact]
+ public void Save_invokes_gameplay_callback_with_draft()
+ {
+ var (vm, _, _, _, _, _, _, savedGameplayHistory, _, _) = Build();
+ vm.SetGameplay(vm.GameplayDraft with
+ {
+ AutoTarget = false,
+ ShowTooltips = false,
+ UseMouseTurning = true,
+ });
+
+ vm.Save();
+
+ Assert.Single(savedGameplayHistory);
+ Assert.False(savedGameplayHistory[0].AutoTarget);
+ Assert.False(savedGameplayHistory[0].ShowTooltips);
+ Assert.True(savedGameplayHistory[0].UseMouseTurning);
+ Assert.False(vm.HasUnsavedChanges);
+ }
+
+ [Fact]
+ public void Cancel_reverts_gameplay_draft_to_persisted()
+ {
+ var custom = GameplaySettings.Default with { LockUI = true };
+ var (vm, _, _, _, _, _, _, _, _, _) = Build(persistedGameplay: custom);
+ vm.SetGameplay(vm.GameplayDraft with { LockUI = false, ShowHelm = false });
+ Assert.True(vm.HasUnsavedChanges);
+
+ vm.Cancel();
+
+ Assert.Equal(custom, vm.GameplayDraft);
+ Assert.False(vm.HasUnsavedChanges);
+ }
+
+ [Fact]
+ public void ResetAllToDefaults_resets_gameplay_to_default()
+ {
+ var custom = GameplaySettings.Default with { AutoTarget = false, LockUI = true };
+ var (vm, _, _, _, _, _, _, _, _, _) = Build(persistedGameplay: custom);
+ Assert.NotEqual(GameplaySettings.Default, vm.GameplayDraft);
+
+ vm.ResetAllToDefaults();
+
+ Assert.Equal(GameplaySettings.Default, vm.GameplayDraft);
+ Assert.True(vm.HasUnsavedChanges);
+ }
+
+ // -- Chat tab state ---------------------------------------------------
+
+ [Fact]
+ public void ChatDraft_initial_value_matches_persisted()
+ {
+ var custom = ChatSettings.Default with { HearTradeChat = false, FontSize = 14f };
+ var (vm, _, _, _, _, _, _, _, _, _) = Build(persistedChat: custom);
+ Assert.Equal(custom, vm.ChatDraft);
+ Assert.False(vm.HasUnsavedChanges);
+ }
+
+ [Fact]
+ public void SetChat_marks_unsaved_changes()
+ {
+ var (vm, _, _, _, _, _, _, _, _, _) = Build();
+ vm.SetChat(vm.ChatDraft with { FontSize = 16f });
+ Assert.True(vm.HasUnsavedChanges);
+ }
+
+ [Fact]
+ public void Save_invokes_chat_callback_with_draft()
+ {
+ var (vm, _, _, _, _, _, _, _, savedChatHistory, _) = Build();
+ vm.SetChat(vm.ChatDraft with { HearTradeChat = false, ShowTimestamps = false });
+
+ vm.Save();
+
+ Assert.Single(savedChatHistory);
+ Assert.False(savedChatHistory[0].HearTradeChat);
+ Assert.False(savedChatHistory[0].ShowTimestamps);
+ Assert.False(vm.HasUnsavedChanges);
+ }
+
+ [Fact]
+ public void Cancel_reverts_chat_draft_to_persisted()
+ {
+ var custom = ChatSettings.Default with { HearLFGChat = false };
+ var (vm, _, _, _, _, _, _, _, _, _) = Build(persistedChat: custom);
+ vm.SetChat(vm.ChatDraft with { HearLFGChat = true, AppearOffline = true });
+ Assert.True(vm.HasUnsavedChanges);
+
+ vm.Cancel();
+
+ Assert.Equal(custom, vm.ChatDraft);
+ Assert.False(vm.HasUnsavedChanges);
+ }
+
+ [Fact]
+ public void ResetAllToDefaults_resets_chat_to_default()
+ {
+ var custom = ChatSettings.Default with { HearGeneralChat = false, FontSize = 18f };
+ var (vm, _, _, _, _, _, _, _, _, _) = Build(persistedChat: custom);
+ Assert.NotEqual(ChatSettings.Default, vm.ChatDraft);
+
+ vm.ResetAllToDefaults();
+
+ Assert.Equal(ChatSettings.Default, vm.ChatDraft);
+ Assert.True(vm.HasUnsavedChanges);
+ }
+
+ // -- Character tab state ----------------------------------------------
+
+ [Fact]
+ public void CharacterDraft_initial_value_matches_persisted()
+ {
+ var custom = CharacterSettings.Default with { AutoAttack = true, DefaultChatChannel = "Allegiance" };
+ var (vm, _, _, _, _, _, _, _, _, _) = Build(persistedCharacter: custom);
+ Assert.Equal(custom, vm.CharacterDraft);
+ Assert.False(vm.HasUnsavedChanges);
+ }
+
+ [Fact]
+ public void SetCharacter_marks_unsaved_changes()
+ {
+ var (vm, _, _, _, _, _, _, _, _, _) = Build();
+ vm.SetCharacter(vm.CharacterDraft with { AutoAttack = true });
+ Assert.True(vm.HasUnsavedChanges);
+ }
+
+ [Fact]
+ public void Save_invokes_character_callback_with_draft()
+ {
+ var (vm, _, _, _, _, _, _, _, _, savedCharacterHistory) = Build();
+ vm.SetCharacter(vm.CharacterDraft with
+ {
+ DefaultChatChannel = "Fellowship",
+ AutoAttack = true,
+ ConfirmSalvage = false,
+ });
+
+ vm.Save();
+
+ Assert.Single(savedCharacterHistory);
+ Assert.Equal("Fellowship", savedCharacterHistory[0].DefaultChatChannel);
+ Assert.True(savedCharacterHistory[0].AutoAttack);
+ Assert.False(savedCharacterHistory[0].ConfirmSalvage);
+ Assert.False(vm.HasUnsavedChanges);
+ }
+
+ [Fact]
+ public void Cancel_reverts_character_draft_to_persisted()
+ {
+ var custom = CharacterSettings.Default with { AutoAttack = true };
+ var (vm, _, _, _, _, _, _, _, _, _) = Build(persistedCharacter: custom);
+ vm.SetCharacter(vm.CharacterDraft with { AutoAttack = false, DefaultChatChannel = "Trade" });
+ Assert.True(vm.HasUnsavedChanges);
+
+ vm.Cancel();
+
+ Assert.Equal(custom, vm.CharacterDraft);
+ Assert.False(vm.HasUnsavedChanges);
+ }
+
+ [Fact]
+ public void ResetAllToDefaults_resets_character_to_default()
+ {
+ var custom = CharacterSettings.Default with { AutoAttack = true, DefaultChatChannel = "Trade" };
+ var (vm, _, _, _, _, _, _, _, _, _) = Build(persistedCharacter: custom);
+ Assert.NotEqual(CharacterSettings.Default, vm.CharacterDraft);
+
+ vm.ResetAllToDefaults();
+
+ Assert.Equal(CharacterSettings.Default, vm.CharacterDraft);
+ Assert.True(vm.HasUnsavedChanges);
+ }
+
+ [Fact]
+ public void LoadCharacterContext_swaps_persisted_and_draft_atomically()
+ {
+ // Simulates the post-EnterWorld toon swap — host loads the
+ // chosen toon's bag from disk and pushes it via
+ // LoadCharacterContext. BOTH persisted and draft must update
+ // so HasUnsavedChanges stays false; otherwise the user would
+ // see a "pending changes" indicator on every login.
+ var (vm, _, _, _, _, _, _, _, _, _) = Build();
+ var newToonBag = CharacterSettings.Default with { DefaultChatChannel = "Allegiance", AutoAttack = true };
+
+ vm.LoadCharacterContext(newToonBag);
+
+ Assert.Equal(newToonBag, vm.CharacterDraft);
+ Assert.False(vm.HasUnsavedChanges);
+ }
+
+ [Fact]
+ public void LoadCharacterContext_clears_pending_unsaved_character_edits()
+ {
+ // If the user had pending character edits from the previous
+ // toon (or pre-login session), swapping to a new toon's bag
+ // must wipe them — Save is per-toon, and bleed-through would
+ // write the pre-login bag's edits to the new toon's slot.
+ var (vm, _, _, _, _, _, _, _, _, _) = Build();
+ vm.SetCharacter(vm.CharacterDraft with { AutoAttack = true });
+ Assert.True(vm.HasUnsavedChanges);
+
+ vm.LoadCharacterContext(CharacterSettings.Default with { DefaultChatChannel = "Fellowship" });
+
+ Assert.Equal("Fellowship", vm.CharacterDraft.DefaultChatChannel);
+ Assert.False(vm.CharacterDraft.AutoAttack);
Assert.False(vm.HasUnsavedChanges);
}
}