Compare commits

..

15 commits

Author SHA1 Message Date
Erik
a2e0bb5e2f Merge branch 'feature/settings-retail' — Phase L.0 Settings interface
Lands the full retail-style Settings interface developed in the
.worktrees/settings-retail worktree. 17 commits delivering:

== Phase L.0 — Settings interface ==

 7665cdf  tabbed Settings shell + IPanelRenderer tab API extension
 382f0ad  Display tab + settings.json persistence layer
 53b1878  Audio tab + live volume sliders driving OpenAL engine
 b7165e5  Gameplay tab — 14 retail CharacterOption-derived toggles
 356b5f2  Chat tab — channel filters + display prefs + font slider
 73749d1  Character tab — per-toon settings; Phase L.0 complete
 fc1e193  wire Display GL knobs + per-toon Character key
 4c75ced  chat Copy mode — read-only multi-line for select + Ctrl+C

== Drag-fix iteration ==

 6273255  first attempt at title-bar-only drag (Begin-level absorber)
 2818fcc  scope drag absorber to BeginChild (fixed Settings tabs)
 df9f2fd  wrap chat panel body in outer BeginChild (fixed chat drag)

== Pre-merge code review fixes ==

 944a036  rescue commit — orphaned FramebufferResize + ResetPanelLayout
          (working-tree changes that never got committed in the cwd
          shenanigans during earlier iteration)
 a37ebde  apply persisted Display + Audio settings without devtools gate
          (settings are runtime state, not devtools state); hide Music
          + Ambient sliders that were inert (R5 MIDI not shipped)
 23aa017  docs/plans/roadmap shipped table updated for K + L.0

== Net delivered ==

 · 6-tab F11 Settings panel: Keybinds (existing) + Display + Audio
   + Gameplay + Chat + Character
 · settings.json at %LOCALAPPDATA%\acdream\ — five sections coexist
   non-destructively, per-toon Character keying
 · Display: Resolution / Fullscreen / VSync / FOV / ShowFps live-wired
   to Silk.NET window + camera FovY + title-bar perf string
 · Audio: Master + SFX volume live-driving OpenAL engine
 · Gameplay/Chat/Character: persist for forthcoming server-sync wiring
 · Chat panel Copy mode (Ctrl+C selectable text)
 · Title-bar-only window drag (BeginChild absorber)
 · FramebufferResize handler — GL viewport + camera aspect + panel
   layout stay in sync on window resize
 · "Reset window layout" View menu item
 · IPanelRenderer extensions: tab API + TextMultilineReadOnly

dotnet build green (0 warnings); dotnet test 1,309 / 1,309 green
(243 Core.Net + 393 UI.Abstractions + 673 Core; +87 net new tests
since fork).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 06:25:06 +02:00
Erik
23aa01738f docs(roadmap): mark Phase K + Phase L.0 shipped
K shipped previously (commit f42c164) but never got a row in the
"Phases already shipped" table — only the per-sub-piece K.3 callout
in the Phase K section. Adding the K row here for completeness.

L.0 — full retail-style Settings interface — shipped this session.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 06:24:24 +02:00
Erik
a37ebdebff fix(ui): pre-merge code review — apply persisted settings without devtools, hide inert sliders
Two should-fix items from the pre-merge code review pass:

1. Persisted settings now apply on startup unconditionally
   (previously gated on ACDREAM_DEVTOOLS=1).
2. Music + Ambient volume sliders are hidden because the
   underlying engine paths don't exist yet (R5 MIDI playback).

== 1. Settings load + apply outside DevToolsEnabled gate ==

Previous structure put SettingsStore construction, LoadDisplay /
LoadAudio / etc, and ApplyDisplayWindowState inside the
`if (DevToolsEnabled)` block. A user running with the env var unset
silently got WindowOptions defaults (1280x720 / VSync=false /
60° FOV) instead of their saved settings.json values — even though
the settings file existed and was valid.

Refactored: extracted LoadAndApplyPersistedSettings() that runs
unconditionally in OnLoad after _audioEngine is constructed but
before the DevToolsEnabled block. Persisted values cached as
_persistedDisplay / _persistedAudio / _persistedGameplay /
_persistedChat / _persistedCharacter fields. The Settings PANEL
construction (devtools-gated, naturally — no UI without ImGui) now
reads those fields when wiring SettingsVM.

The Settings UI gating is correct (panel needs ImGui devtools);
the persisted-runtime-state gating was the bug.

== 2. Music + Ambient sliders hidden ==

OpenAlAudioEngine has Music/MusicVolume/Ambient/AmbientVolume
properties but they're never read — PlayMusic is a stub for R5 MIDI
playback that hasn't shipped, StartAmbient reserves a handle but
doesn't start a source. Dragging those sliders moved a number that
nothing observed.

Hid the Music + Ambient sliders from RenderAudioTab; left the
AudioSettings record fields intact so settings.json round-trips
the values across phases — when R5 lands and the sliders return,
saved values will already be in place. Updated the panel's footer
note to call out the limitation. Updated
Audio_tab_when_active_renders_implemented_volume_sliders to assert
Master + SFX are present AND Music + Ambient are absent.

dotnet build green; dotnet test 1,309 / 1,309 green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 06:22:35 +02:00
Erik
944a0364c5 fix(ui): commit FramebufferResize + ResetPanelLayout — orphaned during earlier cwd/sed shenanigans
These changes were referenced by commits fc1e193 / 6273255 / 2818fcc /
df9f2fd in their messages but the actual edits sat uncommitted in the
working tree — caught by the pre-merge code review pass. Without this
commit the merge to main would lose all the panel-layout fixes the
user already live-verified.

What was orphaned:

 · _window.FramebufferResize += OnFramebufferResize  (Run() wiring)
 · OnFramebufferResize handler — updates GL viewport + camera aspect
   on window resize; force-resets panel layout via ResetPanelLayout.
 · ResetPanelLayout(ImGuiCond) — positions Vitals / Chat / Debug /
   Settings panels at sensible defaults relative to current window
   size. Called at startup with FirstUseEver (imgui.ini wins on later
   launches) and on FramebufferResize / View menu item with Always
   (force reset).
 · View → "Reset window layout" menu item.
 · OnLoad seeding ResetPanelLayout(FirstUseEver) after panel
   registration so first-launch users don't see all panels stacked
   at (0,0).
 · DisplaySettings.Default.Resolution: "1920x1080" → "1280x720" so
   the default matches the WindowOptions startup size — opening
   Display + Save without edits is a complete visual no-op (the
   alternative would have triggered an immediate resize on every
   first-time Save).

dotnet build green; tests unaffected.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 06:19:12 +02:00
Erik
df9f2fd3da fix(ui): wrap chat panel body in outer BeginChild so drag-trap covers it
The InvisibleButton drag-trap inside BeginChild only catches clicks
inside that specific child. Chat had widgets OUTSIDE the inner
##chattail child (the Copy-mode Checkbox + a Separator at top, the
footer Separator + InputTextSubmit at bottom) — empty space around
those widgets fell through directly to the parent window's
window-drag init.

Fix: wrap the entire chat panel body in a single outer ##chatbody
BeginChild before drawing any content. The renderer's drag-trap
fires inside this outer child too, absorbing every empty-space
click in the chat panel body. The inner ##chattail child is now
nested inside it, which doesn't change its scroll-tail semantics
but does mean it gets its own drag-trap as a bonus.

Test fixed: Render_BeginChild_ReservesNegativeFooterFromFrameHeight
was using Single(BeginChild) — there are now two BeginChild calls
(##chatbody outer + ##chattail inner). Switched to Single(... &&
Args[0] == "##chattail") so the test still pins the footer reserve
on the inner call where it lives.

dotnet build green; 1,309 / 1,309 tests green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 23:10:01 +02:00
Erik
2818fcca8c fix(ui): scope title-bar-only-drag absorber to BeginChild — Settings tabs work
Previous fix put the InvisibleButton absorber inside Begin, which
covered the entire panel body — and the Settings panel's tab bar
has its hit-testing in that same area. Tabs lost click priority to
the absorber (their hover/click events were stolen) so the user
couldn't switch tabs. Worse, the chat-panel drag the absorber was
supposed to fix wasn't actually fixed because chat's body is
covered by a BeginChild for the scrollable tail — clicks land in
the child window, not the parent body, so the parent absorber
never sees them.

Right scope: scrollable BeginChild bodies. That's where the chat
panel's empty-space clicks actually land, and where the parent-
drag fall-through originates. Other panels (Settings, Vitals,
Debug) don't use BeginChild for content — their bodies are filled
with widgets that already absorb clicks naturally.

The fix:
 · Begin reverts to ImGui default (title bar drags, body of widget-
   filled panels naturally absorbs through the widgets themselves).
 · BeginChild grows the InvisibleButton absorber inside, so empty-
   space clicks inside a scroll region don't fall through to the
   parent's window-drag init.

Net effect:
 · Chat panel: empty clicks in the scroll tail no longer drag the
   parent window.
 · Settings panel: tabs are clickable again.
 · Vitals, Debug: unchanged.

dotnet build green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 23:04:10 +02:00
Erik
627325559c fix(ui): title-bar-only drag — absorb body clicks via InvisibleButton
User reported that clicking anywhere in a panel (chat, settings, etc)
started a window drag. ImGui's default window-drag init fires on any
body click that doesn't land on an "active" widget — empty space
between Text widgets, BeginChild background pad, etc. all qualified.

Fix: right after Begin, place an InvisibleButton sized to the full
body content region, then reset the cursor so subsequent panel
content renders normally. ImGui's click-priority is "last drawn,
first checked" — so real widgets drawn afterwards still claim their
own clicks. The InvisibleButton catches ONLY clicks on empty body
space, marks itself as the active item, and ImGui's window-drag
check sees ActiveId != 0 → no drag.

Net effect: title bar still drags (ImGui default), body never
drags. Applies uniformly to every panel that calls
IPanelRenderer.Begin (chat / settings / vitals / debug).

dotnet build green (0 warnings).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 22:58:05 +02:00
Erik
4c75ced92b feat(ui): chat Copy mode — select + Ctrl+C any text in the chat tail
User reported wanting to mark text in-game and copy it out (item names,
coordinates, NPC dialogue, etc). ImGui doesn't natively let you select
across multiple TextColored widgets, but a read-only multi-line
InputText is fully click-drag selectable + Ctrl+C copyable. This
commit adds a "Copy mode" toggle to ChatPanel that swaps the chat
tail's render path between the colored-line view and a single
selectable text region.

New IPanelRenderer primitive:

  void TextMultilineReadOnly(string id, string content, Vector2 size);

ImGui maps this to InputTextMultiline with the ReadOnly flag — same
selection + Ctrl+C UX a user expects from any text-input widget.
FakePanelRenderer records the call for tests. The future D.2b
custom retail-look backend implements its own equivalent (likely
the same widget pattern with retail font/skin).

ChatPanel rendering:

  · A "Copy mode (select text to Ctrl+C)" Checkbox at the top of
    the panel toggles _copyMode.
  · Off (default) — current per-line render with colored combat
    entries. Visually unchanged from before.
  · On — the chat tail becomes a single TextMultilineReadOnly
    widget holding every visible line joined with newlines. Loses
    per-line color, gains arbitrary-span text selection.
  · Footer (separator + input field) renders identically in both
    modes so the user can still type while in copy mode.

Existing ChatPanelLayoutTests's footer-separator probe was using
IndexOf("Separator") — which now matches the new pre-tail separator
between the Checkbox and the chat tail. Switched to LastIndexOf
which still pins the footer separator (between EndChild and
InputTextSubmit). Behaviour and intent unchanged.

DisplaySettingsTests' With_expression test was still asserting the
old "1920x1080" Default.Resolution; updated to the new "1280x720"
that the previous wire-up commit introduced (the earlier commit
forgot this one).

dotnet build green (0 warnings); dotnet test 1,309 / 1,309 green
(243 Core.Net + 393 UI.Abstractions + 673 Core).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 21:45:39 +02:00
Erik
fc1e1933aa feat(ui): wire Display GL knobs + per-toon Character key — Settings goes live
Phase L.0 polish — the Display + Character tabs were persisting to disk
but didn't yet drive runtime behavior. This commit flips the live
switches.

DISPLAY ↔ GL window:

 · FOV slider (degrees) → camera FovY (radians) on Orbit + Fly + Chase,
   pushed every frame so dragging is visible immediately. Brainstorm
   said FOV is a live-preview slider; this delivers it.
 · VSync → _window.VSync, change-detected per-frame so flipping the
   checkbox is instant. Applied at startup too so saved-VSync takes
   effect before the first frame.
 · Resolution → _window.Size on Save (TryParseResolution parses
   "WIDTHxHEIGHT"). Live preview would be too jarring; resize is on
   Save only.
 · Fullscreen → _window.WindowState (Silk.NET borderless mode), also
   on Save only.
 · ShowFps → wraps the title-bar perf string. true → full perf line;
   false → just "acdream" for a cleaner alt-tab. Default true matches
   pre-L.0 behavior.

Defaults rebalanced — FieldOfView 75→60° (matches Orbit/Fly/Chase
FovY = π/3), VSync true→false (matches the previous WindowOptions),
ShowFps false→true (preserves the existing perf-in-title behavior).
Net effect: a user who never opens Display tab + later opens it +
Saves without touching anything sees ZERO visual change. Tests pinned
to the new defaults.

ApplyDisplayWindowState helper consolidates the window-side
mutations. Called from the SettingsVM construction site (apply
persisted at startup) and from the onSaveDisplay callback (apply
saved on demand). Malformed resolution strings are silently ignored
to avoid crashing mid-session if settings.json gets hand-edited.

CHARACTER ↔ active toon:

 · _activeToonKey field replaces the hard-coded "default" — starts as
   "default" (used for any pre-login Settings interaction), gets
   swapped to the actual character.Name immediately after EnterWorld
   in BeginLiveSessionAsync.
 · onSaveCharacter callback closes over _activeToonKey by reference
   (lambda captures `this`), so saves always write to the current
   toon's slot without rebinding the lambda.
 · After EnterWorld lands the chosen toon's name, the host loads
   that toon's bag via SettingsStore.LoadCharacter and calls a new
   SettingsVM.LoadCharacterContext to swap BOTH persisted snapshot
   AND draft atomically — HasUnsavedChanges stays false on login so
   the user doesn't see a "pending changes" indicator just because
   they switched toons.

Per-toon storage already worked at the SettingsStore layer (commit
73749d1); this commit just plumbs the actual character name through
to the toonKey instead of always using "default".

2 new tests for LoadCharacterContext: atomic persisted+draft swap,
and pending edits getting wiped on swap (so pre-login bleed-through
can't write to the new toon's slot).

dotnet build green (0 warnings); dotnet test 1,309 / 1,309 green
(243 Core.Net + 393 UI.Abstractions + 673 Core).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 21:18:07 +02:00
Erik
73749d176a feat(ui): Character tab — per-toon settings; Phase L.0 complete
Phase L.0 (final) — last tab on the Settings shell. Per-toon
preferences keyed by toon name in settings.json under
character[<toonName>]. With this commit the L.0 build order
finishes and every approved tab is implemented.

CharacterSettings record (4 fields):
 · DefaultChatChannel (string — Local / Allegiance / Fellowship / etc)
 · AutoAttack (bool — continue swinging until target dies)
 · ConfirmSalvage (bool — prompt before salvaging valuable items)
 · ShowPickupMessages (bool — pickup lines in chat)

AvailableChannels static list exposes the 7 retail-routing targets
for the dropdown.

SettingsStore grows LoadCharacter(toonKey) / SaveCharacter(toonKey)
using JsonNode/JsonObject for the nested-toon write — the existing
SaveSection raw-text-preservation pattern handles top-level keys
but doesn't fit the nested per-toon mutation. The character map
preserves every other toon's settings on save, and other top-level
sections (display / audio / gameplay / chat) are preserved too.

SettingsVM grows the parallel character state machine. The host
owns the toonKey (currently hard-coded to "default" in GameWindow
because we don't have a current-character source plumbed yet) —
the VM just edits whatever bag the host loaded.

SettingsPanel.RenderCharacterTab replaces the L.0-shell placeholder
— a Combo for default chat channel + 3 Checkboxes for
AutoAttack / ConfirmSalvage / ShowPickupMessages. The
RenderPlaceholder helper is now removed (no callers); the old
"Placeholder_tabs_render_coming_soon_text_when_active" test is
replaced by an "all six tabs are implemented" guard test that
fails if any future commit adds a placeholder back.

GameWindow loads/saves character settings under toonKey "default"
with a TODO comment to swap in the real toon name once
CharacterList plumbing exposes a currentCharacter source.

18 new tests:
 · CharacterSettings record (4) — defaults pinned, AvailableChannels
   list shape, value equality, with-expressions
 · SettingsStore character (6) — missing-file / toon-not-in-file →
   defaults, round-trip, multi-toon preservation, preserves other
   top-level sections, all five sections coexist
 · SettingsVM character (5) — initial draft, SetCharacter marks
   dirty, Save invokes callback, Cancel reverts, ResetAllToDefaults
   covers
 · SettingsPanel character tab (3 net, after removing the
   placeholder test) — combo+checkboxes render only when active,
   channel combo uses AvailableChannels, all six tabs are now
   non-placeholder

Phase L.0 final tally:
 · 5 commits on feature/settings-retail (shell + 5 tabs)
 · 6 tabs: Keybinds (Phase K) + Display + Audio + Gameplay + Chat + Character
 · 5 settings sections in settings.json (display/audio/gameplay/chat/character),
   coexisting non-destructively + a sixth file (keybinds.json) on the side.

dotnet build green (0 warnings); dotnet test 1,307 / 1,307 green
(243 Core.Net + 391 UI.Abstractions + 673 Core).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 18:27:07 +02:00
Erik
356b5f219e feat(ui): Chat tab — channel filters + display prefs + font slider
Phase L.0 (cont.) — fourth tab on the Settings shell. Mixes retail's
CharacterOptions2 chat-channel filter bits (Hear*Chat / TimeStamp /
FilterLanguage / AppearOffline) with a font-size slider that has no
retail bitfield equivalent.

ChatSettings record (9 fields):
 · 5 channel filters: HearGeneralChat, HearTradeChat, HearLFGChat,
   HearRoleplayChat, HearSocietyChat
 · 3 display flags: ShowTimestamps, FilterProfanity, AppearOffline
 · 1 visual: FontSize (10..20 pt)

Local-only this phase per the brainstorm — Hear*Chat flags affect
client-side display filtering only; the server still streams every
channel. Server-sync arrives later when the protocol round-trip is
in place.

SettingsStore grows LoadChat / SaveChat using the existing generic
SaveSection helper. All four non-keybind sections (display, audio,
gameplay, chat) now coexist non-destructively in settings.json.

SettingsVM grows the parallel chat state machine. HasUnsavedChanges,
Save, Cancel, ResetAllToDefaults all cover chat. Constructor signature
adds two more params; existing call sites updated.

SettingsPanel.RenderChatTab replaces the L.0-shell placeholder —
8 Checkbox calls grouped under "Channel filters" + "Display"
headers, plus a font-size SliderFloat. The "Coming soon" placeholder
test was retargeted from "Chat" to "Character" since Chat is no
longer a placeholder.

GameWindow wires SettingsStore.LoadChat / SaveChat + a TODO comment
for the future ChatPanel filter integration (read SettingsVM.ChatDraft
when filtering inbound chat lines).

13 new tests:
 · ChatSettings record (3) — defaults pinned, value equality, with-
   expressions
 · SettingsStore chat (3) — missing-file → defaults, round-trip, all
   four sections coexist
 · SettingsVM chat (5) — initial draft, SetChat marks dirty, Save
   invokes callback, Cancel reverts, ResetAllToDefaults covers
 · SettingsPanel chat tab (2) — checkboxes + slider render only when
   active

dotnet build green (0 warnings); dotnet test 1,289 / 1,289 green
(243 Core.Net + 373 UI.Abstractions + 673 Core).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 18:21:14 +02:00
Erik
b7165e5b17 feat(ui): Gameplay tab — 14 retail CharacterOption-derived toggles
Phase L.0 (cont.) — third tab on the Settings shell, in the Easy-wins
build order. Subset of retail's CharacterOption + CharacterOptions2
bitfield flags ported as bools (see acclient.h:3404+ enum). Local-
only this phase per the brainstorm — server sync deferred to a
later phase that will marshal the draft into the retail
CharacterOption packet.

GameplaySettings record exposes 14 named flags grouped by usage:

 · Combat: AutoTarget, AutoRepeatAttack, ToggleRun, AdvancedCombatUI,
   VividTargetingIndicator
 · Display: ShowTooltips, SideBySideVitals, CoordinatesOnRadar,
   SpellDuration, ShowHelm, ShowCloak
 · Interface: AllowGive, LockUI, UseMouseTurning

Retail names + bit values are documented in field-level comments so
the future server-sync phase has a 1:1 mapping. Defaults are
typical-user starting points (NOT bit-exact to retail's
0x50C4A54A / 0x948700 masks); class-level remarks call out that
defaults will be re-anchored to retail values once the wire-format
is the load-bearing source.

SettingsStore grows LoadGameplay / SaveGameplay using the existing
SaveSection generic helper (added in the audio commit). All three
non-keybind sections (display, audio, gameplay) now coexist in
settings.json with non-destructive cross-section saves — verified
by a new "all three sections coexist" round-trip test.

SettingsVM grows the parallel gameplay state machine
(gameplayPersisted / gameplayDraft / SetGameplay / onSaveGameplay).
HasUnsavedChanges, Save, Cancel, ResetAllToDefaults all cover
gameplay too. Constructor signature adds two more params; existing
call sites (App startup + tests) updated.

SettingsPanel.RenderGameplayTab replaces the L.0-shell placeholder —
14 Checkbox calls grouped under three Text+Separator headers, plus
a footer note explaining the local-only-this-phase scope. The
"Coming soon" placeholder test was retargeted from "Gameplay" to
"Chat" since Gameplay is no longer a placeholder.

GameWindow construction site loads gameplay on startup + writes via
the SettingsStore on Save. Server-sync packet wiring is left as a
TODO comment in the onSaveGameplay callback (next phase, after the
protocol round-trip is in place).

14 new tests:
 · GameplaySettings record (3) — defaults pinned, value equality,
   with-expressions
 · SettingsStore gameplay (4) — missing-file → defaults, round-trip,
   partial-file fallback, all-three-sections coexist
 · SettingsVM gameplay (5) — initial draft, SetGameplay marks dirty,
   Save invokes callback, Cancel reverts, ResetAllToDefaults covers
 · SettingsPanel gameplay tab (2) — 8 spot-checked Checkboxes render
   only when active

dotnet build green (0 warnings); dotnet test 1,276 / 1,276 green
(243 Core.Net + 360 UI.Abstractions + 673 Core).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 18:05:07 +02:00
Erik
53b1878c5c feat(ui): Audio tab — live volume sliders driving OpenAL engine
Phase L.0 (cont.) — second tab on the Settings shell, in the Easy-wins
build order. Audio is the live-preview poster child: dragging a slider
is audible immediately, Save persists, Cancel reverts and the engine
catches up on the next frame.

AudioSettings record: Master / Music / Sfx / Ambient (all 0..1 floats).
Defaults match the OpenAlAudioEngine constructor values exactly so a
user who never opens the tab gets identical behaviour to the
pre-Phase-L env-var-only world (Master=1.0, Music=0.7, Sfx=1.0,
Ambient=0.8).

SettingsStore grows LoadAudio / SaveAudio + a generic SaveSection
helper that consolidates the unknown-top-level-key preservation logic.
Display and Audio sections coexist in settings.json:
{ "version": 1, "display": { ... }, "audio": { ... } }
Saving one section preserves the other on disk; a future Gameplay /
Chat / Character section drops in the same way without touching
existing data.

SettingsVM gains a parallel audio state machine (audioPersisted /
audioDraft / SetAudio / onSaveAudio callback). HasUnsavedChanges
covers all three buckets now (keybinds + display + audio); Save /
Cancel / ResetAll are atomic across all of them.

GameWindow wiring is the live-preview mechanism — every render frame
pushes the VM's AudioDraft into _audioEngine.MasterVolume etc. Cheap
(four float assignments) and unconditional. SetListener still applies
MasterVolume each frame too via the existing Phase E.2 code path, so
listener gain stays in sync. Persisted audio is applied to the engine
ONCE at startup before the first frame so the user's saved values
take effect before any sound plays — startup-time apply happens during
the same SettingsVM construction site that does the LoadDisplay +
LoadAudio.

SettingsPanel.RenderAudioTab replaces the L.0-shell placeholder — four
SliderFloat calls clamped to [0, 1], plus a footer note explaining the
live-preview UX. The "Coming soon" placeholder test was retargeted
from "Audio" to "Gameplay" since Audio is no longer a placeholder.

16 new tests:
 · AudioSettings record (3) — defaults pin engine constants, value
   equality, with-expressions
 · SettingsStore audio round-trip (5) — missing-file → defaults,
   round-trip all fields, partial-file per-field fallback, save-audio-
   preserves-display, save-display-preserves-audio
 · SettingsVM audio state (5) — initial draft tracks persisted,
   SetAudio marks dirty, Save invokes audio callback, Cancel reverts,
   ResetAllToDefaults covers audio
 · SettingsPanel audio tab (3) — four sliders render only when active,
   no SliderFloat emitted on inactive tabs, slider range is [0, 1]

dotnet build green (0 warnings); dotnet test 1,262 / 1,262 green
(243 Core.Net + 346 UI.Abstractions + 673 Core).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 17:57:00 +02:00
Erik
382f0ad3fa feat(ui): Display tab + settings.json persistence — first non-keybind tab lands
Phase L.0 (cont.) — first concrete tab on the new Settings shell, in
the Easy-wins build order agreed in the brainstorm
(Display → Audio → Gameplay → Chat → Character).

DisplaySettings (immutable record): Resolution / Fullscreen / VSync /
FieldOfView (30-120°) / Gamma (0.5-2.0) / ShowFps. Six common 16:9
resolutions in the dropdown. Defaults: 1920×1080, windowed, vsync on,
75° FOV, gamma 1.0, FPS off — matches the brainstorm UX agreement.

SettingsStore: JSON persistence at %LOCALAPPDATA%\acdream\settings.json
(coexists with keybinds.json — own load/save path stays put, no
migration needed). LoadDisplay falls back per-field when keys are
missing (partial-file tolerant) and falls back to defaults when the
file is corrupt or the JSON is unparseable. SaveDisplay round-trips
preserved — unknown top-level keys (e.g. an `audio` section written
by a future client) are kept on save so older builds don't silently
drop newer-tab data.

SettingsVM gains a parallel display-state machine: persistedDisplay +
draftDisplay, SetDisplay mutator, HasUnsavedChanges checks both
keybinds and display deltas, Save/Cancel/ResetAll cover both
atomically from the user's POV (one Save commits everything, one
Cancel reverts everything). Constructor signature extends with two
new params; existing keybinds-only callers updated.

SettingsPanel.RenderDisplayTab replaces the L.0-shell placeholder —
Combo for resolution, Checkboxes for fullscreen/vsync/show-fps,
SliderFloat for FOV + gamma. Live-preview note in the panel body
matches the agreed UX: FOV + gamma update visibly while the user
drags; resolution / fullscreen / vsync apply on Save (live preview
would be too jarring).

GameWindow wires SettingsStore into the existing SettingsVM construct
site — load on startup, save on each tab Save. Errors print to
console and don't crash the panel.

19 new tests:
 · DisplaySettings record (4) — defaults pinned, value equality, with-
   expressions, AvailableResolutions sorted ascending
 · SettingsStore (6) — round trip, missing-file → defaults, corrupt-
   file → defaults, partial-file → per-field fallback, unknown-key
   preservation, DefaultPath shape
 · SettingsVM display (6) — initial draft tracks persisted, SetDisplay
   marks dirty, Save invokes display callback, Cancel reverts,
   ResetAllToDefaults covers display, Save-then-Cancel is no-op
 · SettingsPanel display tab (3) — widgets render only when active,
   resolution combo uses AvailableResolutions, no Combo emitted on
   inactive tabs

dotnet build green (0 warnings); dotnet test 1,246 / 1,246 green
(243 Core.Net + 330 UI.Abstractions + 673 Core).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 17:46:31 +02:00
Erik
7665cdf642 feat(ui): tabbed Settings shell — IPanelRenderer tab API + 6 placeholder tabs
Phase L.0 — foundation for the complete retail-style Settings interface
agreed in the 2026-04-26 brainstorm. Splits Phase K's keybind-only F11
panel into a tabbed shell whose first tab wraps the existing keybinds
content unchanged; the other five tabs (Display / Audio / Gameplay /
Chat / Character) render "Coming soon" placeholders so the shape the
user approved is visible immediately and gets filled in over the L.x
sub-phases (Display first per Easy-wins build order).

Why a tab API extension: retail had distinct Options UIs
(gmGameplayOptionsUI / gmChatOptionsUI / gmCharacterSettingsUI per the
PDB at acclient_2013_pseudo_c.txt:170739+) and the existing
IPanelRenderer only exposed CollapsingHeader. ImGui maps
BeginTabBar / BeginTabItem / EndTabItem / EndTabBar 1:1, so the new
primitives stay backend-friendly — the future D.2b custom retail-look
backend implements them via the retail tab UIs without panel changes.

Save / Cancel / Reset-all stay above the tab bar so they remain global
across all tabs (Phase K's UX preserved). FakePanelRenderer grows
matching tab calls + an ActiveTabLabel knob so tests can target a
specific tab's content; default behavior treats the first tab item
seen as active so existing tests keep passing without changes.

5 new SettingsPanelTests assertions: tab bar opens once, six expected
tab labels emitted in order, Keybinds-tab section headers only render
when active, placeholders show "Coming soon" text on inactive-content
tabs, and Save/Cancel buttons render BEFORE the tab bar (regression
guard against accidentally moving them inside a tab item).

dotnet build green (0 warnings); dotnet test 1,227 / 1,227 green
(243 Core.Net + 311 UI.Abstractions + 673 Core).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 17:39:36 +02:00
23 changed files with 3101 additions and 60 deletions

View file

@ -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

View file

@ -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;
/// <summary>
/// L.0 follow-up: load every section from settings.json + apply the
/// runtime-affecting ones (Display window state + Audio engine
/// volumes) at startup. Runs unconditionally — settings are runtime
/// state, not devtools state. Without this, a user running with
/// <c>ACDREAM_DEVTOOLS=0</c> would silently get WindowOptions
/// defaults instead of their saved Display/Audio preferences.
/// </summary>
private void LoadAndApplyPersistedSettings()
{
_settingsStore = new AcDream.UI.Abstractions.Panels.Settings.SettingsStore(
AcDream.UI.Abstractions.Panels.Settings.SettingsStore.DefaultPath());
_persistedDisplay = _settingsStore.LoadDisplay();
_persistedAudio = _settingsStore.LoadAudio();
_persistedGameplay = _settingsStore.LoadGameplay();
_persistedChat = _settingsStore.LoadChat();
// _activeToonKey is "default" pre-EnterWorld; the post-login
// 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;
}
}
/// <summary>
/// L.0 Display tab: framebuffer-resize handler — update GL viewport
/// + camera aspect when the window is resized (by the user dragging
/// the corner OR by ApplyDisplayWindowState applying a saved
/// 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.
/// </summary>
private void OnFramebufferResize(Silk.NET.Maths.Vector2D<int> 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);
}
/// <summary>
/// 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:
/// <list type="bullet">
/// <item>OnFramebufferResize (cond=Always — force-reset on resize).</item>
/// <item>The View → "Reset window layout" menu item (cond=Always).</item>
/// <item>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).</item>
/// </list>
/// </summary>
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);
}
/// <summary>
/// L.0 Display tab: apply the window-state-dependent settings
/// (Resolution + Fullscreen) from a <see cref="AcDream.UI.Abstractions.Panels.Settings.DisplaySettings"/>
/// to the live Silk.NET window. Called at startup (with persisted
/// values) and on every Save (with the saved values). Resolution
/// parses "<c>WIDTHxHEIGHT</c>" (e.g. <c>"1920x1080"</c>); a malformed
/// or unparseable string is silently ignored to avoid crashing the
/// client mid-session.
/// </summary>
private void ApplyDisplayWindowState(
AcDream.UI.Abstractions.Panels.Settings.DisplaySettings display)
{
if (_window is null) return;
// Resolution: parse and resize if changed.
if (TryParseResolution(display.Resolution, out int w, out int h))
{
if (_window.Size.X != w || _window.Size.Y != h)
_window.Size = new Silk.NET.Maths.Vector2D<int>(w, h);
}
// Fullscreen: borderless via Silk.NET's WindowState.Fullscreen
// (no exclusive-mode DXGI dance needed).
var desiredState = display.Fullscreen
? Silk.NET.Windowing.WindowState.Fullscreen
: Silk.NET.Windowing.WindowState.Normal;
if (_window.WindowState != desiredState)
_window.WindowState = desiredState;
}
private static bool TryParseResolution(string spec, out int width, out int height)
{
width = height = 0;
if (string.IsNullOrWhiteSpace(spec)) return false;
var parts = spec.Split('x', 2);
if (parts.Length != 2) return false;
return int.TryParse(parts[0], out width)
&& int.TryParse(parts[1], out height)
&& width > 0
&& height > 0;
}
// Vitals panel reference cached for the View menu's toggle entry.
private AcDream.UI.Abstractions.Panels.Vitals.VitalsPanel? _vitalsPanel;

View file

@ -235,4 +235,48 @@ public interface IPanelRenderer
/// frame the user clicks the item; false otherwise.
/// </summary>
bool MenuItem(string label, string? shortcut = null);
// -- Tab bar (Settings panel + future tabbed surfaces) ---------------
/// <summary>
/// Open a tab bar inside the current window. Returns <c>true</c>
/// when the bar is visible — only emit <see cref="BeginTabItem"/>
/// calls inside that branch. Always pair with
/// <see cref="EndTabBar"/> when the call returned true. Retail had
/// tab bars in the Options UIs (<c>gmGameplayOptionsUI</c> etc), so
/// this primitive must be expressible by the future custom
/// retail-look backend.
/// </summary>
bool BeginTabBar(string id);
/// <summary>Close the tab bar opened by <see cref="BeginTabBar"/>.</summary>
void EndTabBar();
/// <summary>
/// Begin a single tab inside an open <see cref="BeginTabBar"/>.
/// Returns <c>true</c> when the tab is the currently selected one
/// — only render this tab's content in that branch. Always pair
/// with <see cref="EndTabItem"/> when the call returned true.
/// </summary>
bool BeginTabItem(string label);
/// <summary>Close the tab opened by <see cref="BeginTabItem"/>.</summary>
void EndTabItem();
/// <summary>
/// Render a read-only multi-line text region the user can
/// <b>select</b> with click+drag and copy with <c>Ctrl+C</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.
///
/// <para>
/// The widget is sized to <paramref name="size"/>; pass
/// <c>(0, 0)</c> for "fill the current content region" semantics
/// (matches ImGui defaults). <paramref name="id"/> is the ImGui
/// stable identifier — typically <c>"##chatcopy"</c> or similar
/// hidden-label form.
/// </para>
/// </summary>
void TextMultilineReadOnly(string id, string content, Vector2 size);
}

View file

@ -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();
}

View file

@ -0,0 +1,28 @@
namespace AcDream.UI.Abstractions.Panels.Settings;
/// <summary>
/// Audio mixer preferences persisted to <c>settings.json</c>. Drives the
/// existing Phase E.2 OpenAL engine — the host wires these values into
/// <c>OpenAlAudioEngine.MasterVolume</c> / <c>SfxVolume</c> /
/// <c>MusicVolume</c> / <c>AmbientVolume</c> on Save and on startup.
///
/// <para>
/// 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.
/// </para>
/// </summary>
public sealed record AudioSettings(
float Master,
float Music,
float Sfx,
float Ambient)
{
/// <summary>Values used on first launch. Mirror the engine's
/// constructor-default Volume properties.</summary>
public static AudioSettings Default { get; } = new(
Master: 1.0f,
Music: 0.7f,
Sfx: 1.0f,
Ambient: 0.8f);
}

View file

@ -0,0 +1,48 @@
namespace AcDream.UI.Abstractions.Panels.Settings;
/// <summary>
/// Per-character preferences persisted to <c>settings.json</c> under
/// <c>character[toonName]</c>. Settings on this tab are scoped to a
/// single toon; switching characters loads a different bag.
///
/// <para>
/// L.0 scope: <b>local-only</b>. 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 <see cref="AutoAttack"/>
/// would be pushed via the retail Player-Options packet.
/// </para>
///
/// <para>
/// 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.
/// </para>
/// </summary>
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
{
/// <summary>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.</summary>
public static CharacterSettings Default { get; } = new(
DefaultChatChannel: "Local",
AutoAttack: false,
ConfirmSalvage: true,
ShowPickupMessages: true);
/// <summary>Channel-name presets exposed in the dropdown. Order
/// roughly matches retail's chat-channel routing.</summary>
public static System.Collections.Generic.IReadOnlyList<string> AvailableChannels { get; } = new[]
{
"Local",
"Allegiance",
"Fellowship",
"General",
"Trade",
"LFG",
"Roleplay",
};
}

View file

@ -0,0 +1,44 @@
namespace AcDream.UI.Abstractions.Panels.Settings;
/// <summary>
/// Chat-related preferences persisted to <c>settings.json</c>. Mixes
/// retail's <c>CharacterOptions2</c> chat-channel filter bits (Hear*Chat
/// + TimeStamp + FilterLanguage + AppearOffline) with a few visual
/// preferences (font size) that don't have a retail bitfield.
/// See <c>docs/research/named-retail/acclient.h:3451+</c> for the
/// retail bit values.
///
/// <para>
/// L.0 scope: <b>local-only</b> like the rest of L.0. The Hear*Chat
/// flags affect client-side <i>display</i> 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.
/// </para>
/// </summary>
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
{
/// <summary>Sensible starting values matching the retail "all on" stance.</summary>
public static ChatSettings Default { get; } = new(
HearGeneralChat: true,
HearTradeChat: true,
HearLFGChat: true,
HearRoleplayChat: true,
HearSocietyChat: true,
AppearOffline: false,
ShowTimestamps: true,
FilterProfanity: true,
FontSize: 12f);
}

View file

@ -0,0 +1,50 @@
using System.Collections.Generic;
namespace AcDream.UI.Abstractions.Panels.Settings;
/// <summary>
/// Display-related preferences persisted to <c>settings.json</c>.
/// Modern addition (no retail equivalent for FOV / vsync etc) — replaces
/// the various <c>ACDREAM_*</c> environment variables for resolution +
/// windowed mode with an in-game UI.
///
/// <para>
/// Records are immutable; mutation goes through
/// <see cref="SettingsVM.SetDisplay"/> which assigns a new instance via
/// <c>with</c>-expressions.
/// </para>
/// </summary>
public sealed record DisplaySettings(
string Resolution,
bool Fullscreen,
bool VSync,
float FieldOfView,
float Gamma,
bool ShowFps)
{
/// <summary>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.</summary>
public static DisplaySettings Default { get; } = new(
Resolution: "1280x720",
Fullscreen: false,
VSync: false,
FieldOfView: 60f,
Gamma: 1.0f,
ShowFps: true);
/// <summary>16:9 resolution presets offered in the dropdown.</summary>
public static IReadOnlyList<string> AvailableResolutions { get; } = new[]
{
"1280x720",
"1366x768",
"1600x900",
"1920x1080",
"2560x1440",
"3840x2160",
};
}

View file

@ -0,0 +1,61 @@
namespace AcDream.UI.Abstractions.Panels.Settings;
/// <summary>
/// Gameplay-related preferences persisted to <c>settings.json</c>.
/// Mirrors a subset of retail's <c>CharacterOption</c> + <c>CharacterOptions2</c>
/// bitfield flags (see <c>docs/research/named-retail/acclient.h:3404+</c>).
/// Retail names are kept verbatim so future server-sync packs these
/// into the wire-format bitmask without renaming.
///
/// <para>
/// L.0 scope: <b>local-only</b>. The brainstorm explicitly deferred
/// server sync — on Save these values are persisted to <c>settings.json</c>
/// only. A later phase will marshal them into the retail
/// <c>CharacterOption</c> packet (<c>0x...</c>) when the protocol work
/// for player-options round-trip is in place.
/// </para>
///
/// <para>
/// Defaults below are chosen as the typical-user starting point, NOT
/// pinned bit-exact to retail's <c>0x50C4A54A</c> / <c>0x948700</c>
/// masks (those will become the defaults once server-sync ships and
/// the bitmask round-trip is the load-bearing wire format).
/// </para>
/// </summary>
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
{
/// <summary>Sensible starting values for first launch. NOT bit-exact
/// to retail's <c>Default_CharacterOption = 0x50C4A54A</c> +
/// <c>Default_CharacterOptions2 = 0x948700</c> — see class remarks.</summary>
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);
}

View file

@ -5,25 +5,23 @@ using AcDream.UI.Abstractions.Input;
namespace AcDream.UI.Abstractions.Panels.Settings;
/// <summary>
/// K.3: in-game Settings panel for click-to-rebind keymap editing.
/// Hidden by default; opens via <c>F11</c> (which fires the
/// <see cref="InputAction.ToggleOptionsPanel"/> 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).
///
/// <para>
/// Layout: top row of action buttons (Save / Cancel / Reset all), then
/// a sequence of <see cref="IPanelRenderer.CollapsingHeader"/> 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 <see cref="SettingsVM.PendingConflict"/> is
/// non-null, a confirmation prompt is rendered above those buttons
/// (Yes — Reassign / No — Keep existing).
/// </para>
///
/// <para>
/// When <see cref="SettingsVM.PendingConflict"/> 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.
/// </para>
/// </summary>
public sealed class SettingsPanel : IPanel
@ -42,7 +40,7 @@ public sealed class SettingsPanel : IPanel
public string Title => "Settings";
/// <inheritdoc />
/// <remarks>K.3: hidden by default — opened via F11 / View menu.</remarks>
/// <remarks>Hidden by default — opened via F11 / View menu.</remarks>
public bool IsVisible { get; set; } = false;
/// <inheritdoc />
@ -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();
}
/// <summary>
/// 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.
/// </summary>
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();
/// <summary>
/// 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).
/// </summary>
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.");
}
/// <summary>
/// Render the Audio tab — Master + SFX volume sliders (live preview
/// against the running OpenAL engine). Music + Ambient fields exist
/// in <see cref="AudioSettings"/> 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.
/// </summary>
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.");
}
/// <summary>
/// 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.
/// </summary>
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.");
}
/// <summary>
/// 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.
/// </summary>
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.");
}
/// <summary>
/// Render the Character tab — per-toon preferences. The host owns
/// the toon-name key; the panel just edits whatever bag the host
/// loaded into <see cref="SettingsVM.CharacterDraft"/>.
/// </summary>
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[\"<toonName>\"]. Local-only this "
+ "phase; server-sync arrives later when the protocol "
+ "round-trip lands.");
}
private void RenderSection(IPanelRenderer renderer, string label, InputAction[] actions)

View file

@ -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;
/// <summary>
/// JSON-backed persistence for non-keybind settings (Display today; future
/// tabs Audio / Gameplay / Chat / Character will be added to the same
/// file). Path: <c>%LOCALAPPDATA%\acdream\settings.json</c>. Coexists
/// with <c>keybinds.json</c>, which retains its own
/// <see cref="Input.KeyBindings.LoadOrDefault"/> path.
///
/// <para>
/// Schema (current version 1):
/// <code>
/// {
/// "version": 1,
/// "display": { "resolution": "1920x1080", "fullscreen": false, ... }
/// }
/// </code>
/// 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.
/// </para>
/// </summary>
public sealed class SettingsStore
{
private const int CurrentSchemaVersion = 1;
private readonly string _path;
public SettingsStore(string path)
{
_path = path ?? throw new ArgumentNullException(nameof(path));
}
/// <summary>Default path: <c>%LOCALAPPDATA%\acdream\settings.json</c>.</summary>
public static string DefaultPath() => Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"acdream",
"settings.json");
/// <summary>
/// Load Display settings. Missing file → <see cref="DisplaySettings.Default"/>.
/// Missing individual keys fall back to the corresponding default
/// field, so a partial file (e.g. only <c>resolution</c> is set) is
/// non-fatal.
/// </summary>
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;
}
}
/// <summary>
/// Save Display settings, preserving any other top-level keys the file
/// already contains (e.g. an <c>audio</c> section written by a newer
/// client). Unknown keys are round-tripped via raw JSON text so older
/// builds don't silently drop them.
/// </summary>
public void SaveDisplay(DisplaySettings display)
=> SaveSection("display", BuildDisplayObject(display));
/// <summary>
/// Load Audio settings. Same fall-back behaviour as
/// <see cref="LoadDisplay"/>: missing file → defaults, missing fields
/// → per-field defaults, corrupt JSON → defaults.
/// </summary>
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;
}
}
/// <summary>
/// Save Audio settings, preserving every other top-level key
/// (display, future gameplay/chat/character). Same round-trip
/// guarantee as <see cref="SaveDisplay"/>.
/// </summary>
public void SaveAudio(AudioSettings audio)
=> SaveSection("audio", BuildAudioObject(audio));
/// <summary>
/// Load Gameplay settings (subset of retail CharacterOption flags).
/// Same fall-back behaviour as <see cref="LoadDisplay"/>.
/// </summary>
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;
}
}
/// <summary>Save Gameplay settings, preserving all other top-level keys.</summary>
public void SaveGameplay(GameplaySettings gameplay)
=> SaveSection("gameplay", BuildGameplayObject(gameplay));
/// <summary>Load Chat settings. Same fall-back behaviour as <see cref="LoadDisplay"/>.</summary>
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;
}
}
/// <summary>Save Chat settings, preserving all other top-level keys.</summary>
public void SaveChat(ChatSettings chat)
=> SaveSection("chat", BuildChatObject(chat));
/// <summary>
/// Load per-character settings keyed by <paramref name="toonKey"/>.
/// Missing file or missing toon entry → <see cref="CharacterSettings.Default"/>.
/// </summary>
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<string>() ?? d.DefaultChatChannel,
AutoAttack: toon["autoAttack"]?.GetValue<bool>() ?? d.AutoAttack,
ConfirmSalvage: toon["confirmSalvage"]?.GetValue<bool>() ?? d.ConfirmSalvage,
ShowPickupMessages: toon["showPickupMessages"]?.GetValue<bool>() ?? d.ShowPickupMessages);
}
catch (Exception ex)
{
Console.WriteLine($"settings: failed to load {_path}: {ex.Message} — using defaults");
return CharacterSettings.Default;
}
}
/// <summary>
/// Save per-character settings under <paramref name="toonKey"/>.
/// Preserves every other toon's settings + every other top-level
/// section. Uses <see cref="JsonNode"/> rather than the raw-text
/// preservation pattern of <see cref="SaveSection"/> because the
/// per-toon write needs to mutate a nested map, not just replace a
/// top-level key.
/// </summary>
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<string, object> 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<string, object> 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<string, object> 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<string, object> BuildAudioObject(AudioSettings a)
=> new(StringComparer.Ordinal)
{
["ambient"] = a.Ambient,
["master"] = a.Master,
["music"] = a.Music,
["sfx"] = a.Sfx,
};
/// <summary>
/// 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.
/// </summary>
private void SaveSection(string sectionName, SortedDictionary<string, object> 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<string, string>(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;
}

View file

@ -30,6 +30,32 @@ public sealed class SettingsVM
private readonly InputDispatcher _dispatcher;
private readonly Action<KeyBindings> _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<DisplaySettings> _onSaveDisplay;
// L.0 — Audio tab. Same shape as Display.
private AudioSettings _audioPersisted;
private AudioSettings _audioDraft;
private readonly Action<AudioSettings> _onSaveAudio;
// L.0 — Gameplay tab (subset of retail CharacterOption flags).
private GameplaySettings _gameplayPersisted;
private GameplaySettings _gameplayDraft;
private readonly Action<GameplaySettings> _onSaveGameplay;
// L.0 — Chat tab (CharacterOptions2 channel filters + visual prefs).
private ChatSettings _chatPersisted;
private ChatSettings _chatDraft;
private readonly Action<ChatSettings> _onSaveChat;
// L.0 — Character tab (per-toon, host-keyed by toon name).
private CharacterSettings _characterPersisted;
private CharacterSettings _characterDraft;
private readonly Action<CharacterSettings> _onSaveCharacter;
/// <summary>The action currently being rebound, or null when idle.</summary>
public InputAction? RebindInProgress { get; private set; }
@ -50,14 +76,139 @@ public sealed class SettingsVM
/// <summary>True iff the draft differs structurally from the
/// persisted snapshot. Used to grey out the Save button when no
/// rebinds are pending.</summary>
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<KeyBindings> onSave)
/// <summary>The current Display draft. Panel reads from here;
/// mutation goes through <see cref="SetDisplay"/>.</summary>
public DisplaySettings DisplayDraft => _displayDraft;
/// <summary>The current Audio draft. Panel reads from here;
/// mutation goes through <see cref="SetAudio"/>.</summary>
public AudioSettings AudioDraft => _audioDraft;
/// <summary>The current Gameplay draft. Panel reads from here;
/// mutation goes through <see cref="SetGameplay"/>.</summary>
public GameplaySettings GameplayDraft => _gameplayDraft;
/// <summary>The current Chat draft. Panel reads from here;
/// mutation goes through <see cref="SetChat"/>.</summary>
public ChatSettings ChatDraft => _chatDraft;
/// <summary>The current Character draft (per-toon — host owns the
/// toon-name key). Panel reads from here; mutation goes through
/// <see cref="SetCharacter"/>.</summary>
public CharacterSettings CharacterDraft => _characterDraft;
public SettingsVM(
KeyBindings persisted,
InputDispatcher dispatcher,
Action<KeyBindings> onSave,
DisplaySettings persistedDisplay,
Action<DisplaySettings> onSaveDisplay,
AudioSettings persistedAudio,
Action<AudioSettings> onSaveAudio,
GameplaySettings persistedGameplay,
Action<GameplaySettings> onSaveGameplay,
ChatSettings persistedChat,
Action<ChatSettings> onSaveChat,
CharacterSettings persistedCharacter,
Action<CharacterSettings> 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;
}
/// <summary>
/// Replace the entire Display draft with <paramref name="value"/>.
/// Panel calls this with a <c>DisplayDraft with { Field = newValue }</c>
/// so each widget edits exactly one field at a time.
/// </summary>
public void SetDisplay(DisplaySettings value)
{
_displayDraft = value ?? throw new ArgumentNullException(nameof(value));
}
/// <summary>
/// Replace the entire Audio draft with <paramref name="value"/>.
/// Live audio preview is achieved at the host layer by pushing
/// <see cref="AudioDraft"/> 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.
/// </summary>
public void SetAudio(AudioSettings value)
{
_audioDraft = value ?? throw new ArgumentNullException(nameof(value));
}
/// <summary>
/// Replace the entire Gameplay draft with <paramref name="value"/>.
/// Local-only this phase — values persist on Save but don't yet
/// flow to the server. When server-sync ships, the host's
/// <c>onSaveGameplay</c> callback will marshal the draft into the
/// retail <c>CharacterOption</c> wire bitmask.
/// </summary>
public void SetGameplay(GameplaySettings value)
{
_gameplayDraft = value ?? throw new ArgumentNullException(nameof(value));
}
/// <summary>
/// Replace the entire Chat draft with <paramref name="value"/>.
/// Local-only this phase — values persist on Save but the Hear*Chat
/// flags affect client-side display filtering, not server-side
/// channel subscriptions.
/// </summary>
public void SetChat(ChatSettings value)
{
_chatDraft = value ?? throw new ArgumentNullException(nameof(value));
}
/// <summary>
/// Replace the entire Character draft with <paramref name="value"/>.
/// Per-toon — the host knows which toon's bag we're editing because
/// it owned the toonKey when constructing the VM.
/// </summary>
public void SetCharacter(CharacterSettings value)
{
_characterDraft = value ?? throw new ArgumentNullException(nameof(value));
}
/// <summary>
/// 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
/// <see cref="HasUnsavedChanges"/> doesn't flag the swap as a
/// pending edit. Differs from <see cref="SetCharacter"/>, which
/// updates draft only.
/// </summary>
public void LoadCharacterContext(CharacterSettings persisted)
{
_characterPersisted = persisted ?? throw new ArgumentNullException(nameof(persisted));
_characterDraft = persisted;
}
/// <summary>
@ -160,32 +311,58 @@ public sealed class SettingsVM
}
/// <summary>
/// Replace the entire draft with <see cref="KeyBindings.RetailDefaults"/>.
/// Replace the keybinds draft with <see cref="KeyBindings.RetailDefaults"/>
/// AND the display draft with <see cref="DisplaySettings.Default"/>.
/// "Reset all" applies to every tab — it's the user's escape hatch
/// when they've gotten lost.
/// </summary>
public void ResetAllToDefaults()
{
_draft = KeyBindings.RetailDefaults();
_draft = KeyBindings.RetailDefaults();
_displayDraft = DisplaySettings.Default;
_audioDraft = AudioSettings.Default;
_gameplayDraft = GameplaySettings.Default;
_chatDraft = ChatSettings.Default;
_characterDraft = CharacterSettings.Default;
}
/// <summary>
/// Commit the draft via the onSave callback supplied at
/// construction. After save the draft becomes the new persisted
/// snapshot — <see cref="HasUnsavedChanges"/> resets to false.
/// Commit both keybinds + display drafts via the onSave callbacks
/// supplied at construction. After save the drafts become the new
/// persisted snapshots — <see cref="HasUnsavedChanges"/> 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.
/// </summary>
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;
}
/// <summary>
/// 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.
/// </summary>
public void Cancel()
{
_draft = CloneBindings(_persisted);
_draft = CloneBindings(_persisted);
_displayDraft = _displayPersisted;
_audioDraft = _audioPersisted;
_gameplayDraft = _gameplayPersisted;
_chatDraft = _chatPersisted;
_characterDraft = _characterPersisted;
CancelRebind();
}

View file

@ -155,12 +155,41 @@ public sealed class ImGuiPanelRenderer : IPanelRenderer
/// <inheritdoc />
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;
}
/// <inheritdoc />
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 -----------------------------------------------------------
/// <inheritdoc />
public bool BeginTabBar(string id) => ImGuiNET.ImGui.BeginTabBar(id);
/// <inheritdoc />
public void EndTabBar() => ImGuiNET.ImGui.EndTabBar();
/// <inheritdoc />
public bool BeginTabItem(string label) => ImGuiNET.ImGui.BeginTabItem(label);
/// <inheritdoc />
public void EndTabItem() => ImGuiNET.ImGui.EndTabItem();
// -- Selectable / copyable text ---------------------------------------
/// <inheritdoc />
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);
}
}

View file

@ -198,4 +198,40 @@ internal sealed class FakePanelRenderer : IPanelRenderer
Calls.Add(("MenuItem", new object?[] { label, shortcut }));
return MenuItemReturns;
}
// -- Tab bar -----------------------------------------------------------
/// <summary>Pre-set return for <see cref="BeginTabBar"/>.</summary>
public bool TabBarReturns { get; set; } = true;
/// <summary>The label of the tab the next <see cref="BeginTabItem"/>
/// 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.</summary>
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<object?>()));
public bool BeginTabItem(string label)
{
Calls.Add(("BeginTabItem", new object?[] { label }));
_firstTabSeen ??= label;
return ActiveTabLabel is null
? string.Equals(label, _firstTabSeen, StringComparison.Ordinal)
: string.Equals(label, ActiveTabLabel, StringComparison.Ordinal);
}
public void EndTabItem() => Calls.Add(("EndTabItem", Array.Empty<object?>()));
public void TextMultilineReadOnly(string id, string content, Vector2 size)
=> Calls.Add(("TextMultilineReadOnly", new object?[] { id, content, size }));
}

View file

@ -33,7 +33,11 @@ public sealed class ChatPanelLayoutTests
int beginIdx = methods.IndexOf("Begin");
int beginChildIdx = methods.IndexOf("BeginChild");
int endChildIdx = methods.IndexOf("EndChild");
int separatorIdx = methods.IndexOf("Separator");
// L.0 follow-up: Copy-mode toggle adds a Separator above the
// chat tail, so multiple Separators now exist. The footer
// separator (the one we care about for input layout) is the
// LAST one — between EndChild and the input field.
int separatorIdx = methods.LastIndexOf("Separator");
int inputSubmitIdx = methods.IndexOf("InputTextSubmit");
int endIdx = methods.IndexOf("End");
@ -63,8 +67,13 @@ public sealed class ChatPanelLayoutTests
panel.Render(new PanelContext(0.016f, new NoBus()), renderer);
var beginChildCall = renderer.Calls.Single(c => c.Method == "BeginChild");
var size = (System.Numerics.Vector2)beginChildCall.Args[1]!;
// L.0 follow-up: the chat panel now wraps its body in an outer
// ##chatbody BeginChild (so empty-space clicks can't drag the
// parent window). The inner ##chattail BeginChild is the one
// that reserves the footer; that's what this test asserts.
var chattailCall = renderer.Calls.Single(c => c.Method == "BeginChild"
&& (string)c.Args[0]! == "##chattail");
var size = (System.Numerics.Vector2)chattailCall.Args[1]!;
// Width 0 = fill available; height < 0 = "fill minus this".
// Reserved height should equal FrameHeightWithSpacing + a small
// separator pad (~6f) so the input never visually clips the

View file

@ -0,0 +1,44 @@
using AcDream.UI.Abstractions.Panels.Settings;
namespace AcDream.UI.Abstractions.Tests.Panels.Settings;
/// <summary>
/// L.0: <see cref="AudioSettings"/> default-pin tests. Defaults must
/// match the OpenAL engine's hard-coded constructor values so a user
/// who has never opened the Audio tab gets identical behaviour to the
/// pre-Phase-L world.
/// </summary>
public sealed class AudioSettingsTests
{
[Fact]
public void Default_values_match_engine_constructor_defaults()
{
// OpenAlAudioEngine ctor: Master=1.0, Music=0.7, Sfx=1.0,
// Ambient=0.8 — see src/AcDream.App/Audio/OpenAlAudioEngine.cs.
var d = AudioSettings.Default;
Assert.Equal(1.0f, d.Master);
Assert.Equal(0.7f, d.Music);
Assert.Equal(1.0f, d.Sfx);
Assert.Equal(0.8f, d.Ambient);
}
[Fact]
public void Equality_is_value_based()
{
var a = AudioSettings.Default;
var b = AudioSettings.Default with { Master = 0.5f };
var c = AudioSettings.Default with { Master = 0.5f };
Assert.NotEqual(a, b);
Assert.Equal(b, c);
}
[Fact]
public void With_expression_clones_one_field()
{
var d = AudioSettings.Default with { Music = 0.25f };
Assert.Equal(0.25f, d.Music);
// Other fields untouched.
Assert.Equal(AudioSettings.Default.Master, d.Master);
Assert.Equal(AudioSettings.Default.Sfx, d.Sfx);
}
}

View file

@ -0,0 +1,48 @@
using AcDream.UI.Abstractions.Panels.Settings;
namespace AcDream.UI.Abstractions.Tests.Panels.Settings;
/// <summary>L.0: <see cref="CharacterSettings"/> default-pin tests.</summary>
public sealed class CharacterSettingsTests
{
[Fact]
public void Default_values_are_conservative()
{
var d = CharacterSettings.Default;
Assert.Equal("Local", d.DefaultChatChannel);
Assert.False(d.AutoAttack);
Assert.True(d.ConfirmSalvage);
Assert.True(d.ShowPickupMessages);
}
[Fact]
public void AvailableChannels_includes_retail_routing_targets()
{
var list = CharacterSettings.AvailableChannels;
Assert.Contains("Local", list);
Assert.Contains("Allegiance", list);
Assert.Contains("Fellowship", list);
Assert.Contains("General", list);
Assert.Contains("Trade", list);
Assert.Contains("LFG", list);
Assert.Contains("Roleplay", list);
}
[Fact]
public void Equality_is_value_based()
{
var a = CharacterSettings.Default;
var b = CharacterSettings.Default with { AutoAttack = true };
var c = CharacterSettings.Default with { AutoAttack = true };
Assert.NotEqual(a, b);
Assert.Equal(b, c);
}
[Fact]
public void With_expression_clones_one_field()
{
var d = CharacterSettings.Default with { DefaultChatChannel = "Allegiance" };
Assert.Equal("Allegiance", d.DefaultChatChannel);
Assert.False(d.AutoAttack);
}
}

View file

@ -0,0 +1,43 @@
using AcDream.UI.Abstractions.Panels.Settings;
namespace AcDream.UI.Abstractions.Tests.Panels.Settings;
/// <summary>
/// L.0: <see cref="ChatSettings"/> default-pin tests.
/// </summary>
public sealed class ChatSettingsTests
{
[Fact]
public void Default_values_are_all_channels_on_with_timestamps_and_filter()
{
var d = ChatSettings.Default;
Assert.True(d.HearGeneralChat);
Assert.True(d.HearTradeChat);
Assert.True(d.HearLFGChat);
Assert.True(d.HearRoleplayChat);
Assert.True(d.HearSocietyChat);
Assert.False(d.AppearOffline);
Assert.True(d.ShowTimestamps);
Assert.True(d.FilterProfanity);
Assert.Equal(12f, d.FontSize);
}
[Fact]
public void Equality_is_value_based()
{
var a = ChatSettings.Default;
var b = ChatSettings.Default with { HearTradeChat = false };
var c = ChatSettings.Default with { HearTradeChat = false };
Assert.NotEqual(a, b);
Assert.Equal(b, c);
}
[Fact]
public void With_expression_clones_one_field()
{
var d = ChatSettings.Default with { FontSize = 16f };
Assert.Equal(16f, d.FontSize);
Assert.True(d.HearGeneralChat);
Assert.True(d.ShowTimestamps);
}
}

View file

@ -0,0 +1,76 @@
using AcDream.UI.Abstractions.Panels.Settings;
namespace AcDream.UI.Abstractions.Tests.Panels.Settings;
/// <summary>
/// L.0: <see cref="DisplaySettings"/> is the immutable record of
/// display-tab preferences. Defaults are pinned here so a regression
/// (e.g. someone changing the default FOV out from under users)
/// surfaces immediately.
/// </summary>
public sealed class DisplaySettingsTests
{
[Fact]
public void Default_values_match_pre_L0_runtime_state()
{
// Defaults pinned to match the actual pre-L.0 startup state:
// · Resolution matches WindowOptions (1280×720 in GameWindow.Run)
// · FieldOfView matches camera FovY (60° = π/3)
// · VSync matches WindowOptions (false during dev)
// · ShowFps true preserves the perf string in the title bar
// Net effect: opening Display + Save with no edits is a visual
// no-op (no window resize, no camera FovY change, no title
// bar change).
var d = DisplaySettings.Default;
Assert.Equal("1280x720", d.Resolution);
Assert.False(d.Fullscreen);
Assert.False(d.VSync);
Assert.Equal(60f, d.FieldOfView);
Assert.Equal(1.0f, d.Gamma);
Assert.True(d.ShowFps);
}
[Fact]
public void AvailableResolutions_includes_common_16_9_options()
{
var list = DisplaySettings.AvailableResolutions;
Assert.Contains("1280x720", list);
Assert.Contains("1920x1080", list);
Assert.Contains("2560x1440", list);
Assert.Contains("3840x2160", list);
// List should be ascending so the dropdown reads naturally.
for (int i = 1; i < list.Count; i++)
{
int prevW = ParseWidth(list[i - 1]);
int curW = ParseWidth(list[i]);
Assert.True(curW >= prevW, $"Resolutions not sorted: {list[i - 1]} >= {list[i]}");
}
}
[Fact]
public void Equality_is_value_based()
{
var a = DisplaySettings.Default;
var b = DisplaySettings.Default with { Fullscreen = true };
var c = DisplaySettings.Default with { Fullscreen = true };
Assert.NotEqual(a, b);
Assert.Equal(b, c);
}
[Fact]
public void With_expression_clones_one_field()
{
var d = DisplaySettings.Default with { FieldOfView = 90f };
Assert.Equal(90f, d.FieldOfView);
// Other fields untouched.
Assert.Equal("1280x720", d.Resolution);
Assert.False(d.VSync);
Assert.True(d.ShowFps);
}
private static int ParseWidth(string res)
{
int x = res.IndexOf('x');
return int.Parse(res.AsSpan(0, x));
}
}

View file

@ -0,0 +1,54 @@
using AcDream.UI.Abstractions.Panels.Settings;
namespace AcDream.UI.Abstractions.Tests.Panels.Settings;
/// <summary>
/// L.0: <see cref="GameplaySettings"/> default-pin tests + value-equality
/// guarantees. Defaults are intentionally NOT bit-exact to retail's
/// <c>0x50C4A54A</c> mask — see GameplaySettings remarks for rationale.
/// </summary>
public sealed class GameplaySettingsTests
{
[Fact]
public void Default_values_are_typical_user_friendly()
{
// These defaults are reviewed in the L.0 brainstorm — typical-user
// starting point, not retail-bitmask. A change to any of these
// should be a deliberate decision, not a drive-by.
var d = GameplaySettings.Default;
Assert.True(d.AutoTarget);
Assert.True(d.AutoRepeatAttack);
Assert.True(d.ToggleRun);
Assert.False(d.AdvancedCombatUI);
Assert.True(d.ShowTooltips);
Assert.False(d.VividTargetingIndicator);
Assert.False(d.SideBySideVitals);
Assert.False(d.CoordinatesOnRadar);
Assert.True(d.SpellDuration);
Assert.True(d.AllowGive);
Assert.True(d.ShowHelm);
Assert.True(d.ShowCloak);
Assert.False(d.LockUI);
Assert.False(d.UseMouseTurning);
}
[Fact]
public void Equality_is_value_based()
{
var a = GameplaySettings.Default;
var b = GameplaySettings.Default with { AutoTarget = false };
var c = GameplaySettings.Default with { AutoTarget = false };
Assert.NotEqual(a, b);
Assert.Equal(b, c);
}
[Fact]
public void With_expression_clones_one_field()
{
var d = GameplaySettings.Default with { LockUI = true };
Assert.True(d.LockUI);
// Other fields untouched.
Assert.Equal(GameplaySettings.Default.AutoTarget, d.AutoTarget);
Assert.Equal(GameplaySettings.Default.ShowHelm, d.ShowHelm);
}
}

View file

@ -29,7 +29,13 @@ public sealed class SettingsPanelTests
persisted.Add(new Binding(new KeyChord(Key.W, ModifierMask.None), InputAction.MovementForward));
persisted.Add(new Binding(new KeyChord(Key.A, ModifierMask.None), InputAction.MovementTurnLeft));
var dispatcher = new InputDispatcher(kb, mouse, persisted);
var vm = new SettingsVM(persisted, dispatcher, _ => { });
var vm = new SettingsVM(
persisted, dispatcher, _ => { },
DisplaySettings.Default, _ => { },
AudioSettings.Default, _ => { },
GameplaySettings.Default, _ => { },
ChatSettings.Default, _ => { },
CharacterSettings.Default, _ => { });
var panel = new SettingsPanel(vm);
return (panel, vm, kb, dispatcher);
}
@ -165,4 +171,326 @@ public sealed class SettingsPanelTests
var (panel, _, _, _) = Build();
Assert.Equal("acdream.settings", panel.Id);
}
// -- Tabbed shell -----------------------------------------------------
[Fact]
public void Render_opens_tab_bar_with_six_tab_items()
{
var (panel, _, _, _) = Build();
var r = new FakePanelRenderer();
panel.Render(new PanelContext(0.016f, new NullBus()), r);
// BeginTabBar exactly once, EndTabBar exactly once.
Assert.Single(r.Calls, c => c.Method == "BeginTabBar");
Assert.Single(r.Calls, c => c.Method == "EndTabBar");
// The six tab labels approved in the design brainstorm.
var tabLabels = r.Calls.Where(c => c.Method == "BeginTabItem")
.Select(c => (string)c.Args[0]!).ToList();
Assert.Equal(
new[] { "Keybinds", "Display", "Audio", "Gameplay", "Chat", "Character" },
tabLabels);
}
[Fact]
public void Keybinds_tab_renders_section_headers_when_active()
{
var (panel, _, _, _) = Build();
// Default ActiveTabLabel = null → FakePanelRenderer treats the
// first tab item ("Keybinds") as active.
var r = new FakePanelRenderer { CollapsingHeaderNextReturn = false };
panel.Render(new PanelContext(0.016f, new NullBus()), r);
var headers = r.Calls.Where(c => c.Method == "CollapsingHeader")
.Select(c => (string)c.Args[0]!).ToList();
Assert.Contains("Movement", headers);
Assert.Contains("Hotbar", headers);
Assert.Contains("Emotes", headers);
}
[Fact]
public void Inactive_tabs_do_not_render_keybind_section_headers()
{
var (panel, _, _, _) = Build();
// Force "Display" to be the active tab — the Keybinds content
// must NOT render.
var r = new FakePanelRenderer { ActiveTabLabel = "Display" };
panel.Render(new PanelContext(0.016f, new NullBus()), r);
var headers = r.Calls.Where(c => c.Method == "CollapsingHeader")
.Select(c => (string)c.Args[0]!).ToList();
Assert.DoesNotContain("Movement", headers);
Assert.DoesNotContain("Hotbar", headers);
}
// -- Character tab content -------------------------------------------
[Fact]
public void Character_tab_when_active_renders_channel_combo_plus_checkboxes()
{
var (panel, _, _, _) = Build();
var r = new FakePanelRenderer { ActiveTabLabel = "Character" };
panel.Render(new PanelContext(0.016f, new NullBus()), r);
var combos = r.Calls.Where(c => c.Method == "Combo")
.Select(c => (string)c.Args[0]!).ToList();
Assert.Contains("Default chat channel", combos);
var checks = r.Calls.Where(c => c.Method == "Checkbox")
.Select(c => (string)c.Args[0]!).ToList();
Assert.Contains(checks, l => l.StartsWith("Auto-attack"));
Assert.Contains(checks, l => l.StartsWith("Confirm before salvaging"));
Assert.Contains(checks, l => l.StartsWith("Show pickup messages"));
}
[Fact]
public void Character_tab_does_not_render_when_a_different_tab_is_active()
{
var (panel, _, _, _) = Build();
var r = new FakePanelRenderer { ActiveTabLabel = "Display" };
panel.Render(new PanelContext(0.016f, new NullBus()), r);
var combos = r.Calls.Where(c => c.Method == "Combo")
.Select(c => (string)c.Args[0]!).ToList();
Assert.DoesNotContain("Default chat channel", combos);
}
[Fact]
public void Character_tab_channel_combo_uses_AvailableChannels_list()
{
var (panel, _, _, _) = Build();
var r = new FakePanelRenderer { ActiveTabLabel = "Character" };
panel.Render(new PanelContext(0.016f, new NullBus()), r);
var ch = r.Calls.First(c => c.Method == "Combo" && (string)c.Args[0]! == "Default chat channel");
var items = (string[])ch.Args[2]!;
Assert.Contains("Local", items);
Assert.Contains("Allegiance", items);
Assert.Contains("Fellowship", items);
}
[Fact]
public void All_six_tabs_are_now_implemented_no_placeholder_text_remains()
{
// After the L.0 build order finishes, no tab should render the
// "Coming soon" placeholder line. If a future commit re-adds a
// placeholder tab without updating this test, it will fail.
var (panel, _, _, _) = Build();
foreach (var tabLabel in new[] { "Keybinds", "Display", "Audio", "Gameplay", "Chat", "Character" })
{
var r = new FakePanelRenderer { ActiveTabLabel = tabLabel };
panel.Render(new PanelContext(0.016f, new NullBus()), r);
var wrapped = r.Calls.Where(c => c.Method == "TextWrapped")
.Select(c => (string)c.Args[0]!).ToList();
Assert.DoesNotContain(wrapped, t => t.Contains("coming soon"));
}
}
// -- Display tab content ---------------------------------------------
[Fact]
public void Display_tab_when_active_renders_resolution_combo_plus_sliders()
{
var (panel, _, _, _) = Build();
var r = new FakePanelRenderer { ActiveTabLabel = "Display" };
panel.Render(new PanelContext(0.016f, new NullBus()), r);
var combos = r.Calls.Where(c => c.Method == "Combo").Select(c => (string)c.Args[0]!).ToList();
var checks = r.Calls.Where(c => c.Method == "Checkbox").Select(c => (string)c.Args[0]!).ToList();
var sliders = r.Calls.Where(c => c.Method == "SliderFloat").Select(c => (string)c.Args[0]!).ToList();
Assert.Contains("Resolution", combos);
Assert.Contains("Fullscreen", checks);
Assert.Contains("V-Sync", checks);
Assert.Contains("Show FPS", checks);
Assert.Contains("Field of View", sliders);
Assert.Contains("Gamma", sliders);
}
[Fact]
public void Display_tab_does_not_render_when_a_different_tab_is_active()
{
var (panel, _, _, _) = Build();
var r = new FakePanelRenderer { ActiveTabLabel = "Audio" };
panel.Render(new PanelContext(0.016f, new NullBus()), r);
var combos = r.Calls.Where(c => c.Method == "Combo").Select(c => (string)c.Args[0]!).ToList();
Assert.DoesNotContain("Resolution", combos);
}
[Fact]
public void Display_tab_resolution_combo_uses_AvailableResolutions_list()
{
var (panel, _, _, _) = Build();
var r = new FakePanelRenderer { ActiveTabLabel = "Display" };
panel.Render(new PanelContext(0.016f, new NullBus()), r);
var resCall = r.Calls.First(c => c.Method == "Combo" && (string)c.Args[0]! == "Resolution");
var items = (string[])resCall.Args[2]!;
Assert.Contains("1920x1080", items);
Assert.Contains("3840x2160", items);
}
// -- Audio tab content -----------------------------------------------
[Fact]
public void Audio_tab_when_active_renders_implemented_volume_sliders()
{
// L.0 ships Master + SFX only — Music + Ambient sliders are
// hidden until R5 MIDI / ambient-loop engines exist. The
// AudioSettings record still carries those fields so the
// JSON round-trips, but the panel doesn't surface a slider
// that wouldn't actually do anything.
var (panel, _, _, _) = Build();
var r = new FakePanelRenderer { ActiveTabLabel = "Audio" };
panel.Render(new PanelContext(0.016f, new NullBus()), r);
var sliders = r.Calls.Where(c => c.Method == "SliderFloat")
.Select(c => (string)c.Args[0]!).ToList();
Assert.Contains("Master", sliders);
Assert.Contains("SFX", sliders);
Assert.DoesNotContain("Music", sliders);
Assert.DoesNotContain("Ambient", sliders);
}
[Fact]
public void Audio_tab_does_not_render_when_a_different_tab_is_active()
{
var (panel, _, _, _) = Build();
var r = new FakePanelRenderer { ActiveTabLabel = "Display" };
panel.Render(new PanelContext(0.016f, new NullBus()), r);
var sliders = r.Calls.Where(c => c.Method == "SliderFloat")
.Select(c => (string)c.Args[0]!).ToList();
Assert.DoesNotContain("Master", sliders);
Assert.DoesNotContain("Music", sliders);
}
// -- Gameplay tab content --------------------------------------------
[Fact]
public void Gameplay_tab_when_active_renders_expected_checkboxes()
{
var (panel, _, _, _) = Build();
var r = new FakePanelRenderer { ActiveTabLabel = "Gameplay" };
panel.Render(new PanelContext(0.016f, new NullBus()), r);
var checks = r.Calls.Where(c => c.Method == "Checkbox")
.Select(c => (string)c.Args[0]!).ToList();
// Spot check the major retail-named toggles. Don't assert exact
// count — adding new toggles shouldn't break this test.
Assert.Contains("Auto-target on attack", checks);
Assert.Contains("Auto-repeat attacks", checks);
Assert.Contains("Run mode is toggle (vs hold)", checks);
Assert.Contains("Show item tooltips", checks);
Assert.Contains("Show helm on character", checks);
Assert.Contains("Show cloak on character", checks);
Assert.Contains("Lock UI (disable panel drag/resize)", checks);
Assert.Contains("Use mouse turning", checks);
}
[Fact]
public void Gameplay_tab_does_not_render_when_a_different_tab_is_active()
{
var (panel, _, _, _) = Build();
var r = new FakePanelRenderer { ActiveTabLabel = "Display" };
panel.Render(new PanelContext(0.016f, new NullBus()), r);
var checks = r.Calls.Where(c => c.Method == "Checkbox")
.Select(c => (string)c.Args[0]!).ToList();
Assert.DoesNotContain("Auto-target on attack", checks);
Assert.DoesNotContain("Lock UI (disable panel drag/resize)", checks);
}
// -- Chat tab content ------------------------------------------------
[Fact]
public void Chat_tab_when_active_renders_channel_filter_checkboxes_and_font_slider()
{
var (panel, _, _, _) = Build();
var r = new FakePanelRenderer { ActiveTabLabel = "Chat" };
panel.Render(new PanelContext(0.016f, new NullBus()), r);
var checks = r.Calls.Where(c => c.Method == "Checkbox")
.Select(c => (string)c.Args[0]!).ToList();
Assert.Contains("General", checks);
Assert.Contains("Trade", checks);
Assert.Contains("LFG (looking for group)", checks);
Assert.Contains("Roleplay", checks);
Assert.Contains("Society (CD / EW / RB)", checks);
Assert.Contains("Show timestamps", checks);
Assert.Contains("Filter profanity", checks);
Assert.Contains("Appear offline (hide from /who)", checks);
var sliders = r.Calls.Where(c => c.Method == "SliderFloat")
.Select(c => (string)c.Args[0]!).ToList();
Assert.Contains("Font size (pt)", sliders);
}
[Fact]
public void Chat_tab_does_not_render_when_a_different_tab_is_active()
{
var (panel, _, _, _) = Build();
var r = new FakePanelRenderer { ActiveTabLabel = "Display" };
panel.Render(new PanelContext(0.016f, new NullBus()), r);
var checks = r.Calls.Where(c => c.Method == "Checkbox")
.Select(c => (string)c.Args[0]!).ToList();
// The tab labels "General", "Trade" etc only appear inside the
// Chat tab. Confirm none of them rendered.
Assert.DoesNotContain("General", checks);
Assert.DoesNotContain("Trade", checks);
}
[Fact]
public void Audio_sliders_are_clamped_to_zero_one_range()
{
var (panel, _, _, _) = Build();
var r = new FakePanelRenderer { ActiveTabLabel = "Audio" };
panel.Render(new PanelContext(0.016f, new NullBus()), r);
var masterCall = r.Calls.First(c => c.Method == "SliderFloat" && (string)c.Args[0]! == "Master");
Assert.Equal(0f, (float)masterCall.Args[2]!);
Assert.Equal(1f, (float)masterCall.Args[3]!);
}
[Fact]
public void Save_Cancel_buttons_render_outside_the_tab_bar()
{
// The global Save / Cancel / Reset-all row must come BEFORE
// BeginTabBar so it stays visible on every tab. Any change that
// accidentally moves the buttons inside a tab item should fail
// here.
var (panel, _, _, _) = Build();
var r = new FakePanelRenderer();
panel.Render(new PanelContext(0.016f, new NullBus()), r);
int saveIdx = r.Calls.FindIndex(c => c.Method == "Button"
&& (string)c.Args[0]! == "Save changes");
int tabBarIdx = r.Calls.FindIndex(c => c.Method == "BeginTabBar");
Assert.True(saveIdx >= 0);
Assert.True(tabBarIdx >= 0);
Assert.True(saveIdx < tabBarIdx,
$"Save button (index {saveIdx}) must render before BeginTabBar (index {tabBarIdx}).");
}
}

View file

@ -0,0 +1,370 @@
using System.IO;
using AcDream.UI.Abstractions.Panels.Settings;
namespace AcDream.UI.Abstractions.Tests.Panels.Settings;
/// <summary>
/// L.0: <see cref="SettingsStore"/> reads / writes <c>settings.json</c>.
/// Tests use a temp-file path so they don't touch the user's
/// %LOCALAPPDATA% file.
/// </summary>
public sealed class SettingsStoreTests : System.IDisposable
{
private readonly string _tempPath;
public SettingsStoreTests()
{
// Unique per-test file under the system temp dir so parallel test
// runners don't clobber each other.
_tempPath = Path.Combine(
Path.GetTempPath(),
$"acdream-settings-test-{System.Guid.NewGuid():N}.json");
}
public void Dispose()
{
if (File.Exists(_tempPath)) File.Delete(_tempPath);
}
[Fact]
public void LoadDisplay_returns_defaults_when_file_is_missing()
{
var store = new SettingsStore(_tempPath);
var loaded = store.LoadDisplay();
Assert.Equal(DisplaySettings.Default, loaded);
}
[Fact]
public void SaveDisplay_then_LoadDisplay_round_trips_all_fields()
{
var store = new SettingsStore(_tempPath);
var original = new DisplaySettings(
Resolution: "2560x1440",
Fullscreen: true,
VSync: false,
FieldOfView: 100f,
Gamma: 1.4f,
ShowFps: true);
store.SaveDisplay(original);
var loaded = store.LoadDisplay();
Assert.Equal(original, loaded);
}
[Fact]
public void LoadDisplay_falls_back_to_defaults_when_file_is_corrupt()
{
File.WriteAllText(_tempPath, "{ this is not valid json");
var store = new SettingsStore(_tempPath);
var loaded = store.LoadDisplay();
Assert.Equal(DisplaySettings.Default, loaded);
}
[Fact]
public void LoadDisplay_falls_back_per_field_when_keys_missing()
{
// Partial file — only resolution set; everything else should
// pick up DisplaySettings.Default values.
File.WriteAllText(_tempPath, """
{
"version": 1,
"display": { "resolution": "1366x768" }
}
""");
var store = new SettingsStore(_tempPath);
var loaded = store.LoadDisplay();
Assert.Equal("1366x768", loaded.Resolution);
Assert.Equal(DisplaySettings.Default.Fullscreen, loaded.Fullscreen);
Assert.Equal(DisplaySettings.Default.VSync, loaded.VSync);
Assert.Equal(DisplaySettings.Default.FieldOfView, loaded.FieldOfView);
}
[Fact]
public void SaveDisplay_preserves_unknown_top_level_keys()
{
// Forward-compat: a newer client may have written sections we
// don't know about (audio, gameplay). Saving display must not
// delete those, otherwise running an older client would silently
// drop the user's other-tab preferences.
File.WriteAllText(_tempPath, """
{
"version": 1,
"display": { "resolution": "1280x720" },
"audio": { "master": 0.5, "music": 0.7 }
}
""");
var store = new SettingsStore(_tempPath);
store.SaveDisplay(DisplaySettings.Default with { Resolution = "1920x1080" });
var raw = File.ReadAllText(_tempPath);
Assert.Contains("\"audio\"", raw);
Assert.Contains("\"master\"", raw);
Assert.Contains("0.5", raw);
// And the new display value did get written.
Assert.Contains("1920x1080", raw);
}
[Fact]
public void DefaultPath_is_under_LocalAppData_acdream()
{
var path = SettingsStore.DefaultPath();
Assert.EndsWith("acdream" + Path.DirectorySeparatorChar + "settings.json", path);
}
// -- Audio section round-trip ----------------------------------------
[Fact]
public void LoadAudio_returns_defaults_when_file_is_missing()
{
var store = new SettingsStore(_tempPath);
Assert.Equal(AudioSettings.Default, store.LoadAudio());
}
[Fact]
public void SaveAudio_then_LoadAudio_round_trips_all_fields()
{
var store = new SettingsStore(_tempPath);
var original = new AudioSettings(Master: 0.3f, Music: 0.45f, Sfx: 0.9f, Ambient: 0.6f);
store.SaveAudio(original);
var loaded = store.LoadAudio();
Assert.Equal(original, loaded);
}
[Fact]
public void LoadAudio_falls_back_per_field_when_keys_missing()
{
File.WriteAllText(_tempPath, """
{
"version": 1,
"audio": { "master": 0.25 }
}
""");
var store = new SettingsStore(_tempPath);
var loaded = store.LoadAudio();
Assert.Equal(0.25f, loaded.Master);
Assert.Equal(AudioSettings.Default.Music, loaded.Music);
Assert.Equal(AudioSettings.Default.Sfx, loaded.Sfx);
Assert.Equal(AudioSettings.Default.Ambient, loaded.Ambient);
}
[Fact]
public void SaveAudio_preserves_display_section()
{
// Save display first, then audio — display values must survive.
var store = new SettingsStore(_tempPath);
store.SaveDisplay(DisplaySettings.Default with { Resolution = "2560x1440" });
store.SaveAudio(AudioSettings.Default with { Master = 0.4f });
Assert.Equal("2560x1440", store.LoadDisplay().Resolution);
Assert.Equal(0.4f, store.LoadAudio().Master);
}
[Fact]
public void SaveDisplay_after_SaveAudio_preserves_audio_section()
{
// Reverse order — audio must survive a subsequent display save.
var store = new SettingsStore(_tempPath);
store.SaveAudio(AudioSettings.Default with { Music = 0.1f });
store.SaveDisplay(DisplaySettings.Default with { ShowFps = true });
Assert.Equal(0.1f, store.LoadAudio().Music);
Assert.True(store.LoadDisplay().ShowFps);
}
// -- Gameplay section round-trip --------------------------------------
[Fact]
public void LoadGameplay_returns_defaults_when_file_is_missing()
{
var store = new SettingsStore(_tempPath);
Assert.Equal(GameplaySettings.Default, store.LoadGameplay());
}
[Fact]
public void SaveGameplay_then_LoadGameplay_round_trips_all_fields()
{
var store = new SettingsStore(_tempPath);
var original = GameplaySettings.Default with
{
AutoTarget = false,
AdvancedCombatUI = true,
ShowHelm = false,
LockUI = true,
UseMouseTurning = true,
};
store.SaveGameplay(original);
var loaded = store.LoadGameplay();
Assert.Equal(original, loaded);
}
[Fact]
public void LoadGameplay_falls_back_per_field_when_keys_missing()
{
File.WriteAllText(_tempPath, """
{
"version": 1,
"gameplay": { "lockUI": true }
}
""");
var store = new SettingsStore(_tempPath);
var loaded = store.LoadGameplay();
Assert.True(loaded.LockUI);
Assert.Equal(GameplaySettings.Default.AutoTarget, loaded.AutoTarget);
Assert.Equal(GameplaySettings.Default.ShowHelm, loaded.ShowHelm);
}
[Fact]
public void All_three_sections_coexist_in_one_settings_json()
{
var store = new SettingsStore(_tempPath);
store.SaveDisplay(DisplaySettings.Default with { Resolution = "2560x1440" });
store.SaveAudio(AudioSettings.Default with { Master = 0.5f });
store.SaveGameplay(GameplaySettings.Default with { LockUI = true });
// All three load correctly from the same file.
Assert.Equal("2560x1440", store.LoadDisplay().Resolution);
Assert.Equal(0.5f, store.LoadAudio().Master);
Assert.True(store.LoadGameplay().LockUI);
}
// -- Chat section round-trip ------------------------------------------
[Fact]
public void LoadChat_returns_defaults_when_file_is_missing()
{
var store = new SettingsStore(_tempPath);
Assert.Equal(ChatSettings.Default, store.LoadChat());
}
[Fact]
public void SaveChat_then_LoadChat_round_trips_all_fields()
{
var store = new SettingsStore(_tempPath);
var original = new ChatSettings(
HearGeneralChat: false,
HearTradeChat: false,
HearLFGChat: false,
HearRoleplayChat: true,
HearSocietyChat: true,
AppearOffline: true,
ShowTimestamps: false,
FilterProfanity: false,
FontSize: 16f);
store.SaveChat(original);
Assert.Equal(original, store.LoadChat());
}
[Fact]
public void All_four_sections_coexist_in_one_settings_json()
{
var store = new SettingsStore(_tempPath);
store.SaveDisplay(DisplaySettings.Default with { Resolution = "2560x1440" });
store.SaveAudio(AudioSettings.Default with { Master = 0.5f });
store.SaveGameplay(GameplaySettings.Default with { LockUI = true });
store.SaveChat(ChatSettings.Default with { HearTradeChat = false, FontSize = 14f });
Assert.Equal("2560x1440", store.LoadDisplay().Resolution);
Assert.Equal(0.5f, store.LoadAudio().Master);
Assert.True(store.LoadGameplay().LockUI);
Assert.False(store.LoadChat().HearTradeChat);
Assert.Equal(14f, store.LoadChat().FontSize);
}
// -- Character section round-trip (per-toon) --------------------------
[Fact]
public void LoadCharacter_returns_defaults_when_file_is_missing()
{
var store = new SettingsStore(_tempPath);
Assert.Equal(CharacterSettings.Default, store.LoadCharacter("default"));
}
[Fact]
public void LoadCharacter_returns_defaults_when_toonKey_not_in_file()
{
// File exists with a different toon's data; asking for "+Acdream"
// returns defaults rather than the other toon's data.
var store = new SettingsStore(_tempPath);
store.SaveCharacter("Bob", CharacterSettings.Default with { AutoAttack = true });
var loaded = store.LoadCharacter("+Acdream");
Assert.Equal(CharacterSettings.Default, loaded);
}
[Fact]
public void SaveCharacter_then_LoadCharacter_round_trips_all_fields()
{
var store = new SettingsStore(_tempPath);
var original = new CharacterSettings(
DefaultChatChannel: "Allegiance",
AutoAttack: true,
ConfirmSalvage: false,
ShowPickupMessages: false);
store.SaveCharacter("+Acdream", original);
Assert.Equal(original, store.LoadCharacter("+Acdream"));
}
[Fact]
public void SaveCharacter_preserves_other_toons_within_character_section()
{
// Two different toons, each with distinct settings — saving one
// must not clobber the other.
var store = new SettingsStore(_tempPath);
var alice = CharacterSettings.Default with { DefaultChatChannel = "Allegiance" };
var bob = CharacterSettings.Default with { DefaultChatChannel = "Fellowship", AutoAttack = true };
store.SaveCharacter("Alice", alice);
store.SaveCharacter("Bob", bob);
Assert.Equal(alice, store.LoadCharacter("Alice"));
Assert.Equal(bob, store.LoadCharacter("Bob"));
}
[Fact]
public void SaveCharacter_preserves_other_top_level_sections()
{
// Display/audio survive when SaveCharacter writes its nested map.
var store = new SettingsStore(_tempPath);
store.SaveDisplay(DisplaySettings.Default with { Resolution = "2560x1440" });
store.SaveAudio(AudioSettings.Default with { Master = 0.4f });
store.SaveCharacter("+Acdream", CharacterSettings.Default with { AutoAttack = true });
Assert.Equal("2560x1440", store.LoadDisplay().Resolution);
Assert.Equal(0.4f, store.LoadAudio().Master);
Assert.True(store.LoadCharacter("+Acdream").AutoAttack);
}
[Fact]
public void All_five_sections_coexist_in_one_settings_json()
{
var store = new SettingsStore(_tempPath);
store.SaveDisplay(DisplaySettings.Default with { Resolution = "2560x1440" });
store.SaveAudio(AudioSettings.Default with { Master = 0.5f });
store.SaveGameplay(GameplaySettings.Default with { LockUI = true });
store.SaveChat(ChatSettings.Default with { HearTradeChat = false });
store.SaveCharacter("+Acdream",
CharacterSettings.Default with { DefaultChatChannel = "Fellowship" });
Assert.Equal("2560x1440", store.LoadDisplay().Resolution);
Assert.Equal(0.5f, store.LoadAudio().Master);
Assert.True(store.LoadGameplay().LockUI);
Assert.False(store.LoadChat().HearTradeChat);
Assert.Equal("Fellowship", store.LoadCharacter("+Acdream").DefaultChatChannel);
}
}

View file

@ -16,16 +16,33 @@ namespace AcDream.UI.Abstractions.Tests.Panels.Settings;
/// </summary>
public sealed class SettingsVMTests
{
private static (SettingsVM vm, FakeKeyboardSource kb, InputDispatcher dispatcher, KeyBindings persisted, System.Collections.Generic.List<KeyBindings> savedHistory)
Build(KeyBindings? persisted = null)
private static (SettingsVM vm, FakeKeyboardSource kb, InputDispatcher dispatcher, KeyBindings persisted, System.Collections.Generic.List<KeyBindings> savedHistory, System.Collections.Generic.List<DisplaySettings> savedDisplayHistory, System.Collections.Generic.List<AudioSettings> savedAudioHistory, System.Collections.Generic.List<GameplaySettings> savedGameplayHistory, System.Collections.Generic.List<ChatSettings> savedChatHistory, System.Collections.Generic.List<CharacterSettings> 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<KeyBindings>();
var vm = new SettingsVM(persisted, dispatcher, b => savedHistory.Add(b));
return (vm, kb, dispatcher, persisted, savedHistory);
var savedDisplayHistory = new System.Collections.Generic.List<DisplaySettings>();
var savedAudioHistory = new System.Collections.Generic.List<AudioSettings>();
var savedGameplayHistory = new System.Collections.Generic.List<GameplaySettings>();
var savedChatHistory = new System.Collections.Generic.List<ChatSettings>();
var savedCharacterHistory = new System.Collections.Generic.List<CharacterSettings>();
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);
}
}