From fec0d94148fb65fef720108504ce781b9f313646 Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 22 Jul 2026 13:30:22 +0200 Subject: [PATCH] refactor(settings): own two-phase runtime settings Move pre-window loading, startup application, live settings mutation, toon context, quality reapply, and SettingsVM loans behind one RuntimeSettingsController. Preserve retail command behavior, ordered target publication, draft semantics, and retryable failure convergence while removing duplicate GameWindow state and feature bodies. Co-authored-by: Codex --- AGENTS.md | 15 +- CLAUDE.md | 15 +- docs/ISSUES.md | 9 +- docs/architecture/acdream-architecture.md | 10 +- docs/architecture/code-structure.md | 22 +- .../retail-divergence-register.md | 3 +- docs/plans/2026-04-11-roadmap.md | 13 +- docs/plans/2026-05-12-milestones.md | 13 +- ...w-slice-8-checkpoint-g-runtime-settings.md | 132 ++ ...amewindow-slice-8-composition-lifecycle.md | 25 +- memory/project_gamewindow_decomposition.md | 33 +- src/AcDream.App/Rendering/GameWindow.cs | 552 ++------ src/AcDream.App/Rendering/TerrainAtlas.cs | 3 +- .../Rendering/Wb/WbDrawDispatcher.cs | 3 +- .../Rendering/WorldRenderFrameBuilder.cs | 32 +- .../Settings/RuntimeSettingsController.cs | 471 +++++++ .../Settings/RuntimeSettingsTargets.cs | 255 ++++ .../Update/LiveObjectFrameController.cs | 11 +- .../Panels/Settings/SettingsVM.cs | 15 + .../GameWindowSlice8BoundaryTests.cs | 74 +- .../PrivatePresentationRendererTests.cs | 2 +- .../RuntimeSettingsControllerTests.cs | 1159 +++++++++++++++++ .../World/LiveObjectFrameControllerTests.cs | 73 +- .../Panels/Settings/SettingsVMTests.cs | 38 + 24 files changed, 2379 insertions(+), 599 deletions(-) create mode 100644 docs/plans/2026-07-22-gamewindow-slice-8-checkpoint-g-runtime-settings.md create mode 100644 src/AcDream.App/Settings/RuntimeSettingsController.cs create mode 100644 src/AcDream.App/Settings/RuntimeSettingsTargets.cs create mode 100644 tests/AcDream.App.Tests/Settings/RuntimeSettingsControllerTests.cs diff --git a/AGENTS.md b/AGENTS.md index b4af475e..b5d4a495 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -127,7 +127,7 @@ second GUID dictionary. Slices 5–7 are complete: landblock presentation, update-frame orchestration, and render-frame orchestration all have typed owners. `GameWindow.OnUpdate` and `GameWindow.OnRender` are one-handoff methods; the frame owners have no direct window or anonymous callback-bag ownership. -Slice 8 checkpoints A–F are also complete: dead host residue is removed, +Slice 8 checkpoints A–G are also complete: dead host residue is removed, native-window callback intake has reversible lifetime ownership, and `LiveSessionHost` owns reset/binding composition over the sole canonical session controller. `WorldEnvironmentController` solely owns the clock, DAT @@ -139,11 +139,14 @@ transactional, and terminal after disposal; live-session retirement precedes physical callback removal. `GameplayInputActionRouter` now owns the sole semantic action-priority graph; typed owners route combat, diagnostics, and remaining commands, and the -retained-root item-drop edge is reversible. `GameWindow` is 4,057 raw lines / -198 fields / 54 methods, down 11,666 lines (74.2%) from the 15,723-line campaign -baseline. The 7,524-test Release suite, 315.6-second lifecycle/reconnect gate, -and 395.2-second synchronized nine-stop soak pass. Slice 8 checkpoints G–L— -settings, resource ownership, ordered composition, +retained-root item-drop edge is reversible. `GameWindow` was 4,057 raw lines / +198 fields / 54 methods at F. Checkpoint G adds the sole pre-window/runtime +settings owner, one immutable startup snapshot, ordered one-shot startup +application, and inert late target binding. `GameWindow` is now 3,663 raw lines / +162 fields / 37 methods, down 12,060 lines (76.7%) from the 15,723-line campaign +baseline. The 7,553-test / 5-skip Checkpoint G Release suite, 315.6-second +lifecycle/reconnect gate, and 395.2-second synchronized nine-stop soak pass. +Slice 8 checkpoints H–L—resource ownership, ordered composition, retryable shutdown, soak snapshots, and final connected gates—remain active. See `docs/plans/2026-07-22-gamewindow-slice-8-composition-lifecycle.md` and `docs/architecture/code-structure.md`. **Carried:** #232, #153, diff --git a/CLAUDE.md b/CLAUDE.md index 1ddc71c7..518ad669 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -125,7 +125,7 @@ second GUID dictionary. Slices 5–7 are complete: landblock presentation, update-frame orchestration, and render-frame orchestration all have typed owners. `GameWindow.OnUpdate` and `GameWindow.OnRender` are one-handoff methods; the frame owners have no direct window or anonymous callback-bag ownership. -Slice 8 checkpoints A–F are also complete: dead host residue is removed, +Slice 8 checkpoints A–G are also complete: dead host residue is removed, native-window callback intake has reversible lifetime ownership, and `LiveSessionHost` owns reset/binding composition over the sole canonical session controller. `WorldEnvironmentController` solely owns the clock, DAT @@ -137,11 +137,14 @@ transactional, and terminal after disposal; live-session retirement precedes physical callback removal. `GameplayInputActionRouter` now owns the sole semantic action-priority graph; typed owners route combat, diagnostics, and remaining commands, and the -retained-root item-drop edge is reversible. `GameWindow` is 4,057 raw lines / -198 fields / 54 methods, down 11,666 lines (74.2%) from the 15,723-line campaign -baseline. The 7,524-test Release suite, 315.6-second lifecycle/reconnect gate, -and 395.2-second synchronized nine-stop soak pass. Slice 8 checkpoints G–L— -settings, resource ownership, ordered composition, +retained-root item-drop edge is reversible. `GameWindow` was 4,057 raw lines / +198 fields / 54 methods at F. Checkpoint G adds the sole pre-window/runtime +settings owner, one immutable startup snapshot, ordered one-shot startup +application, and inert late target binding. `GameWindow` is now 3,663 raw lines / +162 fields / 37 methods, down 12,060 lines (76.7%) from the 15,723-line campaign +baseline. The 7,553-test / 5-skip Checkpoint G Release suite, 315.6-second +lifecycle/reconnect gate, and 395.2-second synchronized nine-stop soak pass. +Slice 8 checkpoints H–L—resource ownership, ordered composition, retryable shutdown, soak snapshots, and final connected gates—remain active. See `docs/plans/2026-07-22-gamewindow-slice-8-composition-lifecycle.md` and `docs/architecture/code-structure.md`. **Carried:** #232, #153, diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 194d46b8..8cf014d4 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -27,13 +27,14 @@ What does NOT go here: ## Current queue — 2026-07-22 - **Active structural work:** `GameWindow` decomposition. Slices 1–7 are - complete; Slice 8 checkpoints A–F now own native callbacks, live-session + complete; Slice 8 checkpoints A–G now own native callbacks, live-session composition, world-environment state, reversible device input/raw pointer, physical framebuffer publication, gameplay action priority, typed commands, - and the retained-root item-drop edge outside the shell. + the retained-root item-drop edge, and two-phase runtime settings outside the + shell. `GameWindow.OnUpdate` and `GameWindow.OnRender` are typed orchestration - handoffs and the class is 4,057 lines / 198 fields / 54 methods. Remaining - Slice 8 settings/resource/composition/shutdown + handoffs and the class is 3,663 lines / 162 fields / 37 methods. Remaining + Slice 8 resource/composition/shutdown cleanup is active in [`docs/architecture/code-structure.md`](architecture/code-structure.md). This is the behavior-preserving prerequisite before new M4 feature bodies. diff --git a/docs/architecture/acdream-architecture.md b/docs/architecture/acdream-architecture.md index a5f62057..c2605a03 100644 --- a/docs/architecture/acdream-architecture.md +++ b/docs/architecture/acdream-architecture.md @@ -829,11 +829,15 @@ Current movement/collision ownership: routers; Slice 4 moved hydration, teardown, inbound authority, network updates, and reusable motion-runtime policy over canonical `LiveEntityRuntime`. Slices 5–7 complete landblock presentation and the - update/render frame graphs. Slice 8 checkpoints A–F now own the native + update/render frame graphs. Slice 8 checkpoints A–G now own the native callback shell, live-session composition, world environment, reversible input/pointer lifetime, physical framebuffer publication, the sole gameplay - action-priority graph, typed commands, and the retained-root item-drop edge. - Two-phase runtime settings is the next checkpoint. The reconciled ledger and sequence live in + action-priority graph, typed commands, the retained-root item-drop edge, and + two-phase runtime settings. `RuntimeSettingsController` is the sole settings + persistence/current-state owner, supplies the immutable pre-window snapshot, + applies startup pacing/display/FOV/audio once, and later borrows complete + runtime targets without replay. Explicit resource ownership and frame slots + are the next checkpoint. The reconciled ledger and sequence live in `docs/architecture/code-structure.md`. This decision does not reclassify the remaining L.2 conformance work or Track LH. diff --git a/docs/architecture/code-structure.md b/docs/architecture/code-structure.md index 3cf636e6..70e4a417 100644 --- a/docs/architecture/code-structure.md +++ b/docs/architecture/code-structure.md @@ -34,6 +34,7 @@ after Slice 7 draw-frame cutover 4,666 lines / 196 fields / 70 method after Slice 8 checkpoint D 4,330 lines / 192 fields / 67 methods after Slice 8 checkpoint E 4,266 lines / 194 fields / 65 methods after Slice 8 checkpoint F 4,057 lines / 198 fields / 54 methods +after Slice 8 checkpoint G 3,663 lines / 162 fields / 37 methods ``` `GameWindow` is the single object that: @@ -195,8 +196,9 @@ documentation, not an exhaustive allowlist): shared root and sibling controls. - `DevToolsFramePresenter -> DevToolsPanelSet -> panel/ViewModel bindings` for the optional developer UI. -- `WorldRenderFrameBuilder -> RuntimeWorldFrameSettingsPreview -> SettingsVM` - for the live settings draft preview applied before world drawing. +- `WorldRenderFrameBuilder -> RuntimeWorldFrameSettingsPreview -> + IRuntimeSettingsPreviewSource -> RuntimeSettingsController -> optional + SettingsVM` for the live settings draft preview applied before world drawing. - `LocalPlayerPortalViewport -> LocalPlayerTeleportController -> GameplayInputFrameController -> InputDispatcher.Fired -> GameWindow` for the canonical portal/input lifetime and the host's input-action subscription. @@ -692,7 +694,7 @@ slices. `OnClosing` delegates to the existing retryable shutdown transaction. Silk callbacks become narrow calls into the input, update, render, resize, focus, and shutdown owners. -Checkpoints E and F are complete. `CameraPointerInputController` owns raw pointer, +Checkpoints E, F, and G are complete. `CameraPointerInputController` owns raw pointer, camera cursor/focus/scroll/sensitivity behavior; `FramebufferResizeController` alone publishes physical framebuffer changes. All Silk, dispatcher, retained UI, and optional devtools device edges are reversible, transactional, and @@ -705,8 +707,18 @@ subscriber and preserves the accepted input-priority graph. Focused combat, diagnostic, and general command owners replace the remaining window command bodies. The retained-root item-drop edge has one reversible owner, while its deferred toolbar targets become inert before session retirement. `GameWindow` -is now 4,057 raw lines / 198 fields / 54 methods. Checkpoints G–L remain -active, with two-phase runtime settings next. +now has no settings-store construction, duplicate settings loads, persisted +settings mirrors, or display/quality feature bodies. `RuntimeSettingsController` +is constructed before `Window.Create`, owns the one immutable startup snapshot +and current/toon settings state, applies startup display/pacing/FOV/audio once, +then borrows late-bound runtime targets without replay. Saved FOV now applies at +startup even when devtools are disabled; this is a deliberate correction of the +old accidental SettingsVM gate. The controller also preserves unrelated unsaved +Gameplay draft fields when combat preferences change. `GameWindow` is now 3,663 +raw lines / 162 fields / 37 methods. Checkpoints H–L remain active, with explicit +resource ownership and frame slots next. The Checkpoint G production App build +is warning-free, all three corrected-diff reviews are clean, and the complete +Release suite passes 7,553 tests / 5 intentional skips. ### 4.4 Exit criteria diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 29c0347f..66b0a092 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -107,7 +107,7 @@ accepted-divergence entries (#96, #49, #50). --- -## 3. Documented approximation (AP) — 87 active rows +## 3. Documented approximation (AP) — 88 active rows Wave-0 UI ledger repair (2026-07-10) retired stale AP-38, resolved the AP-84 collision, restored overwritten paperdoll rows as AP-92/AP-93, and registered @@ -217,6 +217,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps. | AP-118 | An AutoWield transaction begun in active combat preserves the ready mode implied by the requested weapon. After authoritative `WieldObject`, a mode that settled without a blocker transition clears immediately; local ACE's observed pre-wield transition plus `ready -> NonCombat`, or post-wield `NonCombat -> ready -> NonCombat`, causes one normal `ChangeCombatMode` request from the trailing notice. Explicit user combat input cancels settlement. Retail's client does not need this extra request against the retail server. | `src/AcDream.App/UI/AutoWieldController.cs`; production binding in `GameWindow.cs` | Local ACE queues a trailing NonCombat callback during primary-weapon replacement and rejects an earlier request while the shuffle is busy; responding to the authoritative notice that completes that exact sequence orders the ordinary request after it without suppressing any server state | A non-ACE server that emits a different intermediate sequence can retain the settlement until a later explicit combat request, replacement, or logout clears it; peace-mode equips send none | `CPlayerSystem::AutoWield @ 0x00560A60`; `ACCWeenieObject::ServerSaysMoveItem @ 0x0058DBB0`; ACE `Player_Inventory.TryShuffleStance` / `TryDequipObjectWithNetworking` | | AP-119 | Equal-generation CreateObject refresh applies the packet's complete `PhysicsDesc` to the existing `EntityEffectProfile`, including replacing its network sound/PES-table/default-script description. Retail's equal-`INSTANCE_TS` branch applies the individual ObjDesc/Parent-or-Position/Movement/State/Vector/WeenieDesc tail and does not call `CPhysicsObj::set_description` again. | `src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs` (`ILiveEntitySameGenerationUpdateSink.OnDescription`); `LiveEntitySameGenerationUpdateRouter.cs` | Existing spell/projectile/portal VFX tests and connected behavior were accepted with this refresh. Slice 4 records and isolates it rather than silently changing DAT-effect ownership during an architecture extraction. | A same-generation CreateObject whose PeTable/sound/default-script fields differ from the original can replace effect lookup state where retail would retain the original table, producing a different later typed effect. | `SmartBox::HandleCreateObject @ 0x00454C80`; `CPhysicsObj::set_description @ 0x00514F40`; `docs/research/2026-07-13-retail-projectile-vfx-pseudocode.md` | | AP-120 | `ObjectTableWiring.ApplyEntitySpawn` publishes the CreateObject's WeenieDesc/item state before the same-generation physics update tail. Retail applies WeenieDesc after ObjDesc, Parent-or-Position/Pickup, Movement, State, and Vector. | `src/AcDream.Core.Net/ObjectTableWiring.cs` (`ApplyEntitySpawn`); `src/AcDream.App/World/LiveEntitySameGenerationUpdateRouter.cs` | Core.Net owns item-table ingestion before App callbacks and the current single-thread FIFO prevents a second network packet from interleaving; changing publication order crosses the Core.Net/App ownership boundary and requires a separately tested event transaction. | A synchronous item-table observer can see the refreshed WeenieDesc while the same object's physics/parent/state still reflects the prior snapshot; retail observers see the completed physics tail first. | `SmartBox::HandleCreateObject @ 0x00454C80`; `ACCObjectMaint::CreateObject @ 0x00558870` | +| AP-121 | The `/framerate` command persists acdream's live ShowFps value in the modern `settings.json` Display bag after performing retail's live toggle/notice behavior. The named retail `DoFrameRate` body itself only flips `fShowFramerate` and sends `SetFramerateDisplay`; no equivalent persistence write occurs in that command body. | `src/AcDream.App/Settings/RuntimeSettingsController.cs` (`ToggleFrameRate`) | This preserves acdream's shipped L.0 cross-launch display preference while keeping the live retail mechanism and SmartBox visibility result exact; it changes no gameplay or wire state | A relaunch can retain FPS visibility where the named retail command body alone would not; a future port of retail's wider preference owner must avoid double-persisting or applying conflicting startup state | `ClientCommunicationSystem::DoFrameRate @ 0x005707D0`; `CM_UI::SendNotice_SetFramerateDisplay @ 0x0047A050`; `gmSmartBoxUI::RecvNotice_SetFramerateDisplay @ 0x004D65E0` | ## 4. Temporary stopgap (TS) — 38 active rows + TS-37 historical note (TS-20 retired 2026-07-16 — the later named-retail audit disproved the proposed DrawingBSP polygon filter; TS-37 is a retired-row historical note, not an active count; TS-39 retired R5-V3 — sticky seams bound to the ported PositionManager/StickyManager, radii threaded; TS-45 retired 2026-07-07 — hand-rolled `SphereCollision` replaced by the faithful CSphere family port, fixing the player-vs-monster crowd wedge; TS-3 retired 2026-07-07 — `frames_stationary_fall` accounting ported in the #182 verbatim UpdateObjectInternal rebuild, fixing the airborne falling-animation wedge; TS-41 retired 2026-07-07 — SERVERVEL synth-velocity remote body-drive replaced by the retail interp catch-up + unconditional MovementManager::UseTime, the remote-creature de-overlap #184; TS-42 retired 2026-07-19 — semantic animation completion now precedes the ordered Target/Movement/PartArray/Position tail; TS-44 narrowed again 2026-07-19 — complete orientation joined interpolation, only during-stick enqueue suppression remains) diff --git a/docs/plans/2026-04-11-roadmap.md b/docs/plans/2026-04-11-roadmap.md index 4b46c03b..dd23dcdb 100644 --- a/docs/plans/2026-04-11-roadmap.md +++ b/docs/plans/2026-04-11-roadmap.md @@ -1,6 +1,6 @@ # acdream — strategic roadmap -**Status:** Living document. Updated 2026-07-22. **M3 landed; M4 is next after the active `GameWindow` structural prerequisite.** M3's retail casting/UI, R6 locomotion/collision/projectile/teleport/radar rebaseline, deterministic fresh-login/portal world lifecycle, and final two-client portal observer flow are user-gated. The current program is the behavior-preserving ownership campaign in [`docs/architecture/code-structure.md`](../architecture/code-structure.md); Slices 1–7 and Slice 8 checkpoints A–F are complete, with the remaining composition/shutdown checkpoints active. New M4 quest/emote/character-creation feature bodies wait until that campaign is complete. Issue #225's lifestone/particle alpha comparison remains a separate rendering visual gate. +**Status:** Living document. Updated 2026-07-22. **M3 landed; M4 is next after the active `GameWindow` structural prerequisite.** M3's retail casting/UI, R6 locomotion/collision/projectile/teleport/radar rebaseline, deterministic fresh-login/portal world lifecycle, and final two-client portal observer flow are user-gated. The current program is the behavior-preserving ownership campaign in [`docs/architecture/code-structure.md`](../architecture/code-structure.md); Slices 1–7 and Slice 8 checkpoints A–G are complete, with the remaining composition/shutdown checkpoints active. New M4 quest/emote/character-creation feature bodies wait until that campaign is complete. Issue #225's lifestone/particle alpha comparison remains a separate rendering visual gate. **Purpose:** One source of truth for where the project is and where it's going. Every observed defect or missing feature has a named phase that owns it; when something looks wrong in-game, look here to find the phase that'll address it. Implementation details live in per-phase specs under `docs/superpowers/specs/`, not in this file. --- @@ -79,7 +79,7 @@ second synchronized soak pass with graceful exits, and the six stable PNG checkpoints preserve Slice 6 presentation. Issue #232 tracks the coarse process-residency gate's diagnostic blind spot; no tolerance was loosened. -Final Slice 8 composition/shutdown cleanup is active. Checkpoints A–F now own +Final Slice 8 composition/shutdown cleanup is active. Checkpoints A–G now own the frozen shell boundary, all nine native callback edges, live-session reset/selection/entry/route composition, and world clock/sky/day/weather/ AdminEnvirons state. Checkpoint E adds reversible, transactional Silk, @@ -91,8 +91,13 @@ fields / 65 methods. Checkpoint F adds the sole gameplay action-priority subscriber, typed combat/diagnostic/general commands, and a reversible retained-root item-drop binding. `GameWindow` is now 4,057 lines / 198 fields / 54 methods; the Checkpoint F Release suite passes 7,524 tests / 5 skips. -Settings, startup/resource transfer, shutdown, soak, and closeout checkpoints -G–L remain. +Checkpoint G adds the pre-window `RuntimeSettingsController`, one immutable +startup snapshot, ordered startup pacing/display/FOV/audio application, late +runtime-target binding without replay, and one current/toon settings owner. +`GameWindow` is now 3,663 lines / 162 fields / 37 methods. Resource transfer, +ordered composition, shutdown, soak, and closeout checkpoints H–L remain. The +Checkpoint G Release suite passes 7,553 tests / 5 intentional skips, and all +three corrected-diff reviews are clean. This is a behavior-preserving structural program. Severe regressions still get root-cause fixes in separate commits; ordinary feature work resumes with M4 diff --git a/docs/plans/2026-05-12-milestones.md b/docs/plans/2026-05-12-milestones.md index 84fb2e4e..5cd9a405 100644 --- a/docs/plans/2026-05-12-milestones.md +++ b/docs/plans/2026-05-12-milestones.md @@ -39,7 +39,7 @@ diagnostic owners preserve the accepted draw/failure graph. `GameWindow` is campaign baseline. The full Release suite passes 7,341 tests / 5 skips; the 315.6-second capped/reconnect lifecycle gate and 395.2-second synchronized nine-destination soak pass with graceful exits, and six stable PNG checkpoints -preserve Slice 6 presentation. Final Slice 8 checkpoints A–F are complete: +preserve Slice 6 presentation. Final Slice 8 checkpoints A–G are complete: native callbacks and live-session composition now have explicit retryable owners. Checkpoint D adds the sole world-environment owner for clock, DAT sky, day group, weather, and AdminEnvirons state. Checkpoint E adds terminal, @@ -47,10 +47,13 @@ transactional ownership for Silk/dispatcher/retained/devtools input, the raw camera pointer, focus, and physical framebuffer size. Graceful live-session retirement now precedes physical callback removal. Checkpoint F adds the sole gameplay action-priority owner, typed combat/diagnostic/general commands, and a -reversible retained-root item-drop binding. `GameWindow` is 4,057 lines / 198 -fields / 54 methods; 7,524 Release tests pass / 5 skip. Checkpoints G–L remain -active. Issue #232 tracks process-residency variance in the soak without -loosening its leak threshold. +reversible retained-root item-drop binding. Checkpoint G adds the sole +pre-window/runtime settings owner, one startup snapshot, ordered one-shot +startup application, and inert late target binding. `GameWindow` is 3,663 +lines / 162 fields / 37 methods. Checkpoints H–L remain active. Issue #232 +tracks process-residency variance in the soak without loosening its leak +threshold. The Checkpoint G Release suite passes 7,553 tests / 5 skips and all +three corrected-diff reviews are clean. Carried: #153 far-teleport residual, #116 slide-response, the remaining R6 ownership cleanup plus diff --git a/docs/plans/2026-07-22-gamewindow-slice-8-checkpoint-g-runtime-settings.md b/docs/plans/2026-07-22-gamewindow-slice-8-checkpoint-g-runtime-settings.md new file mode 100644 index 00000000..3b52e87a --- /dev/null +++ b/docs/plans/2026-07-22-gamewindow-slice-8-checkpoint-g-runtime-settings.md @@ -0,0 +1,132 @@ +# GameWindow Slice 8 — Checkpoint G Runtime Settings Plan + +## Objective + +Replace `GameWindow`'s duplicated pre-window and `OnLoad` settings paths with +one two-phase `RuntimeSettingsController`. The controller owns persistence and +live settings state; render, window, audio, UI, and streaming objects remain +borrowed targets. This is a structural cutover, not a redesign of the settings +format or retail option behavior. + +Two carried behaviors are now explicit rather than accidental. Saved FOV is +applied during startup even when developer tools are disabled; previously it +was incorrectly gated on construction of `SettingsVM`. The named-retail +`/framerate` live toggle and notice remain intact, while acdream's already +shipped cross-launch ShowFps persistence is documented as AP-121 rather than +being attributed to the retail command body. + +## Frozen behavior and ordering + +1. Construct the controller before `Window.Create` and load Display, Audio, + Gameplay, Chat, and the `default` Character bag exactly once. +2. Resolve the preset plus environment overrides once for the immutable startup + snapshot. `WindowOptions.VSync` comes from the pacing policy initialized with + that snapshot; `WindowOptions.Samples` comes from the same resolved quality. +3. After window/GL input, camera, DAT effects, and optional audio exist, but + before devtools or world/render factories, apply startup settings in this + order: monitor-aware pacing, resolution/fullscreen, field of view, then audio. +4. Downstream terrain, dispatcher, render-range, and streaming factories read + the same startup quality snapshot. The legacy startup stream-radius override + remains at its current composition boundary. +5. Bind one complete `RuntimeSettingsTargets` only after window, UI (when + enabled), terrain atlas, dispatcher, render-range, and streaming exist. + Binding performs no display, quality, UI-lock, or audio replay. +6. Settings-panel Save preserves its callback order: keybindings, Display, + Audio, Gameplay, Chat, Character. Each section retains its existing + persistence/error boundary. +7. Display Save persists first, applies resolution/fullscreen, publishes the + new display snapshot, then reapplies quality. A changed MSAA sample count is + logged as restart-required and is not applied to the live GL context. +8. Runtime quality reapply preserves the existing order: alpha-to-coverage, + terrain anisotropy, render-range publication, streaming reconciliation, + completion budget. +9. `/framerate`, UI lock, loot permits, and combat preferences mutate the + controller's canonical state, synchronize the optional Settings draft using + the existing draft-preservation rules, and retain their existing save-error + behavior. +10. Enter-world changes the active toon key, loads that character bag even when + devtools are disabled, and publishes it to the optional Settings view model. + Session reset restores the startup/default character context before the + later identity-reset operation restores the key to `default`. +11. Per-frame display/audio preview reads the optional Settings draft through a + typed source. Particle range and FPS visibility read the same source so an + unsaved draft still previews exactly as it does today. +12. Shutdown first withdraws the Settings view-model and runtime-target loans; + the controller never disposes the window, renderer, UI, streaming, audio, or + input objects it borrows. + +## Types and ownership + +- `IRuntimeSettingsStorage` is the test seam for the five settings bags. + `JsonRuntimeSettingsStorage` is the production owner of the one concrete + `SettingsStore` and exposes that store only for retained-window layout + persistence. +- `RuntimeSettingsSnapshot` is the immutable startup value. +- `IRuntimeSettingsStartupTarget` receives the one startup Display/Audio apply. +- `IRuntimeSettingsTargets` receives future display-window, quality, and UI-lock + changes. `BindRuntimeTargets` stores it without invoking it. +- `IRuntimeSettingsPreviewSource` exposes current Display/Audio preview values + and whether a live draft exists. +- `RuntimeSettingsController` owns persistence, current values, active toon, + resolved quality, and the optional `SettingsVM` binding. It also implements + the combat settings source; no second gameplay-state mirror remains. +- `SilkRuntimeDisplayWindowTarget`, `RuntimeSettingsStartupTargets`, and + `RuntimeSettingsTargets` are App-layer adapters over borrowed runtime objects. + +## Automated gates + +- Construction loads every bag once and populates one immutable snapshot. +- Startup apply is ordered, exactly once, and independent of GL in controller + tests. +- Runtime target binding produces zero calls; unbinding prevents future target + calls while state and persistence still advance. +- Display Save ordering, quality transition, and restart-required MSAA logging. +- Quality target receives one complete resolved value per reapply. +- Draft preview/cancel, external FPS mutation, UI lock, loot permits, and combat + option synchronization. +- Toon activate/load/reset/save behavior with and without a Settings view model. +- Save failures preserve each established state/target boundary. +- Source-boundary test proves `GameWindow` no longer constructs or loads a + `SettingsStore`, owns persisted settings fields, or contains quality/display + feature bodies. +- Focused App tests, full App tests, Release solution build, and full Release + suite. + +## Review and commit gate + +After the focused tests pass, the existing retail-conformance, +architecture/integration, and adversarial reviewers inspect the complete diff +in parallel. Confirmed findings are corrected and the same reviewers repeat +until clean. Documentation, roadmap, issues, memory, `CLAUDE.md`, and +`AGENTS.md` are reconciled in the same bisectable Checkpoint G commit. + +## Implementation result + +- `RuntimeSettingsController` is constructed before `Window.Create`; all five + bags load once and `WindowOptions` consumes its immutable startup snapshot. +- Startup substeps commit independently, so an audio failure retries audio + without replaying already successful pacing/display/FOV work. +- Late target binding is inert and reversible; expected-instance SettingsVM + withdrawal cannot detach a replacement binding. +- The concrete quality adapter preserves all five application steps and the + unchanged MSAA restart boundary. +- Combat changes merge their three fields into a live Gameplay draft rather + than overwriting unrelated unsaved fields; successful external changes also + advance the SettingsVM persisted baseline so Cancel cannot resurrect stale + command state. +- UI-lock target, persistence, and baseline publication form one explicit + convergence state. A transient failure leaves the same requested value + retryable instead of letting the equality fast-path strand unfinished work. +- Enter/reset/toon changes load and publish Character state independently of + developer-tool availability. +- `GameWindow` no longer constructs or loads `SettingsStore`, owns persisted + settings mirrors, or contains display/quality/settings feature bodies. +- Candidate metrics: 3,663 raw lines / 162 fields / 37 methods, down 394 lines / + 36 fields / 17 methods from Checkpoint F and 12,060 lines (76.7%) from the + campaign baseline. +- Thirty-five focused App settings/boundary tests, all 43 `SettingsVMTests`, the + UI Abstractions Release suite (534 pass), and the App Release suite (3,183 + pass / 3 intentional skips) pass. All three independent corrected-diff + reviews are clean. The production App Release build has zero warnings/errors; + the solution Release build retains the 17 existing #228 test-project warnings; + and the complete Release suite passes 7,553 tests / 5 intentional skips. diff --git a/docs/plans/2026-07-22-gamewindow-slice-8-composition-lifecycle.md b/docs/plans/2026-07-22-gamewindow-slice-8-composition-lifecycle.md index 194b910a..61a38b97 100644 --- a/docs/plans/2026-07-22-gamewindow-slice-8-composition-lifecycle.md +++ b/docs/plans/2026-07-22-gamewindow-slice-8-composition-lifecycle.md @@ -25,7 +25,7 @@ audit. resize behind symmetric named subscriptions. - [x] F — extract the sole gameplay input-action router plus focused combat and diagnostic command owners. -- [ ] G — extract two-phase persisted settings/display/quality ownership. +- [x] G — extract two-phase persisted settings/display/quality ownership. - [ ] H — give terrain atlas, sky shader, retained `UiHost`, and both frame roots explicit single ownership and transfer seams. - [ ] I — group `OnLoad` into small ordered, fakeable composition phases with @@ -557,6 +557,29 @@ fields / 54 methods. - Preserve restart-required quality behavior and test transitions/previews without requiring GL. +**Implementation result:** one pre-window `RuntimeSettingsController` now owns +the concrete settings store, the immutable startup snapshot, current Display / +Audio / Gameplay / Chat / Character values, the active toon, resolved quality, +and the optional Settings draft. Startup applies pacing, window state, saved +FOV, and audio in order exactly once; a failed later substep retries without +replaying a successful earlier substep. Complete runtime targets bind only +after their borrowers exist and binding performs no replay. Display saves keep +the persistence → live window → snapshot → quality order; quality applies +alpha-to-coverage → anisotropy → render range → streaming → completion budget. +Combat preference changes merge only their three fields into a live draft, so +unrelated unsaved Gameplay edits survive. Saved FOV now applies without +devtools, correcting the old accidental SettingsVM gate. `/framerate` retains +the named-retail live toggle/notice and keeps acdream's shipped cross-launch +persistence as documented AP-121. UI-lock convergence requires target, +persistence, and Settings baseline publication; a failed substep keeps the +same-value command retryable. `GameWindow` is 3,663 raw lines / 162 fields / 37 +methods. Thirty-five focused App settings/boundary tests, all 43 +`SettingsVMTests`, the UI Abstractions Release suite (534 pass), and the App +Release suite (3,183 pass / 3 intentional skips) pass. All three independent +corrected-diff reviews are clean; the production App Release build has zero +warnings/errors, and the complete Release suite passes 7,553 tests / 5 +intentional skips (17 existing test-project warnings remain tracked by #228). + ### H — explicit single resource ownership and frame slots - Publish TerrainAtlas and the dedicated sky Shader as separate lifetime roots; diff --git a/memory/project_gamewindow_decomposition.md b/memory/project_gamewindow_decomposition.md index a5d1c192..de8cd214 100644 --- a/memory/project_gamewindow_decomposition.md +++ b/memory/project_gamewindow_decomposition.md @@ -3,9 +3,9 @@ ## Current state The behavior-preserving App ownership campaign is complete through Slice 7 and -Slice 8 checkpoints A–F. `GameWindow.cs` moved from the 2026-07-21 baseline of -15,723 lines / 278 fields / 205 methods to 4,057 lines / 198 fields / 54 -methods: 11,666 lines (74.2%) were removed without changing accepted gameplay +Slice 8 checkpoints A–G. `GameWindow.cs` moved from the 2026-07-21 baseline of +15,723 lines / 278 fields / 205 methods to 3,663 lines / 162 fields / 37 +methods: 12,060 lines (76.7%) were removed without changing accepted gameplay or rendering behavior. | Slice | Ownership moved out | Closeout size | @@ -19,6 +19,7 @@ or rendering behavior. | 7 | complete render-frame orchestration and failure recovery | 4,666 / 196 / 70 | | 8 A–E | shell freeze, native/session/environment composition, reversible pointer/input/framebuffer ownership | 4,266 / 194 / 65 | | 8 F | sole gameplay action route, typed commands, retained-root callback lifetime | 4,057 / 198 / 54 | +| 8 G | pre-window/runtime settings ownership, one startup snapshot, inert late target binding | 3,663 / 162 / 37 | Slice 8 is an ordered checkpoint campaign rather than another feature-body move. A froze/pruned the shell boundary; B gave all nine native window callback @@ -55,6 +56,22 @@ review also corrected the carried default-combat mismatch: active combat short-circuits directly to peace, and an incompatible Held object prints the exact retail notice without a wire request or local mode mutation. +G adds `RuntimeSettingsController` as the sole concrete settings-store, +current-state, startup-snapshot, resolved-quality, active-toon, and optional +Settings-draft owner. It is constructed before `Window.Create`; startup applies +monitor pacing, window state, saved FOV, and audio in order once, with +substep-granular retry after failure. Complete runtime targets bind only after +UI/terrain/dispatcher/render-range/streaming exist and binding performs no +replay. The Settings save order and five-step quality application order remain +frozen. Combat mutations preserve unrelated unsaved Gameplay draft fields, and +successful external gameplay commands advance only their fields in the +SettingsVM persisted baseline so Cancel cannot resurrect stale command state. +UI-lock target, persistence, and baseline publication share an explicit +convergence flag so target/save failures remain retryable for the same value. +Saved FOV now applies without devtools, fixing the former accidental +SettingsVM gate. AP-121 records the intentional modern persistence added to +the named-retail `/framerate` live toggle/notice. + Slice 6 implementation commits are `99a3e819`, `4e4aac2c`, `0bc9fda9`, `c5570383`, `eeb0f6b4`, `947c61d2`, and production cutover `e91f3102`. Slice 7 implementation commits are `7e4cfb37`, `733126a2`, `bc6f09f9`, @@ -111,6 +128,12 @@ review rule live in `docs/architecture/code-structure.md`. ## Accepted verification +- Slice 8 Checkpoint G Release suite: 7,553 passed / 5 fixture or environment + skips; App 3,183/3 skips, UI Abstractions 534/0, 35 focused App + settings/boundary tests, and all 43 `SettingsVMTests` pass. The production App + build has zero warnings/errors; the complete solution retains the 17 existing + test-project warnings tracked by #228. All three independent corrected-diff + reviews are clean. - Slice 8 Checkpoint F Release suite: 7,524 passed / 5 fixture or environment skips; App 3,155/3 skips, 54 focused App ownership tests and 20 Core combat conformance tests pass, and all three independent corrected-diff reviews are @@ -147,8 +170,8 @@ review rule live in `docs/architecture/code-structure.md`. ## Next work -Slice 8 checkpoints G–L group the remaining settings, resource ownership, -startup, and shutdown wiring and reduce Silk callbacks to +Slice 8 checkpoints H–L group the remaining resource ownership, ordered +startup composition, and shutdown wiring and reduce Silk callbacks to narrow calls into input, update, render, resize, focus, and shutdown owners. It removes leftover feature state only where Slices 1–7 already established a canonical owner; it does not redesign gameplay, rendering, or resource lifetime. diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index e467ca3c..24818871 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -1,5 +1,6 @@ using AcDream.Core.Plugins; using AcDream.App.Physics; +using AcDream.App.Settings; using AcDream.App.World; using AcDream.Content; using DatReaderWriter; @@ -97,6 +98,7 @@ public sealed class GameWindow : IDisposable new(Console.WriteLine); private ResourceShutdownTransaction? _shutdown; private readonly DisplayFramePacingController _displayFramePacing; + private readonly RuntimeSettingsController _runtimeSettings; // Phase A.1: streaming fields replacing the one-shot _entities list. private AcDream.App.Streaming.LandblockStreamer? _streamer; @@ -124,14 +126,6 @@ public sealed class GameWindow : IDisposable get => _renderRange.FarRadius; set => _renderRange.FarRadius = value; } - // A.5 T22.5: resolved quality settings (preset + env-var overrides). - // Set once in OnLoad after LoadAndApplyPersistedSettings(); re-set on - // ReapplyQualityPreset(). Default matches QualityPreset.High so the field - // is valid before OnLoad fires (no GL calls are made before OnLoad anyway). - private AcDream.UI.Abstractions.Settings.QualitySettings _resolvedQuality = - AcDream.UI.Abstractions.Settings.QualitySettings.From( - AcDream.UI.Abstractions.Settings.QualityPreset.High); - // Phase B.3: physics engine — populated from the streaming pipeline. private readonly AcDream.Core.Physics.PhysicsEngine _physicsEngine = new(); @@ -563,6 +557,10 @@ public sealed class GameWindow : IDisposable _displayFramePacing = new DisplayFramePacingController( options.UncappedRendering, _frameProfiler); + _runtimeSettings = new RuntimeSettingsController( + new JsonRuntimeSettingsStorage( + AcDream.UI.Abstractions.Panels.Settings.SettingsStore.DefaultPath()), + log: Console.WriteLine); _selection = selection ?? throw new System.ArgumentNullException(nameof(selection)); _animationDiagnostics = AnimationPresentationDiagnostics.FromEnvironment(); _uiRegistry = uiRegistry; @@ -594,17 +592,13 @@ public sealed class GameWindow : IDisposable public void Run() { - // A.5 T22.5: resolve quality preset BEFORE creating the window so - // Samples (MSAA) is baked into WindowOptions correctly. GL context - // sample count cannot change at runtime; all other quality fields are - // applied again in OnLoad after the full settings load. - var startupStore = new AcDream.UI.Abstractions.Panels.Settings.SettingsStore( - AcDream.UI.Abstractions.Panels.Settings.SettingsStore.DefaultPath()); - var startupDisplay = startupStore.LoadDisplay(); - var startupBase = AcDream.UI.Abstractions.Settings.QualitySettings.From(startupDisplay.Quality); - var startupQuality = AcDream.UI.Abstractions.Settings.QualitySettings.WithEnvOverrides(startupBase); + // The runtime-settings owner was constructed before Window.Create and + // exposes the one immutable startup snapshot. MSAA is a context + // attribute, so it must come from this same snapshot rather than a + // second settings load during OnLoad. + RuntimeSettingsSnapshot startup = _runtimeSettings.Startup; FramePacingPolicy startupPacing = - _displayFramePacing.InitializeStartup(startupDisplay.VSync); + _displayFramePacing.InitializeStartup(startup.Display.VSync); var options = WindowOptions.Default with { Size = new Vector2D(1280, 720), @@ -619,7 +613,7 @@ public sealed class GameWindow : IDisposable // Silk.NET passes this to SDL_GL_SetAttribute(SDL_GL_MULTISAMPLESAMPLES). // Cannot be changed at runtime; Quality changes mid-session that would // alter MsaaSamples are logged as a restart-required warning. - Samples = startupQuality.MsaaSamples, + Samples = startup.Quality.MsaaSamples, // #117 (2026-06-11): the aperture punch's depth gate needs a // stencil buffer (PortalDepthMaskRenderer two-pass mark+punch). // GLFW defaults to 8 stencil bits, but make the requirement @@ -843,28 +837,15 @@ 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(); - - // A.5 T22.5: resolve quality preset immediately after settings load so - // _resolvedQuality is available for TerrainAtlas.SetAnisotropic, - // WbDrawDispatcher.AlphaToCoverage, and StreamingController wiring below. - { - var qBase = AcDream.UI.Abstractions.Settings.QualitySettings.From(_persistedDisplay.Quality); - _resolvedQuality = AcDream.UI.Abstractions.Settings.QualitySettings.WithEnvOverrides(qBase); - if (!_resolvedQuality.Equals(qBase)) - Console.WriteLine($"[QUALITY] Preset {_persistedDisplay.Quality} overridden by env vars: {_resolvedQuality}"); - else - Console.WriteLine($"[QUALITY] Preset {_persistedDisplay.Quality} → {_resolvedQuality}"); - } + // Settings are runtime state, not devtools state. Apply the immutable + // pre-window snapshot once after camera/audio exist and before any + // optional frontend or world/render factory consumes it. + _runtimeSettings.ApplyStartup( + new RuntimeSettingsStartupTargets( + new SilkRuntimeDisplayWindowTarget(_window!), + _displayFramePacing, + _cameraController!, + _audioEngine)); // Phase D.2a — ImGui devtools overlay. Zero cost when the env var // isn't set: no context creation, no per-frame branches hit. @@ -872,6 +853,7 @@ public sealed class GameWindow : IDisposable if (DevToolsEnabled) { AcDream.UI.ImGui.ImGuiBootstrapper? imguiBootstrap = null; + AcDream.UI.Abstractions.Panels.Settings.SettingsVM? settingsVm = null; try { _devToolsInputContext = new AcDream.App.Input.QuiescentInputContext( @@ -984,19 +966,12 @@ public sealed class GameWindow : IDisposable // dispatcher because the dispatcher is built earlier in // the same OnLoad path (see _inputDispatcher field). AcDream.UI.Abstractions.Panels.Settings.SettingsPanel? settingsPanel = null; - if (_inputDispatcher is not null && _settingsStore is not null) + if (_inputDispatcher 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, - onSave: bindings => + settingsVm = _runtimeSettings.CreateViewModel( + _keyBindings, + _inputDispatcher, + bindings => { _inputDispatcher.SetBindings(bindings); try @@ -1011,120 +986,9 @@ 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); - // A.5 T22.5: apply quality preset if it changed. - // MSAA changes log a restart-required warning - // inside ReapplyQualityPreset; all other fields - // apply immediately. - _persistedDisplay = display; - ReapplyQualityPreset(display.Quality); - } - 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); - // Runtime consumers (including retained gmRadarUI) read - // the live persisted snapshot, not SettingsVM's draft. - _persistedGameplay = gameplay; - if (_uiHost is not null) - _uiHost.Root.UiLocked = gameplay.LockUI; - 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 - // LiveSessionHost entered-world transition - // so saving character settings always - // writes under the chosen character's - // name (or "default" pre-login). - settingsStore.SaveCharacter(_activeToonKey, character); - if (string.Equals( - _activeToonKey, - "default", - StringComparison.OrdinalIgnoreCase)) - { - _persistedCharacter = 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); + new AcDream.UI.Abstractions.Panels.Settings.SettingsPanel(settingsVm); panelHost.Register(settingsPanel); } @@ -1185,7 +1049,7 @@ public sealed class GameWindow : IDisposable _vitalsVm = null; _combatFeedback.ViewModel = null; _debugVm = null; - _settingsVm = null; + _runtimeSettings.UnbindViewModel(settingsVm); } } @@ -1240,7 +1104,7 @@ public sealed class GameWindow : IDisposable // A.5 T22.5: apply anisotropic level from quality preset. Build() // hard-codes 16x; override here to match the resolved quality so Low // (4x) and Medium (8x) actually take effect. - terrainAtlas.SetAnisotropic(_resolvedQuality.AnisotropicLevel); + terrainAtlas.SetAnisotropic(_runtimeSettings.ResolvedQuality.AnisotropicLevel); _terrain = new TerrainModernRenderer( _gl, @@ -1302,7 +1166,7 @@ public sealed class GameWindow : IDisposable _combatTargetController = new AcDream.App.Combat.CombatTargetController( Combat, _selection, - autoTarget: () => _persistedGameplay.AutoTarget, + autoTarget: () => _runtimeSettings.Gameplay.AutoTarget, selectClosestTarget: () => SelectClosestCombatTarget(showToast: false)); _externalContainerLifecycle = new AcDream.App.World.ExternalContainerLifecycleController( _externalContainers, @@ -1372,7 +1236,7 @@ public sealed class GameWindow : IDisposable _debugFont, _hostQuiescence); _retainedInputCapture.Root = _uiHost.Root; - _uiHost.Root.UiLocked = _persistedGameplay.LockUI; + _uiHost.Root.UiLocked = _runtimeSettings.Gameplay.LockUI; var cursorFeedbackController = new AcDream.App.UI.CursorFeedbackController( _itemInteractionController, // Retail UpdateCursorState (0x00564630) keys target-mode @@ -1384,7 +1248,7 @@ public sealed class GameWindow : IDisposable _characterSheetProvider = new AcDream.App.UI.Layout.CharacterSheetProvider( Objects, LocalPlayer, playerGuid: () => _playerServerGuid, - activeToonName: () => _activeToonKey, + activeToonName: () => _runtimeSettings.ActiveToonKey, fallbackSheet: AcDream.App.Studio.SampleData.SampleCharacter, canSendRaise: () => _liveSessionHost?.IsInWorld == true, sendRaiseAttribute: (statId, cost) => LiveSession?.SendRaiseAttribute(statId, cost), @@ -1463,17 +1327,19 @@ public sealed class GameWindow : IDisposable playerYawRadians: () => _playerController?.Yaw ?? 0f, playerCellId: () => _playerController?.CellId ?? 0u, selectedGuid: () => _selection.SelectedObjectId, - coordinatesOnRadar: () => _persistedGameplay.CoordinatesOnRadar, - uiLocked: () => _persistedGameplay.LockUI, + coordinatesOnRadar: () => _runtimeSettings.Gameplay.CoordinatesOnRadar, + uiLocked: () => _runtimeSettings.Gameplay.LockUI, playerEntities: () => _entitiesByServerGuid, spatialQuery: () => _worldState); var retailChatVm = new AcDream.UI.Abstractions.Panels.Chat.ChatVM(Chat, displayLimit: 200); _retailChatVm = retailChatVm; - AcDream.App.UI.RetailUiPersistenceBindings? persistence = _settingsStore is null + AcDream.UI.Abstractions.Panels.Settings.SettingsStore? layoutStore = + _runtimeSettings.LayoutStore; + AcDream.App.UI.RetailUiPersistenceBindings? persistence = layoutStore is null ? null : new AcDream.App.UI.RetailUiPersistenceBindings( - _settingsStore, - CharacterKey: () => _activeToonKey, + layoutStore, + CharacterKey: () => _runtimeSettings.ActiveToonKey, ScreenSize: () => (_window.Size.X, _window.Size.Y)); void UiProbeLog(string message) => Console.WriteLine("[UI-PROBE] " + message); @@ -1508,12 +1374,12 @@ public sealed class GameWindow : IDisposable Radar: new AcDream.App.UI.RadarRuntimeBindings( radarSnapshotProvider.BuildSnapshot, _selection, - SetRetailUiLocked), + _runtimeSettings.SetUiLocked), Combat: new AcDream.App.UI.CombatRuntimeBindings( Combat, _combatAttackController, - () => _persistedGameplay, - SetRetailCombatGameplay), + () => _runtimeSettings.Gameplay, + _runtimeSettings.SetCombatGameplay), Magic: new AcDream.App.UI.MagicRuntimeBindings( SpellBook, _magicRuntime.Casting, @@ -1549,12 +1415,11 @@ public sealed class GameWindow : IDisposable // adaptive distance degradation (tracked by TS-15), so its // effective multiplier is exactly 1.0. () => 1.0, - () => _settingsVm?.DisplayDraft.ShowFps - ?? _persistedDisplay.ShowFps), + () => _runtimeSettings.DisplayPreview.ShowFps), VividTarget: new AcDream.App.UI.Layout.VividTargetRuntimeBindings( _selection, () => _playerServerGuid, - () => _persistedGameplay.VividTargetingIndicator, + () => _runtimeSettings.Gameplay.VividTargetingIndicator, ResolveVividTargetInfo, GetSelectionCamera), Indicators: new AcDream.App.UI.IndicatorRuntimeBindings( @@ -2019,7 +1884,8 @@ public sealed class GameWindow : IDisposable _retainedUiGameplayBinding.Attach(); } // A.5 T22.5: apply A2C gate from quality preset. - _wbDrawDispatcher.AlphaToCoverage = _resolvedQuality.AlphaToCoverage; + _wbDrawDispatcher.AlphaToCoverage = + _runtimeSettings.ResolvedQuality.AlphaToCoverage; // Slice 2: now that the dispatcher exists, build the paperdoll doll RTT renderer and hand it to // the viewport widget. Reuses the dispatcher + the scene-lighting UBO + _gl. The widget only @@ -2163,12 +2029,11 @@ public sealed class GameWindow : IDisposable _options.UiProbeDump, resourceDiagnostics); - // A.5 T22.5: apply radii from the already-resolved _resolvedQuality. - // _resolvedQuality was set by the quality block immediately after - // LoadAndApplyPersistedSettings() above, absorbing all env-var overrides. + // Apply radii from the same immutable quality snapshot used for the + // window's MSAA, terrain anisotropy, and dispatcher A2C setup. // Legacy ACDREAM_STREAM_RADIUS is still honoured for backward-compat. - _nearRadius = _resolvedQuality.NearRadius; - _farRadius = _resolvedQuality.FarRadius; + _nearRadius = _runtimeSettings.ResolvedQuality.NearRadius; + _farRadius = _runtimeSettings.ResolvedQuality.FarRadius; // Legacy override: ACDREAM_STREAM_RADIUS acts as nearRadius and // ensures farRadius >= streamRadius. Parsed once into @@ -2233,7 +2098,17 @@ public sealed class GameWindow : IDisposable _streamingController, _liveWorldOrigin); // A.5 T22.5: apply max-completions from resolved quality. - _streamingController.MaxCompletionsPerFrame = _resolvedQuality.MaxCompletionsPerFrame; + _streamingController.MaxCompletionsPerFrame = + _runtimeSettings.ResolvedQuality.MaxCompletionsPerFrame; + _runtimeSettings.BindRuntimeTargets( + new RuntimeSettingsTargets( + new SilkRuntimeDisplayWindowTarget(_window!), + _wbDrawDispatcher!, + terrainAtlas, + _streamingController, + _renderRange, + _uiHost?.Root, + Console.WriteLine)); _worldReveal = new AcDream.App.Streaming.WorldRevealCoordinator( isRenderNeighborhoodReady: _streamingController.IsRenderNeighborhoodResident, isSpawnCellReady: _physicsEngine.IsSpawnCellReady, @@ -2397,7 +2272,7 @@ public sealed class GameWindow : IDisposable _liveEntities!, Objects, _localPlayerIdentity), - _gameplaySettings, + _runtimeSettings, _playerControllerSlot, _localPlayerOutbound, _liveSessionController, @@ -2452,8 +2327,7 @@ public sealed class GameWindow : IDisposable _scriptRunner!, _updateFrameClock, new AcDream.App.Update.SettingsParticleRangeSource( - _settingsVm, - _persistedDisplay.ParticleRange)); + _runtimeSettings)); var liveSpatialReconciler = new AcDream.App.Update.LiveSpatialPresentationReconciler( _entityEffects!, @@ -2659,7 +2533,7 @@ public sealed class GameWindow : IDisposable _worldReveal, _envCellFrustum), new AcDream.App.Rendering.RuntimeWorldFrameSettingsPreview( - _settingsVm, + _runtimeSettings, _audioEngine, _cameraController!, _displayFramePacing), @@ -2803,7 +2677,7 @@ public sealed class GameWindow : IDisposable localPlayerFrame, liveSpatialReconciler, new AcDream.App.Combat.CombatCameraTargetSource( - _gameplaySettings, + _runtimeSettings, Combat, _selection, _worldSelectionQuery!)); @@ -2929,10 +2803,10 @@ public sealed class GameWindow : IDisposable AcDream.App.Streaming.EntityVanishProbe.PlayerGuid = id, ClearCombat: Combat.Clear), EnteredWorld: new( - SetActiveCharacter: name => _activeToonKey = name, + SetActiveCharacter: _runtimeSettings.SetActiveCharacter, RestoreLayout: () => _retailUiRuntime?.RestoreLayout(), SyncToolbar: SyncToolbarWindowButtons, - LoadCharacterSettings: LoadLiveSessionCharacterSettings, + LoadCharacterSettings: _runtimeSettings.LoadCharacterContext, ArmPlayerModeAutoEntry: () => _playerModeAutoEntry?.Arm()), Connecting: (host, port, user) => Chat.OnSystemMessage( @@ -2943,17 +2817,6 @@ public sealed class GameWindow : IDisposable "connected — character list received", chatType: 1))); - private void LoadLiveSessionCharacterSettings(string characterName) - { - if (_settingsStore is not null && _settingsVm is not null) - { - var toonBag = _settingsStore.LoadCharacter(characterName); - _settingsVm.LoadCharacterContext(toonBag); - Console.WriteLine( - $"settings: loaded character[{characterName}] preferences"); - } - } - private AcDream.App.Net.LiveSessionResetBindings CreateLiveSessionResetBindings() => new() { @@ -2962,8 +2825,7 @@ public sealed class GameWindow : IDisposable TeleportTransit = _localPlayerTeleportSink.ResetSession, SessionDialogs = () => _retailUiRuntime?.ResetSessionDialogs(), ChatCommandTargets = () => _retailChatVm?.ResetSessionTargets(), - SettingsCharacterContext = () => - _settingsVm?.LoadCharacterContext(_persistedCharacter), + SettingsCharacterContext = _runtimeSettings.RestoreDefaultCharacterContext, EquippedChildren = () => _equippedChildRenderer?.Clear(), ExternalContainer = () => _externalContainers.Reset(), InteractionAndSelection = ResetSessionInteraction, @@ -3011,7 +2873,7 @@ public sealed class GameWindow : IDisposable _vitalsVm?.SetLocalPlayerGuid(0u); Chat.ResetSessionIdentity(); AcDream.App.Streaming.EntityVanishProbe.PlayerGuid = 0u; - _activeToonKey = "default"; + _runtimeSettings.ResetActiveCharacterKey(); _characterOptions1 = AcDream.Core.Net.Messages.PlayerDescriptionParser.CharacterOptions1.Default; _localPlayerSkills.ResetSession(); @@ -3132,8 +2994,9 @@ public sealed class GameWindow : IDisposable TeleportToMansion: session.SendTeleportToMansion, QueryAge: session.SendQueryAge, QueryBirth: session.SendQueryBirth, - ToggleFrameRate: ToggleRetailFrameRate, - ToggleUiLock: () => SetRetailUiLocked(!_persistedGameplay.LockUI), + ToggleFrameRate: _runtimeSettings.ToggleFrameRate, + ToggleUiLock: () => + _runtimeSettings.SetUiLocked(!_runtimeSettings.Gameplay.LockUI), ShowSystemMessage: text => Chat.OnSystemMessage(text, 0u), ShowWeenieError: code => Chat.OnWeenieError(code, null), PlayerPublicWeenieBitfield: () => @@ -3155,8 +3018,8 @@ public sealed class GameWindow : IDisposable Objects.Get(_playerServerGuid)?.Properties.GetBool(0x6Eu) == true, SetAway: away => SetRetailAway(session, away), SetAwayMessage: session.SendSetAfkMessage, - AcceptLootPermits: () => _persistedGameplay.AcceptLootPermits, - SetAcceptLootPermits: SetRetailAcceptLootPermits, + AcceptLootPermits: () => _runtimeSettings.Gameplay.AcceptLootPermits, + SetAcceptLootPermits: _runtimeSettings.SetAcceptLootPermits, DisplayConsent: session.SendDisplayConsent, ClearConsent: session.SendClearConsent, RemoveConsent: session.SendRemoveConsent, @@ -3327,238 +3190,20 @@ public sealed class GameWindow : IDisposable private bool GetDebugPlayerOnGround() => _playerMode && _playerController is not null && !_playerController.IsAirborne; - // Phase K.3 settings state remains runtime-owned; the presenter owns the - // optional SettingsPanel and its visibility/input operations. - private AcDream.UI.Abstractions.Panels.Settings.SettingsVM? _settingsVm; - // L.0: settings.json store + active toon key. The store is held as - // a field so LiveSessionHost 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"; - private void SyncToolbarWindowButtons() { _retailUiRuntime?.SyncToolbarWindowButtons(); } - private void SetRetailUiLocked(bool locked) - { - if (_persistedGameplay.LockUI == locked) - return; - - _persistedGameplay = _persistedGameplay with { LockUI = locked }; - if (_uiHost is not null) - _uiHost.Root.UiLocked = locked; - if (_settingsVm is not null) - _settingsVm.SetGameplay(_settingsVm.GameplayDraft with { LockUI = locked }); - - try - { - _settingsStore?.SaveGameplay(_persistedGameplay); - } - catch (Exception ex) - { - Console.WriteLine($"settings: radar lock save failed: {ex.Message}"); - } - } - private void SetRetailAway(AcDream.Core.Net.WorldSession session, bool away) { session.SendSetAfkMode(away); } - private void SetRetailAcceptLootPermits(bool enabled) - { - _persistedGameplay = _persistedGameplay with { AcceptLootPermits = enabled }; - _settingsVm?.SetGameplay( - _settingsVm.GameplayDraft with { AcceptLootPermits = enabled }); - _settingsStore?.SaveGameplay(_persistedGameplay); - } - - /// - /// Retail ClientCommunicationSystem::DoFrameRate @ 0x005707D0: - /// flip the live display flag and persist it through the same settings - /// path used by the Display panel. - /// - private void ToggleRetailFrameRate() - { - _persistedDisplay = _persistedDisplay with - { - ShowFps = !_persistedDisplay.ShowFps, - }; - if (_settingsVm is not null) - _settingsVm.ApplyExternalDisplayChange(display => display with - { - ShowFps = _persistedDisplay.ShowFps, - }); - - try - { - _settingsStore?.SaveDisplay(_persistedDisplay); - } - catch (Exception ex) - { - Console.WriteLine($"settings: framerate display save failed: {ex.Message}"); - } - } - - private void SetRetailCombatGameplay( - AcDream.UI.Abstractions.Panels.Settings.GameplaySettings gameplay) - { - _persistedGameplay = gameplay; - _settingsVm?.SetGameplay(gameplay); - - try - { - _settingsStore?.SaveGameplay(gameplay); - } - catch (Exception ex) - { - Console.WriteLine($"settings: combat option save failed: {ex.Message}"); - } - } - - // 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 readonly AcDream.App.Combat.GameplaySettingsState - _gameplaySettings = new(); - private AcDream.UI.Abstractions.Panels.Settings.GameplaySettings _persistedGameplay - { - get => _gameplaySettings.Value; - set => _gameplaySettings.Value = value; - } - private AcDream.UI.Abstractions.Panels.Settings.ChatSettings _persistedChat - = AcDream.UI.Abstractions.Panels.Settings.ChatSettings.Default; - private AcDream.UI.Abstractions.Panels.Settings.CharacterSettings _persistedCharacter - = AcDream.UI.Abstractions.Panels.Settings.CharacterSettings.Default; - - /// - /// L.0 follow-up: load every section from settings.json + apply the - /// runtime-affecting ones (Display window state + Audio engine - /// volumes) at startup. Runs unconditionally — settings are runtime - /// state, not devtools state. Without this, a user running with - /// ACDREAM_DEVTOOLS=0 would silently get WindowOptions - /// defaults instead of their saved Display/Audio preferences. - /// - private void LoadAndApplyPersistedSettings() - { - _settingsStore = new AcDream.UI.Abstractions.Panels.Settings.SettingsStore( - AcDream.UI.Abstractions.Panels.Settings.SettingsStore.DefaultPath()); - _persistedDisplay = _settingsStore.LoadDisplay(); - _persistedAudio = _settingsStore.LoadAudio(); - _persistedGameplay = _settingsStore.LoadGameplay(); - _persistedChat = _settingsStore.LoadChat(); - // _activeToonKey is "default" pre-EnterWorld; the post-login - // LiveSessionHost 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 and its bounded - // refresh-rate fallback go through the shared pacing owner; - // resolution + fullscreen use the on-Save path below. - if (_window is not null) - { - _displayFramePacing.RefreshActiveMonitor(); - _displayFramePacing.ApplyPreference(_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; - } - } - - /// - /// A.5 T22.5: apply a new quality preset mid-session (called from the - /// Settings panel Save path when - /// changes). - /// - /// - /// What changes immediately: - /// - /// Streaming radii: transactionally reconciles the existing - /// against its published world; - /// the worker and controller retain their identity. - /// Anisotropic filtering: calls - /// TerrainAtlas.SetAnisotropic. - /// Alpha-to-coverage gate: sets - /// WbDrawDispatcher.AlphaToCoverage. - /// Max completions per frame: updates - /// StreamingController.MaxCompletionsPerFrame. - /// - /// - /// - /// - /// What requires a restart: - /// MSAA samples are baked into the GL context via WindowOptions.Samples - /// at window creation time and cannot change at runtime. If the new preset - /// would change MsaaSamples, a warning is logged and MSAA is left - /// at its current level until the next launch. - /// - /// - public void ReapplyQualityPreset(AcDream.UI.Abstractions.Settings.QualityPreset newPreset) - { - var newBase = AcDream.UI.Abstractions.Settings.QualitySettings.From(newPreset); - var newResolved = AcDream.UI.Abstractions.Settings.QualitySettings.WithEnvOverrides(newBase); - - Console.WriteLine($"[QUALITY] ReapplyQualityPreset: {newPreset} → {newResolved}"); - - // MSAA samples cannot change at runtime — warn if preset would differ. - if (newResolved.MsaaSamples != _resolvedQuality.MsaaSamples) - { - Console.WriteLine( - $"[QUALITY] MSAA samples change ({_resolvedQuality.MsaaSamples} → " + - $"{newResolved.MsaaSamples}) requires a restart — skipped for this session."); - } - - _resolvedQuality = newResolved; - - // A2C gate — immediate toggle, no GL context restart needed. - if (_wbDrawDispatcher is not null) - _wbDrawDispatcher.AlphaToCoverage = newResolved.AlphaToCoverage; - - // Anisotropic — immediate GL TexParameter call on the terrain atlas. - _terrain?.Atlas?.SetAnisotropic(newResolved.AnisotropicLevel); - - // Reconcile streaming radii against the current published world. This - // preserves resident blocks that remain in range and never rebuilds - // the worker merely to change a completion budget. - if (_streamingController is not null) - { - _nearRadius = newResolved.NearRadius; - _farRadius = newResolved.FarRadius; - _streamingController.ReconfigureRadii(_nearRadius, _farRadius); - _streamingController.MaxCompletionsPerFrame = newResolved.MaxCompletionsPerFrame; - - Console.WriteLine( - $"[QUALITY] Streaming reconciled: nearRadius={_nearRadius}, " + - $"farRadius={_farRadius}, maxCompletions={newResolved.MaxCompletionsPerFrame}"); - } - } - /// /// 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 + /// the corner OR by the runtime display target 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 @@ -3567,47 +3212,6 @@ public sealed class GameWindow : IDisposable private void OnFramebufferResize(Silk.NET.Maths.Vector2D newSize) => _framebufferResize.Resize(newSize); - /// - /// 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; - } /// /// Item-target-mode world pick at the current cursor. The renderer supplies the /// exact visible CPhysicsPart equivalents and RetailWorldPicker performs @@ -3763,6 +3367,7 @@ public sealed class GameWindow : IDisposable ]), new ResourceShutdownStage("input callback detach", [ + new("settings view model", () => _runtimeSettings.UnbindViewModel()), new("retained gameplay", () => { AcDream.App.Input.RetainedUiGameplayBinding? binding = @@ -3836,6 +3441,7 @@ public sealed class GameWindow : IDisposable // the later render-frontend stage still disposes the GL owners. new ResourceShutdownStage("frame borrowers", [ + new("runtime settings targets", _runtimeSettings.UnbindRuntimeTargets), new("world frame composition", () => { _renderFrameOrchestrator = null; diff --git a/src/AcDream.App/Rendering/TerrainAtlas.cs b/src/AcDream.App/Rendering/TerrainAtlas.cs index 471716fa..57d6b0a2 100644 --- a/src/AcDream.App/Rendering/TerrainAtlas.cs +++ b/src/AcDream.App/Rendering/TerrainAtlas.cs @@ -444,7 +444,8 @@ public sealed unsafe class TerrainAtlas : IDisposable /// /// A.5 T22.5: update GL_TEXTURE_MAX_ANISOTROPY on the terrain atlas at - /// runtime (called by when + /// runtime (called by + /// when /// the user changes Quality preset mid-session). Idempotent — calling with /// the same level as the current setting is safe and produces no visual /// change. The texture must not be resident-bindless when its parameters diff --git a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs index cb230e04..f46d47fe 100644 --- a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs +++ b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs @@ -378,7 +378,8 @@ public sealed unsafe class WbDrawDispatcher : IDisposable /// Default true matches T20 behavior. Set false for Low/Medium presets that /// have MsaaSamples=0 (A2C is a no-op without MSAA, but turning it off /// avoids the unnecessary GL state thrash and is cleaner diagnostics). - /// Can be toggled mid-session via . + /// Can be toggled mid-session via + /// . /// public bool AlphaToCoverage { get; set; } = true; diff --git a/src/AcDream.App/Rendering/WorldRenderFrameBuilder.cs b/src/AcDream.App/Rendering/WorldRenderFrameBuilder.cs index 06e1632b..35ccbc20 100644 --- a/src/AcDream.App/Rendering/WorldRenderFrameBuilder.cs +++ b/src/AcDream.App/Rendering/WorldRenderFrameBuilder.cs @@ -4,13 +4,13 @@ using AcDream.App.Input; using AcDream.App.Rendering.Selection; using AcDream.App.Rendering.Vfx; using AcDream.App.Rendering.Wb; +using AcDream.App.Settings; using AcDream.App.Streaming; using AcDream.App.World; using AcDream.Core.Lighting; using AcDream.Core.Physics; using AcDream.Core.Rendering; using AcDream.Core.World; -using AcDream.UI.Abstractions.Panels.Settings; namespace AcDream.App.Rendering; @@ -348,18 +348,18 @@ internal sealed class RuntimeWorldFrameVisibilityPreparation internal sealed class RuntimeWorldFrameSettingsPreview : IWorldFrameSettingsPreview { - private readonly SettingsVM? _settings; + private readonly IRuntimeSettingsPreviewSource _settings; private readonly OpenAlAudioEngine? _audio; private readonly CameraController _cameras; private readonly DisplayFramePacingController _pacing; public RuntimeWorldFrameSettingsPreview( - SettingsVM? settings, + IRuntimeSettingsPreviewSource settings, OpenAlAudioEngine? audio, CameraController cameras, DisplayFramePacingController pacing) { - _settings = settings; + _settings = settings ?? throw new ArgumentNullException(nameof(settings)); _audio = audio; _cameras = cameras ?? throw new ArgumentNullException(nameof(cameras)); _pacing = pacing ?? throw new ArgumentNullException(nameof(pacing)); @@ -367,23 +367,15 @@ internal sealed class RuntimeWorldFrameSettingsPreview : IWorldFrameSettingsPrev public void Apply(in WorldCameraFrame camera) { - if (_audio is { IsAvailable: true } && _settings is not null) + if (_settings.HasDraftPreview) { - var audio = _settings.AudioDraft; - _audio.MasterVolume = audio.Master; - _audio.MusicVolume = audio.Music; - _audio.SfxVolume = audio.Sfx; - _audio.AmbientVolume = audio.Ambient; - } - - if (_settings is not null) - { - var display = _settings.DisplayDraft; - float fieldOfView = display.FieldOfView * (MathF.PI / 180f); - _cameras.Orbit.FovY = fieldOfView; - _cameras.Fly.FovY = fieldOfView; - if (_cameras.Chase is not null) - _cameras.Chase.FovY = fieldOfView; + RuntimeSettingsStartupTargets.ApplyAudio( + _audio, + _settings.AudioPreview); + var display = _settings.DisplayPreview; + RuntimeSettingsStartupTargets.ApplyFieldOfView( + _cameras, + display.FieldOfView); _pacing.ApplyPreference(display.VSync); } diff --git a/src/AcDream.App/Settings/RuntimeSettingsController.cs b/src/AcDream.App/Settings/RuntimeSettingsController.cs new file mode 100644 index 00000000..e4e573e8 --- /dev/null +++ b/src/AcDream.App/Settings/RuntimeSettingsController.cs @@ -0,0 +1,471 @@ +using AcDream.App.Combat; +using AcDream.UI.Abstractions.Input; +using AcDream.UI.Abstractions.Panels.Settings; +using AcDream.UI.Abstractions.Settings; + +namespace AcDream.App.Settings; + +internal interface IRuntimeSettingsStorage +{ + SettingsStore? LayoutStore { get; } + + string Location { get; } + + DisplaySettings LoadDisplay(); + + AudioSettings LoadAudio(); + + GameplaySettings LoadGameplay(); + + ChatSettings LoadChat(); + + CharacterSettings LoadCharacter(string toonKey); + + void SaveDisplay(DisplaySettings display); + + void SaveAudio(AudioSettings audio); + + void SaveGameplay(GameplaySettings gameplay); + + void SaveChat(ChatSettings chat); + + void SaveCharacter(string toonKey, CharacterSettings character); +} + +internal sealed class JsonRuntimeSettingsStorage : IRuntimeSettingsStorage +{ + private readonly SettingsStore _store; + + public JsonRuntimeSettingsStorage(string path) + { + ArgumentException.ThrowIfNullOrWhiteSpace(path); + Location = path; + _store = new SettingsStore(path); + } + + public SettingsStore LayoutStore => _store; + + public string Location { get; } + + public DisplaySettings LoadDisplay() => _store.LoadDisplay(); + + public AudioSettings LoadAudio() => _store.LoadAudio(); + + public GameplaySettings LoadGameplay() => _store.LoadGameplay(); + + public ChatSettings LoadChat() => _store.LoadChat(); + + public CharacterSettings LoadCharacter(string toonKey) => + _store.LoadCharacter(toonKey); + + public void SaveDisplay(DisplaySettings display) => _store.SaveDisplay(display); + + public void SaveAudio(AudioSettings audio) => _store.SaveAudio(audio); + + public void SaveGameplay(GameplaySettings gameplay) => + _store.SaveGameplay(gameplay); + + public void SaveChat(ChatSettings chat) => _store.SaveChat(chat); + + public void SaveCharacter(string toonKey, CharacterSettings character) => + _store.SaveCharacter(toonKey, character); +} + +internal sealed record RuntimeSettingsSnapshot( + DisplaySettings Display, + AudioSettings Audio, + GameplaySettings Gameplay, + ChatSettings Chat, + CharacterSettings Character, + QualitySettings Quality); + +internal interface IRuntimeSettingsStartupTarget +{ + void ApplyDisplay(DisplaySettings display); + + void ApplyAudio(AudioSettings audio); +} + +internal interface IRuntimeSettingsTargets +{ + void ApplyDisplayWindowState(DisplaySettings display); + + void ApplyQuality(QualitySettings quality); + + void ApplyUiLock(bool locked); +} + +internal interface IRuntimeSettingsPreviewSource +{ + bool HasDraftPreview { get; } + + DisplaySettings DisplayPreview { get; } + + AudioSettings AudioPreview { get; } +} + +/// +/// Owns the one persisted settings snapshot, its live mutations, and the active +/// character context. Runtime objects are borrowed through typed targets and +/// are never constructed or disposed here. +/// +internal sealed class RuntimeSettingsController : + IRuntimeSettingsPreviewSource, + ICombatGameplaySettingsSource +{ + private const string DefaultToonKey = "default"; + + private readonly IRuntimeSettingsStorage _storage; + private readonly Func _resolveQuality; + private readonly Action _log; + private IRuntimeSettingsTargets? _runtimeTargets; + private SettingsVM? _viewModel; + private CharacterSettings _defaultCharacter; + private bool _startupDisplayApplied; + private bool _startupAudioApplied; + private bool _startupApplied; + private bool _uiLockConverged = true; + + public RuntimeSettingsController( + IRuntimeSettingsStorage storage, + Func? resolveQuality = null, + Action? log = null) + { + _storage = storage ?? throw new ArgumentNullException(nameof(storage)); + _resolveQuality = resolveQuality ?? ResolveQuality; + _log = log ?? Console.WriteLine; + + Display = _storage.LoadDisplay(); + Audio = _storage.LoadAudio(); + Gameplay = _storage.LoadGameplay(); + Chat = _storage.LoadChat(); + _defaultCharacter = _storage.LoadCharacter(DefaultToonKey); + Character = _defaultCharacter; + ResolvedQuality = _resolveQuality(Display.Quality); + Startup = new RuntimeSettingsSnapshot( + Display, + Audio, + Gameplay, + Chat, + Character, + ResolvedQuality); + } + + public RuntimeSettingsSnapshot Startup { get; } + + public SettingsStore? LayoutStore => _storage.LayoutStore; + + public string ActiveToonKey { get; private set; } = DefaultToonKey; + + public DisplaySettings Display { get; private set; } + + public AudioSettings Audio { get; private set; } + + public GameplaySettings Gameplay { get; private set; } + + public ChatSettings Chat { get; private set; } + + public CharacterSettings Character { get; private set; } + + public QualitySettings ResolvedQuality { get; private set; } + + public bool HasDraftPreview => _viewModel is not null; + + public DisplaySettings DisplayPreview => _viewModel?.DisplayDraft ?? Display; + + public AudioSettings AudioPreview => _viewModel?.AudioDraft ?? Audio; + + public bool AutoTarget => Gameplay.AutoTarget; + + public bool AutoRepeatAttack => Gameplay.AutoRepeatAttack; + + public bool ViewCombatTarget => Gameplay.ViewCombatTarget; + + public void ApplyStartup(IRuntimeSettingsStartupTarget target) + { + ArgumentNullException.ThrowIfNull(target); + if (_startupApplied) + throw new InvalidOperationException("Runtime settings startup was already applied."); + + if (!_startupDisplayApplied) + { + target.ApplyDisplay(Startup.Display); + _startupDisplayApplied = true; + } + if (!_startupAudioApplied) + { + target.ApplyAudio(Startup.Audio); + _startupAudioApplied = true; + } + _startupApplied = true; + + QualitySettings baseQuality = QualitySettings.From(Startup.Display.Quality); + _log(Startup.Quality.Equals(baseQuality) + ? $"[QUALITY] Preset {Startup.Display.Quality} -> {Startup.Quality}" + : $"[QUALITY] Preset {Startup.Display.Quality} overridden by env vars: {Startup.Quality}"); + } + + /// + /// Installs the complete future-change target. Deliberately does not replay + /// startup display, quality, or UI-lock values; those were consumed by the + /// factories that created the borrowed target objects. + /// + public void BindRuntimeTargets(IRuntimeSettingsTargets targets) + { + ArgumentNullException.ThrowIfNull(targets); + if (_runtimeTargets is not null) + throw new InvalidOperationException("Runtime settings targets are already bound."); + _runtimeTargets = targets; + } + + public void UnbindRuntimeTargets() => _runtimeTargets = null; + + public SettingsVM CreateViewModel( + KeyBindings persistedBindings, + InputDispatcher dispatcher, + Action saveBindings) + { + ArgumentNullException.ThrowIfNull(persistedBindings); + ArgumentNullException.ThrowIfNull(dispatcher); + ArgumentNullException.ThrowIfNull(saveBindings); + if (_viewModel is not null) + throw new InvalidOperationException("A settings view model is already bound."); + + _viewModel = new SettingsVM( + persistedBindings, + dispatcher, + saveBindings, + Display, + SaveDisplay, + Audio, + SaveAudio, + Gameplay, + SaveGameplay, + Chat, + SaveChat, + Character, + SaveCharacter); + return _viewModel; + } + + public void UnbindViewModel(SettingsVM? expected = null) + { + if (expected is null || ReferenceEquals(_viewModel, expected)) + _viewModel = null; + } + + public void SetUiLocked(bool locked) + { + if (Gameplay.LockUI == locked && _uiLockConverged) + return; + + _uiLockConverged = false; + Gameplay = Gameplay with { LockUI = locked }; + _runtimeTargets?.ApplyUiLock(locked); + _viewModel?.SetGameplay( + _viewModel.GameplayDraft with { LockUI = locked }); + + try + { + _storage.SaveGameplay(Gameplay); + _viewModel?.ApplyExternalGameplayChange(gameplay => gameplay with + { + LockUI = locked, + }); + _uiLockConverged = true; + } + catch (Exception ex) + { + _log($"settings: radar lock save failed: {ex.Message}"); + } + } + + public void SetAcceptLootPermits(bool enabled) + { + Gameplay = Gameplay with { AcceptLootPermits = enabled }; + _viewModel?.SetGameplay( + _viewModel.GameplayDraft with { AcceptLootPermits = enabled }); + _storage.SaveGameplay(Gameplay); + _viewModel?.ApplyExternalGameplayChange(gameplay => gameplay with + { + AcceptLootPermits = enabled, + }); + } + + /// + /// Retail ClientCommunicationSystem::DoFrameRate @ 0x005707D0 + /// flips the live flag and sends the framerate-display UI notice. acdream + /// additionally persists that live value through its modern Display bag + /// (registered as AP-121). + /// + public void ToggleFrameRate() + { + Display = Display with { ShowFps = !Display.ShowFps }; + _viewModel?.ApplyExternalDisplayChange(display => display with + { + ShowFps = Display.ShowFps, + }); + + try + { + _storage.SaveDisplay(Display); + } + catch (Exception ex) + { + _log($"settings: framerate display save failed: {ex.Message}"); + } + } + + public void SetCombatGameplay(GameplaySettings gameplay) + { + Gameplay = gameplay ?? throw new ArgumentNullException(nameof(gameplay)); + if (_viewModel is not null) + { + _viewModel.SetGameplay(_viewModel.GameplayDraft with + { + AutoTarget = gameplay.AutoTarget, + AutoRepeatAttack = gameplay.AutoRepeatAttack, + ViewCombatTarget = gameplay.ViewCombatTarget, + }); + } + + try + { + _storage.SaveGameplay(gameplay); + _viewModel?.ApplyExternalGameplayChange(current => current with + { + AutoTarget = gameplay.AutoTarget, + AutoRepeatAttack = gameplay.AutoRepeatAttack, + ViewCombatTarget = gameplay.ViewCombatTarget, + }); + } + catch (Exception ex) + { + _log($"settings: combat option save failed: {ex.Message}"); + } + } + + public void SetActiveCharacter(string characterName) + { + ArgumentException.ThrowIfNullOrWhiteSpace(characterName); + ActiveToonKey = characterName; + } + + public void LoadCharacterContext(string characterName) + { + ArgumentException.ThrowIfNullOrWhiteSpace(characterName); + ActiveToonKey = characterName; + Character = _storage.LoadCharacter(characterName); + _viewModel?.LoadCharacterContext(Character); + _log($"settings: loaded character[{characterName}] preferences"); + } + + public void RestoreDefaultCharacterContext() + { + Character = _defaultCharacter; + _viewModel?.LoadCharacterContext(Character); + } + + public void ResetActiveCharacterKey() => ActiveToonKey = DefaultToonKey; + + public void ReapplyQualityPreset(QualityPreset preset) + { + QualitySettings resolved = _resolveQuality(preset); + _log($"[QUALITY] ReapplyQualityPreset: {preset} -> {resolved}"); + + if (resolved.MsaaSamples != ResolvedQuality.MsaaSamples) + { + _log( + $"[QUALITY] MSAA samples change ({ResolvedQuality.MsaaSamples} -> " + + $"{resolved.MsaaSamples}) requires a restart - skipped for this session."); + } + + ResolvedQuality = resolved; + _runtimeTargets?.ApplyQuality(resolved); + } + + private void SaveDisplay(DisplaySettings display) + { + try + { + _storage.SaveDisplay(display); + _log($"settings: display saved to {_storage.Location}"); + _runtimeTargets?.ApplyDisplayWindowState(display); + Display = display; + ReapplyQualityPreset(display.Quality); + } + catch (Exception ex) + { + _log($"settings: display save failed: {ex.Message}"); + } + } + + private void SaveAudio(AudioSettings audio) + { + try + { + _storage.SaveAudio(audio); + Audio = audio; + _log($"settings: audio saved to {_storage.Location}"); + } + catch (Exception ex) + { + _log($"settings: audio save failed: {ex.Message}"); + } + } + + private void SaveGameplay(GameplaySettings gameplay) + { + try + { + _storage.SaveGameplay(gameplay); + Gameplay = gameplay; + _uiLockConverged = false; + _runtimeTargets?.ApplyUiLock(gameplay.LockUI); + _uiLockConverged = true; + _log($"settings: gameplay saved to {_storage.Location}"); + } + catch (Exception ex) + { + _log($"settings: gameplay save failed: {ex.Message}"); + } + } + + private void SaveChat(ChatSettings chat) + { + try + { + _storage.SaveChat(chat); + Chat = chat; + _log($"settings: chat saved to {_storage.Location}"); + } + catch (Exception ex) + { + _log($"settings: chat save failed: {ex.Message}"); + } + } + + private void SaveCharacter(CharacterSettings character) + { + try + { + _storage.SaveCharacter(ActiveToonKey, character); + Character = character; + if (string.Equals( + ActiveToonKey, + DefaultToonKey, + StringComparison.OrdinalIgnoreCase)) + { + _defaultCharacter = character; + } + _log($"settings: character[{ActiveToonKey}] saved to {_storage.Location}"); + } + catch (Exception ex) + { + _log($"settings: character save failed: {ex.Message}"); + } + } + + private static QualitySettings ResolveQuality(QualityPreset preset) => + QualitySettings.WithEnvOverrides(QualitySettings.From(preset)); +} diff --git a/src/AcDream.App/Settings/RuntimeSettingsTargets.cs b/src/AcDream.App/Settings/RuntimeSettingsTargets.cs new file mode 100644 index 00000000..f94d0d94 --- /dev/null +++ b/src/AcDream.App/Settings/RuntimeSettingsTargets.cs @@ -0,0 +1,255 @@ +using AcDream.App.Audio; +using AcDream.App.Rendering; +using AcDream.App.Rendering.Wb; +using AcDream.App.Streaming; +using AcDream.App.UI; +using AcDream.UI.Abstractions.Panels.Settings; +using AcDream.UI.Abstractions.Settings; +using Silk.NET.Maths; +using Silk.NET.Windowing; + +namespace AcDream.App.Settings; + +internal interface IRuntimeDisplayWindowTarget +{ + void Apply(DisplaySettings display); +} + +internal interface IRuntimeQualityApplicationTarget +{ + void SetAlphaToCoverage(bool enabled); + + void SetAnisotropic(int level); + + void PublishRenderRange(int nearRadius, int farRadius); + + void ReconfigureStreamingRadii(int nearRadius, int farRadius); + + void SetCompletionBudget(int maxCompletionsPerFrame); +} + +internal interface IRuntimeUiLockTarget +{ + void Apply(bool locked); +} + +internal sealed class SilkRuntimeDisplayWindowTarget : IRuntimeDisplayWindowTarget +{ + private readonly IWindow _window; + + public SilkRuntimeDisplayWindowTarget(IWindow window) + { + _window = window ?? throw new ArgumentNullException(nameof(window)); + } + + public void Apply(DisplaySettings display) + { + ArgumentNullException.ThrowIfNull(display); + if (TryParseResolution(display.Resolution, out int width, out int height) + && (_window.Size.X != width || _window.Size.Y != height)) + { + _window.Size = new Vector2D(width, height); + } + + WindowState desired = display.Fullscreen + ? WindowState.Fullscreen + : WindowState.Normal; + if (_window.WindowState != desired) + _window.WindowState = desired; + } + + internal static bool TryParseResolution( + string spec, + out int width, + out int height) + { + width = height = 0; + if (string.IsNullOrWhiteSpace(spec)) + return false; + string[] parts = spec.Split('x', 2); + return parts.Length == 2 + && int.TryParse(parts[0], out width) + && int.TryParse(parts[1], out height) + && width > 0 + && height > 0; + } +} + +internal sealed class RuntimeSettingsStartupTargets : IRuntimeSettingsStartupTarget +{ + private readonly IRuntimeDisplayWindowTarget _displayWindow; + private readonly DisplayFramePacingController _pacing; + private readonly CameraController _cameras; + private readonly OpenAlAudioEngine? _audio; + + public RuntimeSettingsStartupTargets( + IRuntimeDisplayWindowTarget displayWindow, + DisplayFramePacingController pacing, + CameraController cameras, + OpenAlAudioEngine? audio) + { + _displayWindow = displayWindow + ?? throw new ArgumentNullException(nameof(displayWindow)); + _pacing = pacing ?? throw new ArgumentNullException(nameof(pacing)); + _cameras = cameras ?? throw new ArgumentNullException(nameof(cameras)); + _audio = audio; + } + + public void ApplyDisplay(DisplaySettings display) + { + ArgumentNullException.ThrowIfNull(display); + _pacing.RefreshActiveMonitor(); + _pacing.ApplyPreference(display.VSync); + _displayWindow.Apply(display); + ApplyFieldOfView(_cameras, display.FieldOfView); + } + + public void ApplyAudio(AudioSettings audio) => ApplyAudio(_audio, audio); + + internal static void ApplyFieldOfView( + CameraController cameras, + float degrees) + { + float radians = degrees * (MathF.PI / 180f); + cameras.Orbit.FovY = radians; + cameras.Fly.FovY = radians; + if (cameras.Chase is not null) + cameras.Chase.FovY = radians; + } + + internal static void ApplyAudio( + OpenAlAudioEngine? engine, + AudioSettings audio) + { + ArgumentNullException.ThrowIfNull(audio); + if (engine is not { IsAvailable: true }) + return; + engine.MasterVolume = audio.Master; + engine.MusicVolume = audio.Music; + engine.SfxVolume = audio.Sfx; + engine.AmbientVolume = audio.Ambient; + } +} + +internal sealed class RuntimeQualityApplicationTarget + : IRuntimeQualityApplicationTarget +{ + private readonly WbDrawDispatcher _dispatcher; + private readonly TerrainAtlas _terrainAtlas; + private readonly StreamingController _streaming; + private readonly WorldRenderRangeState _renderRange; + + public RuntimeQualityApplicationTarget( + WbDrawDispatcher dispatcher, + TerrainAtlas terrainAtlas, + StreamingController streaming, + WorldRenderRangeState renderRange) + { + _dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher)); + _terrainAtlas = terrainAtlas ?? throw new ArgumentNullException(nameof(terrainAtlas)); + _streaming = streaming ?? throw new ArgumentNullException(nameof(streaming)); + _renderRange = renderRange ?? throw new ArgumentNullException(nameof(renderRange)); + } + + public void SetAlphaToCoverage(bool enabled) => + _dispatcher.AlphaToCoverage = enabled; + + public void SetAnisotropic(int level) => _terrainAtlas.SetAnisotropic(level); + + public void PublishRenderRange(int nearRadius, int farRadius) + { + _renderRange.NearRadius = nearRadius; + _renderRange.FarRadius = farRadius; + } + + public void ReconfigureStreamingRadii(int nearRadius, int farRadius) => + _streaming.ReconfigureRadii(nearRadius, farRadius); + + public void SetCompletionBudget(int maxCompletionsPerFrame) => + _streaming.MaxCompletionsPerFrame = maxCompletionsPerFrame; +} + +internal sealed class RuntimeUiLockTarget(UiRoot root) : IRuntimeUiLockTarget +{ + private readonly UiRoot _root = root ?? throw new ArgumentNullException(nameof(root)); + + public void Apply(bool locked) => _root.UiLocked = locked; +} + +internal sealed class NullRuntimeUiLockTarget : IRuntimeUiLockTarget +{ + public static NullRuntimeUiLockTarget Instance { get; } = new(); + + private NullRuntimeUiLockTarget() + { + } + + public void Apply(bool locked) + { + } +} + +/// +/// Complete late-bound target for changes made after startup. Construction and +/// binding are inert; only an explicit controller command mutates borrowers. +/// +internal sealed class RuntimeSettingsTargets : IRuntimeSettingsTargets +{ + private readonly IRuntimeDisplayWindowTarget _displayWindow; + private readonly IRuntimeQualityApplicationTarget _quality; + private readonly IRuntimeUiLockTarget _uiLock; + private readonly Action _log; + + public RuntimeSettingsTargets( + IRuntimeDisplayWindowTarget displayWindow, + WbDrawDispatcher dispatcher, + TerrainAtlas terrainAtlas, + StreamingController streaming, + WorldRenderRangeState renderRange, + UiRoot? uiRoot, + Action? log = null) + : this( + displayWindow, + new RuntimeQualityApplicationTarget( + dispatcher, + terrainAtlas, + streaming, + renderRange), + uiRoot is null + ? NullRuntimeUiLockTarget.Instance + : new RuntimeUiLockTarget(uiRoot), + log) + { + } + + internal RuntimeSettingsTargets( + IRuntimeDisplayWindowTarget displayWindow, + IRuntimeQualityApplicationTarget quality, + IRuntimeUiLockTarget uiLock, + Action? log = null) + { + _displayWindow = displayWindow + ?? throw new ArgumentNullException(nameof(displayWindow)); + _quality = quality ?? throw new ArgumentNullException(nameof(quality)); + _uiLock = uiLock ?? throw new ArgumentNullException(nameof(uiLock)); + _log = log ?? Console.WriteLine; + } + + public void ApplyDisplayWindowState(DisplaySettings display) => + _displayWindow.Apply(display); + + public void ApplyQuality(QualitySettings quality) + { + _quality.SetAlphaToCoverage(quality.AlphaToCoverage); + _quality.SetAnisotropic(quality.AnisotropicLevel); + _quality.PublishRenderRange(quality.NearRadius, quality.FarRadius); + _quality.ReconfigureStreamingRadii(quality.NearRadius, quality.FarRadius); + _quality.SetCompletionBudget(quality.MaxCompletionsPerFrame); + _log( + $"[QUALITY] Streaming reconciled: nearRadius={quality.NearRadius}, " + + $"farRadius={quality.FarRadius}, " + + $"maxCompletions={quality.MaxCompletionsPerFrame}"); + } + + public void ApplyUiLock(bool locked) => _uiLock.Apply(locked); +} diff --git a/src/AcDream.App/Update/LiveObjectFrameController.cs b/src/AcDream.App/Update/LiveObjectFrameController.cs index 21b3739c..6b76745e 100644 --- a/src/AcDream.App/Update/LiveObjectFrameController.cs +++ b/src/AcDream.App/Update/LiveObjectFrameController.cs @@ -3,6 +3,7 @@ using AcDream.App.Input; using AcDream.App.Interaction; using AcDream.App.Rendering; using AcDream.App.Rendering.Vfx; +using AcDream.App.Settings; using AcDream.App.World; using AcDream.Core.Physics; using AcDream.Core.Rendering; @@ -39,17 +40,15 @@ internal interface IParticleRangeSource internal sealed class SettingsParticleRangeSource : IParticleRangeSource { - private readonly SettingsVM? _settings; - private readonly ParticleRange _fallback; + private readonly IRuntimeSettingsPreviewSource _settings; - public SettingsParticleRangeSource(SettingsVM? settings, ParticleRange fallback) + public SettingsParticleRangeSource(IRuntimeSettingsPreviewSource settings) { - _settings = settings; - _fallback = fallback; + _settings = settings ?? throw new ArgumentNullException(nameof(settings)); } public float RangeMultiplier => - (_settings?.DisplayDraft.ParticleRange ?? _fallback) == ParticleRange.Extended + _settings.DisplayPreview.ParticleRange == ParticleRange.Extended ? ParticleVisibilityController.ExtendedRangeMultiplier : 1f; } diff --git a/src/AcDream.UI.Abstractions/Panels/Settings/SettingsVM.cs b/src/AcDream.UI.Abstractions/Panels/Settings/SettingsVM.cs index be14d6a4..da2f59f3 100644 --- a/src/AcDream.UI.Abstractions/Panels/Settings/SettingsVM.cs +++ b/src/AcDream.UI.Abstractions/Panels/Settings/SettingsVM.cs @@ -189,6 +189,21 @@ public sealed class SettingsVM _gameplayDraft = value ?? throw new ArgumentNullException(nameof(value)); } + /// + /// Apply one externally persisted gameplay-option change to both snapshots. + /// Existing unsaved edits to unrelated fields remain drafts rather than + /// being accidentally promoted to persisted state. + /// + public void ApplyExternalGameplayChange( + Func update) + { + ArgumentNullException.ThrowIfNull(update); + _gameplayPersisted = update(_gameplayPersisted) + ?? throw new InvalidOperationException("Gameplay update returned null."); + _gameplayDraft = update(_gameplayDraft) + ?? throw new InvalidOperationException("Gameplay update returned null."); + } + /// /// Replace the entire Chat draft with . /// Local-only this phase — values persist on Save but the Hear*Chat diff --git a/tests/AcDream.App.Tests/Rendering/GameWindowSlice8BoundaryTests.cs b/tests/AcDream.App.Tests/Rendering/GameWindowSlice8BoundaryTests.cs index 42c200a6..1241f58f 100644 --- a/tests/AcDream.App.Tests/Rendering/GameWindowSlice8BoundaryTests.cs +++ b/tests/AcDream.App.Tests/Rendering/GameWindowSlice8BoundaryTests.cs @@ -15,11 +15,10 @@ public sealed class GameWindowSlice8BoundaryTests AssertAppearsInOrder( body, - "startupStore.LoadDisplay()", - "QualitySettings.WithEnvOverrides(startupBase)", - "_displayFramePacing.InitializeStartup(startupDisplay.VSync)", + "RuntimeSettingsSnapshot startup = _runtimeSettings.Startup", + "_displayFramePacing.InitializeStartup(startup.Display.VSync)", "VSync = startupPacing.UseVSync", - "Samples = startupQuality.MsaaSamples", + "Samples = startup.Quality.MsaaSamples", "Window.Create(options)", "_displayFramePacing.BindSurface(", "_windowCallbacks = SilkWindowCallbackBinding.Create(", @@ -66,7 +65,8 @@ public sealed class GameWindowSlice8BoundaryTests "_cameraPointerInput = AcDream.App.Input.CameraPointerInputController.Create(", "_cameraPointerInput.AttachRaw();", "_dats = RuntimeDatCollectionFactory.OpenReadOnly(_datDir);", - "LoadAndApplyPersistedSettings();", + "_runtimeSettings.ApplyStartup(", + "new RuntimeSettingsStartupTargets(", "_uiHost = new AcDream.App.UI.UiHost(", "_uiHost.WireMouse(m)", "_uiHost.WireKeyboard(kb)", @@ -192,7 +192,7 @@ public sealed class GameWindowSlice8BoundaryTests "private AcDream.App.Net.LiveSessionHost"); string body = MethodBody( "private void OnFramebufferResize(", - "private void ApplyDisplayWindowState("); + "private uint? PickWorldGuidAtCursor("); Assert.Contains("=> _framebufferResize.Resize(newSize);", body, StringComparison.Ordinal); Assert.DoesNotContain("Viewport(", body, StringComparison.Ordinal); @@ -209,6 +209,68 @@ public sealed class GameWindowSlice8BoundaryTests StringComparison.Ordinal); } + [Fact] + public void RuntimeSettings_IsOneTwoPhaseOwnerWithoutWindowStateMirrors() + { + string source = GameWindowSource(); + string run = MethodBody("public void Run()", "private void OnLoad()"); + string load = MethodBody( + "private void OnLoad()", + "private AcDream.App.Net.LiveSessionHost"); + string shutdown = MethodBody( + "private ResourceShutdownTransaction CreateShutdownTransaction()", + "private void OnFocusChanged(bool focused)"); + + Assert.Contains( + "private readonly RuntimeSettingsController _runtimeSettings;", + source, + StringComparison.Ordinal); + Assert.Contains( + "_runtimeSettings = new RuntimeSettingsController(", + source, + StringComparison.Ordinal); + Assert.DoesNotContain("new SettingsStore(", source, StringComparison.Ordinal); + Assert.DoesNotContain("new AcDream.UI.Abstractions.Panels.Settings.SettingsStore(", source, StringComparison.Ordinal); + Assert.DoesNotContain(".LoadDisplay()", source, StringComparison.Ordinal); + Assert.DoesNotContain(".LoadAudio()", source, StringComparison.Ordinal); + Assert.DoesNotContain(".LoadGameplay()", source, StringComparison.Ordinal); + Assert.DoesNotContain(".LoadChat()", source, StringComparison.Ordinal); + Assert.DoesNotContain(".LoadCharacter(", source, StringComparison.Ordinal); + Assert.DoesNotContain("_persistedDisplay", source, StringComparison.Ordinal); + Assert.DoesNotContain("_persistedAudio", source, StringComparison.Ordinal); + Assert.DoesNotContain("_persistedGameplay", source, StringComparison.Ordinal); + Assert.DoesNotContain("_persistedChat", source, StringComparison.Ordinal); + Assert.DoesNotContain("_persistedCharacter", source, StringComparison.Ordinal); + Assert.DoesNotContain("_activeToonKey", source, StringComparison.Ordinal); + Assert.DoesNotContain("_settingsStore", source, StringComparison.Ordinal); + Assert.DoesNotContain("_settingsVm", source, StringComparison.Ordinal); + Assert.DoesNotContain("LoadAndApplyPersistedSettings", source, StringComparison.Ordinal); + Assert.DoesNotContain("private void ApplyDisplayWindowState", source, StringComparison.Ordinal); + Assert.DoesNotContain("public void ReapplyQualityPreset", source, StringComparison.Ordinal); + + AssertAppearsInOrder( + run, + "RuntimeSettingsSnapshot startup = _runtimeSettings.Startup", + "_displayFramePacing.InitializeStartup(startup.Display.VSync)", + "Samples = startup.Quality.MsaaSamples", + "Window.Create(options)"); + AssertAppearsInOrder( + load, + "_runtimeSettings.ApplyStartup(", + "if (DevToolsEnabled)", + "TerrainAtlas.Build(", + "_runtimeSettings.BindRuntimeTargets(", + "_liveSessionHost.Start(_options)"); + AssertAppearsInOrder( + shutdown, + "_runtimeSettings.UnbindViewModel()", + "_runtimeSettings.UnbindRuntimeTargets", + "_retailUiRuntime?.Dispose()", + "_streamer?.Dispose()", + "_wbDrawDispatcher?.Dispose()", + "_terrain?.Dispose()"); + } + [Fact] public void UpdateRenderFocusAndCloseRemainNarrowHostEdges() { diff --git a/tests/AcDream.App.Tests/Rendering/PrivatePresentationRendererTests.cs b/tests/AcDream.App.Tests/Rendering/PrivatePresentationRendererTests.cs index 4d384a67..318ee615 100644 --- a/tests/AcDream.App.Tests/Rendering/PrivatePresentationRendererTests.cs +++ b/tests/AcDream.App.Tests/Rendering/PrivatePresentationRendererTests.cs @@ -152,7 +152,7 @@ public sealed class PrivatePresentationRendererTests typeof(AcDream.UI.Abstractions.Panels.Debug.DebugVM), FieldTypes(typeof(DevToolsPanelSet))); Assert.Contains( - typeof(AcDream.UI.Abstractions.Panels.Settings.SettingsVM), + typeof(AcDream.App.Settings.IRuntimeSettingsPreviewSource), FieldTypes(typeof(RuntimeWorldFrameSettingsPreview))); Assert.Contains( typeof(AcDream.App.Streaming.LocalPlayerTeleportController), diff --git a/tests/AcDream.App.Tests/Settings/RuntimeSettingsControllerTests.cs b/tests/AcDream.App.Tests/Settings/RuntimeSettingsControllerTests.cs new file mode 100644 index 00000000..3ff2177d --- /dev/null +++ b/tests/AcDream.App.Tests/Settings/RuntimeSettingsControllerTests.cs @@ -0,0 +1,1159 @@ +using AcDream.App.Diagnostics; +using AcDream.App.Rendering; +using AcDream.App.Settings; +using AcDream.UI.Abstractions.Input; +using AcDream.UI.Abstractions.Panels.Settings; +using AcDream.UI.Abstractions.Settings; +using Silk.NET.Input; + +namespace AcDream.App.Tests.Settings; + +public sealed class RuntimeSettingsControllerTests +{ + [Fact] + public void ConstructionLoadsEachBagOnceAndPublishesOneStartupSnapshot() + { + var storage = new FakeStorage + { + DisplayValue = DisplaySettings.Default with + { + VSync = false, + Quality = QualityPreset.Ultra, + }, + GameplayValue = GameplaySettings.Default with + { + AutoTarget = true, + AutoRepeatAttack = true, + ViewCombatTarget = true, + }, + }; + var resolved = new QualitySettings(7, 18, 8, 16, true, 9); + int resolveCount = 0; + + var controller = new RuntimeSettingsController( + storage, + preset => + { + resolveCount++; + Assert.Equal(QualityPreset.Ultra, preset); + return resolved; + }, + static _ => { }); + + Assert.Equal(1, storage.DisplayLoads); + Assert.Equal(1, storage.AudioLoads); + Assert.Equal(1, storage.GameplayLoads); + Assert.Equal(1, storage.ChatLoads); + Assert.Equal(1, storage.CharacterLoads); + Assert.Equal(1, resolveCount); + Assert.Equal("default", storage.LastLoadedCharacter); + Assert.Same(storage.DisplayValue, controller.Startup.Display); + Assert.Same(storage.AudioValue, controller.Startup.Audio); + Assert.Same(storage.GameplayValue, controller.Startup.Gameplay); + Assert.Same(storage.ChatValue, controller.Startup.Chat); + Assert.Same(storage.DefaultCharacterValue, controller.Startup.Character); + Assert.Equal(resolved, controller.Startup.Quality); + Assert.Equal(resolved, controller.ResolvedQuality); + Assert.True(controller.AutoTarget); + Assert.True(controller.AutoRepeatAttack); + Assert.True(controller.ViewCombatTarget); + Assert.Equal("default", controller.ActiveToonKey); + } + + [Fact] + public void StartupApplyIsOrderedExactlyOnceAndRuntimeBindingDoesNotReplay() + { + var events = new List(); + var controller = CreateController(events: events); + var startup = new FakeStartupTarget(events); + + controller.ApplyStartup(startup); + + Assert.Equal(["startup-display", "startup-audio"], events); + Assert.Throws(() => controller.ApplyStartup(startup)); + + events.Clear(); + var runtime = new FakeRuntimeTargets(events); + controller.BindRuntimeTargets(runtime); + + Assert.Empty(events); + Assert.Throws(() => + controller.BindRuntimeTargets(new FakeRuntimeTargets(events))); + } + + [Fact] + public void StartupRetryResumesAfterLastSuccessfulStage() + { + var displayEvents = new List(); + var displayController = CreateController(); + var displayTarget = new FakeStartupTarget(displayEvents) + { + RemainingDisplayFailures = 1, + }; + + Assert.Throws(() => + displayController.ApplyStartup(displayTarget)); + displayController.ApplyStartup(displayTarget); + + Assert.Equal( + ["startup-display", "startup-display", "startup-audio"], + displayEvents); + + var audioEvents = new List(); + var audioController = CreateController(); + var audioTarget = new FakeStartupTarget(audioEvents) + { + RemainingAudioFailures = 1, + }; + + Assert.Throws(() => + audioController.ApplyStartup(audioTarget)); + audioController.ApplyStartup(audioTarget); + + Assert.Equal( + ["startup-display", "startup-audio", "startup-audio"], + audioEvents); + } + + [Fact] + public void ConcreteStartupTargetAppliesPacingThenWindowThenPersistedFov() + { + using var profiler = new FrameProfiler(); + using var pacing = new DisplayFramePacingController( + uncappedRendering: false, + profiler, + new FramePacingController(new FakeClock(), new NullWaiter())); + var surface = new FakePacingSurface + { + VSync = true, + ActiveMonitorRefreshHz = 144, + }; + pacing.InitializeStartup(requestedVSync: true); + pacing.BindSurface(surface); + var cameras = new CameraController(new OrbitCamera(), new FlyCamera()); + float originalFov = cameras.Orbit.FovY; + var displayWindow = new InspectingDisplayWindowTarget(display => + { + Assert.False(pacing.RequestedVSync); + Assert.Equal(originalFov, cameras.Orbit.FovY); + Assert.Equal("1600x900", display.Resolution); + }); + var target = new RuntimeSettingsStartupTargets( + displayWindow, + pacing, + cameras, + audio: null); + + target.ApplyDisplay(DisplaySettings.Default with + { + Resolution = "1600x900", + VSync = false, + FieldOfView = 83f, + }); + + Assert.Equal(1, displayWindow.ApplyCount); + Assert.Equal(1, surface.RefreshReadCount); + Assert.Equal(new FramePacingPolicy(false, 144d), pacing.Policy); + Assert.Equal(83f * (MathF.PI / 180f), cameras.Orbit.FovY, precision: 5); + Assert.Equal(83f * (MathF.PI / 180f), cameras.Fly.FovY, precision: 5); + } + + [Fact] + public void ConcreteRuntimeTargetAppliesEveryQualityDimensionInOrder() + { + var events = new List(); + var qualityTarget = new RecordingQualityApplicationTarget(events); + var displayTarget = new InspectingDisplayWindowTarget( + _ => events.Add("display")); + var uiTarget = new RecordingUiLockTarget(events); + var target = new RuntimeSettingsTargets( + displayTarget, + qualityTarget, + uiTarget, + static _ => { }); + var quality = new QualitySettings(6, 17, 4, 12, true, 7); + + target.ApplyQuality(quality); + + Assert.Equal( + [ + "a2c:True", + "aniso:12", + "range:6:17", + "stream:6:17", + "budget:7", + ], + events); + Assert.Equal(quality, qualityTarget.Observed); + } + + [Fact] + public void ConcreteRuntimeQualityTargetStopsAtTheThrowingStep() + { + string[] allSteps = + [ + "a2c", + "aniso", + "range", + "stream", + "budget", + ]; + + for (int failureIndex = 0; failureIndex < allSteps.Length; failureIndex++) + { + var events = new List(); + var target = new RuntimeSettingsTargets( + new InspectingDisplayWindowTarget(static _ => { }), + new FailingQualityApplicationTarget(events, failureIndex), + new RecordingUiLockTarget(events), + static _ => { }); + + Assert.Throws(() => + target.ApplyQuality(QualitySettings.From(QualityPreset.High))); + Assert.Equal(allSteps[..(failureIndex + 1)], events); + } + } + + [Fact] + public void SettingsViewModelSavePreservesSectionAndTargetOrder() + { + var events = new List(); + var storage = new FakeStorage(events); + var resolved = new QualitySettings(5, 15, 4, 16, true, 6); + var controller = new RuntimeSettingsController( + storage, + _ => resolved, + static _ => { }); + storage.ClearEvents(); + var targets = new FakeRuntimeTargets(events); + controller.BindRuntimeTargets(targets); + using InputDispatcher dispatcher = CreateDispatcher(); + SettingsVM viewModel = controller.CreateViewModel( + new KeyBindings(), + dispatcher, + _ => events.Add("save-bindings")); + + viewModel.SetDisplay(viewModel.DisplayDraft with + { + Resolution = "1920x1080", + Quality = QualityPreset.Ultra, + }); + viewModel.SetAudio(viewModel.AudioDraft with { Master = 0.25f }); + viewModel.SetGameplay(viewModel.GameplayDraft with { LockUI = true }); + viewModel.SetChat(viewModel.ChatDraft with { ShowTimestamps = true }); + viewModel.SetCharacter(viewModel.CharacterDraft with { AutoAttack = true }); + + viewModel.Save(); + + Assert.Equal( + [ + "save-bindings", + "save-display", + "target-display", + "target-quality", + "save-audio", + "save-gameplay", + "target-ui-lock:True", + "save-chat", + "save-character:default", + ], + events); + Assert.Equal("1920x1080", controller.Display.Resolution); + Assert.Equal(0.25f, controller.Audio.Master); + Assert.True(controller.Gameplay.LockUI); + Assert.True(controller.Chat.ShowTimestamps); + Assert.True(controller.Character.AutoAttack); + Assert.Equal(resolved, controller.ResolvedQuality); + } + + [Fact] + public void DraftPreviewAndExternalCommandsShareCanonicalState() + { + var events = new List(); + var storage = new FakeStorage(events); + var controller = CreateController(storage, events: events); + storage.ClearEvents(); + var targets = new FakeRuntimeTargets(events); + controller.BindRuntimeTargets(targets); + using InputDispatcher dispatcher = CreateDispatcher(); + SettingsVM viewModel = controller.CreateViewModel( + new KeyBindings(), + dispatcher, + static _ => { }); + viewModel.SetDisplay(viewModel.DisplayDraft with + { + FieldOfView = 91f, + ParticleRange = ParticleRange.Retail, + }); + viewModel.SetAudio(viewModel.AudioDraft with { Sfx = 0.33f }); + viewModel.SetGameplay(viewModel.GameplayDraft with { AutoTarget = true }); + + Assert.True(controller.HasDraftPreview); + Assert.Equal(91f, controller.DisplayPreview.FieldOfView); + Assert.Equal(ParticleRange.Retail, controller.DisplayPreview.ParticleRange); + Assert.Equal(0.33f, controller.AudioPreview.Sfx); + + controller.ToggleFrameRate(); + controller.SetUiLocked(true); + controller.SetAcceptLootPermits(true); + + Assert.True(controller.Display.ShowFps); + Assert.True(controller.DisplayPreview.ShowFps); + Assert.Equal(91f, controller.DisplayPreview.FieldOfView); + Assert.True(controller.Gameplay.LockUI); + Assert.True(controller.Gameplay.AcceptLootPermits); + Assert.True(viewModel.GameplayDraft.AutoTarget); + Assert.True(viewModel.GameplayDraft.LockUI); + Assert.True(viewModel.GameplayDraft.AcceptLootPermits); + Assert.Contains("target-ui-lock:True", events); + Assert.Equal(2, storage.GameplaySaves); + Assert.Equal(1, storage.DisplaySaves); + } + + [Fact] + public void DraftCancelRestoresPreviewAndCombatTogglePreservesUnrelatedDrafts() + { + var storage = new FakeStorage(); + var controller = CreateController(storage); + using InputDispatcher dispatcher = CreateDispatcher(); + SettingsVM viewModel = controller.CreateViewModel( + new KeyBindings(), + dispatcher, + static _ => { }); + viewModel.SetDisplay(viewModel.DisplayDraft with { FieldOfView = 99f }); + viewModel.SetGameplay(viewModel.GameplayDraft with + { + ShowTooltips = false, + CoordinatesOnRadar = false, + }); + + controller.SetCombatGameplay(controller.Gameplay with { AutoTarget = false }); + controller.SetUiLocked(true); + controller.SetAcceptLootPermits(true); + + Assert.False(controller.Gameplay.AutoTarget); + Assert.False(viewModel.GameplayDraft.AutoTarget); + Assert.False(viewModel.GameplayDraft.ShowTooltips); + Assert.False(viewModel.GameplayDraft.CoordinatesOnRadar); + Assert.True(viewModel.GameplayDraft.LockUI); + Assert.True(viewModel.GameplayDraft.AcceptLootPermits); + Assert.Equal(99f, controller.DisplayPreview.FieldOfView); + + viewModel.Cancel(); + + Assert.Equal(controller.Display.FieldOfView, controller.DisplayPreview.FieldOfView); + Assert.Equal(controller.Gameplay.ShowTooltips, viewModel.GameplayDraft.ShowTooltips); + Assert.Equal( + controller.Gameplay.CoordinatesOnRadar, + viewModel.GameplayDraft.CoordinatesOnRadar); + Assert.False(viewModel.GameplayDraft.AutoTarget); + Assert.True(viewModel.GameplayDraft.LockUI); + Assert.True(viewModel.GameplayDraft.AcceptLootPermits); + + viewModel.SetGameplay(viewModel.GameplayDraft with { ShowHelm = false }); + viewModel.Save(); + + Assert.False(storage.GameplayValue.AutoTarget); + Assert.True(storage.GameplayValue.LockUI); + Assert.True(storage.GameplayValue.AcceptLootPermits); + Assert.False(storage.GameplayValue.ShowHelm); + } + + [Fact] + public void FailedExternalGameplayPersistenceDoesNotPromoteViewModelBaseline() + { + var storage = new FakeStorage { ThrowOnGameplaySave = true }; + var logs = new List(); + var controller = new RuntimeSettingsController( + storage, + static preset => QualitySettings.From(preset), + logs.Add); + using InputDispatcher dispatcher = CreateDispatcher(); + SettingsVM viewModel = controller.CreateViewModel( + new KeyBindings(), + dispatcher, + static _ => { }); + + controller.SetUiLocked(true); + controller.SetCombatGameplay(controller.Gameplay with { AutoTarget = false }); + Assert.Throws(() => controller.SetAcceptLootPermits(true)); + + Assert.True(viewModel.GameplayDraft.LockUI); + Assert.False(viewModel.GameplayDraft.AutoTarget); + Assert.True(viewModel.GameplayDraft.AcceptLootPermits); + + viewModel.Cancel(); + + Assert.Equal(GameplaySettings.Default.LockUI, viewModel.GameplayDraft.LockUI); + Assert.Equal( + GameplaySettings.Default.AutoTarget, + viewModel.GameplayDraft.AutoTarget); + Assert.Equal( + GameplaySettings.Default.AcceptLootPermits, + viewModel.GameplayDraft.AcceptLootPermits); + Assert.Contains(logs, line => + line.Contains("radar lock save failed", StringComparison.Ordinal)); + Assert.Contains(logs, line => + line.Contains("combat option save failed", StringComparison.Ordinal)); + } + + [Fact] + public void CharacterContextLoadsWithoutViewModelAndSynchronizesWhenBound() + { + var storage = new FakeStorage(); + storage.Characters["Alice"] = CharacterSettings.Default with + { + DefaultChatChannel = "Trade", + }; + storage.Characters["Bob"] = CharacterSettings.Default with + { + ConfirmSalvage = false, + }; + var controller = CreateController(storage); + + controller.SetActiveCharacter("Alice"); + controller.LoadCharacterContext("Alice"); + + Assert.Equal("Alice", controller.ActiveToonKey); + Assert.Equal("Trade", controller.Character.DefaultChatChannel); + + using InputDispatcher dispatcher = CreateDispatcher(); + SettingsVM viewModel = controller.CreateViewModel( + new KeyBindings(), + dispatcher, + static _ => { }); + controller.LoadCharacterContext("Bob"); + + Assert.Equal("Bob", controller.ActiveToonKey); + Assert.False(viewModel.CharacterDraft.ConfirmSalvage); + + controller.RestoreDefaultCharacterContext(); + controller.ResetActiveCharacterKey(); + + Assert.Equal("default", controller.ActiveToonKey); + Assert.Same(storage.DefaultCharacterValue, controller.Character); + Assert.Same(storage.DefaultCharacterValue, viewModel.CharacterDraft); + } + + [Fact] + public void CharacterSaveUsesActiveToonAndDefaultSaveBecomesResetContext() + { + var storage = new FakeStorage(); + var controller = CreateController(storage); + using InputDispatcher dispatcher = CreateDispatcher(); + SettingsVM viewModel = controller.CreateViewModel( + new KeyBindings(), + dispatcher, + static _ => { }); + var newDefault = CharacterSettings.Default with { AutoAttack = true }; + viewModel.SetCharacter(newDefault); + viewModel.Save(); + + controller.LoadCharacterContext("Alice"); + var alice = CharacterSettings.Default with { DefaultChatChannel = "Trade" }; + viewModel.SetCharacter(alice); + viewModel.Save(); + + Assert.Same(newDefault, storage.Characters["default"]); + Assert.Same(alice, storage.Characters["Alice"]); + controller.RestoreDefaultCharacterContext(); + Assert.Same(newDefault, controller.Character); + Assert.Same(newDefault, viewModel.CharacterDraft); + } + + [Fact] + public void ViewModelAndRuntimeTargetLoansCanBeWithdrawnAndReboundPrecisely() + { + var events = new List(); + var controller = CreateController(events: events); + using InputDispatcher dispatcher = CreateDispatcher(); + SettingsVM viewModel = controller.CreateViewModel( + new KeyBindings(), + dispatcher, + static _ => { }); + SettingsVM foreign = CreateStandaloneViewModel(dispatcher); + + controller.UnbindViewModel(foreign); + Assert.True(controller.HasDraftPreview); + Assert.Throws(() => + controller.CreateViewModel( + new KeyBindings(), + dispatcher, + static _ => { })); + + controller.UnbindViewModel(viewModel); + Assert.False(controller.HasDraftPreview); + controller.CreateViewModel( + new KeyBindings(), + dispatcher, + static _ => { }); + + var first = new FakeRuntimeTargets(events); + controller.BindRuntimeTargets(first); + controller.UnbindRuntimeTargets(); + var second = new FakeRuntimeTargets(events); + controller.BindRuntimeTargets(second); + controller.SetUiLocked(true); + + Assert.Equal(0, first.UiLockCalls); + Assert.Equal(1, second.UiLockCalls); + } + + [Fact] + public void DisplayPersistenceFailureDoesNotPublishStateOrTargets() + { + var events = new List(); + var storage = new FakeStorage(events) { ThrowOnDisplaySave = true }; + var logs = new List(); + var controller = new RuntimeSettingsController( + storage, + static preset => QualitySettings.From(preset), + logs.Add); + storage.ClearEvents(); + controller.BindRuntimeTargets(new FakeRuntimeTargets(events)); + using InputDispatcher dispatcher = CreateDispatcher(); + SettingsVM viewModel = controller.CreateViewModel( + new KeyBindings(), + dispatcher, + static _ => { }); + DisplaySettings original = controller.Display; + viewModel.SetDisplay(viewModel.DisplayDraft with + { + Resolution = "2560x1440", + Quality = QualityPreset.Ultra, + }); + + viewModel.Save(); + + Assert.Same(original, controller.Display); + Assert.DoesNotContain("target-display", events); + Assert.DoesNotContain("target-quality", events); + Assert.Contains(logs, line => line.Contains("display save failed", StringComparison.Ordinal)); + } + + [Fact] + public void DisplayTargetFailurePreservesEstablishedStoreThenPublishBoundary() + { + var events = new List(); + var storage = new FakeStorage(events); + var logs = new List(); + var controller = new RuntimeSettingsController( + storage, + static preset => QualitySettings.From(preset), + logs.Add); + storage.ClearEvents(); + controller.BindRuntimeTargets(new FakeRuntimeTargets(events) + { + ThrowOnDisplay = true, + }); + using InputDispatcher dispatcher = CreateDispatcher(); + SettingsVM viewModel = controller.CreateViewModel( + new KeyBindings(), + dispatcher, + static _ => { }); + DisplaySettings original = controller.Display; + viewModel.SetDisplay(viewModel.DisplayDraft with + { + Resolution = "3840x2160", + Quality = QualityPreset.Ultra, + }); + + viewModel.Save(); + + Assert.Equal(1, storage.DisplaySaves); + Assert.Same(original, controller.Display); + Assert.DoesNotContain("target-quality", events); + Assert.Contains(logs, line => line.Contains("display save failed", StringComparison.Ordinal)); + } + + [Fact] + public void NonDisplayPersistenceFailuresContinueAndPreserveControllerState() + { + var storage = new FakeStorage + { + ThrowOnAudioSave = true, + ThrowOnGameplaySave = true, + ThrowOnChatSave = true, + ThrowOnCharacterSave = true, + }; + var logs = new List(); + var controller = new RuntimeSettingsController( + storage, + static preset => QualitySettings.From(preset), + logs.Add); + AudioSettings originalAudio = controller.Audio; + GameplaySettings originalGameplay = controller.Gameplay; + ChatSettings originalChat = controller.Chat; + CharacterSettings originalCharacter = controller.Character; + using InputDispatcher dispatcher = CreateDispatcher(); + SettingsVM viewModel = controller.CreateViewModel( + new KeyBindings(), + dispatcher, + static _ => { }); + viewModel.SetAudio(viewModel.AudioDraft with { Master = 0.1f }); + viewModel.SetGameplay(viewModel.GameplayDraft with { LockUI = true }); + viewModel.SetChat(viewModel.ChatDraft with { ShowTimestamps = true }); + viewModel.SetCharacter(viewModel.CharacterDraft with { AutoAttack = true }); + + viewModel.Save(); + + Assert.Same(originalAudio, controller.Audio); + Assert.Same(originalGameplay, controller.Gameplay); + Assert.Same(originalChat, controller.Chat); + Assert.Same(originalCharacter, controller.Character); + Assert.Contains(logs, line => line.Contains("audio save failed", StringComparison.Ordinal)); + Assert.Contains(logs, line => line.Contains("gameplay save failed", StringComparison.Ordinal)); + Assert.Contains(logs, line => line.Contains("chat save failed", StringComparison.Ordinal)); + Assert.Contains(logs, line => line.Contains("character save failed", StringComparison.Ordinal)); + } + + [Fact] + public void MsaaChangeIsRestartRequiredWhileOtherQualityStateAdvances() + { + var logs = new List(); + var events = new List(); + var controller = new RuntimeSettingsController( + new FakeStorage(), + static preset => QualitySettings.From(preset), + logs.Add); + controller.BindRuntimeTargets(new FakeRuntimeTargets(events)); + + controller.ReapplyQualityPreset(QualityPreset.Low); + + Assert.Equal(QualitySettings.From(QualityPreset.Low), controller.ResolvedQuality); + Assert.Equal(["target-quality"], events); + Assert.Contains(logs, line => + line.Contains("MSAA samples change (4 -> 0) requires a restart", StringComparison.Ordinal)); + } + + [Fact] + public void QualityTargetFailurePublishesResolvedQualityThenPropagates() + { + var events = new List(); + var controller = CreateController(events: events); + controller.BindRuntimeTargets(new FakeRuntimeTargets(events) + { + ThrowOnQuality = true, + }); + QualitySettings requested = QualitySettings.From(QualityPreset.Ultra); + + Assert.Throws(() => + controller.ReapplyQualityPreset(QualityPreset.Ultra)); + + Assert.Equal(requested, controller.ResolvedQuality); + Assert.Equal(["target-quality"], events); + } + + [Fact] + public void UiLockTargetFailureCanRetryTheSameRequestedValue() + { + var events = new List(); + var storage = new FakeStorage(events); + var controller = CreateController(storage, events); + storage.ClearEvents(); + var targets = new FakeRuntimeTargets(events) + { + RemainingUiLockFailures = 1, + }; + controller.BindRuntimeTargets(targets); + using InputDispatcher dispatcher = CreateDispatcher(); + SettingsVM viewModel = controller.CreateViewModel( + new KeyBindings(), + dispatcher, + static _ => { }); + + Assert.Throws(() => controller.SetUiLocked(true)); + + Assert.True(controller.Gameplay.LockUI); + Assert.Equal(GameplaySettings.Default.LockUI, viewModel.GameplayDraft.LockUI); + Assert.Equal(0, storage.GameplaySaves); + Assert.Equal(["target-ui-lock:True"], events); + + events.Clear(); + controller.SetUiLocked(true); + + Assert.True(controller.Gameplay.LockUI); + Assert.True(viewModel.GameplayDraft.LockUI); + viewModel.Cancel(); + Assert.True(viewModel.GameplayDraft.LockUI); + Assert.Equal(1, storage.GameplaySaves); + Assert.Equal(["target-ui-lock:True", "save-gameplay"], events); + } + + [Fact] + public void UiLockPersistenceFailureCanRetryTheSameRequestedValue() + { + var events = new List(); + var storage = new FakeStorage(events) + { + RemainingGameplaySaveFailures = 1, + }; + var logs = new List(); + var controller = new RuntimeSettingsController( + storage, + static preset => QualitySettings.From(preset), + logs.Add); + storage.ClearEvents(); + controller.BindRuntimeTargets(new FakeRuntimeTargets(events)); + using InputDispatcher dispatcher = CreateDispatcher(); + SettingsVM viewModel = controller.CreateViewModel( + new KeyBindings(), + dispatcher, + static _ => { }); + + controller.SetUiLocked(true); + + Assert.True(controller.Gameplay.LockUI); + Assert.True(viewModel.GameplayDraft.LockUI); + Assert.Equal(1, storage.GameplaySaves); + Assert.Contains(logs, line => + line.Contains("radar lock save failed", StringComparison.Ordinal)); + + events.Clear(); + controller.SetUiLocked(true); + + Assert.True(controller.Gameplay.LockUI); + viewModel.Cancel(); + Assert.True(viewModel.GameplayDraft.LockUI); + Assert.Equal(2, storage.GameplaySaves); + Assert.Equal(["target-ui-lock:True", "save-gameplay"], events); + } + + [Fact] + public void UnbindingLoansStopsCallsButStateAndPersistenceContinue() + { + var events = new List(); + var storage = new FakeStorage(events); + var controller = CreateController(storage, events: events); + storage.ClearEvents(); + controller.BindRuntimeTargets(new FakeRuntimeTargets(events)); + controller.UnbindRuntimeTargets(); + + controller.SetUiLocked(true); + controller.ReapplyQualityPreset(QualityPreset.Ultra); + + Assert.True(controller.Gameplay.LockUI); + Assert.Equal( + QualitySettings.From(QualityPreset.Ultra), + controller.ResolvedQuality); + Assert.DoesNotContain(events, value => value.StartsWith("target-", StringComparison.Ordinal)); + Assert.Equal(1, storage.GameplaySaves); + } + + [Theory] + [InlineData("1920x1080", true, 1920, 1080)] + [InlineData(" 800x600 ", true, 800, 600)] + [InlineData("", false, 0, 0)] + [InlineData("1920", false, 0, 0)] + [InlineData("0x1080", false, 0, 1080)] + [InlineData("1920x-1", false, 1920, -1)] + public void ResolutionParserMatchesWindowTargetPolicy( + string spec, + bool expected, + int expectedWidth, + int expectedHeight) + { + bool parsed = SilkRuntimeDisplayWindowTarget.TryParseResolution( + spec, + out int width, + out int height); + + Assert.Equal(expected, parsed); + Assert.Equal(expectedWidth, width); + Assert.Equal(expectedHeight, height); + } + + private static RuntimeSettingsController CreateController( + FakeStorage? storage = null, + List? events = null) + { + storage ??= new FakeStorage(events); + return new RuntimeSettingsController( + storage, + static preset => QualitySettings.From(preset), + static _ => { }); + } + + private static InputDispatcher CreateDispatcher() + { + InputDispatcher dispatcher = InputDispatcher.CreateDetached( + new NullKeyboardSource(), + new NullMouseSource(), + new KeyBindings()); + dispatcher.Attach(); + return dispatcher; + } + + private static SettingsVM CreateStandaloneViewModel(InputDispatcher dispatcher) => + new( + new KeyBindings(), + dispatcher, + static _ => { }, + DisplaySettings.Default, + static _ => { }, + AudioSettings.Default, + static _ => { }, + GameplaySettings.Default, + static _ => { }, + ChatSettings.Default, + static _ => { }, + CharacterSettings.Default, + static _ => { }); + + private sealed class FakeStartupTarget(List events) + : IRuntimeSettingsStartupTarget + { + public int RemainingDisplayFailures { get; set; } + + public int RemainingAudioFailures { get; set; } + + public void ApplyDisplay(DisplaySettings display) + { + events.Add("startup-display"); + + if (RemainingDisplayFailures > 0) + { + RemainingDisplayFailures--; + throw new InvalidOperationException("display startup failed"); + } + } + + public void ApplyAudio(AudioSettings audio) + { + events.Add("startup-audio"); + if (RemainingAudioFailures > 0) + { + RemainingAudioFailures--; + throw new InvalidOperationException("audio startup failed"); + } + } + } + + private sealed class FakeRuntimeTargets(List events) + : IRuntimeSettingsTargets + { + public bool ThrowOnDisplay { get; init; } + + public bool ThrowOnQuality { get; init; } + + public int RemainingUiLockFailures { get; set; } + + public int UiLockCalls { get; private set; } + + public void ApplyDisplayWindowState(DisplaySettings display) + { + events.Add("target-display"); + if (ThrowOnDisplay) + throw new InvalidOperationException("display target failed"); + } + + public void ApplyQuality(QualitySettings quality) + { + events.Add("target-quality"); + if (ThrowOnQuality) + throw new InvalidOperationException("quality target failed"); + } + + public void ApplyUiLock(bool locked) + { + UiLockCalls++; + events.Add($"target-ui-lock:{locked}"); + if (RemainingUiLockFailures > 0) + { + RemainingUiLockFailures--; + throw new InvalidOperationException("UI-lock target failed"); + } + } + } + + private sealed class InspectingDisplayWindowTarget( + Action apply) + : IRuntimeDisplayWindowTarget + { + public int ApplyCount { get; private set; } + + public void Apply(DisplaySettings display) + { + ApplyCount++; + apply(display); + } + } + + private sealed class RecordingQualityApplicationTarget(List events) + : IRuntimeQualityApplicationTarget + { + private bool _alphaToCoverage; + private int _anisotropic; + private int _near; + private int _far; + private int _streamNear; + private int _streamFar; + private int _budget; + + public QualitySettings Observed => new( + _near, + _far, + 4, + _anisotropic, + _alphaToCoverage, + _budget); + + public void SetAlphaToCoverage(bool enabled) + { + _alphaToCoverage = enabled; + events.Add($"a2c:{enabled}"); + } + + public void SetAnisotropic(int level) + { + _anisotropic = level; + events.Add($"aniso:{level}"); + } + + public void PublishRenderRange(int nearRadius, int farRadius) + { + _near = nearRadius; + _far = farRadius; + events.Add($"range:{nearRadius}:{farRadius}"); + } + + public void ReconfigureStreamingRadii(int nearRadius, int farRadius) + { + _streamNear = nearRadius; + _streamFar = farRadius; + events.Add($"stream:{nearRadius}:{farRadius}"); + } + + public void SetCompletionBudget(int maxCompletionsPerFrame) + { + Assert.Equal(_near, _streamNear); + Assert.Equal(_far, _streamFar); + _budget = maxCompletionsPerFrame; + events.Add($"budget:{maxCompletionsPerFrame}"); + } + } + + private sealed class FailingQualityApplicationTarget( + List events, + int failureIndex) + : IRuntimeQualityApplicationTarget + { + private int _step; + + public void SetAlphaToCoverage(bool enabled) => Record("a2c"); + + public void SetAnisotropic(int level) => Record("aniso"); + + public void PublishRenderRange(int nearRadius, int farRadius) => Record("range"); + + public void ReconfigureStreamingRadii(int nearRadius, int farRadius) => + Record("stream"); + + public void SetCompletionBudget(int maxCompletionsPerFrame) => Record("budget"); + + private void Record(string step) + { + events.Add(step); + if (_step++ == failureIndex) + throw new InvalidOperationException($"{step} failed"); + } + } + + private sealed class RecordingUiLockTarget(List events) + : IRuntimeUiLockTarget + { + public void Apply(bool locked) => events.Add($"ui:{locked}"); + } + + private sealed class FakeStorage(List? events = null) + : IRuntimeSettingsStorage + { + private readonly List _events = events ?? []; + + public DisplaySettings DisplayValue { get; set; } = DisplaySettings.Default; + + public AudioSettings AudioValue { get; set; } = AudioSettings.Default; + + public GameplaySettings GameplayValue { get; set; } = GameplaySettings.Default; + + public ChatSettings ChatValue { get; set; } = ChatSettings.Default; + + public CharacterSettings DefaultCharacterValue { get; set; } = + CharacterSettings.Default; + + public Dictionary Characters { get; } = + new(StringComparer.OrdinalIgnoreCase); + + public SettingsStore? LayoutStore => null; + + public string Location => "memory://settings"; + + public int DisplayLoads { get; private set; } + + public int AudioLoads { get; private set; } + + public int GameplayLoads { get; private set; } + + public int ChatLoads { get; private set; } + + public int CharacterLoads { get; private set; } + + public int DisplaySaves { get; private set; } + + public int GameplaySaves { get; private set; } + + public string? LastLoadedCharacter { get; private set; } + + public bool ThrowOnDisplaySave { get; init; } + + public bool ThrowOnAudioSave { get; init; } + + public bool ThrowOnGameplaySave { get; init; } + + public int RemainingGameplaySaveFailures { get; set; } + + public bool ThrowOnChatSave { get; init; } + + public bool ThrowOnCharacterSave { get; init; } + + public DisplaySettings LoadDisplay() + { + DisplayLoads++; + return DisplayValue; + } + + public AudioSettings LoadAudio() + { + AudioLoads++; + return AudioValue; + } + + public GameplaySettings LoadGameplay() + { + GameplayLoads++; + return GameplayValue; + } + + public ChatSettings LoadChat() + { + ChatLoads++; + return ChatValue; + } + + public CharacterSettings LoadCharacter(string toonKey) + { + CharacterLoads++; + LastLoadedCharacter = toonKey; + return Characters.TryGetValue(toonKey, out CharacterSettings? value) + ? value + : DefaultCharacterValue; + } + + public void SaveDisplay(DisplaySettings display) + { + DisplaySaves++; + _events.Add("save-display"); + if (ThrowOnDisplaySave) + throw new IOException("display persistence failed"); + DisplayValue = display; + } + + public void SaveAudio(AudioSettings audio) + { + _events.Add("save-audio"); + if (ThrowOnAudioSave) + throw new IOException("audio persistence failed"); + AudioValue = audio; + } + + public void SaveGameplay(GameplaySettings gameplay) + { + GameplaySaves++; + _events.Add("save-gameplay"); + if (RemainingGameplaySaveFailures > 0) + { + RemainingGameplaySaveFailures--; + throw new IOException("gameplay persistence failed"); + } + if (ThrowOnGameplaySave) + throw new IOException("gameplay persistence failed"); + GameplayValue = gameplay; + } + + public void SaveChat(ChatSettings chat) + { + _events.Add("save-chat"); + if (ThrowOnChatSave) + throw new IOException("chat persistence failed"); + ChatValue = chat; + } + + public void SaveCharacter(string toonKey, CharacterSettings character) + { + _events.Add($"save-character:{toonKey}"); + if (ThrowOnCharacterSave) + throw new IOException("character persistence failed"); + Characters[toonKey] = character; + if (string.Equals(toonKey, "default", StringComparison.OrdinalIgnoreCase)) + DefaultCharacterValue = character; + } + + public void ClearEvents() => _events.Clear(); + } + + private sealed class FakeClock : IFramePacingClock + { + public long Frequency => 1_000; + + public long GetTimestamp() => 0; + } + + private sealed class NullWaiter : IFramePacingWaiter + { + public void Wait(long durationTicks, long clockFrequency) + { + } + } + + private sealed class FakePacingSurface : IDisplayFramePacingSurface + { + private int? _refreshRate; + + public bool VSync { get; set; } + + public int RefreshReadCount { get; private set; } + + public int? ActiveMonitorRefreshHz + { + get + { + RefreshReadCount++; + return _refreshRate; + } + set => _refreshRate = value; + } + } + + private sealed class NullKeyboardSource : IKeyboardSource + { +#pragma warning disable CS0067 + public event Action? KeyDown; + public event Action? KeyUp; +#pragma warning restore CS0067 + public bool IsHeld(Key key) => false; + public ModifierMask CurrentModifiers => ModifierMask.None; + } + + private sealed class NullMouseSource : IMouseSource + { +#pragma warning disable CS0067 + public event Action? MouseDown; + public event Action? MouseUp; + public event Action? MouseMove; + public event Action? Scroll; +#pragma warning restore CS0067 + public bool IsHeld(MouseButton button) => false; + public bool WantCaptureMouse => false; + public bool WantCaptureKeyboard => false; + } +} diff --git a/tests/AcDream.App.Tests/World/LiveObjectFrameControllerTests.cs b/tests/AcDream.App.Tests/World/LiveObjectFrameControllerTests.cs index eed4b3d1..c46427b1 100644 --- a/tests/AcDream.App.Tests/World/LiveObjectFrameControllerTests.cs +++ b/tests/AcDream.App.Tests/World/LiveObjectFrameControllerTests.cs @@ -1,8 +1,7 @@ +using AcDream.App.Settings; using AcDream.App.Rendering.Vfx; using AcDream.App.Update; -using AcDream.UI.Abstractions.Input; using AcDream.UI.Abstractions.Panels.Settings; -using Silk.NET.Input; namespace AcDream.App.Tests.World; @@ -13,73 +12,45 @@ public sealed class LiveObjectFrameControllerTests [InlineData( ParticleRange.Extended, ParticleVisibilityController.ExtendedRangeMultiplier)] - public void ParticleRange_NullSettingsUsesConfiguredFallback( - ParticleRange fallback, + public void ParticleRange_UsesCurrentSettingsPreview( + ParticleRange range, float expected) { - var source = new SettingsParticleRangeSource(null, fallback); + var preview = new FakeSettingsPreview( + DisplaySettings.Default with { ParticleRange = range }); + var source = new SettingsParticleRangeSource(preview); Assert.Equal(expected, source.RangeMultiplier); } [Fact] - public void ParticleRange_LiveSettingsDraftOverridesFallbackAndUpdatesImmediately() + public void ParticleRange_UpdatesImmediatelyWhenPreviewChanges() { - SettingsVM settings = CreateSettings(); - var source = new SettingsParticleRangeSource(settings, ParticleRange.Retail); + var preview = new FakeSettingsPreview(DisplaySettings.Default); + var source = new SettingsParticleRangeSource(preview); - settings.SetDisplay(settings.DisplayDraft with { ParticleRange = ParticleRange.Retail }); + preview.DisplayPreview = preview.DisplayPreview with + { + ParticleRange = ParticleRange.Retail, + }; Assert.Equal(1f, source.RangeMultiplier); - settings.SetDisplay(settings.DisplayDraft with { ParticleRange = ParticleRange.Extended }); + preview.DisplayPreview = preview.DisplayPreview with + { + ParticleRange = ParticleRange.Extended, + }; Assert.Equal( ParticleVisibilityController.ExtendedRangeMultiplier, source.RangeMultiplier); } - private static SettingsVM CreateSettings() + private sealed class FakeSettingsPreview(DisplaySettings display) + : IRuntimeSettingsPreviewSource { - var dispatcher = InputDispatcher.CreateDetached( - new NullKeyboardSource(), - new NullMouseSource(), - new KeyBindings()); - dispatcher.Attach(); - return new SettingsVM( - new KeyBindings(), - dispatcher, - static _ => { }, - DisplaySettings.Default, - static _ => { }, - AudioSettings.Default, - static _ => { }, - GameplaySettings.Default, - static _ => { }, - ChatSettings.Default, - static _ => { }, - CharacterSettings.Default, - static _ => { }); - } + public bool HasDraftPreview => true; - private sealed class NullKeyboardSource : IKeyboardSource - { -#pragma warning disable CS0067 - public event Action? KeyDown; - public event Action? KeyUp; -#pragma warning restore CS0067 - public bool IsHeld(Key key) => false; - public ModifierMask CurrentModifiers => ModifierMask.None; - } + public DisplaySettings DisplayPreview { get; set; } = display; - private sealed class NullMouseSource : IMouseSource - { -#pragma warning disable CS0067 - public event Action? MouseDown; - public event Action? MouseUp; - public event Action? MouseMove; - public event Action? Scroll; -#pragma warning restore CS0067 - public bool IsHeld(MouseButton button) => false; - public bool WantCaptureMouse => false; - public bool WantCaptureKeyboard => false; + public AudioSettings AudioPreview => AudioSettings.Default; } } diff --git a/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsVMTests.cs b/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsVMTests.cs index 2c75537d..b4a15961 100644 --- a/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsVMTests.cs +++ b/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsVMTests.cs @@ -434,6 +434,44 @@ public sealed class SettingsVMTests Assert.True(vm.HasUnsavedChanges); } + [Fact] + public void ApplyExternalGameplayChange_updates_both_snapshots_and_preserves_other_drafts() + { + var (vm, _, _, _, _, _, _, _, _, _) = Build(); + bool persistedAutoTarget = !GameplaySettings.Default.AutoTarget; + + vm.SetGameplay(vm.GameplayDraft with + { + ShowTooltips = !GameplaySettings.Default.ShowTooltips, + CoordinatesOnRadar = !GameplaySettings.Default.CoordinatesOnRadar, + }); + + vm.ApplyExternalGameplayChange(gameplay => gameplay with + { + AutoTarget = persistedAutoTarget, + }); + + Assert.Equal(persistedAutoTarget, vm.GameplayDraft.AutoTarget); + Assert.Equal( + !GameplaySettings.Default.ShowTooltips, + vm.GameplayDraft.ShowTooltips); + Assert.Equal( + !GameplaySettings.Default.CoordinatesOnRadar, + vm.GameplayDraft.CoordinatesOnRadar); + Assert.True(vm.HasUnsavedChanges); + + vm.Cancel(); + + Assert.Equal(persistedAutoTarget, vm.GameplayDraft.AutoTarget); + Assert.Equal( + GameplaySettings.Default.ShowTooltips, + vm.GameplayDraft.ShowTooltips); + Assert.Equal( + GameplaySettings.Default.CoordinatesOnRadar, + vm.GameplayDraft.CoordinatesOnRadar); + Assert.False(vm.HasUnsavedChanges); + } + [Fact] public void Save_invokes_gameplay_callback_with_draft() {