diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs
index 6e102a5..e6b4f88 100644
--- a/src/AcDream.App/Rendering/GameWindow.cs
+++ b/src/AcDream.App/Rendering/GameWindow.cs
@@ -901,22 +901,34 @@ public sealed class GameWindow : IDisposable
// the same OnLoad path (see _inputDispatcher field).
if (_inputDispatcher is not null)
{
- // L.0 — settings.json (display + audio + future gameplay /
- // chat / character tabs). Coexists with keybinds.json,
- // which keeps its own load/save path.
- var settingsStore = new AcDream.UI.Abstractions.Panels.Settings.SettingsStore(
+ // L.0 — settings.json (display + audio + gameplay + chat
+ // + character). Coexists with keybinds.json, which
+ // keeps its own load/save path. Field-stored so the
+ // post-EnterWorld branch can re-load the chosen
+ // toon's character bag.
+ _settingsStore = new AcDream.UI.Abstractions.Panels.Settings.SettingsStore(
AcDream.UI.Abstractions.Panels.Settings.SettingsStore.DefaultPath());
+ var settingsStore = _settingsStore;
var persistedDisplay = settingsStore.LoadDisplay();
var persistedAudio = settingsStore.LoadAudio();
var persistedGameplay = settingsStore.LoadGameplay();
var persistedChat = settingsStore.LoadChat();
- // Per-toon character settings keyed by name. We don't
- // know which toon the user will pick until after
- // CharacterList lands, so use a "default" bag for now.
- // Future: swap to the actual toon name once a
- // currentCharacter source is plumbed.
- const string toonKey = "default";
- var persistedCharacter = settingsStore.LoadCharacter(toonKey);
+ // Character bag is loaded against _activeToonKey ("default"
+ // until BeginLiveSessionAsync swaps in the real name).
+ var persistedCharacter = settingsStore.LoadCharacter(_activeToonKey);
+
+ // L.0 Display tab — apply persisted window-level
+ // settings BEFORE the first frame so the user's saved
+ // VSync / Resolution / Fullscreen take effect from
+ // the start instead of flashing the WindowOptions
+ // defaults. FOV and ShowFps come through the
+ // per-frame push in OnRender so live preview works.
+ if (_window is not null)
+ {
+ if (_window.VSync != persistedDisplay.VSync)
+ _window.VSync = persistedDisplay.VSync;
+ ApplyDisplayWindowState(persistedDisplay);
+ }
// Apply persisted audio to the engine BEFORE the panel
// host starts pushing per-frame so the first frame uses
@@ -957,6 +969,12 @@ public sealed class GameWindow : IDisposable
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)
{
@@ -1020,9 +1038,14 @@ public sealed class GameWindow : IDisposable
{
try
{
- settingsStore.SaveCharacter(toonKey, character);
+ // _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[{toonKey}] saved to "
+ $"settings: character[{_activeToonKey}] saved to "
+ AcDream.UI.Abstractions.Panels.Settings.SettingsStore.DefaultPath());
}
catch (Exception ex)
@@ -1501,6 +1524,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
@@ -4209,6 +4246,25 @@ public sealed class GameWindow : IDisposable
_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)
@@ -4492,9 +4548,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;
@@ -5337,6 +5400,55 @@ 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 Display tab: apply the window-state-dependent settings
+ /// (Resolution + Fullscreen) from a
+ /// to the live Silk.NET window. Called at startup (with persisted
+ /// values) and on every Save (with the saved values). Resolution
+ /// parses "WIDTHxHEIGHT" (e.g. "1920x1080"); a malformed
+ /// or unparseable string is silently ignored to avoid crashing the
+ /// client mid-session.
+ ///
+ private void ApplyDisplayWindowState(
+ AcDream.UI.Abstractions.Panels.Settings.DisplaySettings display)
+ {
+ if (_window is null) return;
+
+ // Resolution: parse and resize if changed.
+ if (TryParseResolution(display.Resolution, out int w, out int h))
+ {
+ if (_window.Size.X != w || _window.Size.Y != h)
+ _window.Size = new Silk.NET.Maths.Vector2D(w, h);
+ }
+
+ // Fullscreen: borderless via Silk.NET's WindowState.Fullscreen
+ // (no exclusive-mode DXGI dance needed).
+ var desiredState = display.Fullscreen
+ ? Silk.NET.Windowing.WindowState.Fullscreen
+ : Silk.NET.Windowing.WindowState.Normal;
+ if (_window.WindowState != desiredState)
+ _window.WindowState = desiredState;
+ }
+
+ private static bool TryParseResolution(string spec, out int width, out int height)
+ {
+ width = height = 0;
+ if (string.IsNullOrWhiteSpace(spec)) return false;
+ var parts = spec.Split('x', 2);
+ if (parts.Length != 2) return false;
+ return int.TryParse(parts[0], out width)
+ && int.TryParse(parts[1], out height)
+ && width > 0
+ && height > 0;
+ }
// Vitals panel reference cached for the View menu's toggle entry.
private AcDream.UI.Abstractions.Panels.Vitals.VitalsPanel? _vitalsPanel;
diff --git a/src/AcDream.UI.Abstractions/Panels/Settings/DisplaySettings.cs b/src/AcDream.UI.Abstractions/Panels/Settings/DisplaySettings.cs
index dd89b6c..505a2d3 100644
--- a/src/AcDream.UI.Abstractions/Panels/Settings/DisplaySettings.cs
+++ b/src/AcDream.UI.Abstractions/Panels/Settings/DisplaySettings.cs
@@ -22,14 +22,17 @@ public sealed record DisplaySettings(
float Gamma,
bool ShowFps)
{
- /// Values used on first launch / when settings.json is absent.
+ /// Values used on first launch / when settings.json is absent.
+ /// FieldOfView (60°) and VSync (false) match the camera + window
+ /// defaults that shipped before L.0, so opening Display + Save
+ /// without touching anything is a visual no-op.
public static DisplaySettings Default { get; } = new(
Resolution: "1920x1080",
Fullscreen: false,
- VSync: true,
- FieldOfView: 75f,
+ VSync: false,
+ FieldOfView: 60f,
Gamma: 1.0f,
- ShowFps: false);
+ ShowFps: true);
/// 16:9 resolution presets offered in the dropdown.
public static IReadOnlyList AvailableResolutions { get; } = new[]
diff --git a/src/AcDream.UI.Abstractions/Panels/Settings/SettingsVM.cs b/src/AcDream.UI.Abstractions/Panels/Settings/SettingsVM.cs
index c32fade..f4bcc36 100644
--- a/src/AcDream.UI.Abstractions/Panels/Settings/SettingsVM.cs
+++ b/src/AcDream.UI.Abstractions/Panels/Settings/SettingsVM.cs
@@ -196,6 +196,21 @@ public sealed class SettingsVM
_characterDraft = value ?? throw new ArgumentNullException(nameof(value));
}
+ ///
+ /// Replace BOTH the persisted snapshot and the live draft for the
+ /// Character bag. Used when the active toon changes (e.g. on
+ /// EnterWorld with a non-default character) — the host loads that
+ /// toon's settings from disk and pushes them into the VM here so
+ /// doesn't flag the swap as a
+ /// pending edit. Differs from , which
+ /// updates draft only.
+ ///
+ public void LoadCharacterContext(CharacterSettings persisted)
+ {
+ _characterPersisted = persisted ?? throw new ArgumentNullException(nameof(persisted));
+ _characterDraft = persisted;
+ }
+
///
/// Begin rebinding . The supplied
/// binding will be removed when the new
diff --git a/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/DisplaySettingsTests.cs b/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/DisplaySettingsTests.cs
index f73db09..9e4e13d 100644
--- a/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/DisplaySettingsTests.cs
+++ b/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/DisplaySettingsTests.cs
@@ -11,15 +11,19 @@ namespace AcDream.UI.Abstractions.Tests.Panels.Settings;
public sealed class DisplaySettingsTests
{
[Fact]
- public void Default_values_match_brainstorm_agreement()
+ public void Default_values_match_pre_L0_runtime_state()
{
+ // Defaults pinned to match the camera FovY (60° = π/3) and the
+ // pre-L.0 window options (VSync off, FPS in title bar). Opening
+ // Display + Save without touching anything must NOT change the
+ // user's visual experience.
var d = DisplaySettings.Default;
Assert.Equal("1920x1080", d.Resolution);
Assert.False(d.Fullscreen);
- Assert.True(d.VSync);
- Assert.Equal(75f, d.FieldOfView);
+ Assert.False(d.VSync);
+ Assert.Equal(60f, d.FieldOfView);
Assert.Equal(1.0f, d.Gamma);
- Assert.False(d.ShowFps);
+ Assert.True(d.ShowFps);
}
[Fact]
@@ -43,8 +47,8 @@ public sealed class DisplaySettingsTests
public void Equality_is_value_based()
{
var a = DisplaySettings.Default;
- var b = DisplaySettings.Default with { ShowFps = true };
- var c = DisplaySettings.Default with { ShowFps = true };
+ var b = DisplaySettings.Default with { Fullscreen = true };
+ var c = DisplaySettings.Default with { Fullscreen = true };
Assert.NotEqual(a, b);
Assert.Equal(b, c);
}
@@ -56,7 +60,8 @@ public sealed class DisplaySettingsTests
Assert.Equal(90f, d.FieldOfView);
// Other fields untouched.
Assert.Equal("1920x1080", d.Resolution);
- Assert.True(d.VSync);
+ Assert.False(d.VSync);
+ Assert.True(d.ShowFps);
}
private static int ParseWidth(string res)
diff --git a/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsVMTests.cs b/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsVMTests.cs
index 1bedd6c..b892cf1 100644
--- a/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsVMTests.cs
+++ b/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsVMTests.cs
@@ -271,7 +271,9 @@ public sealed class SettingsVMTests
public void SetDisplay_marks_unsaved_changes()
{
var (vm, _, _, _, _, _, _, _, _, _) = Build();
- vm.SetDisplay(vm.DisplayDraft with { ShowFps = true });
+ // 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);
}
@@ -584,4 +586,39 @@ public sealed class SettingsVMTests
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);
+ }
}